Coverage for ldap_auth.py : 45%

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 'ldap_domain_dict')) except Exception as e: log.error({'error': e}, event='auth-config-processing-failed') domain_dict = {}
# When waiting for a response, check every POLL_INTERVAL ms.
# How long before we timeout LDAP calls, in seconds.
# We test this in the destructor, make sure the attribute exists if # initialize throws. ldap_host = config.get('authentication', 'ldap_host') # Note that initialize does not result in any network communication. '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. # Once bound, the identity we used to bind. # start_tls does not have an asynchronous version so we unfortunately # block here. # This will throw on failure. self.con.start_tls_s()
"""Log exception and proceed on failure.""" return
except Exception as e: self.log.error({'error': e}, event='ldap-unbind-failed') else:
"""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. """ functools.partial(self._get_result, msgid, tornado.ioloop.IOLoop.instance().time(), callback), self.POLL_INTERVAL)
"""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. """ 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. try: self.con.abandon(msgid) except: pass res_data = 'Timed out' else:
self.pending_results.keys()) assert msgid in self.pending_results msgid, tornado.ioloop.IOLoop.instance().time() - start_time, res_type, res_data)
"""Invoked once the bind call completed.
@callback takes one argument: the outcome. """ self.log.error({'result_type': res_type, 'result_data': res_data}, event='ldap-bind-failed', opslog=True) callback(False) else:
"""Binds to the server as @who.
When done, invokes @callback with a boolean indicating success. """ except Exception as e: self.log.error({'error': e}, event='ldap-bind-failed') callback(False) return
functools.partial(self._on_bind_result, callback))
res_data): """Invoked once the recursive check for membership completes.
@callback takes one argument: the outcome. """ 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. 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. """ 'ldap_authorized_group').lower() # Check if the surfcrew group is in the list. callback(username) return 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)
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
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. 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
user_dn, authorized_group, msgid) 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. """ self.log.error({'username': username}, event='ldap-invalid-username') callback(False, error='ID_ALERT_INVALID_USERNAME') return
self._on_search_result, callback)
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
# There should be only one result. 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
callback): 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 else: domain_dn = ''
'ldap_use_custom_filter_clause') and config.get('authentication', 'ldap_custom_filter_clause') or '') '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 domain_dn, ldap.SCOPE_SUBTREE, attrlist=attributes, filterstr=search_filter)
account_name, domain_dn, msgid) account_name) 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).""" callback(False) return # Now we do the query - con is bound, we can do a search.
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. """ # 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.error({'details': 'no domain info', 'ldap_domain': domain}, event='ldap-auth-error') callback(False) return
# Always use a new connection to verify a user's identity.
except Exception as e: log.error({'error': e}, event='ldap-service-connection-failed') callback(False) return
"""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()} |