auth: Add LDAP support

Signed-off-by: Jan Lindemann <jan@janware.com>
This commit is contained in:
Jan Lindemann 2025-06-05 20:48:14 +02:00
commit 8a316ead21
4 changed files with 283 additions and 29 deletions

View file

@ -1,11 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Optional, Union from typing import Optional, Union, Self
import abc import abc
from enum import Enum, auto from enum import Flag, Enum, auto
from jwutils import log, Config
from jwutils.log import *
from jwutils import Config, load_object
class Access(Enum): # export class Access(Enum): # export
Read = auto() Read = auto()
@ -13,6 +15,11 @@ class Access(Enum): # export
Create = auto() Create = auto()
Delete = auto() Delete = auto()
class ProjectFlags(Flag): # export
NoFlags = auto()
Contributing = auto()
Active = auto()
class Group: # export class Group: # export
def __repr__(self): def __repr__(self):
@ -33,40 +40,94 @@ class User: # export
@abc.abstractmethod @abc.abstractmethod
def _name(self) -> str: def _name(self) -> str:
pass raise NotImplementedError
@abc.abstractmethod
def _groups(self) -> list[Group]:
pass
@property @property
def name(self) -> str: def name(self) -> str:
return self._name() 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 @property
def groups(self) -> list[Group]: def groups(self) -> list[Group]:
return self._groups() 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): def __init__(self, conf: Config):
self.__conf = conf self.__conf = conf
self.__base_user_by_email: Optional[dict[str, User]] = None
@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
@property @property
def conf(self): def conf(self):
return self.__conf 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 @property
def current_user(self) -> User: def current_user(self) -> User:
return self._current_user() return self._current_user()
def access(self, what: str, access_type: Optional[Access]=None, who: Optional[Union[User|Group]]=None) -> bool: @abc.abstractmethod
return self._access(what, access_type, who) 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)

View file

@ -8,6 +8,7 @@ from .. import Access
from .. import Auth as AuthBase from .. import Auth as AuthBase
from .. import Group as GroupBase from .. import Group as GroupBase
from .. import User as UserBase from .. import User as UserBase
from .. import ProjectFlags
class Group(GroupBase): # export class Group(GroupBase): # export
@ -18,12 +19,18 @@ class Group(GroupBase): # export
def _name(self) -> str: def _name(self) -> str:
return self.__name 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.__name = name
self.__conf = conf
self.__auth = auth self.__auth = auth
self.__groups: Optional[list[GroupBase]] = None self.__groups: Optional[list[GroupBase]] = None
self.__email = conf.get('email')
@property
def conf(self):
return self.__conf
def _name(self) -> str: def _name(self) -> str:
return self.__name return self.__name
@ -32,32 +39,54 @@ class User(UserBase):
if self.__groups is None: if self.__groups is None:
name: str = '' name: str = ''
ret: list[GroupBase] = [] ret: list[GroupBase] = []
for name in self.__auth.conf['user.' + name + '.groups']: for name in self.conf['groups']:
ret.append(Group(self.__auth, name)) ret.append(Group(self.__auth, name))
self.__groups = ret self.__groups = ret
return self.__groups return self.__groups
def _email(self) -> str:
return self.__email
class Auth(AuthBase): # export class Auth(AuthBase): # export
def __init__(self, conf: Config): def __init__(self, conf: Config):
self.__conf = conf super().__init__(conf)
self.__users: Optional[dict[str, User]] = None self.___users: Optional[dict[str, User]] = None
self.__groups = None self.__groups = None
self.__current_user: User|None = None self.__current_user: User|None = None
def _user(self, name_) -> User: @property
if self.__users is None: def __users(self) -> User:
if self.___users is None:
ret: dict[str, User] = {} ret: dict[str, User] = {}
for name in self.conf.entries('user'): for name in self.conf.entries('user'):
ret[name] = User(self, name) conf = self.conf.branch('user.' + name)
self.__users = ret ret[name] = User(self, name, conf)
return self.__users[name_] self.___users = ret
return self.___users
def _access(self, what: str, access_type: Optional[Access], who: User|GroupBase|None) -> bool: # type: ignore 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}') slog(WARNING, f'Returning False for {access_type} access to resource {what} by {who}')
return False return False
def _user(self, name) -> User:
return self.__users[name]
def _users(self) -> list[User]:
return self.__users
def _current_user(self) -> User: def _current_user(self) -> User:
if self.__current_user is None: if self.__current_user is None:
self.__current_user = self._user(self.conf['current_user']) self.__current_user = self._user(self.conf['current_user'])
return self.__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

View file

@ -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

View file

@ -0,0 +1,4 @@
TOPDIR = ../../../../..
include $(TOPDIR)/make/proj.mk
include $(JWBDIR)/make/py-mod.mk