diff --git a/synapse/util/ldap_auth_provider.py b/synapse/util/ldap_auth_provider.py
new file mode 100644
index 0000000000..f852e9b037
--- /dev/null
+++ b/synapse/util/ldap_auth_provider.py
@@ -0,0 +1,368 @@
+
+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)
+ )
+ )
|