Coverage for ldap_auth.py : 13%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
############################################################################# # LDAP support module. # # vim:ts=3:sw=3:expandtab # # Copyright (C) 2014 SurfCrew, Inc. # # Author: Lionel Litty # # Module encapsulating ldap interaction, including state, and making it # asynchronous. # # python-ldap provides non-blocking versions of the APIs (for the most part) # but no notification mechanism. This forces us to resort to periodic polling # to check if results have been received. This is achieved by adding one # PeriodicCallback per pending LDAP call to the Tornado ioloop. # # Encapsulation + asynchrony means four layers of callbacks are used: # * Callbacks from the tornado ioloop to the LDAPConnection object (_get_result). # * Handling of result (_on_xxx_result). # * Callback from the result handler to the logic flow (verifying a user # involves two LDAP calls: binding with credentials, then checking group # membership). # * Callback from the module to the requester with final outcome. # #############################################################################
# FIXME - SECURITY! This disables verification of the server certificate.
# If the server is not responsive at the network level, abandon after 10 secs.
# Cache the service ldap connection object and try to reuse it.
# Cache the parsed domain dict.
global domain_dict if not domain_dict: try: domain_dict = json.loads(config.get('authentication', 'ldap_domain_dict')) except Exception as e: log.error({'error': e}, event='auth-config-processing-failed') domain_dict = {} return domain_dict
# When waiting for a response, check every POLL_INTERVAL ms.
# How long before we timeout LDAP calls, in seconds.
SafelyLoggerMixin.__init__(self, 'auth-ldap') # We test this in the destructor, make sure the attribute exists if # initialize throws. self.con = None if not ldap_host: ldap_host = config.get('authentication', 'ldap_host') # Note that initialize does not result in any network communication. self.con = ldap.initialize('%s://%s:%s' % ( 'ldaps' if config.get('authentication', 'ldap_mode') == 'ldaps' else 'ldap', ldap_host, config.getint('authentication', 'ldap_port'))) # Results we are polling for, indexed by msgid. self.pending_results = {} # Once bound, the identity we used to bind. self.who = None # start_tls does not have an asynchronous version so we unfortunately # block here. if config.get('authentication', 'ldap_mode') == 'ldap_starttls': # This will throw on failure. self.con.start_tls_s()
"""Log exception and proceed on failure.""" if not self.con: return
try: self.con.unbind() except Exception as e: self.log.error({'error': e}, event='ldap-unbind-failed') else: self.log.debug('Unbound connection for %s.', self.who) self.con = None
"""Initiates a periodic check for the result of @msgid.
callback takes three arguments: success (True or False), result_type and result_data. When an error occurs, success is False, result_type is None and result_data has details. """ self.log.debug('Polling for result for %s', msgid) periodic_callback = tornado.ioloop.PeriodicCallback( functools.partial(self._get_result, msgid, tornado.ioloop.IOLoop.instance().time(), callback), self.POLL_INTERVAL) self.pending_results[msgid] = periodic_callback periodic_callback.start()
"""Invokes callback once result has been obtained or timeout was reached.
callback takes three arguments: success (True or False), result_type and result_data. When an error occurs, success is False, result_type is None and result_data has details. """ success = False try: res_type, res_data = self.con.result(msgid, timeout=0) except Exception as e: if self.log.isEnabledFor(logging.DEBUG): self.log.exception({'error': e}, event='ldap-no-result')
res_data = str(e) res_type = None else: # No result yet. if not res_type: if tornado.ioloop.IOLoop.instance().time() - start_time < self.CALL_TIMEOUT: self.log.debug('No result yet for %s', msgid) return try: self.con.abandon(msgid) except: pass res_data = 'Timed out' else: success = True
self.log.debug('Got some results for msgid %s among %s', msgid, self.pending_results.keys()) assert msgid in self.pending_results self.pending_results[msgid].stop() del self.pending_results[msgid] self.log.debug('Result for %s after %s seconds: %s, %s.', msgid, tornado.ioloop.IOLoop.instance().time() - start_time, res_type, res_data) callback(success, res_type, res_data)
"""Invoked once the bind call completed.
@callback takes one argument: the outcome. """ if not success or res_type != ldap.RES_BIND or res_data: self.log.error({'result_type': res_type, 'result_data': res_data}, event='ldap-bind-failed', opslog=True) callback(False) else: callback(True)
"""Binds to the server as @who.
When done, invokes @callback with a boolean indicating success. """ self.who = who try: msgid = self.con.simple_bind(who, password) except Exception as e: self.log.error({'error': e}, event='ldap-bind-failed') callback(False) return
self.log.debug('Bind for %s: %s', who, msgid) self._poll_for_result(msgid, functools.partial(self._on_bind_result, callback))
res_data): """Invoked once the recursive check for membership completes.
@callback takes one argument: the outcome. """ if not success or res_type != ldap.RES_SEARCH_RESULT: self.log.error({'details': 'membership check', 'result_type': res_type, 'result_data': res_data}, event='ldap-auth-error', opslog=True) callback(False) return # If the result list is not empty, the user belongs to the group. if res_data: callback(username) else: self.log.error({'details': 'recursive membership check', 'result_type': res_type, 'result_data': res_data}, event='ldap-auth-error', opslog=True) callback(False, error='ID_ALERT_NOT_AUTHORIZED')
"""Invoked once the search call for membership completes.
@callback takes one argument: the outcome. """ user_dn = None authorized_group = config.get('authentication', 'ldap_authorized_group').lower() # Check if the surfcrew group is in the list. try: member_of = res_data[0][1].get('memberOf', []) for group in member_of: if group.lower() == authorized_group: callback(username) return user_dn = res_data[0][0] except Exception as e: details = {'details': 'membership check', 'error': e} if self.log.isEnabledFor(logging.DEBUG): self.log.exception(details, event='ldap-auth-error') else: self.log.error(details, event='ldap-auth-error', opslog=True)
if not config.getboolean('authentication', 'ldap_recursive_group_check'): self.log.info({'details': 'membership check', 'result_type': res_type, 'result_data': res_data}, event='ldap-auth-failed', opslog=True) callback(False, error='ID_ALERT_NOT_AUTHORIZED') return
if not user_dn: self.log.error({'details': 'no dn for recursive membership check', 'result_type': res_type, 'result_data': res_data}, event='ldap-auth-error', opslog=True) callback(False, error='ID_ALERT_NOT_AUTHORIZED') return
# Use LDAP_MATCHING_RULE_IN_CHAIN OID (1.2.840.113556.1.4.1941) to # traverse groups for membership. # attrlist does not matter. We specify one to limit the size of the # response. try: msgid = self.con.search( user_dn, ldap.SCOPE_BASE, attrlist=['cn'], filterstr='(memberof:1.2.840.113556.1.4.1941:=%s)' % authorized_group) except Exception as e: self.log.error({'details': 'recursive membership check', 'error': e}, event='ldap-auth-error') callback(False) return
self.log.debug('Recursive membership check for %s, %s: %s', user_dn, authorized_group, msgid) self._poll_for_result( msgid, functools.partial(self._on_recursive_check_result, callback, username))
"""Asks the server if @username belongs to the authorized group.
When done, invokes @callback with a boolean indicating success. """ if not '\\' in username: self.log.error({'username': username}, event='ldap-invalid-username') callback(False, error='ID_ALERT_INVALID_USERNAME') return
domain_name, account_name = username.split('\\', 1) self._get_attributes(account_name, domain_name, ['memberOf'], self._on_search_result, callback)
if not success or res_type != ldap.RES_SEARCH_RESULT: self.log.error({'details': 'attribute retrieval', 'result_type': res_type, 'result_data': res_data}, event='ldap-auth-error', opslog=True) callback(False) return
# Filter out ref: from res_data - they don't contain the DN part res_data = [k for k in res_data if k[0]]
# There should be only one result. if len(res_data) != 1: if not res_data: self.log.error({'details': 'user not found'}, event='ldap-auth-error', opslog=True) else: self.log.error({'details': 'cannot uniquely identify user', 'result_data': res_data}, event='ldap-auth-error', opslog=True) callback(False) return cb(callback, username, res_type, res_data)
callback): if domain_name: domain_info = _get_domains().get(domain_name) if not domain_info: self.log.error({'details': 'could not get domain info', 'ldap_domain': domain_name}, event='ldap-auth-error', opslog=True) callback(False, error='ID_ALERT_BAD_DOMAIN') return domain_dn = domain_info[0] else: domain_dn = ''
custom_clause = (config.getboolean('authentication', 'ldap_use_custom_filter_clause') and config.get('authentication', 'ldap_custom_filter_clause') or '') search_filter = (config.get('authentication', 'ldap_default_search_filter') % {'account_name': account_name, 'custom_clause': custom_clause}) # TODO: catching the exception should really happen in # _get_attributes, and passed to callback, but # _do_with_service_con expects exceptions to be thrown msgid = self.con.search( domain_dn, ldap.SCOPE_SUBTREE, attrlist=attributes, filterstr=search_filter)
self.log.debug('Getting attributes %s for %s, %s: %s', attributes, account_name, domain_dn, msgid) username = (domain_name and '\\'.join([domain_name, account_name]) or account_name) self._poll_for_result( msgid, functools.partial(self._std_checks, on_search, callback, username))
def _cont(callback, username, res_type, res_data): res_data[0][1]['dn'] = [res_data[0][0]] callback(res_data[0][1])
self._get_attributes(account_name, domain, attributes, _cont, callback)
"""Invoked once the binding step is complete (it may have failed).""" if not success: callback(False) return # Now we do the query - con is bound, we can do a search. query()
global service_con
if not service_con: log.debug('Establishing service connection.') try: service_con = LDAPConnection() except Exception as e: log.error({'error': e}, event='ldap-service-connection-failed') callback(False) return service_con.bind(config.get('authentication', 'ldap_service_username'), config.get('authentication', 'ldap_service_password'), functools.partial(_con_bound, callback, functools.partial(method, service_con))) return
log.debug('Reusing service connection.') try: method(service_con) except ldap.SERVER_DOWN as e: # TODO: this is a horrendous mixture of callback-based and try-catch based # error handling # TODO: now we only handle reconnection attempts if search fails, but not # if ldap.SERVER_DOWN occurs asynchronously in _get_result if log.isEnabledFor(logging.DEBUG): log.exception({'details': 'Failed to reuse service connection', 'error': e}, event='ldap-service-communication-failed') # Reset service_con and retry. service_con = None _do_with_service_con(method, callback) except Exception as e: log.error({'error': e}, event='ldap-service-communication-failed') callback(False) return
"""Invoked once we have bound as the user.""" if success: if member: callback(username) else: callback(False, error='ID_ALERT_NOT_AUTHORIZED') else: callback(False)
"""Check credentials and authorization.
This is done by binding to the LDAP server with the form domain\account_name.
On success, callback is invoked with the user's username (domain\account_name). On failure, callback is invoked with False. """ if not callback: # should not happen, as callback is really mandatory; it is a kwarg # only to enable its use with tornado.gen.Task callback = lambda *args, **kwargs: None
domain_info = _get_domains().get(domain) if not domain_info: log.error({'details': 'no domain info', 'ldap_domain': domain}, event='ldap-auth-error') callback(False) return
ldap_host = domain_info[1] username = '\\'.join([domain, account_name]) # Always use a new connection to verify a user's identity. log.debug('Verifying credentials for %s', username)
try: con = LDAPConnection(ldap_host) except Exception as e: log.error({'error': e}, event='ldap-service-connection-failed') callback(False) return
method = functools.partial(con.check_membership, username, callback) con.bind(username, password, functools.partial(_con_bound, callback, method))
"""Get the list of attributes.
This is done by binding to the LDAP server with the admin credentials.
On success, callback is invoked with the user's attributes. On failure, callback is invoked with False. """ def method(con): try: passthrough_method(con) except Exception as e: if log.isEnabledFor(logging.DEBUG): details = {'details': 'get attributes', 'error': e, 'attributes': attributes, 'account_name': account_name, 'ldap_domain': domain} log.exception(details, event='ldap-auth-error') else: log.error(details, event='ldap-auth-error') callback(False)
def passthrough_method(con): con.get_attributes(account_name, domain, attributes, callback)
if not config.getboolean('authentication', 'ldap_user_details_lookup'): log.error({}, event='ldap-user-details-lookup-disabled') callback(False, error='ID_ALERT_NOT_ENABLED') return
if not domain: # if domain is not specified, reuse service connection log.debug('Retrieving attributes for %s using service connection', account_name) _do_with_service_con(passthrough_method, callback) return
domain_info = _get_domains().get(domain) if not domain_info: log.error({'details': 'no domain info', 'ldap_domain': domain}, event='ldap-auth-error') callback(False) return
ldap_host = domain_info[1] admin = config.get('authentication', 'ldap_service_username') password = config.get('authentication', 'ldap_service_password')
log.debug('Retrieving attributes for %s', account_name) try: # TODO: reuse existing connections to get user's attributes con = LDAPConnection(ldap_host) except Exception as e: log.error({'error': e}, event='ldap-service-connection-failed') callback(False) return
con.bind(admin, password, functools.partial(_con_bound, callback, functools.partial(method, con)))
"""Check that user is still authorized to use the service.
User is in the domain\\username form.
The user is already authenticated and did not provide credentials, so an service account is used for the lookup.
On success, callback is invoked with the user's DN. On failure, callback is invoked with False. """ if not callback: # should not happen, as callback is really mandatory; it is a kwarg # only to enable its use with tornado.gen.Task callback = lambda *args, **kwargs: None
log.debug('Verifying authorization for %s', username)
# Without a service account, we cannot re-authorize the user, so # re-authorization always succeeds. if not config.getboolean('authentication', 'ldap_use_service'): callback(username) return
method = functools.partial(LDAPConnection.check_membership, username=username, callback=callback) _do_with_service_con(method, callback)
"""Retrieve a dictionary of domains the user can choose from.
The list is part of the config (populated via the dashboard). """ # We do not need to send the domain DN to the user. return {k: k for k in _get_domains().keys()} |