Compare commits

..

No commits in common. "5ef603ee50bf4df83bf1c0199b9139568a180ac3" and "f7e07fdadec4ffdd0490ff08a5dfd033e7e1badc" have entirely different histories.

4 changed files with 89 additions and 114 deletions

View File

@ -29,16 +29,17 @@ level = "WARNING"
[auth_plain] [auth_plain]
enabled = true enabled = true
[auth_ldap] #[auth_ldap]
# Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap # enabled = true
enabled = false # host =
# host = "localhost" # port =
# port = 389 # bind_dn =
# base_dn = "dc=example,dc=com" # base_dn =
# root_dn = "cn=Manager,dc=example,dc=com" # secret =
# root_secret = "SuperS3cret" # use_ssl =
# Uncomment to use secured LDAP (ldaps) # admin_dn =
# use_ssl = true # admin_dn =
# default_gid =
[MESSAGES] [MESSAGES]
welcome_subject = "Welcome to Flaschengeist {name}" welcome_subject = "Welcome to Flaschengeist {name}"

View File

@ -56,7 +56,7 @@ class User(db.Model, ModelSerializeMixin):
display_name: str = db.Column(db.String(30)) display_name: str = db.Column(db.String(30))
firstname: str = db.Column(db.String(50), nullable=False) firstname: str = db.Column(db.String(50), nullable=False)
lastname: str = db.Column(db.String(50), nullable=False) lastname: str = db.Column(db.String(50), nullable=False)
mail: str = db.Column(db.String(60)) mail: str = db.Column(db.String(60), nullable=False)
birthday: Optional[date] = db.Column(db.Date) birthday: Optional[date] = db.Column(db.Date)
roles: list[str] = [] roles: list[str] = []
permissions: Optional[list[str]] = None permissions: Optional[list[str]] = None

View File

