From 3e21cd05b1eecf8fd668c7c3d798f90503d6ad1d Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 29 Jul 2021 15:50:03 +0200 Subject: [PATCH] [auth_ldap] Allow more configuration * Allow configuring the password hash (SSHA, PBKDF2 or Argon2) * Allow setting custom dn templates for users and groups to e.g. allow "ou=people" or "ou=user" * Allow setting custom object class for entries * Stop using deprecated openssl constants --- flaschengeist/flaschengeist.toml | 21 ++- flaschengeist/models/user.py | 2 +- flaschengeist/plugins/auth_ldap/__init__.py | 163 +++++++++++--------- setup.py | 7 +- 4 files changed, 107 insertions(+), 86 deletions(-) diff --git a/flaschengeist/flaschengeist.toml b/flaschengeist/flaschengeist.toml index ffbb51d..a920eb6 100644 --- a/flaschengeist/flaschengeist.toml +++ b/flaschengeist/flaschengeist.toml @@ -29,17 +29,16 @@ level = "WARNING" [auth_plain] enabled = true -#[auth_ldap] -# enabled = true -# host = -# port = -# bind_dn = -# base_dn = -# secret = -# use_ssl = -# admin_dn = -# admin_dn = -# default_gid = +[auth_ldap] +# Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap +enabled = false +# host = "localhost" +# port = 389 +# base_dn = "dc=example,dc=com" +# root_dn = "cn=Manager,dc=example,dc=com" +# root_secret = "SuperS3cret" +# Uncomment to use secured LDAP (ldaps) +# use_ssl = true [MESSAGES] welcome_subject = "Welcome to Flaschengeist {name}" diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 43ef91e..84116a6 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -56,7 +56,7 @@ class User(db.Model, ModelSerializeMixin): display_name: str = db.Column(db.String(30)) firstname: str = db.Column(db.String(50), nullable=False) lastname: str = db.Column(db.String(50), nullable=False) - mail: str = db.Column(db.String(60), nullable=False) + mail: str = db.Column(db.String(60)) birthday: Optional[date] = db.Column(db.Date) roles: list[str] = [] permissions: Optional[list[str]] = None diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index c3ae7c6..f8c6d00 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -1,12 +1,12 @@ """LDAP Authentication Provider Plugin""" import io +import os import ssl from typing import Optional from flask_ldapconn import LDAPConn from flask import current_app as app -from ldap3.utils.hashed import hashed from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError -from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_MD5 +from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from flaschengeist import logger @@ -16,32 +16,36 @@ import flaschengeist.controller.userController as userController class AuthLDAP(AuthPlugin): - def __init__(self, cfg): + def __init__(self, config): super().__init__() - config = {"port": 389, "use_ssl": False} - config.update(cfg) app.config.update( - LDAP_SERVER=config["host"], - LDAP_PORT=config["port"], - LDAP_BINDDN=config["bind_dn"], + LDAP_SERVER=config.get("host", "localhost"), + LDAP_PORT=config.get("port", 389), + LDAP_BINDDN=config.get("bind_dn", None), + LDAP_SECRET=config.get("secret", None), + LDAP_USE_SSL=config.get("use_ssl", False), + # That's not TLS, its dirty StartTLS on unencrypted LDAP LDAP_USE_TLS=False, - LDAP_USE_SSL=config["use_ssl"], - LDAP_TLS_VERSION=ssl.PROTOCOL_TLSv1_2, - LDAP_REQUIRE_CERT=ssl.CERT_NONE, + LDAP_TLS_VERSION=ssl.PROTOCOL_TLS, FORCE_ATTRIBUTE_VALUE_AS_LIST=True, ) - if "secret" in config: - app.config["LDAP_SECRET"] = config["secret"] + logger.warning(app.config.get("LDAP_USE_SSL")) + if "ca_cert" in config: + app.config["LDAP_CA_CERTS_FILE"] = config["ca_cert"] + else: + # Default is CERT_REQUIRED + app.config["LDAP_REQUIRE_CERT"] = ssl.CERT_OPTIONAL self.ldap = LDAPConn(app) - self.dn = config["base_dn"] - self.default_gid = config["default_gid"] + self.base_dn = config["base_dn"] + self.search_dn = config.get("search_dn", "ou=people,{base_dn}").format(base_dn=self.base_dn) + self.group_dn = config.get("group_dn", "ou=group,{base_dn}").format(base_dn=self.base_dn) + self.password_hash = config.get("password_hash", "SSHA").upper() + self.object_classes = config.get("object_classes", ["inetOrgPerson"]) + self.user_attributes: dict = config.get("user_attributes", {}) # TODO: might not be set if modify is called - if "admin_dn" in config: - self.admin_dn = config["admin_dn"] - self.admin_secret = config["admin_secret"] - else: - self.admin_dn = None + self.root_dn = config.get("root_dn", None) + self.root_secret = self.root_dn = config.get("root_secret", None) @after_role_updated def _role_updated(role, new_name): @@ -50,7 +54,7 @@ class AuthLDAP(AuthPlugin): def login(self, user, password): if not user: return False - return self.ldap.authenticate(user.userid, password, "uid", self.dn) + return self.ldap.authenticate(user.userid, password, "uid", self.base_dn) def find_user(self, userid, mail=None): attr = self.__find(userid, mail) @@ -64,42 +68,41 @@ class AuthLDAP(AuthPlugin): self.__update(user, attr) def create_user(self, user, password): - if self.admin_dn is None: - logger.error("admin_dn missing in ldap config!") + if self.root_dn is None: + logger.error("root_dn missing in ldap config!") raise InternalServerError try: - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - self.ldap.connection.search( - "ou=user,{}".format(self.dn), - "(uidNumber=*)", - SUBTREE, - attributes=["uidNumber"], + ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) + attributes = self.user_attributes.copy() + if "uidNumber" in attributes: + self.ldap.connection.search( + self.search_dn, + "(uidNumber=*)", + SUBTREE, + attributes=["uidNumber"], + ) + resp = sorted( + self.ldap.response(), + key=lambda i: i["attributes"]["uidNumber"], + reverse=True, + ) + attributes = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"] + dn = self.dn_template.format( + firstname=user.firstname, + lastname=user.lastname, + userid=user.userid, + mail=user.mail, + display_name=user.display_name, + base_dn=self.base_dn, ) - uid_number = ( - sorted(self.ldap.response(), key=lambda i: i["attributes"]["uidNumber"], reverse=True,)[0][ - "attributes" - ]["uidNumber"] - + 1 - ) - dn = f"cn={user.firstname} {user.lastname},ou=user,{self.dn}" - object_class = [ - "inetOrgPerson", - "posixAccount", - "person", - "organizationalPerson", - ] - attributes = { - "sn": user.firstname, - "givenName": user.lastname, - "gidNumber": self.default_gid, - "homeDirectory": f"/home/{user.userid}", - "loginShell": "/bin/bash", + attributes.update({ + "sn": user.lastname, + "givenName": user.firstname, "uid": user.userid, - "userPassword": hashed(HASHED_SALTED_MD5, password), - "uidNumber": uid_number, - } - ldap_conn.add(dn, object_class, attributes) + "userPassword": self.__hash(password), + }) + ldap_conn.add(dn, self.object_classes, attributes) self._set_roles(user) except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest @@ -110,10 +113,10 @@ class AuthLDAP(AuthPlugin): if password: ldap_conn = self.ldap.connect(dn, password) else: - if self.admin_dn is None: - logger.error("admin_dn missing in ldap config!") + if self.root_dn is None: + logger.error("root_dn missing in ldap config!") raise InternalServerError - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) + ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) modifier = {} for name, ldap_name in [ ("firstname", "givenName"), @@ -124,9 +127,7 @@ class AuthLDAP(AuthPlugin): if hasattr(user, name): modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])] if new_password: - # TODO: Use secure hash! - salted_password = hashed(HASHED_SALTED_MD5, new_password) - modifier["userPassword"] = [(MODIFY_REPLACE, [salted_password])] + modifier["userPassword"] = [(MODIFY_REPLACE, [self.__hash(new_password)])] ldap_conn.modify(dn, modifier) self._set_roles(user) except (LDAPPasswordIsMandatoryError, LDAPBindError): @@ -134,7 +135,7 @@ class AuthLDAP(AuthPlugin): def get_avatar(self, user): self.ldap.connection.search( - "ou=user,{}".format(self.dn), + self.search_dn, "(uid={})".format(user.userid), SUBTREE, attributes=["jpegPhoto"], @@ -150,8 +151,8 @@ class AuthLDAP(AuthPlugin): raise NotFound def set_avatar(self, user, avatar: _Avatar): - if self.admin_dn is None: - logger.error("admin_dn missing in ldap config!") + if self.root_dn is None: + logger.error("root_dn missing in ldap config!") raise InternalServerError if avatar.mimetype != "image/jpeg": @@ -172,16 +173,16 @@ class AuthLDAP(AuthPlugin): raise BadRequest("Unsupported image format") dn = user.get_attribute("DN") - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) + ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]}) def __find(self, userid, mail=None): """Find attributes of an user by uid or mail in LDAP""" con = self.ldap.connection if not con: - con = self.ldap.connect(self.admin_dn, self.admin_secret) + con = self.ldap.connect(self.root_dn, self.root_secret) con.search( - f"ou=user,{self.dn}", + self.search_dn, f"(| (uid={userid})(mail={mail}))" if mail else f"(uid={userid})", SUBTREE, attributes=["uid", "givenName", "sn", "mail"], @@ -205,12 +206,12 @@ class AuthLDAP(AuthPlugin): role: Role, new_name: Optional[str], ): - if self.admin_dn is None: - logger.error("admin_dn missing in ldap config!") + if self.root_dn is None: + logger.error("root_dn missing in ldap config!") raise InternalServerError try: - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - ldap_conn.search(f"ou=group,{self.dn}", f"(cn={role.name})", SUBTREE, attributes=["cn"]) + ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) + ldap_conn.search(self.group_dn, f"(cn={role.name})", SUBTREE, attributes=["cn"]) if len(ldap_conn.response) > 0: dn = ldap_conn.response[0]["dn"] if new_name: @@ -221,10 +222,27 @@ class AuthLDAP(AuthPlugin): except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest + def __hash(self, password): + if self.password_hash == "ARGON2": + from argon2 import PasswordHasher + + return f"{{ARGON2}}{PasswordHasher().hash(password)}" + else: + from hashlib import pbkdf2_hmac, sha1 + import base64 + + salt = os.urandom(16) + if self.password_hash == "PBKDF2": + rounds = 200000 + password_hash = base64.b64encode(pbkdf2_hmac("sha512", password.encode("utf-8"), salt, rounds)).decode() + return f"{{PBKDF2-SHA512}}{rounds}${base64.b64encode(salt).decode()}${password_hash}" + else: + return f"{{SSHA}}{base64.b64encode(sha1(password + salt) + salt)}" + def _get_groups(self, uid): groups = [] self.ldap.connection.search( - "ou=group,{}".format(self.dn), + self.group_dn, "(memberUID={})".format(uid), SUBTREE, attributes=["cn"], @@ -236,7 +254,7 @@ class AuthLDAP(AuthPlugin): def _get_all_roles(self): self.ldap.connection.search( - f"ou=group,{self.dn}", + self.group_dn, "(cn=*)", SUBTREE, attributes=["cn", "gidNumber", "memberUid"], @@ -245,8 +263,7 @@ class AuthLDAP(AuthPlugin): def _set_roles(self, user: User): try: - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - + ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) ldap_roles = self._get_all_roles() gid_numbers = sorted(ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True) @@ -255,7 +272,7 @@ class AuthLDAP(AuthPlugin): for user_role in user.roles: if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]: ldap_conn.add( - f"cn={user_role},ou=group,{self.dn}", + f"cn={user_role},{self.group_dn}", ["posixGroup"], attributes={"gidNumber": gid_number}, ) diff --git a/setup.py b/setup.py index d7bfd18..7860856 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,12 @@ setup( "werkzeug", mysql_driver, ], - extras_require={"ldap": ["flask_ldapconn", "ldap3"], "pricelist": ["pillow"], "test": ["pytest", "coverage"]}, + extras_require={ + "ldap": ["flask_ldapconn", "ldap3"], + "argon": ["argon2-cffi"], + "pricelist": ["pillow"], + "test": ["pytest", "coverage"], + }, entry_points={ "flaschengeist.plugin": [ # Authentication providers