Compare commits

...

4 Commits

Author SHA1 Message Date
Ferdinand Thiessen 06b38b8231 [auth_ldap] modify_role has to be called before the update to change it on the backend 2021-07-29 17:18:44 +02:00
Ferdinand Thiessen 84f085f357 [auth_ldap] Fix typo in __init__ 2021-07-29 17:18:44 +02:00
Ferdinand Thiessen eaeb103e93 [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
2021-07-29 17:18:44 +02:00
Ferdinand Thiessen 7129469835 [roles] MySQL is caseinsensitive for strings so workaround it for renaming roles 2021-07-29 17:18:01 +02:00
6 changed files with 124 additions and 92 deletions

View File

@ -2,7 +2,7 @@ from sqlalchemy.exc import IntegrityError
from werkzeug.exceptions import BadRequest, NotFound from werkzeug.exceptions import BadRequest, NotFound
from flaschengeist.models.user import Role, Permission from flaschengeist.models.user import Role, Permission
from flaschengeist.database import db from flaschengeist.database import db, case_sensitive
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.utils.hook import Hook from flaschengeist.utils.hook import Hook
@ -36,8 +36,8 @@ def update_role(role, new_name):
except IntegrityError: except IntegrityError:
logger.debug("IntegrityError: Role might still be in use", exc_info=True) logger.debug("IntegrityError: Role might still be in use", exc_info=True)
raise BadRequest("Role still in use") raise BadRequest("Role still in use")
elif role.name != new_name: else:
if db.session.query(db.exists().where(Role.name == new_name)).scalar(): if role.name == new_name or db.session.query(db.exists().where(Role.name == case_sensitive(new_name))).scalar():
raise BadRequest("Name already used") raise BadRequest("Name already used")
role.name = new_name role.name = new_name
db.session.commit() db.session.commit()

View File

@ -1,3 +1,10 @@
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy() db = SQLAlchemy()
def case_sensitive(s):
if db.session.bind.dialect.name == "mysql":
from sqlalchemy import func
return func.binary(s)
return s

View File

@ -29,17 +29,16 @@ level = "WARNING"
[auth_plain] [auth_plain]
enabled = true enabled = true
#[auth_ldap] [auth_ldap]
# enabled = true # Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap
# host = enabled = false
# port = # host = "localhost"
# bind_dn = # port = 389
# base_dn = # base_dn = "dc=example,dc=com"
# secret = # root_dn = "cn=Manager,dc=example,dc=com"
# use_ssl = # root_secret = "SuperS3cret"
# admin_dn = # Uncomment to use secured LDAP (ldaps)
# admin_dn = # use_ssl = true
# 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), nullable=False) mail: str = db.Column(db.String(60))
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,56 +1,61 @@
"""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, HASHED_SALTED_MD5 from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE
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, after_role_updated from flaschengeist.plugins import AuthPlugin, before_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, cfg): def __init__(self, config):
super().__init__() super().__init__()
config = {"port": 389, "use_ssl": False}
config.update(cfg)
app.config.update( app.config.update(
LDAP_SERVER=config["host"], LDAP_SERVER=config.get("host", "localhost"),
LDAP_PORT=config["port"], LDAP_PORT=config.get("port", 389),
LDAP_BINDDN=config["bind_dn"], 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_TLS=False,
LDAP_USE_SSL=config["use_ssl"], LDAP_TLS_VERSION=ssl.PROTOCOL_TLS,
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,
) )
if "secret" in config: logger.warning(app.config.get("LDAP_USE_SSL"))
app.config["LDAP_SECRET"] = config["secret"] 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.ldap = LDAPConn(app)
self.dn = config["base_dn"] self.base_dn = config["base_dn"]
self.default_gid = config["default_gid"] 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 # TODO: might not be set if modify is called
if "admin_dn" in config: self.root_dn = config.get("root_dn", None)
self.admin_dn = config["admin_dn"] self.root_secret = config.get("root_secret", None)
self.admin_secret = config["admin_secret"]
else:
self.admin_dn = None
@after_role_updated @before_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.dn) return self.ldap.authenticate(user.userid, password, "uid", self.base_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)
@ -64,42 +69,41 @@ 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.admin_dn is None: if self.root_dn is None:
logger.error("admin_dn missing in ldap config!") logger.error("root_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
try: try:
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) 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.ldap.connection.search(
"ou=user,{}".format(self.dn), self.search_dn,
"(uidNumber=*)", "(uidNumber=*)",
SUBTREE, SUBTREE,
attributes=["uidNumber"], attributes=["uidNumber"],
) )
uid_number = ( resp = sorted(
sorted(self.ldap.response(), key=lambda i: i["attributes"]["uidNumber"], reverse=True,)[0][ self.ldap.response(),
"attributes" key=lambda i: i["attributes"]["uidNumber"],
]["uidNumber"] reverse=True,
+ 1
) )
dn = f"cn={user.firstname} {user.lastname},ou=user,{self.dn}" attributes = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"]
object_class = [ dn = self.dn_template.format(
"inetOrgPerson", firstname=user.firstname,
"posixAccount", lastname=user.lastname,
"person", userid=user.userid,
"organizationalPerson", mail=user.mail,
] display_name=user.display_name,
attributes = { base_dn=self.base_dn,
"sn": user.firstname, )
"givenName": user.lastname, attributes.update({
"gidNumber": self.default_gid, "sn": user.lastname,
"homeDirectory": f"/home/{user.userid}", "givenName": user.firstname,
"loginShell": "/bin/bash",
"uid": user.userid, "uid": user.userid,
"userPassword": hashed(HASHED_SALTED_MD5, password), "userPassword": self.__hash(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
@ -110,10 +114,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.admin_dn is None: if self.root_dn is None:
logger.error("admin_dn missing in ldap config!") logger.error("root_dn missing in ldap config!")
raise InternalServerError 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 = {} modifier = {}
for name, ldap_name in [ for name, ldap_name in [
("firstname", "givenName"), ("firstname", "givenName"),
@ -124,9 +128,7 @@ 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:
# TODO: Use secure hash! modifier["userPassword"] = [(MODIFY_REPLACE, [self.__hash(new_password)])]
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):
@ -134,7 +136,7 @@ class AuthLDAP(AuthPlugin):
def get_avatar(self, user): def get_avatar(self, user):
self.ldap.connection.search( self.ldap.connection.search(
"ou=user,{}".format(self.dn), self.search_dn,
"(uid={})".format(user.userid), "(uid={})".format(user.userid),
SUBTREE, SUBTREE,
attributes=["jpegPhoto"], attributes=["jpegPhoto"],
@ -150,8 +152,8 @@ class AuthLDAP(AuthPlugin):
raise NotFound raise NotFound
def set_avatar(self, user, avatar: _Avatar): def set_avatar(self, user, avatar: _Avatar):
if self.admin_dn is None: if self.root_dn is None:
logger.error("admin_dn missing in ldap config!") logger.error("root_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
if avatar.mimetype != "image/jpeg": if avatar.mimetype != "image/jpeg":
@ -172,16 +174,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.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])]}) 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.admin_dn, self.admin_secret) con = self.ldap.connect(self.root_dn, self.root_secret)
con.search( con.search(
f"ou=user,{self.dn}", self.search_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"],
@ -205,12 +207,12 @@ class AuthLDAP(AuthPlugin):
role: Role, role: Role,
new_name: Optional[str], new_name: Optional[str],
): ):
if self.admin_dn is None: if self.root_dn is None:
logger.error("admin_dn missing in ldap config!") logger.error("root_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
try: try:
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_conn.search(f"ou=group,{self.dn}", f"(cn={role.name})", SUBTREE, attributes=["cn"]) ldap_conn.search(self.group_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:
@ -218,13 +220,33 @@ class AuthLDAP(AuthPlugin):
else: else:
ldap_conn.delete(dn) ldap_conn.delete(dn)
except (LDAPPasswordIsMandatoryError, LDAPBindError): except LDAPPasswordIsMandatoryError:
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(
"ou=group,{}".format(self.dn), self.group_dn,
"(memberUID={})".format(uid), "(memberUID={})".format(uid),
SUBTREE, SUBTREE,
attributes=["cn"], attributes=["cn"],
@ -236,7 +258,7 @@ class AuthLDAP(AuthPlugin):
def _get_all_roles(self): def _get_all_roles(self):
self.ldap.connection.search( self.ldap.connection.search(
f"ou=group,{self.dn}", self.group_dn,
"(cn=*)", "(cn=*)",
SUBTREE, SUBTREE,
attributes=["cn", "gidNumber", "memberUid"], attributes=["cn", "gidNumber", "memberUid"],
@ -245,8 +267,7 @@ class AuthLDAP(AuthPlugin):
def _set_roles(self, user: User): def _set_roles(self, user: User):
try: 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() 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)
@ -255,7 +276,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},ou=group,{self.dn}", f"cn={user_role},{self.group_dn}",
["posixGroup"], ["posixGroup"],
attributes={"gidNumber": gid_number}, attributes={"gidNumber": gid_number},
) )

View File

@ -41,7 +41,12 @@ setup(
"werkzeug", "werkzeug",
mysql_driver, 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={ entry_points={
"flaschengeist.plugin": [ "flaschengeist.plugin": [
# Authentication providers # Authentication providers