369 lines
12 KiB
Python
369 lines
12 KiB
Python
|
|
||
|
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)
|
||
|
)
|
||
|
)
|