From feeae4062141736b51868734cbee817590071f0f Mon Sep 17 00:00:00 2001 From: Tyler Moore Date: Sat, 31 Aug 2024 14:46:43 -0600 Subject: [PATCH] Add Support for Microsoft AD Auth - add support for ldap auth using double bind method - improve configurability of ldap module - add strict tests to ldap module intialization - update ldap examples in settings.py --- gui/dsiprouter.py | 2 +- gui/modules/api/auth/ldap/classes.py | 303 +++++++++++++++++++++++++ gui/modules/api/auth/ldap/functions.py | 19 ++ gui/modules/api/auth/ldap/interface.py | 131 ++++++----- gui/settings.py | 7 +- 5 files changed, 396 insertions(+), 66 deletions(-) create mode 100644 gui/modules/api/auth/ldap/classes.py create mode 100644 gui/modules/api/auth/ldap/functions.py diff --git a/gui/dsiprouter.py b/gui/dsiprouter.py index 906862f0..5ee679d5 100755 --- a/gui/dsiprouter.py +++ b/gui/dsiprouter.py @@ -2415,7 +2415,7 @@ def intializeAuthModules(): ) auth_mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(auth_mod) - auth_mod.initialize() + auth_mod.initialize(settings) auth_modules.append(auth_mod) def guiLicenseCheck(tag): diff --git a/gui/modules/api/auth/ldap/classes.py b/gui/modules/api/auth/ldap/classes.py new file mode 100644 index 00000000..eeb8666d --- /dev/null +++ b/gui/modules/api/auth/ldap/classes.py @@ -0,0 +1,303 @@ +import ldap, ldap.filter, ldapurl, sys +from typing import Any, Dict, Generator, List, Union +from shared import IO +from modules.api.auth.ldap.functions import filterValidSearchResults, filterSearchValuesByRdn + + +class LdapAuthenticator(object): + """ + A wrapper around ldap connections adding support for failover between multiple LDAP servers + """ + + def __init__(self, ldap_urls: List[str], ldap_debug=False, **ldap_settings: Dict[str, Any]) -> None: + """ + Initialize the LDAP objects + + :param ldap_urls: URLs of the LDAP servers to connect to + """ + + # all the parameter that will be initialized + self._base_dn: str + self._required_group: Union[str, None] + self._referrals: int + self._network_timeout: int + self._search_timeout: int + self._double_bind: bool + self._bind_filter: Union[str, None] + self._bind_dn: Union[str, None] + self._bind_pass: Union[str, None] + self._user_filter: Union[str, None] + self._user_attr = Union[str, None] + self._clients: List[ldap.ldapobject.ReconnectLDAPObject] = [] + self.__bind_idx: Union[int, None] = None + self.__debug: bool = ldap_debug + + # required settings + if not isinstance(ldap_urls, list): + raise ValueError('"ldap_urls" must be a list of strings') + if len(ldap_urls) == 0: + raise ValueError('"ldap_urls" cannot be empty') + + # optional settings + self._base_dn = ldap_settings.get('base_dn', '') + if not ldap.dn.is_dn(self._base_dn): + raise ValueError('"base_dn" is not a valid distinguished name') + + self._required_group = ldap_settings.get('required_group', None) + if not isinstance(self._required_group, (str, type(None))): + raise ValueError('"required_group" must be a string') + + referrals = ldap_settings.get('referrals', 0) + if not isinstance(referrals, (int, bool)): + raise ValueError('"referrals" must be an integer or boolean') + self._referrals = int(referrals) + + self._network_timeout = ldap_settings.get('network_timeout', 3) + if not isinstance(self._network_timeout, int): + raise ValueError('"network_timeout" must be an integer') + + self._search_timeout = ldap_settings.get('search_timeout', 5) + if not isinstance(self._search_timeout, int): + raise ValueError('"search_timeout" must be an integer') + + # single bind auth mode + if 'bind_filter' in ldap_settings: + self._double_bind = False + self._bind_filter = ldap_settings['bind_filter'] + if not isinstance(self._bind_filter, str): + raise ValueError('"bind_filter" must be a string') + try: + _ = ldap.filter.filter_format(self._bind_filter, ['test_username']) + except TypeError as ex: + raise ValueError(f'"bind_filter" is invalid ({str(ex)})') + self._bind_dn = None + self._bind_pass = None + # double bind auth mode + elif 'bind_dn' in ldap_settings and 'bind_pass' in ldap_settings: + self._double_bind = True + self._bind_dn = ldap_settings['bind_dn'] + self._bind_pass = ldap_settings['bind_pass'] + # no dn validation because it could be a plain username as well + if not isinstance(self._bind_dn, str): + raise ValueError('"bind_dn" must be a string') + if not isinstance(self._bind_pass, str): + raise ValueError('"bind_pass" must be a string') + self._bind_filter = None + # not a valid use case + else: + raise ValueError('invalid combination of module settings') + + # dependent settings + if self._double_bind is True or self._required_group is not None: + if 'user_filter' not in ldap_settings: + raise ValueError('missing required setting "user_filter"') + self._user_filter = ldap_settings['user_filter'] + if not isinstance(self._user_filter, str): + raise ValueError('"user_filter" must be a string') + try: + _ = ldap.filter.filter_format(self._user_filter, ['test_username']) + except TypeError as ex: + raise ValueError(f'"user_filter" is invalid ({str(ex)})') + if 'user_attr' not in ldap_settings: + raise ValueError('missing required setting "user_attr"') + self._user_attr = ldap_settings['user_attr'] + if not isinstance(self._user_attr, str): + raise ValueError('"user_attr" must be a string') + else: + self._user_filter = None + self._user_attr = None + + # create the ldap objects + for url in ldap_urls: + try: + url_obj = ldapurl.LDAPUrl(url) + except ValueError: + raise ValueError(f'ldap url "{url}" is not valid') + + client = ldap.ldapobject.ReconnectLDAPObject( + uri=url_obj.initializeUrl(), + trace_level=1 if self.__debug else 0, + trace_file=sys.stderr if self.__debug else None, + retry_max=1, + retry_delay=0 + ) + + client.set_option(ldap.OPT_PROTOCOL_VERSION, 3) + if url_obj.urlscheme == 'ldaps': + client.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND) + client.set_option(ldap.OPT_X_TLS_DEMAND, True) + client.set_option(ldap.OPT_X_TLS_NEWCTX, 0) + + client.set_option(ldap.OPT_REFERRALS, referrals) + client.set_option(ldap.OPT_NETWORK_TIMEOUT, self._network_timeout) + + self._clients.append(client) + + # allow passing to dict() and iterable() + def __iter__(self) -> Generator[tuple[str, Any], Any, None]: + for k, v in self._asDict().items(): + yield k, v + + # only return select attributes in the iterable/dict representation + def _asDict(self) -> Dict[str, Any]: + return { + 'base_dn': self._base_dn, + 'required_group': self._required_group, + 'referrals': self._referrals, + 'network_timeout': self._network_timeout, + 'search_timeout': self._search_timeout, + 'double_bind': self._double_bind, + 'bind_filter': self._bind_filter, + 'bind_dn': self._bind_dn, + 'bind_pass': self._bind_pass, + 'user_filter': self._user_filter, + 'user_attr': self._user_attr, + } + + # TODO: allow updating attributes on the fly (clients would have to be recreated) + + def validateConnection(self) -> None: + """ + Check if a connection to one of the ldap servers can be made + """ + + for client in self._clients: + try: + client.reconnect( + client._uri, + client._retry_max, + client._retry_delay + ) + return None + except (ldap.SERVER_DOWN, ldap.TIMEOUT): + continue + + raise ldap.SERVER_DOWN('ldap connection(s) failed') + + def validateBind(self) -> None: + """ + Check if the bind settings are valid + """ + + if self._double_bind is False: + return None + + for client in self._clients: + try: + client.simple_bind_s( + self._bind_dn, + self._bind_pass + ) + client.unbind_s() + return None + except ldap.LDAPError: + continue + + raise ldap.LDAPError('ldap bind failed') + + def bind(self, username: str, password: str) -> None: + """ + Bind to the ldap server + """ + + if self.__bind_idx is not None: + self.unbind() + + for idx, client in zip(range(len(self._clients)), self._clients): + try: + # double bind auth mode + if self._double_bind: + try: + client.simple_bind_s( + self._bind_dn, + self._bind_pass + ) + + res = filterValidSearchResults( + client.search_st( + self._base_dn, + ldap.SCOPE_SUBTREE, + ldap.filter.filter_format(self._user_filter, [username]), + [self._user_attr], + timeout=self._search_timeout + ) + ) + finally: + client.unbind_s() + + if len(res) == 0: + raise ldap.NO_SUCH_OBJECT('user not found') + if len(res) > 1: + IO.logwarn(f'multiple records found searching attribute {self._user_attr} for user {username}') + if self.__debug: + IO.printwarn(f'multiple records found searching attribute {self._user_attr} for user {username}') + + user_login = res[0][1][self._user_attr][0].decode('utf-8') + if self.__debug: + IO.printdbg(f'found {self._user_attr} "{user_login}" for username "{username}"') + client.simple_bind_s( + user_login, + password + ) + self.__bind_idx = idx + return None + + # single bind auth mode + client.simple_bind_s( + ldap.filter.filter_format(self._bind_filter, [username]), + password + ) + self.__bind_idx = idx + return None + except (ldap.SERVER_DOWN, ldap.TIMEOUT): + continue + + raise ldap.LDAPError('ldap bind failed') + + def unbind(self) -> None: + """ + Bind to the ldap server + """ + + if self.__bind_idx is None: + return None + + try: + self._clients[self.__bind_idx].unbind_s() + self.__bind_idx = None + return None + except ldap.SERVER_DOWN: + return None + + def queryUser(self, username: str, attrs: Union[List[str], None] = None) -> Dict[str, List[str]]: + """ + Perform an ldap query + """ + + if self.__bind_idx is None: + raise Exception('not bound to any ldap servers') + + client = self._clients[self.__bind_idx] + client.reconnect( + client._uri, + client._retry_max, + client._retry_delay + ) + + res = filterValidSearchResults( + client.search_st( + self._base_dn, + ldap.SCOPE_SUBTREE, + ldap.filter.filter_format(self._user_filter, [username]), + [self._user_attr], + timeout=self._search_timeout + ) + ) + + if len(res) == 0: + raise ldap.NO_SUCH_OBJECT('user not found') + vals = res[0][1] + + return { + k: filterSearchValuesByRdn(v, 'CN') for k, v in vals.items() if k in attrs + } diff --git a/gui/modules/api/auth/ldap/functions.py b/gui/modules/api/auth/ldap/functions.py new file mode 100644 index 00000000..168dccdd --- /dev/null +++ b/gui/modules/api/auth/ldap/functions.py @@ -0,0 +1,19 @@ +import ldap +from typing import Dict, List, Tuple + + +def filterValidSearchResults( + raw_result: List[Tuple[str, Dict[str, List[bytes]]]] +) -> List[Tuple[str, Dict[str, List[bytes]]]]: + return [ + res for res in raw_result if res[0] is not None + ] + +def filterSearchValuesByRdn(raw_values: List[bytes], rdn: str) -> List[str]: + rdn_filter = f'{rdn}=' + return [ + next( + (dn for dn in ldap.dn.explode_dn(val) if rdn_filter in dn), + '' + ).replace(rdn_filter, '') for val in raw_values + ] diff --git a/gui/modules/api/auth/ldap/interface.py b/gui/modules/api/auth/ldap/interface.py index 75514184..d487203c 100644 --- a/gui/modules/api/auth/ldap/interface.py +++ b/gui/modules/api/auth/ldap/interface.py @@ -1,10 +1,9 @@ -import sys +import copy +from types import ModuleType +from typing import Union +from shared import debugException, IO +from modules.api.auth.ldap.classes import LdapAuthenticator -if sys.path[0] != '/etc/dsiprouter/gui': - sys.path.insert(0, '/etc/dsiprouter/gui') - -import ldap -import settings # required plugin global METADATA = { @@ -12,87 +11,93 @@ 'version': '1.0.0' } +# plugin specific globals +auth_client: Union[LdapAuthenticator, None] = None +auth_debug: bool = False + + # required plugin interface -def initialize(): +def initialize(project_settings: ModuleType) -> None: """ Validate the module settings and perform any verification needed - :return: None - :rtype: None - :raises: ValueError - when settings are invalid + :raises: ValueError - when settings are invalid + :raises: ldap.LDAPError - when ldap connection/bind fails """ - mod_settings = settings.AUTH_MODULES['ldap'] - for req_key in ['LDAP_HOST', 'USER_ATTRIBUTE', 'USER_SEARCH_BASE']: - if req_key not in mod_settings: - raise ValueError(f'ldap module failed initialization: missing required setting "{req_key}"') - if 'REQUIRED_GROUP' in mod_settings: - if not 'GROUP_SEARCH_BASE' in mod_settings or not 'GROUP_MEMBER_ATTRIBUTE' in mod_settings: - raise ValueError(f'ldap module failed initialization: "REQUIRED_GROUP" requires "GROUP_SEARCH_BASE" avd "GROUP_MEMBER_ATTRIBUTE" to be set') + global auth_client, auth_debug + + IO.loginfo('initializing ldap authentication module') + auth_debug = project_settings.DEBUG + if auth_debug: + IO.printdbg('initializing ldap authentication module') + IO.printdbg(f'metadata: {METADATA}') + + mod_settings = copy.deepcopy(project_settings.AUTH_MODULES['ldap']) + + # create the client that will be utilized later for auth + try: + auth_client = LdapAuthenticator( + ldap_urls=mod_settings.pop('ldap_urls', None), + ldap_debug=auth_debug, + **mod_settings + ) + except Exception as ex: + raise ValueError(f'ldap module failed initialization: {str(ex)}') + + # make sure the client will work properly during operation + try: + auth_client.validateConnection() + auth_client.validateBind() + except Exception as ex: + raise ValueError(f'ldap module failed initialization: {str(ex)}') + + IO.loginfo('ldap module initialized') + if auth_debug: + IO.printdbg(f'ldap module initialized: {dict(auth_client)}') - # TODO: validate ldap connection / store connection object # required plugin interface -def teardown(): +def teardown() -> None: """ Cleanup any artifacts from the module - - :return: None - :rtype: None """ - pass + + global auth_client + + del auth_client # required plugin interface -def authenticate(username, password): +def authenticate(username: str, password: str) -> bool: """ Authenticate a user via an external LDAP server :param username: The username to authenticate with - :type username: str :param password: The password to authenticate with - :type password: str :return: Whether authentication was successful or not - :rtype: bool """ - mod_settings = settings.AUTH_MODULES['ldap'] - - try: - # Enable TLS if ldaps is specified in the URI - if mod_settings['LDAP_HOST'][0:4].lower() == "ldaps": - ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) + global auth_client, auth_debug - connect = ldap.initialize(mod_settings['LDAP_HOST']) - connect.set_option(ldap.OPT_REFERRALS, 0) + if auth_client is None: + IO.logerr('ldap module has not been initialized') + raise RuntimeError('ldap module has not been initialized') - ldap_bind_user = "{}={},{}".format( - mod_settings['USER_ATTRIBUTE'], - username, - mod_settings['USER_SEARCH_BASE'] - ) - connect.simple_bind_s(ldap_bind_user, password) - if settings.DEBUG: - print(f'{METADATA["name"]} - User authenticated: {ldap_bind_user}') - - if 'REQUIRED_GROUP' in mod_settings: - ldap_member_filter = "{}={}".format(mod_settings['GROUP_MEMBER_ATTRIBUTE'], username) - if settings.DEBUG: - print("LDAP Member Filter: {}".format(ldap_member_filter)) - - groups = connect.search_s( - mod_settings['GROUP_SEARCH_BASE'], - ldap.SCOPE_SUBTREE, - ldap_member_filter, - ['dn'] + try: + auth_client.bind(username, password) + if auth_client._required_group is not None: + attrs = auth_client.queryUser( + username, + ['memberOf'] ) - if settings.DEBUG: - print("List of groups found: {}".format(groups)) - - for group in groups: - if mod_settings['REQUIRED_GROUP'] in group[0]: - return True - return False - + groups = attrs['memberOf'] + if auth_client._required_group not in groups: + return False return True - except ldap.INVALID_CREDENTIALS: + except Exception as ex: + if auth_debug: + debugException(ex) return False + finally: + auth_client.unbind() + diff --git a/gui/settings.py b/gui/settings.py index dc81ee3a..c456d525 100644 --- a/gui/settings.py +++ b/gui/settings.py @@ -229,7 +229,10 @@ # auth modules # a dictionary of authentication modules to load and their corresponding settings -# example for ldap module: -# AUTH_MODULES = {"ldap": {"LDAP_HOST":"ldap://ldap.dopensource.com", "USER_SEARCH_BASE":"ou=People,dc=dopensource,dc=com", "GROUP_SEARCH_BASE":"dc=dopensource,dc=com", "GROUP_MEMBER_ATTRIBUTE":"memberUid", "REQUIRED_GROUP":"support", "USER_ATTRIBUTE":"uid"}} +# make sure these dicts are single line for now as dsip_lib.sh does not support set/get on multiline settings AUTH_MODULES = {} +# example of using the ldap module for openldap authentication +#{'ldap': {'ldap_urls':['ldap://ldap.dopensource.com'], 'required_group':'support', 'bind_filter':'uid=%s,dc=dopensource,dc=com', 'base_dn':'dc=dopensource,dc=com', 'user_filter':'(uid=%s)', 'user_attr':'distinguishedName'}} +# example of using the ldap module for active directory authentication +#{'ldap': {'ldap_urls': ['ldaps://ldap.dopensource.com:636'], 'bind_dn': 'example_user@ldap.dopensource.com', 'bind_pass': 'example_pass', 'base_dn': 'DC=dopensource,DC=com', 'referrals': 0, 'user_filter': '(& (memberof=CN=voice-team,OU=Telephony,DC=dopensource,DC=com) (SamAccountName=%s))', 'user_attr': 'userPrincipalName'}} ############### End Local-Only Settings ##################