def log_err(self, stuff, why): try: # DRPC still logs errors in the old format. (Exception, String Explaining Error) # log.failure() can autodetect the exception in flight so we will just log the reason log.failure(why) except Exception: pass
def rotate_skeys(duo_client): """ Generate new DRPC skeys (signing and encryption) via ECDHE with the Duo cloud service. Args: duo_client (CloudSSODuoClient): a duo client with the current credentials Returns: (signing_skey, encryption_skey) tuple with the new keys as bytes, or reraises API errors """ proxy_public_key, proxy_private_key = drpc_crypto.generate_ephemeral_keys() try: ser_proxy_public_key = drpc_crypto.serialize_ephemeral_key( proxy_public_key) rotate_result = yield duo_client.proxy_rotate_skeys( ser_proxy_public_key) duo_public_key = drpc_crypto.deserialize_ephemeral_key( rotate_result["duo_public_key"]) except duo_async.DuoAPIError as e: log.failure("Rotate call failed") raise e new_signing_skey_bytes, new_encryption_skey_bytes = drpc_crypto.derive_shared_keys( duo_public_key, proxy_private_key) defer.returnValue((new_signing_skey_bytes, new_encryption_skey_bytes))
def get_creds_for_ldap_idp(self, rikey, auth_type): try: creds = self.credential_mapping[rikey] if auth_type == const.AD_AUTH_TYPE_SSPI: creds = ldap_base.ServiceAccountCredential(username=None, password=None) return creds except KeyError as e: log.failure(ERR_LDAP_SSO_MISSING_RIKEY) raise drpc.CallError(ERR_LDAP_SSO_MISSING_RIKEY, { "error": str(e), })
def datagramReceived(self, datagram, addr): """addr is a tuple of (host, port). If calling from a test case, you probably want to call handle_datagram_received instead so exceptions aren't lost.""" host, port = addr try: yield self.handle_datagram_received(datagram, host, port) except packet.PacketError as err: log.msg("dropping packet from %s:%s - %s" % (host, port, err)) except Exception: log.failure( "unhandled error processing request from {host}:{port}", host=host, port=port, )
def do_get_proxy_counter(self): log.msg("Reporting proxy counter") try: counter = secret_storage.access_proxy_counter() if self.debug: log.msg( "Found counter value {counter}".format(counter=counter)) except Exception as e: log.failure(ERR_COUNTER_FAILURE) raise drpc.CallError(ERR_COUNTER_FAILURE, { "during": "get_proxy_counter", "error": str(e) }) return { "counter": counter, }
def rotate_and_rejoin(self): new_signing_skey, new_encryption_skey = yield drpc_keys_rotation.rotate_skeys( self.duo_client) self.update_secrets(new_signing_skey, new_encryption_skey) # Rebuild the client with the new credentials self.duo_client = self.make_duo_client( duo_creds=self.duo_creds, host=self.host, client_type=duo_async.CloudSSODuoClient, ) try: server_protocol = yield self.duo_client.proxy_join( server_module=self, drpc_path=self.drpc_path) defer.returnValue(server_protocol) except Exception as e: log.failure("Failed to rejoin after rotation") raise e
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, ))
def _get_client( self, host, port, transport_type, ssl_verify_depth, ssl_verify_hostname, ssl_ca_certs, timeout, debug, is_logging_insecure, ): if ssl_ca_certs: ssl_ca_certs = load_ca_bundle(ssl_ca_certs) if not ssl_ca_certs: # Didn't parse out any PEM certificates. raise drpc_exceptions.CallBadArgError(["ssl_ca_certs"]) else: # Ensure ssl_ca_certs is a list. ssl_ca_certs = [] is_ssl = transport_type != const.AD_TRANSPORT_CLEAR if is_ssl: if ssl_verify_hostname and not ssl_ca_certs: log.msg("Missing required configuration item: " "'SSL verify hostname' requires that " "'SSL CA certs' also be specified " "(and non-empty).") raise drpc_exceptions.CallError(ERR_LDAP_CONFIGURATION_FAILED) try: factory = self.ldap_client_factory( timeout=timeout, transport_type=transport_type, ssl_verify_depth=ssl_verify_depth, ssl_verify_hostname=ssl_verify_hostname, ssl_ca_certs=ssl_ca_certs, debug=debug, is_logging_insecure=is_logging_insecure, ) except Exception as e: log.failure(ERR_LDAP_CONFIGURATION_FAILED) raise drpc_exceptions.CallError(ERR_LDAP_CONFIGURATION_FAILED, { "error": str(e), }) try: factory.connect_ldap(host, port) client = yield factory.deferred except ldap.client.ADClientError as e: if isinstance(e.underlying_exception, DNSLookupError): log.failure(ERR_LDAP_HOSTNAME_RESOLUTION_FAILED) raise drpc_exceptions.CallError( ERR_LDAP_HOSTNAME_RESOLUTION_FAILED, { "error": str(e), }) else: log.failure(ERR_LDAP_CONNECTION_FAILED) raise drpc_exceptions.CallError(ERR_LDAP_CONNECTION_FAILED, { "error": str(e), }) except Exception as e: log.failure(ERR_LDAP_CONNECTION_FAILED) raise drpc_exceptions.CallError(ERR_LDAP_CONNECTION_FAILED, { "error": str(e), }) def timeout_cb(): log.msg("LDAP operation timed out") client.transport.abortConnection() timeout_dc = reactor.callLater(timeout, timeout_cb) defer.returnValue((client, timeout_dc))
def do_ldap_search( self, host: str, port: int, base_dn: str, filter_text: Optional[str] = None, attributes: Optional[Set[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, pagination: Optional[str] = PAGINATION_TYPE_CRITICAL, page_size: int = DEFAULT_PAGE_SIZE, max_result_size: Optional[int] = None, timeout: int = 60, call_id: Optional[int] = None, ): """ * host: LDAP IP address we will perform actions against. * port: LDAP server port. * filter_text: Filter user search. * attributes: Retrieve only the listed attributes. If None, retrieve all attributes. * ntlm_{domain, workstation}: Windows authentication mechanism. Allows for bypassing primary bind if already within the domain. * auth_type: Bind method to use, e.g. NTLM, Plain, SSPI, etc. * transport_type: Method of transportation to use, e.g. Clear, TLS, LDAPS, etc. * timeout: If either establishing the connection or binding and searching take longer than this number of seconds the response will be an error. * page_size: As in RFC 2696. * max_result_size: If paging, stop requesting results after when at least this number of results have been received. """ if attributes is None: attributes = set() try: attributes = set(k.lower() for k in attributes) if "userpassword" in attributes: attributes.remove("userpassword") except Exception: log.failure("Error parsing provided attributes dict") raise drpc.CallBadArgError(["attributes"]) page_size = parse_positive_int(page_size, "page_size") if max_result_size is not None: max_result_size = parse_positive_int(max_result_size, "max_result_size") if not (filter_text is None or isinstance(filter_text, six.string_types)): raise drpc.CallBadArgError(["filter_text"]) bind_dn = self.service_account_username bind_pw = self.service_account_password log.msg( "Performing LDAP search: " "call_id={call_id} host={host} port={port} base_dn={base_dn} " "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} attributes={attributes}", call_id=call_id, host=host, port=port, base_dn=base_dn, auth_type=auth_type, transport_type=transport_type, ssl_verify_depth=ssl_verify_depth, ssl_verify_hostname=ssl_verify_hostname, attributes=attributes, ssl_ca_certs=ssl_ca_certs is not None, ) self._verify_ldap_config_args(bind_dn, bind_pw, auth_type, transport_type) 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: 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: if timeout_dc.active(): log.failure(ldap_base.ERR_LDAP_BIND_FAILED) raise drpc.CallError(ldap_base.ERR_LDAP_BIND_FAILED) else: log.failure(ldap_base.ERR_LDAP_TIMEOUT) raise drpc.CallError(ldap_base.ERR_LDAP_TIMEOUT, { "during": "bind", }) try: filter_bytes = None if filter_text is not None: filter_bytes = filter_text.encode("utf-8") result = yield self._paged_search( client=cl, base_dn=base_dn, filter_text=filter_bytes, attributes=[a.encode("utf-8") for a in attributes], pagination=pagination, page_size=page_size, max_result_size=max_result_size, ) # any object that has a 'member' attribute is a potential # group object. if that attribute is an empty list then the # group is either empty or contains more values than the # server's configured maximum attribute length (usually 1500) # results. # try to check for members using range before giving up. try_ranged = [obj for obj in result if obj.get("member") == []] if try_ranged: yield self.try_ranged_search(cl, base_dn, try_ranged) except ldap.client.ConnectionNotProperlyBoundError: log.failure(ldap_base.ERR_LDAP_SEARCH_FAILED_BAD_BIND) raise drpc.CallError(ldap_base.ERR_LDAP_SEARCH_FAILED_BAD_BIND) except Exception: if timeout_dc.active(): log.failure(ldap_base.ERR_LDAP_SEARCH_FAILED) raise drpc.CallError(ldap_base.ERR_LDAP_SEARCH_FAILED) else: log.failure(ldap_base.ERR_LDAP_TIMEOUT) raise drpc.CallError(ldap_base.ERR_LDAP_TIMEOUT, { "during": "search", }) finally: if timeout_dc.active(): timeout_dc.cancel() try: cl.transport.abortConnection() except Exception: pass defer.returnValue({ "results": result, })
def try_ranged_search(self, client, base_dn, objs): """ So, you are an empty group object or a group with too many members to return in one shot. Let's try to grab those members a few at a time until we determine that the group is empty or we've built up the full list of members. """ for obj in objs: member_range = None previous_result_empty = False # loop until we've exhausted all range segments while True: # can't help you without a dn. sorry. if not obj.get("distinguishedname"): break previous_range = member_range member_range = next_range(previous_range, previous_result_empty) # create an ldap filter object to search with. # pureldap will escape the distinguishedname, making it safe. # Since we're reading the distinguishedname from search results, # we'll need to unescape it before it gets re-escaped. attr_obj = pureldap.LDAPAttributeDescription( "distinguishedname") value_obj = pureldap.LDAPAssertionValue( distinguishedname.unescape(obj["distinguishedname"][0])) filter_object = pureldap.LDAPFilter_equalityMatch( attr_obj, value_obj) attributes = [ "member;range={0}-{1}".format(member_range[0], member_range[1]) ] try: ranged_res = yield client.perform_search( base_dn, filter_object, attributes=attributes) except LDAPOperationsError as e: # searched for a range that exceeds the total number of # elements in the attribute. # if this is the first instance of this error, we may # just need to try again with a '*' as the upper bound # to get the last few members. # if this is the second time (we are using the '*'), then # we already have all of the members and can break. if ldap.client.ERR_AD_CANT_RETRIEVE_ATTS in e.message: if previous_result_empty: break previous_result_empty = True continue # something bad happened... give up else: log.failure("Unexpected error") break # no results; we're done here if len(ranged_res) == 0: break else: # update the result object with the newly discovered # members for k, vs in ranged_res[0].items(): k = k.decode().lower() vs = [escape_bytes(v) for v in vs] if k.startswith("member;"): obj["member"].extend(vs)
def get_challenge_response(self, request: _ProxyRequest, state: Dict[str, Any]): """ Gets a challenge response for the given request Returns: packet.Packet """ # Do not have access to factor - request.password is users cookie success = False self.log_request(request, "Challenge Response: %r" % request.password) if state and "primary_res" in state: radius_attrs = state["primary_res"].radius_attrs else: radius_attrs = {} auth_cookie = urllib.parse.unquote(self._get_authcookie(request)) try: finish_res = yield self.client.proxy_finish(auth_cookie) except duo_async.DuoAPIError as e: log.failure("Duo proxy_finish call failed") response_packet = self.response_for_api_error( request, state["primary_res"], e, state["primary_res"].radius_attrs, ) defer.returnValue(response_packet) self.log_request(request, "Authcookie validation result: %r" % finish_res) if finish_res["valid_cookie"] and (finish_res["user"] == request.username): success = True if success: log.auth_standard( msg="Valid login from iframe", username=request.username, auth_stage=log.AUTH_SECONDARY, status=log.AUTH_ALLOW, server_section=self.server_section_name, server_section_ikey=self.server_section_ikey, client_ip=request.client_ip, ) defer.returnValue( self.create_accept_packet( request, radius_attrs=radius_attrs, )) else: log.auth_standard( msg="Invalid login from iframe", username=request.username, auth_stage=log.AUTH_SECONDARY, status=log.AUTH_REJECT, server_section=self.server_section_name, server_section_ikey=self.server_section_ikey, client_ip=request.client_ip, ) defer.returnValue(self.create_reject_packet(request))
def get_initial_response(self, request: _ProxyRequest): """ Gets a response for the first request over a connection from an appliance Returns: packet.Packet """ # make sure username, password were provided if request.username is None: msg = "No username provided" self.log_request(request, msg) log.auth_standard( msg=msg, username=request.username, auth_stage="Unknown", status=log.AUTH_ERROR, server_section=self.server_section_name, server_section_ikey=self.server_section_ikey, client_ip=request.client_ip, ) defer.returnValue(self.create_reject_packet(request, msg)) self.log_request(request, "login attempt for username %r" % request.username) if request.password is None: self.log_request( request, "Only the PAP with a Shared Secret format is" " supported. Is the system communicating with" " the Authentication Proxy using CHAP or" " MSCHAPv2 instead?", ) msg = "No password provided" self.log_request(request, msg) log.auth_standard( msg=msg, username=request.username, auth_stage=log.AUTH_PRIMARY, status=log.AUTH_ERROR, server_section=self.server_section_name, server_section_ikey=self.server_section_ikey, client_ip=request.client_ip, ) defer.returnValue(self.create_reject_packet(request, msg)) # perform primary authentication primary_res = yield self.primary_auth(request, request.password) if not primary_res.success: defer.returnValue( self.create_reject_packet(request, primary_res.msg)) if request.username in self.exempt_usernames: msg = "User exempted from 2FA" log.auth_standard( msg=msg, username=request.username, auth_stage=log.AUTH_SECONDARY, status=log.AUTH_ALLOW, server_section=self.server_section_name, server_section_ikey=self.server_section_ikey, client_ip=request.client_ip, ) defer.returnValue( self.create_accept_packet( request, msg, radius_attrs=primary_res.radius_attrs)) # get a txid from duo service try: init_res = yield self.client.proxy_init(request.username) except duo_async.DuoAPIError as e: log.failure("Duo proxy_init call failed") response_packet = self.response_for_api_error( request, primary_res, e, primary_res.radius_attrs, ) defer.returnValue(response_packet) # Note: MUST NOT YIELD between calling _create_challenge_id # and calling create_challenge() challenge_id = self._create_challenge_id() # build script injection with the txid params = { "script_uri": self.script_uri, "proxy_txid": init_res["proxy_txid"], "api_host": self.client.host, "state": challenge_id, } challenge_msg = self.script_inject % params if len(challenge_msg) > 253: raise ValueError( "response string is %d chars long, but cannot exceed 253 " "chars. If you specified a custom iframe_script_uri, you " "may need to shorten it by at least %d chars" % (len(challenge_msg), len(challenge_msg) - 253)) self.log_request(request, "Sending authentication challenge packet") state = { "primary_res": primary_res, } challenge_packet = self.create_challenge(request, challenge_msg, state=state, challenge_id=challenge_id) defer.returnValue(challenge_packet)
def do_ldap_health_check( self, rikey, host, port, base_dns, filter_text=None, attributes=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, call_id=None, ): """ Performs a health check against the specified host by binding as the service user and executing a dummy search against each provided base DN to ensure the searches are executed without error. """ # Do this outside of the try/except. If something goes wrong with these # operations, then we should raise that error and not treat it as an # unhealthy result. service_account_credentials = self.get_creds_for_ldap_idp( rikey, auth_type) self._verify_ldap_config_args( service_account_credentials.username, service_account_credentials.password, auth_type, transport_type, ) log.msg(( "Performing health check: " "call_id={call_id} host={host} port={port} rikey={rikey} " "attributes={attributes} 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}").format( call_id=call_id, host=host, port=port, rikey=rikey, attributes=attributes, 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 is not None, )) 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: try: # First make sure we can bind as the service user yield client.perform_bind( auth_type=auth_type, dn=service_account_credentials.username, username=service_account_credentials.username, password=service_account_credentials.password, domain=ntlm_domain, workstation=ntlm_workstation, permit_implicit=True, ) except OpenSSL.SSL.Error as e: error_message = util.retrieve_error_string_from_openssl_error( e) if OPENSSL_ERROR_INVALID_CERT in error_message: drpc_error = ERR_TLS_CERT elif OPENSSL_ERROR_INVALID_PROTOCOL in error_message: drpc_error = ERR_TLS_INVALID_PROTOCOL else: drpc_error = ERR_TLS_GENERIC log.failure(drpc_error) raise drpc.CallError(drpc_error, { "error": str(e), }) except ldaperrors.LDAPInvalidCredentials as e: raise drpc.CallError(ERR_LDAP_BIND_INVALID_CREDS, { "error": str(e), }) except Exception as e: # T76043: add better/more specific error handling so we can tell # Gary why the search failed 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), }) # Then execute a search over each provided base DN to make sure that: # 1) The service user has search permissions # 2) Each of the provided base DN's exists filter_obj = (ldapfilter.parseFilter(filter_text.encode("utf-8")) if filter_text else None) try: for base_dn in base_dns: yield client.perform_search( dn=base_dn, filter_object=filter_obj, attributes=attributes, scope=pureldap.LDAP_SCOPE_baseObject, sizeLimit=1, ) except (ldaperrors.LDAPNoSuchObject, ldaperrors.LDAPReferral) as e: # https://ldap.com/ldap-result-code-reference-core-ldapv3-result-codes/#rc-noSuchObject # Object at the requested BaseDN doesn't exist. Since we are searching for just anything at all # this likely means the Base DN is invalid. # https://ldap.com/ldap-result-code-reference-core-ldapv3-result-codes/#rc-referral # We dont support referrals so it's basically an unknown/bad DN log.failure(ERR_LDAP_INVALID_BASE_DN) raise drpc.CallError(ERR_LDAP_INVALID_BASE_DN, { "error": str(e), "base_dn": str(base_dn) }) except ldap.client.ConnectionNotProperlyBoundError as e: log.failure(ldap_base.ERR_LDAP_SEARCH_FAILED_BAD_BIND) raise drpc.CallError(ldap_base.ERR_LDAP_SEARCH_FAILED_BAD_BIND, { "error": str(e), }) except Exception as e: # T76043: add better/more specific error handling so we can tell # Gary why the search failed if timeout_dc.active(): log.failure(ldap_base.ERR_LDAP_SEARCH_FAILED) raise drpc.CallError(ldap_base.ERR_LDAP_SEARCH_FAILED, { "error": str(e), }) else: log.failure(ldap_base.ERR_LDAP_TIMEOUT) raise drpc.CallError( ldap_base.ERR_LDAP_TIMEOUT, { "during": "search", "error": str(e), }, ) finally: if timeout_dc.active(): timeout_dc.cancel() try: client.transport.abortConnection() except Exception: log.failure( "Error cleaning up connection to host {}".format(host)) # If nothing went wrong by this point, then everything's healthy as # far as we can tell. result = { "healthy": True, } log.msg(result) defer.returnValue(result)
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))