Compare commits
	
		
			6 Commits
		
	
	
		
			06b38b8231
			...
			a094edf6cd
		
	
	| Author | SHA1 | Date | 
|---|---|---|
|  | a094edf6cd | |
|  | 2dc511db11 | |
|  | 459b61aa70 | |
|  | cadde543f2 | |
|  | 4e46ea1ca3 | |
|  | 0d1a39f217 | 
|  | @ -54,6 +54,8 @@ def __load_plugins(app): | |||
|                 logger.error( | ||||
|                     f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", exc_info=True | ||||
|                 ) | ||||
|                 del plugin | ||||
|                 continue | ||||
|         if isinstance(plugin, AuthPlugin): | ||||
|             logger.debug(f"Found authentication plugin: {entry_point.name}") | ||||
|             if entry_point.name == config["FLASCHENGEIST"]["auth"]: | ||||
|  | @ -65,6 +67,7 @@ def __load_plugins(app): | |||
|             app.config["FG_PLUGINS"][entry_point.name] = plugin | ||||
|     if "FG_AUTH_BACKEND" not in app.config: | ||||
|         logger.error("No authentication plugin configured or authentication plugin not found") | ||||
|         raise RuntimeError("No authentication plugin configured or authentication plugin not found") | ||||
| 
 | ||||
| 
 | ||||
| def install_all(): | ||||
|  |  | |||
|  | @ -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}" | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -1,56 +1,61 @@ | |||
| """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 | ||||
| from flaschengeist.plugins import AuthPlugin, after_role_updated | ||||
| from flaschengeist.plugins import AuthPlugin, before_role_updated | ||||
| from flaschengeist.models.user import User, Role, _Avatar | ||||
| 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 = config.get("root_secret", None) | ||||
| 
 | ||||
|         @after_role_updated | ||||
|         @before_role_updated | ||||
|         def _role_updated(role, new_name): | ||||
|             logger.debug(f"LDAP: before_role_updated called with ({role}, {new_name})") | ||||
|             self.__modify_role(role, new_name) | ||||
| 
 | ||||
|     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 +69,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) | ||||
|             ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) | ||||
|             attributes = self.user_attributes.copy() | ||||
|             if "uidNumber" in attributes: | ||||
|                 self.ldap.connection.search( | ||||
|                 "ou=user,{}".format(self.dn), | ||||
|                     self.search_dn, | ||||
|                     "(uidNumber=*)", | ||||
|                     SUBTREE, | ||||
|                     attributes=["uidNumber"], | ||||
|                 ) | ||||
|             uid_number = ( | ||||
|                 sorted(self.ldap.response(), key=lambda i: i["attributes"]["uidNumber"], reverse=True,)[0][ | ||||
|                     "attributes" | ||||
|                 ]["uidNumber"] | ||||
|                 + 1 | ||||
|                 resp = sorted( | ||||
|                     self.ldap.response(), | ||||
|                     key=lambda i: i["attributes"]["uidNumber"], | ||||
|                     reverse=True, | ||||
|                 ) | ||||
|             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 = 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, | ||||
|             ) | ||||
|             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 +114,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 +128,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 +136,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 +152,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 +174,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 +207,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: | ||||
|  | @ -218,13 +220,33 @@ class AuthLDAP(AuthPlugin): | |||
|                 else: | ||||
|                     ldap_conn.delete(dn) | ||||
| 
 | ||||
|         except (LDAPPasswordIsMandatoryError, LDAPBindError): | ||||
|         except LDAPPasswordIsMandatoryError: | ||||
|             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): | ||||
|         groups = [] | ||||
|         self.ldap.connection.search( | ||||
|             "ou=group,{}".format(self.dn), | ||||
|             self.group_dn, | ||||
|             "(memberUID={})".format(uid), | ||||
|             SUBTREE, | ||||
|             attributes=["cn"], | ||||
|  | @ -236,7 +258,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 +267,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 +276,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}, | ||||
|                     ) | ||||
|  |  | |||
|  | @ -113,7 +113,9 @@ def get_transaction(transaction_id) -> Transaction: | |||
|     return transaction | ||||
| 
 | ||||
| 
 | ||||
| def get_transactions(user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True): | ||||
| def get_transactions( | ||||
|     user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True, descending=False | ||||
| ): | ||||
|     count = None | ||||
|     query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user)) | ||||
|     if start: | ||||
|  | @ -125,6 +127,9 @@ def get_transactions(user, start=None, end=None, limit=None, offset=None, show_r | |||
|         query = query.filter(Transaction.original_ == None) | ||||
|     if not show_cancelled: | ||||
|         query = query.filter(Transaction.reversal_id.is_(None)) | ||||
|     if descending: | ||||
|         query = query.order_by(Transaction.time.desc()) | ||||
|     else: | ||||
|         query = query.order_by(Transaction.time) | ||||
|     if limit is not None: | ||||
|         count = query.count() | ||||
|  |  | |||
|  | @ -99,6 +99,32 @@ def set_limit(userid, current_session: Session): | |||
|     return HTTP.no_content() | ||||
| 
 | ||||
| 
 | ||||
| @BalancePlugin.blueprint.route("/users/balance/limit", methods=["GET", "PUT"]) | ||||
| @login_required(permission=permissions.SET_LIMIT) | ||||
| def limits(current_session: Session): | ||||
|     """Get, Modify limit of all users | ||||
| 
 | ||||
