def _check_connection(self): disconnected = False if not self.rpc_server: discon_msg = "no rpc server" disconnected = True elif self.rpc_server.transport.disconnected: discon_msg = "transport disconnected" disconnected = True elif not self.rpc_server.transport.connected: discon_msg = "transport not connected" disconnected = True elif (self.rpc_server.factory.idle_rpc_timeout is not None and self.rpc_server.last_rpc is not None): last_rpc = int(time.time() - self.rpc_server.last_rpc) if last_rpc > self.rpc_server.factory.idle_rpc_timeout: discon_msg = "Missed pings for {0} seconds, maximum {1} seconds allowed.".format( last_rpc, self.rpc_server.factory.idle_rpc_timeout) try: # Disconnect the transport just in case. self.rpc_server.transport.abortConnection() log.msg( "Connection to Duo service was intentionally closed.") except Exception as e: log.msg( "Attempted to forcibly disconnect the transport but was unable. Transport likely already disconnected. Exception: {}" .format(e)) disconnected = True if disconnected: # No connection to service found! Fix that. log.msg("DRPC Disconnected: {0}".format(discon_msg)) log.msg("(Re)connecting to service...") # Notify depending on the subclass implementation self.log_disconnect(self.rpc_server) self.rpc_server = None try: self.rpc_server = yield self.perform_join() except Exception as e: if is_fatal_error(e): log.error("Error: {e}", e=str(e)) log.msg( "This exception requires manual intervention. Directory Sync and/or SSO functionality will " "be unavailable until the problem is resolved") self.stopService() else: log.msg( "Error connecting to service: {0}. Will retry again in {1} seconds." .format(str(e), self.reconnect_interval)) else: self.log_connect(self.rpc_server)
def _info_callback(conn, where, ret): """Use the info callback to gather information about attempted SSL connections and warn about incompatibilities. See the man page[1] for further information. [1] https://www.openssl.org/docs/manmaster/man3/SSL_CTX_set_info_callback.html """ if where & OpenSSL.SSL.SSL_CB_WRITE_ALERT: if conn.get_cipher_name() is None: log.error( "Unable to establish SSL connection. " "Client may be attemping incompatible protocol version or cipher." )
def _verify_ldap_config_args(self, bind_dn: str, bind_pw: str, auth_type: str, transport_type: str) -> None: if util.is_windows_os(): if auth_type not in const.AD_AUTH_TYPES_WIN: raise drpc_exceptions.CallBadArgError(["auth_type"]) else: if auth_type not in const.AD_AUTH_TYPES_NIX: raise drpc_exceptions.CallBadArgError(["auth_type"]) if transport_type not in const.AD_TRANSPORTS: raise drpc_exceptions.CallBadArgError(["transport_type"]) if auth_type != const.AD_AUTH_TYPE_SSPI and (bind_dn == "" or bind_pw == ""): e = drpc_exceptions.CallError( ERR_LDAP_CONFIGURATION_FAILED, { "authproxy_configuration_error": "Missing {0} or {1}".format(CONFIG_BIND_USER, CONFIG_BIND_PASSWORD) }, ) log.error("{msg}. Error: {error}", msg=ERR_LDAP_CONFIGURATION_FAILED, error=e) raise e
def _paged_search( self, client, base_dn, filter_text, attributes, pagination=PAGINATION_TYPE_CRITICAL, max_result_size=None, page_size=DEFAULT_PAGE_SIZE, ): """ Given a bound client, search exhaustively using RFC 2696. Return a list of dictionaries containing the attributes of the resulting entries. * attributes: Set of lower-case byte-strings. """ res = [] def handle_msg(value, controls, d): try: if isinstance(value, pureldap.LDAPSearchResultDone): e = ldaperrors.get(value.resultCode, value.errorMessage) if isinstance(e, (ldaperrors.Success, ldaperrors.LDAPSizeLimitExceeded)): cookie = get_cookie(controls) d.callback((None, cookie)) else: d.callback((e, None)) elif isinstance(value, pureldap.LDAPSearchResultEntry): # Always send DN. Overwrite DN from attribute set, if any. obj = { "distinguishedname": [escape_bytes(value.objectName)] } for k, vs in value.attributes: # Smash attribute name case. k = k.decode().lower() # Server may not honor attributes (e.g. # SearchByTreeWalkingMixin). if attributes and k.encode() not in attributes: continue # Covert value to list and encode for JSON. vs = [escape_bytes(v) for v in vs] obj[k] = vs # Refuse to return certain attributes even if all # attributes were requested. obj.pop("userpassword", None) res.append(obj) except Exception: log.failure("Unexpected error handling message") finally: return isinstance(value, ( pureldap.LDAPBindResponse, pureldap.LDAPSearchResultDone, )) if filter_text: filter_obj = ldapfilter.parseFilter(filter_text) else: filter_obj = None op = pureldap.LDAPSearchRequest( baseObject=base_dn, scope=pureldap.LDAP_SCOPE_wholeSubtree, derefAliases=0, sizeLimit=0, timeLimit=0, typesOnly=0, filter=filter_obj, attributes=attributes, ) if pagination == PAGINATION_TYPE_CRITICAL: # AD may ignore the RFC 2696 control if it is not # critical. The caller can override this default if the # control should be present but not critical or absent. criticality = True else: criticality = False cookie = pureber.BEROctetString("") while True: if pagination == PAGINATION_TYPE_DISABLE: controls = None else: controls = [ ( RFC_2696_CONTROL_TYPE, criticality, pureber.BERSequence([ pureber.BERInteger(page_size), cookie, ]), ), ] d = defer.Deferred() yield client.send( op=op, controls=controls, handler=functools.partial(handle_msg, d=d), return_controls=True, ) # handle_msg() is synchronous so d should be called by the # time it returns to LDAPClient.handle(). if d.called: e, cookie = yield d else: log.error("Paging cookie not found!") break if e is not None: # So the RPC caller can distinguish between problems # with the search (e.g. bad configuration) and # searches that return no results. if isinstance(e, ldaperrors.LDAPOperationsError ) and e.message.decode().startswith( const.LDAP_SUCCESSFUL_BIND_NEEDED_ERROR): raise ldap.client.ConnectionNotProperlyBoundError( "Search failed because either there was no bind on this connection or there were insufficient privileges with the bound user. If you are attempting to use integrated authentication with SSPI please make sure the server running the Authentication Proxy is domain joined." ) else: raise e if not cookie.value: break if max_result_size is not None and len(res) > max_result_size: break defer.returnValue(res)
def fetch_ldap_attributes_from_server( self, host, port, rikey, desired_attributes, user_dn, ntlm_domain=None, ntlm_workstation=None, auth_type=const.AD_AUTH_TYPE_NTLM_V2, transport_type=const.AD_TRANSPORT_STARTTLS, ssl_verify_depth=const.DEFAULT_SSL_VERIFY_DEPTH, ssl_verify_hostname=True, ssl_ca_certs=None, timeout=60, call_id=None, ): creds = self.get_creds_for_ldap_idp(rikey, auth_type) bind_dn = creds.username bind_pw = creds.password log.msg(( "Performing user attributes fetch: " "call_id={call_id} host={host} port={port} rikey={rikey} " "desired_attributes={desired_attributes} user_dn={user_dn} ntlm_domain={ntlm_domain} " "ntlm_workstation={ntlm_workstation} auth_type={auth_type} transport_type={transport_type} " "ssl_verify_depth={ssl_verify_depth} ssl_verify_hostname={ssl_verify_hostname} " "ssl_ca_certs={ssl_ca_certs}").format( call_id=call_id, host=host, port=port, rikey=rikey, desired_attributes=desired_attributes, user_dn=user_dn, ntlm_domain=ntlm_domain, ntlm_workstation=ntlm_workstation, auth_type=auth_type, transport_type=transport_type, ssl_verify_depth=ssl_verify_depth, ssl_verify_hostname=ssl_verify_hostname, ssl_ca_certs=ssl_ca_certs is not None, )) self._verify_ldap_config_args(bind_dn, bind_pw, auth_type, transport_type) client, timeout_dc = yield self._get_client( host, port, transport_type, ssl_verify_depth, ssl_verify_hostname, ssl_ca_certs, timeout, self.debug, self.is_logging_insecure, ) # Try to do everything network-related try: # Bind as service user, for searching try: yield client.perform_bind( auth_type=auth_type, dn=bind_dn, username=bind_dn, password=bind_pw, domain=ntlm_domain, workstation=ntlm_workstation, permit_implicit=True, ) except Exception as e: if timeout_dc.active(): log.failure(ldap_base.ERR_LDAP_BIND_FAILED) raise drpc.CallError(ldap_base.ERR_LDAP_BIND_FAILED, {"error": six.text_type(e)}) else: log.failure(ldap_base.ERR_LDAP_TIMEOUT) raise drpc.CallError( ldap_base.ERR_LDAP_TIMEOUT, { "during": "bind", "error": six.text_type(e) }, ) # Search for the user try: result = yield client.perform_search( user_dn, None, attributes=desired_attributes, scope=pureldap.LDAP_SCOPE_baseObject, ) if len(result) != 1: log.error(AUTH_INVALID_USER) raise Exception(AUTH_INVALID_USER) result = result[0] except distinguishedname.InvalidRelativeDistinguishedName as irdn: log.failure(ldap_base.ERR_LDAP_BAD_AD_CONFIGURATION) raise drpc.CallError( ldap_base.ERR_LDAP_BAD_AD_CONFIGURATION, {"error": six.text_type(irdn)}, ) except Exception as e: log.failure(ldap_base.ERR_LDAP_SEARCH_FAILED) raise drpc.CallError(ldap_base.ERR_LDAP_SEARCH_FAILED, {"error": six.text_type(e)}) finally: # Clean up networking if timeout_dc.active(): timeout_dc.cancel() try: client.transport.abortConnection() except Exception: pass # At this point, result is a single LDAPEntry, or something has gone wrong and we ideally raised somewhere above encoded_dict = transform_result(result, desired_attributes) logged_atts = transform_result( result, desired_attributes, value_transform=self.to_unicode_ignore_errors) log.msg("For user dn {dn}, found attributes {atts}".format( dn=user_dn, atts=logged_atts)) defer.returnValue(encoded_dict)
def authenticate_against_server( self, host: str, port: str, username: str, password: str, service_account_username: str, service_account_password: str, base_dns: Optional[List[str]] = None, ntlm_domain: Optional[str] = None, ntlm_workstation: Optional[str] = None, auth_type: str = const.AD_AUTH_TYPE_NTLM_V2, transport_type: str = const.AD_TRANSPORT_STARTTLS, ssl_verify_depth: int = const.DEFAULT_SSL_VERIFY_DEPTH, ssl_verify_hostname: bool = True, ssl_ca_certs: Optional[str] = None, timeout: int = 60, username_attributes: Optional[List[str]] = None, call_id: Optional[int] = None, ): """ * username_attribute: attribute within AD that we will compare the username against. See do_ldap_search for further argument documentation. Returns: tuple(bool, str). - True if the auth was successful, - The full dn of the user who authed """ bind_dn = service_account_username bind_pw = service_account_password # At this point we *should* have a username attribute passed to us. But if we don't have one # we will default to the most popular attribute, samaccountname. This will only work for AD. username_attributes = (username_attributes if username_attributes else ["samaccountname"]) base_dns = base_dns if base_dns else [""] log.msg(( "Performing LDAP authentication: " "call_id={call_id} host={host} port={port} base_dns={base_dns} " "auth_type={auth_type} transport_type={transport_type} " "ssl_verify_depth={ssl_verify_depth} ssl_verify_hostname={ssl_verify_hostname} " "ssl_ca_certs={ssl_ca_certs} username={username} username_attributes={username_attributes}" ).format( call_id=call_id, host=host, port=port, base_dns=base_dns, auth_type=auth_type, transport_type=transport_type, ssl_verify_depth=ssl_verify_depth, ssl_verify_hostname=ssl_verify_hostname, username=username, username_attributes=username_attributes, ssl_ca_certs=ssl_ca_certs is not None, )) # The factory has a username attribute field on it but we will not be using it cl, timeout_dc = yield self._get_client( host, port, transport_type, ssl_verify_depth, ssl_verify_hostname, ssl_ca_certs, timeout, self.debug, self.is_logging_insecure, ) try: # Primary bind. This authenticates us to AD and allows us to # make search queries. try: yield cl.perform_bind( auth_type=auth_type, dn=bind_dn, username=bind_dn, password=bind_pw, domain=ntlm_domain, workstation=ntlm_workstation, permit_implicit=True, ) except Exception as e: if timeout_dc.active(): log.failure(ldap_base.ERR_LDAP_BIND_FAILED) raise drpc.CallError(ldap_base.ERR_LDAP_BIND_FAILED, { "error": str(e), }) else: log.failure(ldap_base.ERR_LDAP_TIMEOUT) raise drpc.CallError(ldap_base.ERR_LDAP_TIMEOUT, { "during": "bind", "error": str(e), }) username_match = dict.fromkeys(username_attributes, username) filterObject = yield cl.user_filter_object( username_matches=username_match) # Search for the user. With AD, the user's cn is their full # name, not username. So, we need to search for the user by comparing # the username to a selection of specific attributes. For example: # sAMAccountName or userprincipalname search_hits: List[BytesLDAPEntry] = [] try: # Always fetch msDS-PrincipalName since we need the user's # sAMAccountName and domain. The username entered by the user # may not be in a valid format for SSPI or NTLM depending on # the username attributes configured, but the sAMAccountName # that's part of msDS-PrincipalName will always work. attributes_to_fetch = { str(attr).lower() for attr in username_attributes } attributes_to_fetch.add("msds-principalname") # Also fetch some helpful attributes for debugging if something goes wrong attributes_to_fetch.add("objectclass") attributes_to_fetch.add("objectcategory") # To check multiple base dns we need to perform a search for each one. If the # combination of all of these searches returns more than 1 unique user we will # fail the auth for base_dn in base_dns: result = yield cl.perform_search( base_dn, filterObject, attributes=tuple(attributes_to_fetch)) search_hits.extend(result) except distinguishedname.InvalidRelativeDistinguishedName as e: log.failure(ldap_base.ERR_LDAP_BAD_AD_CONFIGURATION) raise drpc.CallError(ldap_base.ERR_LDAP_BAD_AD_CONFIGURATION, { "error": str(e), }) except Exception as e: log.failure( "{msg} for {username}", msg=ldap_base.ERR_LDAP_SEARCH_FAILED, username=username, ) raise drpc.CallError(ldap_base.ERR_LDAP_SEARCH_FAILED, { "error": str(e), }) if len(search_hits) == 0: err_msg = AUTH_INVALID_USER err = drpc.CallError(ldap_base.ERR_LDAP_SEARCH_FAILED, { "error": err_msg, }) log.error( "{msg} {username}. Error: {error}", msg=err_msg, username=username, error=err, ) raise err if len(search_hits) > 1: err_msg = AUTH_TOO_MANY_USERS err = drpc.CallError(ldap_base.ERR_LDAP_SEARCH_FAILED, { "error": err_msg, }) log.error( "{msg} while searching for {username}: {users}. Error: {error}", msg=err_msg, username=username, users=[user.dn.getText() for user in search_hits], error=err, ) raise err user_result = search_hits[0] user_full_dn = user_result.dn.getText() # Log out the search result matched_attributes = determine_matched_attributes( username, username_attributes, user_result) log.msg("Found {username} with attributes {atts}".format( username=username, atts=matched_attributes)) # Initialize these as None since Plain binds don't these values bind_username = None user_domain = None # If the auth type is not plain, determine the user's domain and # sAMAccountName by pulling them from msDS-PrincipalName. We need # both in order to do NTLM and SSPI authentications. if auth_type != AD_AUTH_TYPE_PLAIN: msds_principalname_attribute_set = user_result.get( "msDS-PrincipalName") if not msds_principalname_attribute_set: # msDS-PrincipalName must be provided so we can determine # the domain of the user authenticating. Abort the # authentication if the attribute doesn't exist for the user. err = drpc.CallError( ERR_LDAP_MISSING_REQUIRED_ATTRIBUTE, { "error": MSDS_PRINCIPAL_NAME_MISSING.format(username) }, ) log.error( "Error: {error}, User Result: {user_result}", error=err, user_result=user_result, ) raise err msds_principalname = list( user_result.get("msDS-PrincipalName"))[0].decode("utf8") if not msds_principalname or "\\" not in msds_principalname: # This means that msDS-PrincipalName was empty, a form # we can't work with (e.g. SID), or didn't have any # domain information on it. We can't guarantee we'll # log in the correct user without knowing their domain, # so abort the authentication. err = drpc.CallError( ERR_LDAP_INVALID_ATTRIBUTE_VALUE, { "error": INVALID_MSDS_PRINCIPAL_NAME.format(username) }, ) log.error( "Error: {error}, User Result: {user_result}", error=err, user_result=user_result, ) raise err user_domain, bind_username = msds_principalname.split("\\", 1) # Secondary bind. Assuming the user we queried exists, attempt # to bind as them (this is essentially performing an authentication). try: yield cl.perform_bind( auth_type=auth_type, dn=user_full_dn, username=bind_username, password=password, domain=user_domain, workstation=ntlm_workstation, permit_implicit=False, ) except Exception as e: log.msg( "Authentication failed for user {username} ({dn})".format( username=username, dn=user_full_dn)) log.msg(e) defer.returnValue((False, None)) finally: if timeout_dc.active(): timeout_dc.cancel() try: cl.transport.abortConnection() except Exception: pass log.msg("Authentication succeeded for user {username} ({dn})".format( username=username, dn=user_full_dn)) defer.returnValue((True, user_full_dn))
def perform_authentication( self, servers, username, password, service_account_username, service_account_password, base_dns=None, ntlm_domain=None, ntlm_workstation=None, auth_type=const.AD_AUTH_TYPE_NTLM_V2, transport_type=const.AD_TRANSPORT_STARTTLS, ssl_verify_depth=const.DEFAULT_SSL_VERIFY_DEPTH, ssl_verify_hostname=True, ssl_ca_certs=None, timeout=60, username_attributes=None, call_id=None, ): """ Authenticates against the provided servers in random order until a successful authentication occurs or the list of servers has been exhausted. See do_ldap_search for further argument documentation. Args: servers (list): List of dicts, each containing a 'hostname' and 'port' Returns: A dict containing: success (bool): True if the auth was successful msg (str): The message associated with the result of the auth exceptions (list): Dict of CallError keyed on hostname containing any errors that occurred during the authentication process. Note that the presence of errors does not indicate auth failure! For example, the exceptions list will be non-empty if the first server could not be reached but the second server serviced the auth. """ # Assume user authentication never legitimately uses anonymous bind. if not password: defer.returnValue({ "success": False, "msg": AUTH_FAILED, "exceptions": {}, }) exceptions = {} auth_successful = False user_full_dn = None # Try each server in random order for server in servers: host = server["hostname"] port = server["port"] try: auth_successful, user_full_dn = yield self.authenticate_against_server( host=host, port=port, username=username, password=password, service_account_username=service_account_username, service_account_password=service_account_password, base_dns=base_dns, ntlm_domain=ntlm_domain, ntlm_workstation=ntlm_workstation, auth_type=auth_type, transport_type=transport_type, ssl_verify_depth=ssl_verify_depth, ssl_verify_hostname=ssl_verify_hostname, ssl_ca_certs=ssl_ca_certs, timeout=timeout, username_attributes=username_attributes, call_id=call_id, ) # If we reach this line without an exception, the auth was # serviced successfully and we don't need to check any more # servers. if auth_successful: log.sso_ldap( msg="Successful authentication against server", query_type=log.SSO_LDAP_QUERY_TYPE_AUTH, status=log.SSO_LDAP_QUERY_SUCCEEDED, server=host, port=port, username=username, proxy_key=self.proxy_key, ) else: log.sso_ldap( msg="Failed authentication against server", query_type=log.SSO_LDAP_QUERY_TYPE_AUTH, status=log.SSO_LDAP_QUERY_FAILED, server=host, port=port, username=username, proxy_key=self.proxy_key, reason="Invalid credentials", ) break except drpc.CallError as e: # serialize the errors in a similar format to what drpc does log.sso_ldap( msg="Failed authentication against server", query_type=log.SSO_LDAP_QUERY_TYPE_AUTH, status=log.SSO_LDAP_QUERY_FAILED, server=host, port=port, username=username, proxy_key=self.proxy_key, reason=e.error, ) exceptions[host] = { "error": e.error, "error_args": e.error_args, } if self.connection_failures_for_all_servers(servers, exceptions): raise drpc.CallError( ERR_LDAP_CANNOT_SERVICE_REQUEST, { "error": str("Failed to communicate with any domain controllers") }, ) if self.too_many_users_failure_on_all_servers(servers, exceptions): error = drpc.CallError( ERR_LDAP_NON_UNIQUE_USERNAME_ATTR, { "error": "Each domain controller returned more than one user for the provided username attributes." }, ) log.error( "{msg} {username}. Error: {error}", msg=ERR_LDAP_NON_UNIQUE_USERNAME_ATTR, username=username, error=error, ) raise error defer.returnValue({ "success": auth_successful, "user_full_dn": user_full_dn, "msg": AUTH_SUCCEEDED if auth_successful else AUTH_FAILED, "exceptions": exceptions, })
def get_func_for_drpc_call(self, call_name): if call_name in self.drpc_calls: return self.drpc_calls[call_name] log.error("Unknown DRPC call '{0}'".format(call_name)) return None