from twisted.internet import defer from synapse.config._base import ConfigError from synapse.types import UserID import ldap3 import ldap3.core.exceptions import logging try: import ldap3 import ldap3.core.exceptions except ImportError: ldap3 = None pass logger = logging.getLogger(__name__) class LDAPMode(object): SIMPLE = "simple", SEARCH = "search", LIST = (SIMPLE, SEARCH) class LdapAuthProvider(object): __version__ = "0.1" def __init__(self, config, account_handler): self.account_handler = account_handler if not ldap3: raise RuntimeError( 'Missing ldap3 library. This is required for LDAP Authentication.' ) self.ldap_mode = config.mode self.ldap_uri = config.uri self.ldap_start_tls = config.start_tls self.ldap_base = config.base self.ldap_attributes = config.attributes if self.ldap_mode == LDAPMode.SEARCH: self.ldap_bind_dn = config.bind_dn self.ldap_bind_password = config.bind_password self.ldap_filter = config.filter @defer.inlineCallbacks def check_password(self, user_id, password): """ Attempt to authenticate a user against an LDAP Server and register an account if none exists. Returns: True if authentication against LDAP was successful """ localpart = UserID.from_string(user_id).localpart try: server = ldap3.Server(self.ldap_uri) logger.debug( "Attempting LDAP connection with %s", self.ldap_uri ) if self.ldap_mode == LDAPMode.SIMPLE: result, conn = self._ldap_simple_bind( server=server, localpart=localpart, password=password ) logger.debug( 'LDAP authentication method simple bind returned: %s (conn: %s)', result, conn ) if not result: defer.returnValue(False) elif self.ldap_mode == LDAPMode.SEARCH: result, conn = self._ldap_authenticated_search( server=server, localpart=localpart, password=password ) logger.debug( 'LDAP auth method authenticated search returned: %s (conn: %s)', result, conn ) if not result: defer.returnValue(False) else: raise RuntimeError( 'Invalid LDAP mode specified: {mode}'.format( mode=self.ldap_mode ) ) try: logger.info( "User authenticated against LDAP server: %s", conn ) except NameError: logger.warn( "Authentication method yielded no LDAP connection, aborting!" ) defer.returnValue(False) # check if user with user_id exists if (yield self.account_handler.check_user_exists(user_id)): # exists, authentication complete conn.unbind() defer.returnValue(True) else: # does not exist, fetch metadata for account creation from # existing ldap connection query = "({prop}={value})".format( prop=self.ldap_attributes['uid'], value=localpart ) if self.ldap_mode == LDAPMode.SEARCH and self.ldap_filter: query = "(&{filter}{user_filter})".format( filter=query, user_filter=self.ldap_filter ) logger.debug( "ldap registration filter: %s", query ) conn.search( search_base=self.ldap_base, search_filter=query, attributes=[ self.ldap_attributes['name'], self.ldap_attributes['mail'] ] ) if len(conn.response) == 1: attrs = conn.response[0]['attributes'] mail = attrs[self.ldap_attributes['mail']][0] name = attrs[self.ldap_attributes['name']][0] # create account user_id, access_token = ( yield self.account_handler.register(localpart=localpart) ) # TODO: bind email, set displayname with data from ldap directory logger.info( "Registration based on LDAP data was successful: %d: %s (%s, %)", user_id, localpart, name, mail ) defer.returnValue(True) else: if len(conn.response) == 0: logger.warn("LDAP registration failed, no result.") else: logger.warn( "LDAP registration failed, too many results (%s)", len(conn.response) ) defer.returnValue(False) defer.returnValue(False) except ldap3.core.exceptions.LDAPException as e: logger.warn("Error during ldap authentication: %s", e) defer.returnValue(False) @staticmethod def parse_config(config): class _LdapConfig(object): pass ldap_config = _LdapConfig() ldap_config.enabled = config.get("enabled", False) ldap_config.mode = LDAPMode.SIMPLE # verify config sanity _require_keys(config, [ "uri", "base", "attributes", ]) ldap_config.uri = config["uri"] ldap_config.start_tls = config.get("start_tls", False) ldap_config.base = config["base"] ldap_config.attributes = config["attributes"] if "bind_dn" in config: ldap_config.mode = LDAPMode.SEARCH _require_keys(config, [ "bind_dn", "bind_password", ]) ldap_config.bind_dn = config["bind_dn"] ldap_config.bind_password = config["bind_password"] ldap_config.filter = config.get("filter", None) # verify attribute lookup _require_keys(config['attributes'], [ "uid", "name", "mail", ]) return ldap_config def _ldap_simple_bind(self, server, localpart, password): """ Attempt a simple bind with the credentials given by the user against the LDAP server. Returns True, LDAP3Connection if the bind was successful Returns False, None if an error occured """ try: # bind with the the local users ldap credentials bind_dn = "{prop}={value},{base}".format( prop=self.ldap_attributes['uid'], value=localpart, base=self.ldap_base ) conn = ldap3.Connection(server, bind_dn, password) logger.debug( "Established LDAP connection in simple bind mode: %s", conn ) if self.ldap_start_tls: conn.start_tls() logger.debug( "Upgraded LDAP connection in simple bind mode through StartTLS: %s", conn ) if conn.bind(): # GOOD: bind okay logger.debug("LDAP Bind successful in simple bind mode.") return True, conn # BAD: bind failed logger.info( "Binding against LDAP failed for '%s' failed: %s", localpart, conn.result['description'] ) conn.unbind() return False, None except ldap3.core.exceptions.LDAPException as e: logger.warn("Error during LDAP authentication: %s", e) return False, None def _ldap_authenticated_search(self, server, localpart, password): """ Attempt to login with the preconfigured bind_dn and then continue searching and filtering within the base_dn Returns (True, LDAP3Connection) if a single matching DN within the base was found that matched the filter expression, and with which a successful bind was achieved The LDAP3Connection returned is the instance that was used to verify the password not the one using the configured bind_dn. Returns (False, None) if an error occured """ try: conn = ldap3.Connection( server, self.ldap_bind_dn, self.ldap_bind_password ) logger.debug( "Established LDAP connection in search mode: %s", conn ) if self.ldap_start_tls: conn.start_tls() logger.debug( "Upgraded LDAP connection in search mode through StartTLS: %s", conn ) if not conn.bind(): logger.warn( "Binding against LDAP with `bind_dn` failed: %s", conn.result['description'] ) conn.unbind() return False, None # construct search_filter like (uid=localpart) query = "({prop}={value})".format( prop=self.ldap_attributes['uid'], value=localpart ) if self.ldap_filter: # combine with the AND expression query = "(&{query}{filter})".format( query=query, filter=self.ldap_filter ) logger.debug( "LDAP search filter: %s", query ) conn.search( search_base=self.ldap_base, search_filter=query ) if len(conn.response) == 1: # GOOD: found exactly one result user_dn = conn.response[0]['dn'] logger.debug('LDAP search found dn: %s', user_dn) # unbind and simple bind with user_dn to verify the password # Note: do not use rebind(), for some reason it did not verify # the password for me! conn.unbind() return self._ldap_simple_bind(server, localpart, password) else: # BAD: found 0 or > 1 results, abort! if len(conn.response) == 0: logger.info( "LDAP search returned no results for '%s'", localpart ) else: logger.info( "LDAP search returned too many (%s) results for '%s'", len(conn.response), localpart ) conn.unbind() return False, None except ldap3.core.exceptions.LDAPException as e: logger.warn("Error during LDAP authentication: %s", e) return False, None def _require_keys(config, required): missing = [key for key in required if key not in config] if missing: raise ConfigError( "LDAP enabled but missing required config values: {}".format( ", ".join(missing) ) )