|     Args: | ||||
|         current_ession: Session sent with Authorization Header | ||||
| 
 | ||||
|     Returns: | ||||
|         JSON encoded array of userid with limit or HTTP-error | ||||
|     """ | ||||
| 
 | ||||
|     users = userController.get_users() | ||||
|     if request.method == "GET": | ||||
|         return jsonify([{"userid": user.userid, "limit": user.get_attribute("balance_limit")} for user in users]) | ||||
| 
 | ||||
|     data = request.get_json() | ||||
|     try: | ||||
|         limit = data["limit"] | ||||
|     except (TypeError, KeyError): | ||||
|         raise BadRequest | ||||
|     for user in users: | ||||
|         balance_controller.set_limit(user, limit) | ||||
|     return HTTP.no_content() | ||||
| 
 | ||||
| 
 | ||||
| @BalancePlugin.blueprint.route("/users/<userid>/balance", methods=["GET"]) | ||||
| @login_required(permission=permissions.SHOW) | ||||
| def get_balance(userid, current_session: Session): | ||||
|  | @ -170,6 +196,7 @@ def get_transactions(userid, current_session: Session): | |||
|     show_cancelled = request.args.get("showCancelled", True) | ||||
|     limit = request.args.get("limit") | ||||
|     offset = request.args.get("offset") | ||||
|     descending = request.args.get("descending", False) | ||||
|     try: | ||||
|         if limit is not None: | ||||
|             limit = int(limit) | ||||
|  | @ -179,11 +206,20 @@ def get_transactions(userid, current_session: Session): | |||
|             show_reversals = str2bool(show_reversals) | ||||
|         if not isinstance(show_cancelled, bool): | ||||
|             show_cancelled = str2bool(show_cancelled) | ||||
|         if not isinstance(descending, bool): | ||||
|             descending = str2bool(descending) | ||||
|     except ValueError: | ||||
|         raise BadRequest | ||||
| 
 | ||||
|     transactions, count = balance_controller.get_transactions( | ||||
|         user, start, end, limit, offset, show_reversal=show_reversals, show_cancelled=show_cancelled | ||||
|         user, | ||||
|         start, | ||||
|         end, | ||||
|         limit, | ||||
|         offset, | ||||
|         show_reversal=show_reversals, | ||||
|         show_cancelled=show_cancelled, | ||||
|         descending=descending, | ||||
|     ) | ||||
|     return {"transactions": transactions, "count": count} | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										7
									
								
								setup.py
								
								
								
								
							
							
						
						
									
										7
									
								
								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 | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue