|
|
|
@ -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},
|
|
|
|
|
)
|
|
|
|
|