@ -1,61 +1,56 @@
"""LDAP Authentication Provider Plugin""" """LDAP Authentication Provider Plugin"""
import io import io
import os
import ssl import ssl
from typing import Optional from typing import Optional
from flask_ldapconn import LDAPConn from flask_ldapconn import LDAPConn
from flask import current_app as app from flask import current_app as app
from ldap3.utils.hashed import hashed
from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError
from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_MD5
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.plugins import AuthPlugin, before_role_updated from flaschengeist.plugins import AuthPlugin, after_role_updated
from flaschengeist.models.user import User, Role, _Avatar from flaschengeist.models.user import User, Role, _Avatar
import flaschengeist.controller.userController as userController import flaschengeist.controller.userController as userController
class AuthLDAP(AuthPlugin): class AuthLDAP(AuthPlugin):
def __init__(self, config): def __init__(self, cfg):
super().__init__() super().__init__()
config = {"port": 389, "use_ssl": False}
config.update(cfg)
app.config.update( app.config.update(
LDAP_SERVER=config.get("host", "localhost"), LDAP_SERVER=config["host"],
LDAP_PORT=config.get("port", 389), LDAP_PORT=config["port"],
LDAP_BINDDN=config.get("bind_dn", None), LDAP_BINDDN=config["bind_dn"],
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_TLS=False,
LDAP_TLS_VERSION=ssl.PROTOCOL_TLS, LDAP_USE_SSL=config["use_ssl"],
LDAP_TLS_VERSION=ssl.PROTOCOL_TLSv1_2,
LDAP_REQUIRE_CERT=ssl.CERT_NONE,
FORCE_ATTRIBUTE_VALUE_AS_LIST=True, FORCE_ATTRIBUTE_VALUE_AS_LIST=True,
) )
logger.warning(app.config.get("LDAP_USE_SSL")) if "secret" in config:
if "ca_cert" in config: app.config["LDAP_SECRET"] = config["secret"]
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.ldap = LDAPConn(app)
self.base_dn = config["base_dn"] self.dn = config["base_dn"]
self.search_dn = config.get("search_dn", "ou=people,{base_dn}").format(base_dn=self.base_dn) self.default_gid = config["default_gid"]
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 # TODO: might not be set if modify is called
self.root_dn = config.get("root_dn", None) if "admin_dn" in config:
self.root_secret = config.get("root_secret", None) self.admin_dn = config["admin_dn"]
self.admin_secret = config["admin_secret"]
else:
self.admin_dn = None
@before_role_updated @after_role_updated
def _role_updated(role, new_name): def _role_updated(role, new_name):
logger.debug(f"LDAP: before_role_updated called with ({role}, {new_name})")
self.__modify_role(role, new_name) self.__modify_role(role, new_name)
def login(self, user, password): def login(self, user, password):
if not user: if not user:
return False return False
return self.ldap.authenticate(user.userid, password, "uid", self.base_dn) return self.ldap.authenticate(user.userid, password, "uid", self.dn)
def find_user(self, userid, mail=None): def find_user(self, userid, mail=None):
attr = self.__find(userid, mail) attr = self.__find(userid, mail)
@ -69,41 +64,42 @@ class AuthLDAP(AuthPlugin):
self.__update(user, attr) self.__update(user, attr)
def create_user(self, user, password): def create_user(self, user, password):
if self.root_dn is None: if self.admin_dn is None:
logger.error("root_dn missing in ldap config!") logger.error("admin_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
try: try:
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
attributes = self.user_attributes.copy()
if "uidNumber" in attributes:
self.ldap.connection.search( self.ldap.connection.search(
self.search_dn, "ou=user,{}".format(self.dn),
"(uidNumber=*)", "(uidNumber=*)",
SUBTREE, SUBTREE,
attributes=["uidNumber"], attributes=["uidNumber"],
) )
resp = sorted( uid_number = (
self.ldap.response(), sorted(self.ldap.response(), key=lambda i: i["attributes"]["uidNumber"], reverse=True,)[0][
key=lambda i: i["attributes"]["uidNumber"], "attributes"
reverse=True, ]["uidNumber"]
+ 1
) )
attributes = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"] dn = f"cn={user.firstname} {user.lastname},ou=user,{self.dn}"
dn = self.dn_template.format( object_class = [
firstname=user.firstname, "inetOrgPerson",
lastname=user.lastname, "posixAccount",
userid=user.userid, "person",
mail=user.mail, "organizationalPerson",
display_name=user.display_name, ]
base_dn=self.base_dn, attributes = {
) "sn": user.firstname,
attributes.update({ "givenName": user.lastname,
"sn": user.lastname, "gidNumber": self.default_gid,
"givenName": user.firstname, "homeDirectory": f"/home/{user.userid}",
"loginShell": "/bin/bash",
"uid": user.userid, "uid": user.userid,
"userPassword": self.__hash(password), "userPassword": hashed(HASHED_SALTED_MD5, password),
}) "uidNumber": uid_number,
ldap_conn.add(dn, self.object_classes, attributes) }
ldap_conn.add(dn, object_class, attributes)
self._set_roles(user) self._set_roles(user)
except (LDAPPasswordIsMandatoryError, LDAPBindError): except (LDAPPasswordIsMandatoryError, LDAPBindError):
raise BadRequest raise BadRequest
@ -114,10 +110,10 @@ class AuthLDAP(AuthPlugin):
if password: if password:
ldap_conn = self.ldap.connect(dn, password) ldap_conn = self.ldap.connect(dn, password)
else: else:
if self.root_dn is None: if self.admin_dn is None:
logger.error("root_dn missing in ldap config!") logger.error("admin_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
modifier = {} modifier = {}
for name, ldap_name in [ for name, ldap_name in [
("firstname", "givenName"), ("firstname", "givenName"),
@ -128,7 +124,9 @@ class AuthLDAP(AuthPlugin):
if hasattr(user, name): if hasattr(user, name):
modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])] modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])]
if new_password: if new_password:
modifier["userPassword"] = [(MODIFY_REPLACE, [self.__hash(new_password)])] # TODO: Use secure hash!
salted_password = hashed(HASHED_SALTED_MD5, new_password)
modifier["userPassword"] = [(MODIFY_REPLACE, [salted_password])]
ldap_conn.modify(dn, modifier) ldap_conn.modify(dn, modifier)
self._set_roles(user) self._set_roles(user)
except (LDAPPasswordIsMandatoryError, LDAPBindError): except (LDAPPasswordIsMandatoryError, LDAPBindError):
@ -136,7 +134,7 @@ class AuthLDAP(AuthPlugin):
def get_avatar(self, user): def get_avatar(self, user):
self.ldap.connection.search( self.ldap.connection.search(
self.search_dn, "ou=user,{}".format(self.dn),
"(uid={})".format(user.userid), "(uid={})".format(user.userid),
SUBTREE, SUBTREE,
attributes=["jpegPhoto"], attributes=["jpegPhoto"],
@ -152,8 +150,8 @@ class AuthLDAP(AuthPlugin):
raise NotFound raise NotFound
def set_avatar(self, user, avatar: _Avatar): def set_avatar(self, user, avatar: _Avatar):
if self.root_dn is None: if self.admin_dn is None:
logger.error("root_dn missing in ldap config!") logger.error("admin_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
if avatar.mimetype != "image/jpeg": if avatar.mimetype != "image/jpeg":
@ -174,16 +172,16 @@ class AuthLDAP(AuthPlugin):
raise BadRequest("Unsupported image format") raise BadRequest("Unsupported image format")
dn = user.get_attribute("DN") dn = user.get_attribute("DN")
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]}) ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]})
def __find(self, userid, mail=None): def __find(self, userid, mail=None):
"""Find attributes of an user by uid or mail in LDAP""" """Find attributes of an user by uid or mail in LDAP"""
con = self.ldap.connection con = self.ldap.connection
if not con: if not con:
con = self.ldap.connect(self.root_dn, self.root_secret) con = self.ldap.connect(self.admin_dn, self.admin_secret)
con.search( con.search(
self.search_dn, f"ou=user,{self.dn}",
f"(| (uid={userid})(mail={mail}))" if mail else f"(uid={userid})", f"(| (uid={userid})(mail={mail}))" if mail else f"(uid={userid})",
SUBTREE, SUBTREE,
attributes=["uid", "givenName", "sn", "mail"], attributes=["uid", "givenName", "sn", "mail"],
@ -207,12 +205,12 @@ class AuthLDAP(AuthPlugin):
role: Role, role: Role,
new_name: Optional[str], new_name: Optional[str],
): ):
if self.root_dn is None: if self.admin_dn is None:
logger.error("root_dn missing in ldap config!") logger.error("admin_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
try: try:
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
ldap_conn.search(self.group_dn, f"(cn={role.name})", SUBTREE, attributes=["cn"]) ldap_conn.search(f"ou=group,{self.dn}", f"(cn={role.name})", SUBTREE, attributes=["cn"])
if len(ldap_conn.response) > 0: if len(ldap_conn.response) > 0:
dn = ldap_conn.response[0]["dn"] dn = ldap_conn.response[0]["dn"]
if new_name: if new_name:
@ -220,33 +218,13 @@ class AuthLDAP(AuthPlugin):
else: else:
ldap_conn.delete(dn) ldap_conn.delete(dn)
except LDAPPasswordIsMandatoryError: except (LDAPPasswordIsMandatoryError, LDAPBindError):
raise BadRequest raise BadRequest
except LDAPBindError:
logger.debug(f"Could not bind to LDAP server", exc_info=True)
raise InternalServerError
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): def _get_groups(self, uid):
groups = [] groups = []
self.ldap.connection.search( self.ldap.connection.search(
self.group_dn, "ou=group,{}".format(self.dn),
"(memberUID={})".format(uid), "(memberUID={})".format(uid),
SUBTREE, SUBTREE,
attributes=["cn"], attributes=["cn"],
@ -258,7 +236,7 @@ class AuthLDAP(AuthPlugin):
def _get_all_roles(self): def _get_all_roles(self):
self.ldap.connection.search( self.ldap.connection.search(
self.group_dn, f"ou=group,{self.dn}",
"(cn=*)", "(cn=*)",
SUBTREE, SUBTREE,
attributes=["cn", "gidNumber", "memberUid"], attributes=["cn", "gidNumber", "memberUid"],
@ -267,7 +245,8 @@ class AuthLDAP(AuthPlugin):
def _set_roles(self, user: User): def _set_roles(self, user: User):
try: try:
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
ldap_roles = self._get_all_roles() ldap_roles = self._get_all_roles()
gid_numbers = sorted(ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True) gid_numbers = sorted(ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True)
@ -276,7 +255,7 @@ class AuthLDAP(AuthPlugin):
for user_role in user.roles: for user_role in user.roles:
if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]: if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]:
ldap_conn.add( ldap_conn.add(
f"cn={user_role},{self.group_dn}", f"cn={user_role},ou=group,{self.dn}",
["posixGroup"], ["posixGroup"],
attributes={"gidNumber": gid_number}, attributes={"gidNumber": gid_number},
) )

View File

@ -41,12 +41,7 @@ setup(
"werkzeug", "werkzeug",
mysql_driver, mysql_driver,
], ],
extras_require={ extras_require={"ldap": ["flask_ldapconn", "ldap3"], "pricelist": ["pillow"], "test": ["pytest", "coverage"]},
"ldap": ["flask_ldapconn", "ldap3"],
"argon": ["argon2-cffi"],
"pricelist": ["pillow"],
"test": ["pytest", "coverage"],
},
entry_points={ entry_points={
"flaschengeist.plugin": [ "flaschengeist.plugin": [
# Authentication providers # Authentication providers