From 8a316ead216847c0c0489d1175a6258b833fedf3 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Thu, 5 Jun 2025 20:48:14 +0200 Subject: [PATCH] auth: Add LDAP support Signed-off-by: Jan Lindemann --- tools/python/jwutils/auth/Auth.py | 99 ++++++++++++--- tools/python/jwutils/auth/dummy/Auth.py | 49 ++++++-- tools/python/jwutils/auth/ldap/Auth.py | 160 ++++++++++++++++++++++++ tools/python/jwutils/auth/ldap/Makefile | 4 + 4 files changed, 283 insertions(+), 29 deletions(-) create mode 100644 tools/python/jwutils/auth/ldap/Auth.py create mode 100644 tools/python/jwutils/auth/ldap/Makefile diff --git a/tools/python/jwutils/auth/Auth.py b/tools/python/jwutils/auth/Auth.py index 03763f2..82396b9 100644 --- a/tools/python/jwutils/auth/Auth.py +++ b/tools/python/jwutils/auth/Auth.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- -from typing import Optional, Union +from typing import Optional, Union, Self import abc -from enum import Enum, auto -from jwutils import log, Config +from enum import Flag, Enum, auto + +from jwutils.log import * +from jwutils import Config, load_object class Access(Enum): # export Read = auto() @@ -13,6 +15,11 @@ class Access(Enum): # export Create = auto() Delete = auto() +class ProjectFlags(Flag): # export + NoFlags = auto() + Contributing = auto() + Active = auto() + class Group: # export def __repr__(self): @@ -33,40 +40,94 @@ class User: # export @abc.abstractmethod def _name(self) -> str: - pass - - @abc.abstractmethod - def _groups(self) -> list[Group]: - pass + raise NotImplementedError @property def name(self) -> str: return self._name() + def _display_name(self) -> str: + return self._name() + + @property + def display_name(self) -> str: + return self._display_name() + + @abc.abstractmethod + def _groups(self) -> list[Group]: + raise NotImplementedError + @property def groups(self) -> list[Group]: return self._groups() -class Auth: # export + @abc.abstractmethod + def _email(self) -> str: + raise NotImplementedError + + @property + def email(self) -> str: + return self._email() + +class Auth(abc.ABC): # export + + @classmethod + def load(cls, tp: str, conf: Config) -> Self: + return load_object(f'jwutils.auth.{tp}.Auth', Auth, 'Auth', conf) def __init__(self, conf: Config): self.__conf = conf - - @abc.abstractmethod - def _access(self, what: str, access_type: Optional[Access], who: User|Group|None) -> bool: - pass - - @abc.abstractmethod - def _current_user(self) -> User: - pass + self.__base_user_by_email: Optional[dict[str, User]] = None @property def conf(self): return self.__conf + @abc.abstractmethod + def _access(self, what: str, access_type: Optional[Access], who: User|Group|None) -> bool: + raise NotImplementedError + + def access(self, what: str, access_type: Optional[Access]=None, who: Optional[Union[User|Group]]=None) -> bool: + return self._access(what, access_type, who) + + @abc.abstractmethod + def _current_user(self) -> User: + raise NotImplementedError + @property def current_user(self) -> User: return self._current_user() - def access(self, what: str, access_type: Optional[Access]=None, who: Optional[Union[User|Group]]=None) -> bool: - return self._access(what, access_type, who) + @abc.abstractmethod + def _user(self, name) -> User: + raise NotImplementedError + + def user(self, name) -> User: + return self._user(name) + + @abc.abstractmethod + def _users(self) -> list[User]: + raise NotImplementedError + + def _user_by_email(self, email: str) -> User: + if self.__base_user_by_email is None: + ret: dict[str, User] = dict() + users = self._users() + for user in users.values(): + ret[user.email] = user + self.__base_user_by_email = ret + return self.__base_user_by_email[email] + + def user_by_email(self, email) -> User: + return self._user_by_email(email) + + @property + def users(self) -> list[User]: + return self._users() + + @abc.abstractmethod + def _projects(self, name, flags: ProjectFlags) -> list[str]: + raise NotImplementedError + + def projects(self, name, flags: ProjectFlags) -> list[str]: + return self._projects(name, flags) diff --git a/tools/python/jwutils/auth/dummy/Auth.py b/tools/python/jwutils/auth/dummy/Auth.py index 3850e56..eb31744 100644 --- a/tools/python/jwutils/auth/dummy/Auth.py +++ b/tools/python/jwutils/auth/dummy/Auth.py @@ -8,6 +8,7 @@ from .. import Access from .. import Auth as AuthBase from .. import Group as GroupBase from .. import User as UserBase +from .. import ProjectFlags class Group(GroupBase): # export @@ -18,12 +19,18 @@ class Group(GroupBase): # export def _name(self) -> str: return self.__name -class User(UserBase): +class User(UserBase): # export - def __init__(self, auth: AuthBase, name: str): + def __init__(self, auth: AuthBase, name: str, conf: Config): self.__name = name + self.__conf = conf self.__auth = auth self.__groups: Optional[list[GroupBase]] = None + self.__email = conf.get('email') + + @property + def conf(self): + return self.__conf def _name(self) -> str: return self.__name @@ -32,32 +39,54 @@ class User(UserBase): if self.__groups is None: name: str = '' ret: list[GroupBase] = [] - for name in self.__auth.conf['user.' + name + '.groups']: + for name in self.conf['groups']: ret.append(Group(self.__auth, name)) self.__groups = ret return self.__groups + def _email(self) -> str: + return self.__email + class Auth(AuthBase): # export def __init__(self, conf: Config): - self.__conf = conf - self.__users: Optional[dict[str, User]] = None + super().__init__(conf) + self.___users: Optional[dict[str, User]] = None self.__groups = None self.__current_user: User|None = None - def _user(self, name_) -> User: - if self.__users is None: + @property + def __users(self) -> User: + if self.___users is None: ret: dict[str, User] = {} for name in self.conf.entries('user'): - ret[name] = User(self, name) - self.__users = ret - return self.__users[name_] + conf = self.conf.branch('user.' + name) + ret[name] = User(self, name, conf) + self.___users = ret + return self.___users def _access(self, what: str, access_type: Optional[Access], who: User|GroupBase|None) -> bool: # type: ignore slog(WARNING, f'Returning False for {access_type} access to resource {what} by {who}') return False + def _user(self, name) -> User: + return self.__users[name] + + def _users(self) -> list[User]: + return self.__users + def _current_user(self) -> User: if self.__current_user is None: self.__current_user = self._user(self.conf['current_user']) return self.__current_user + + def _user_by_email(self, email: str) -> User: + if self.__user_by_email is None: + ret: dict[str, User] = dict() + for user in self.__users.values(): + ret[user.email] = user + self.__user_by_email = ret + return self.__user_by_email[email] + + def _projects(self, name, flags: ProjectFlags) -> list[str]: + return None diff --git a/tools/python/jwutils/auth/ldap/Auth.py b/tools/python/jwutils/auth/ldap/Auth.py new file mode 100644 index 0000000..e4c398a --- /dev/null +++ b/tools/python/jwutils/auth/ldap/Auth.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- + +from typing import Optional, Union + +import ldap + +from ...log import * +from ... import Config +from .. import Access +from .. import Auth as AuthBase +from .. import Group as GroupBase +from .. import User as UserBase +from .. import ProjectFlags + +class Group(GroupBase): # export + + def __init__(self, auth: AuthBase, name: str): + self.__name = name + self.__auth = auth + + def _name(self) -> str: + return self.__name + +class User(UserBase): + + def __init__( + self, + auth: AuthBase, + name: str, + cn: str, + email: str + ): + + self.__auth = auth + self.__name = name + self.__cn = cn + self.__email = email + self.__groups: Optional[list[GroupBase]] = None + + def _name(self) -> str: + return self.__name + + def _groups(self) -> list[GroupBase]: + raise NotImplementedError + + def _email(self) -> str: + return self.__email + + def _display_name(self) -> str: + return self.__cn + +class Auth(AuthBase): # export + + def __init__(self, conf: Config): + super().__init__(conf) + self.___users: Optional[dict[str, User]] = None + self.___user_by_email: Optional[dict[str, User]] = None + self.__groups = None + self.__current_user: User|None = None + self.__user_base_dn = conf['user_base_dn'] + self.__conn = self.__bind() + self.__dummy = self.load('dummy', conf) + + def __bind(self): + ldap_uri = self.conf['ldap_uri'] + bind_dn = self.conf['bind_dn'] + bind_pw = self.conf.get('password') + if bind_pw is None: + with open(ldap_secret_file, 'r') as file: + bind_pw = file.read() + file.closed + bind_pw = bind_pw.strip() + ret = ldap.initialize(ldap_uri) + ret.start_tls_s() + try: + rr = ret.bind_s(bind_dn, bind_pw) # method) + except Exception as e: + #pw = f' (pw={bind_pw})' + raise Exception(f'Failed to bind to {ldap_uri} with dn {bind_dn} ({e})') + return ret + + @property + def __users(self) -> User: + if self.___users is None: + ret: dict[str, User] = {} + ret_by_email: dict[str, User] = {} + ldap_result_id = self.__conn.search( + self.__user_base_dn, + ldap.SCOPE_SUBTREE, + "objectClass=inetOrgPerson", + ('uid', 'cn', 'uidNumber', 'mail', 'maildrop') + ) + while True: + result_type, result_data = self.__conn.result(ldap_result_id, 0) + if (result_data == []): + break + if result_type != ldap.RES_SEARCH_ENTRY: + continue + for res in result_data: + try: + display_name = None + if 'displayName' in res[1]: + cn = res[1]['displayName'][0].decode('utf-8') + else: + cn = res[1]['cn'][0].decode('utf-8') + uid = res[1]['uid'][0].decode('utf-8') + uidNumber = res[1]['uidNumber'][0].decode('utf-8') + emails = [] + #for attr in ['mail', 'maildrop']: + for attr in ['mail']: + if attr in res[1]: + for entry in res[1][attr]: + emails.append(entry.decode('utf-8')) + if not emails: + slog(DEBUG, f'No email for user "{uid}", skipping') + continue + user = User(self, name=uid, cn=cn, email=emails[0]) + ret[uid] = user + for email in emails: + ret_by_email[email] = user + except Exception as e: + slog(WARNING, f'Exception {e}') + continue + for user in self.__dummy.users.values(): + ret[user.name] = user + self.___users = ret + self.___user_by_email = ret_by_email + return self.___users + + @property + def __user_by_email(self) -> User: + if self.___user_by_email is None: + self.__users + return self.___user_by_email + + def _access(self, what: str, access_type: Optional[Access], who: User|GroupBase|None) -> bool: # type: ignore + slog(WARNING, f'Returning False for {access_type} access to resource {what} by {who}') + return False + + def _user(self, name) -> User: + try: + return self.__users[name] + except: + slog(ERR, f'No such user: "{name}"') + raise + + def _user_by_email(self, email: str) -> User: + return self.__user_by_email[email] + + def _current_user(self) -> User: + raise NotImplementedError + + def _users(self) -> list[User]: + return self.__users + + def _projects(self, name, flags: ProjectFlags) -> list[str]: + if flags & ProjectFlags.Contributing: + # TODO: Ask LDAP + pass + return None diff --git a/tools/python/jwutils/auth/ldap/Makefile b/tools/python/jwutils/auth/ldap/Makefile new file mode 100644 index 0000000..781b0c8 --- /dev/null +++ b/tools/python/jwutils/auth/ldap/Makefile @@ -0,0 +1,4 @@ +TOPDIR = ../../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/py-mod.mk