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 -*-
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)

View file

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

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