We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Add UnwillingToPerformError to ldap/password.py:95
**Is your feature request related to a problem? Please describe.**
Authentik tries to modify the password the AD way, which uses a `modify` operation with the `unicodePwd` field, and then if it gets an `AttributeError` it tries the extended operation. However, [LLDAP](https://github.com/lldap/lldap) doesn't return an `AttributeError` but an `UnwillingToPerformError` since LLDAP doesn't support the modify operation at all, so it completely fails.

**Describe the solution you'd like**
Add an `UnwillingToPerformError` to the `except` on [`ldap/password.py:95`](https://github.com/goauthentik/authentik/blob/main/authentik/sources/ldap/password.py#L95)

**Describe alternatives you've considered**
There's no alternative.

**Additional context**
• [LLDAP log](https://cdn.discordapp.com/attachments/1108339414494613514/1108870676980449300/dockerlogs.txt)
<details>
<summary>Authentik Event Log</summary>
<img width="1604" alt="image" src="https://github.com/goauthentik/authentik/assets/2737082/047b64fa-468f-4a91-b5e7-4eecd27768b6">
</details>


This is a follow-up on https://github.com/goauthentik/authentik/issues/5652.


--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `authentik/sources/ldap/password.py`
Content:
```
1 """Help validate and update passwords in LDAP"""
2 from enum import IntFlag
3 from re import split
4 from typing import Optional
5 
6 from ldap3 import BASE
7 from ldap3.core.exceptions import LDAPAttributeError
8 from structlog.stdlib import get_logger
9 
10 from authentik.core.models import User
11 from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
12 from authentik.sources.ldap.models import LDAPSource
13 
14 LOGGER = get_logger()
15 
16 NON_ALPHA = r"~!@#$%^&*_-+=`|\(){}[]:;\"'<>,.?/"
17 RE_DISPLAYNAME_SEPARATORS = r",\.–—_\s#\t"
18 
19 
20 class PwdProperties(IntFlag):
21     """Possible values for the pwdProperties attribute"""
22 
23     DOMAIN_PASSWORD_COMPLEX = 1
24     DOMAIN_PASSWORD_NO_ANON_CHANGE = 2
25     DOMAIN_PASSWORD_NO_CLEAR_CHANGE = 4
26     DOMAIN_LOCKOUT_ADMINS = 8
27     DOMAIN_PASSWORD_STORE_CLEARTEXT = 16
28     DOMAIN_REFUSE_PASSWORD_CHANGE = 32
29 
30 
31 class PasswordCategories(IntFlag):
32     """Password categories as defined by Microsoft, a category can only be counted
33     once, hence intflag."""
34 
35     NONE = 0
36     ALPHA_LOWER = 1
37     ALPHA_UPPER = 2
38     ALPHA_OTHER = 4
39     NUMERIC = 8
40     SYMBOL = 16
41 
42 
43 class LDAPPasswordChanger:
44     """Help validate and update passwords in LDAP"""
45 
46     _source: LDAPSource
47 
48     def __init__(self, source: LDAPSource) -> None:
49         self._source = source
50         self._connection = source.connection()
51 
52     def get_domain_root_dn(self) -> str:
53         """Attempt to get root DN via MS specific fields or generic LDAP fields"""
54         info = self._connection.server.info
55         if "rootDomainNamingContext" in info.other:
56             return info.other["rootDomainNamingContext"][0]
57         naming_contexts = info.naming_contexts
58         naming_contexts.sort(key=len)
59         return naming_contexts[0]
60 
61     def check_ad_password_complexity_enabled(self) -> bool:
62         """Check if DOMAIN_PASSWORD_COMPLEX is enabled"""
63         root_dn = self.get_domain_root_dn()
64         try:
65             root_attrs = self._connection.extend.standard.paged_search(
66                 search_base=root_dn,
67                 search_filter="(objectClass=*)",
68                 search_scope=BASE,
69                 attributes=["pwdProperties"],
70             )
71             root_attrs = list(root_attrs)[0]
72         except (LDAPAttributeError, KeyError, IndexError):
73             return False
74         raw_pwd_properties = root_attrs.get("attributes", {}).get("pwdProperties", None)
75         if not raw_pwd_properties:
76             return False
77 
78         try:
79             pwd_properties = PwdProperties(raw_pwd_properties)
80         except ValueError:
81             return False
82         if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties:
83             return True
84 
85         return False
86 
87     def change_password(self, user: User, password: str):
88         """Change user's password"""
89         user_dn = user.attributes.get(LDAP_DISTINGUISHED_NAME, None)
90         if not user_dn:
91             LOGGER.info(f"User has no {LDAP_DISTINGUISHED_NAME} set.")
92             return
93         try:
94             self._connection.extend.microsoft.modify_password(user_dn, password)
95         except LDAPAttributeError:
96             self._connection.extend.standard.modify_password(user_dn, new_password=password)
97 
98     def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:
99         """Check if a password contains sAMAccount or displayName"""
100         users = list(
101             self._connection.extend.standard.paged_search(
102                 search_base=user_dn,
103                 search_filter=self._source.user_object_filter,
104                 search_scope=BASE,
105                 attributes=["displayName", "sAMAccountName"],
106             )
107         )
108         if len(users) != 1:
109             raise AssertionError()
110         user_attributes = users[0]["attributes"]
111         # If sAMAccountName is longer than 3 chars, check if its contained in password
112         if len(user_attributes["sAMAccountName"]) >= 3:
113             if password.lower() in user_attributes["sAMAccountName"].lower():
114                 return False
115         # No display name set, can't check any further
116         if len(user_attributes["displayName"]) < 1:
117             return True
118         for display_name in user_attributes["displayName"]:
119             display_name_tokens = split(RE_DISPLAYNAME_SEPARATORS, display_name)
120             for token in display_name_tokens:
121                 # Ignore tokens under 3 chars
122                 if len(token) < 3:
123                     continue
124                 if token.lower() in password.lower():
125                     return False
126         return True
127 
128     def ad_password_complexity(self, password: str, user: Optional[User] = None) -> bool:
129         """Check if password matches Active directory password policies
130 
131         https://docs.microsoft.com/en-us/windows/security/threat-protection/
132             security-policy-settings/password-must-meet-complexity-requirements
133         """
134         if user:
135             # Check if password contains sAMAccountName or displayNames
136             if LDAP_DISTINGUISHED_NAME in user.attributes:
137                 existing_user_check = self._ad_check_password_existing(
138                     password, user.attributes.get(LDAP_DISTINGUISHED_NAME)
139                 )
140                 if not existing_user_check:
141                     LOGGER.debug("Password failed name check", user=user)
142                     return existing_user_check
143 
144         # Step 2, match at least 3 of 5 categories
145         matched_categories = PasswordCategories.NONE
146         required = 3
147         for letter in password:
148             # Only match one category per letter,
149             if letter.islower():
150                 matched_categories |= PasswordCategories.ALPHA_LOWER
151             elif letter.isupper():
152                 matched_categories |= PasswordCategories.ALPHA_UPPER
153             elif not letter.isascii() and letter.isalpha():
154                 # Not exactly matching microsoft's policy, but count it as "Other unicode" char
155                 # when its alpha and not ascii
156                 matched_categories |= PasswordCategories.ALPHA_OTHER
157             elif letter.isnumeric():
158                 matched_categories |= PasswordCategories.NUMERIC
159             elif letter in NON_ALPHA:
160                 matched_categories |= PasswordCategories.SYMBOL
161         if bin(matched_categories).count("1") < required:
162             LOGGER.debug(
163                 "Password didn't match enough categories",
164                 has=matched_categories,
165                 must=required,
166             )
167             return False
168         LOGGER.debug("Password matched categories", has=matched_categories, must=required)
169         return True
170
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/authentik/sources/ldap/password.py b/authentik/sources/ldap/password.py
--- a/authentik/sources/ldap/password.py
+++ b/authentik/sources/ldap/password.py
@@ -4,7 +4,7 @@
 from typing import Optional
 
 from ldap3 import BASE
-from ldap3.core.exceptions import LDAPAttributeError
+from ldap3.core.exceptions import LDAPAttributeError, LDAPUnwillingToPerformResult
 from structlog.stdlib import get_logger
 
 from authentik.core.models import User
@@ -69,7 +69,7 @@
                 attributes=["pwdProperties"],
             )
             root_attrs = list(root_attrs)[0]
-        except (LDAPAttributeError, KeyError, IndexError):
+        except (LDAPAttributeError, LDAPUnwillingToPerformResult, KeyError, IndexError):
             return False
         raw_pwd_properties = root_attrs.get("attributes", {}).get("pwdProperties", None)
         if not raw_pwd_properties:
@@ -92,7 +92,7 @@
             return
         try:
             self._connection.extend.microsoft.modify_password(user_dn, password)
-        except LDAPAttributeError:
+        except (LDAPAttributeError, LDAPUnwillingToPerformResult):
             self._connection.extend.standard.modify_password(user_dn, new_password=password)
 
     def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:

{"golden_diff": "diff --git a/authentik/sources/ldap/password.py b/authentik/sources/ldap/password.py\n--- a/authentik/sources/ldap/password.py\n+++ b/authentik/sources/ldap/password.py\n@@ -4,7 +4,7 @@\n from typing import Optional\n \n from ldap3 import BASE\n-from ldap3.core.exceptions import LDAPAttributeError\n+from ldap3.core.exceptions import LDAPAttributeError, LDAPUnwillingToPerformResult\n from structlog.stdlib import get_logger\n \n from authentik.core.models import User\n@@ -69,7 +69,7 @@\n                 attributes=[\"pwdProperties\"],\n             )\n             root_attrs = list(root_attrs)[0]\n-        except (LDAPAttributeError, KeyError, IndexError):\n+        except (LDAPAttributeError, LDAPUnwillingToPerformResult, KeyError, IndexError):\n             return False\n         raw_pwd_properties = root_attrs.get(\"attributes\", {}).get(\"pwdProperties\", None)\n         if not raw_pwd_properties:\n@@ -92,7 +92,7 @@\n             return\n         try:\n             self._connection.extend.microsoft.modify_password(user_dn, password)\n-        except LDAPAttributeError:\n+        except (LDAPAttributeError, LDAPUnwillingToPerformResult):\n             self._connection.extend.standard.modify_password(user_dn, new_password=password)\n \n     def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:\n", "issue": "Add UnwillingToPerformError to ldap/password.py:95\n**Is your feature request related to a problem? Please describe.**\r\nAuthentik tries to modify the password the AD way, which uses a `modify` operation with the `unicodePwd` field, and then if it gets an `AttributeError` it tries the extended operation. However, [LLDAP](https://github.com/lldap/lldap) doesn't return an `AttributeError` but an `UnwillingToPerformError` since LLDAP doesn't support the modify operation at all, so it completely fails.\r\n\r\n**Describe the solution you'd like**\r\nAdd an `UnwillingToPerformError` to the `except` on [`ldap/password.py:95`](https://github.com/goauthentik/authentik/blob/main/authentik/sources/ldap/password.py#L95)\r\n\r\n**Describe alternatives you've considered**\r\nThere's no alternative.\r\n\r\n**Additional context**\r\n\u2022 [LLDAP log](https://cdn.discordapp.com/attachments/1108339414494613514/1108870676980449300/dockerlogs.txt)\r\n<details>\r\n<summary>Authentik Event Log</summary>\r\n<img width=\"1604\" alt=\"image\" src=\"https://github.com/goauthentik/authentik/assets/2737082/047b64fa-468f-4a91-b5e7-4eecd27768b6\">\r\n</details>\r\n\r\n\r\nThis is a follow-up on https://github.com/goauthentik/authentik/issues/5652.\r\n\n", "before_files": [{"content": "\"\"\"Help validate and update passwords in LDAP\"\"\"\nfrom enum import IntFlag\nfrom re import split\nfrom typing import Optional\n\nfrom ldap3 import BASE\nfrom ldap3.core.exceptions import LDAPAttributeError\nfrom structlog.stdlib import get_logger\n\nfrom authentik.core.models import User\nfrom authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME\nfrom authentik.sources.ldap.models import LDAPSource\n\nLOGGER = get_logger()\n\nNON_ALPHA = r\"~!@#$%^&*_-+=`|\\(){}[]:;\\\"'<>,.?/\"\nRE_DISPLAYNAME_SEPARATORS = r\",\\.\u2013\u2014_\\s#\\t\"\n\n\nclass PwdProperties(IntFlag):\n    \"\"\"Possible values for the pwdProperties attribute\"\"\"\n\n    DOMAIN_PASSWORD_COMPLEX = 1\n    DOMAIN_PASSWORD_NO_ANON_CHANGE = 2\n    DOMAIN_PASSWORD_NO_CLEAR_CHANGE = 4\n    DOMAIN_LOCKOUT_ADMINS = 8\n    DOMAIN_PASSWORD_STORE_CLEARTEXT = 16\n    DOMAIN_REFUSE_PASSWORD_CHANGE = 32\n\n\nclass PasswordCategories(IntFlag):\n    \"\"\"Password categories as defined by Microsoft, a category can only be counted\n    once, hence intflag.\"\"\"\n\n    NONE = 0\n    ALPHA_LOWER = 1\n    ALPHA_UPPER = 2\n    ALPHA_OTHER = 4\n    NUMERIC = 8\n    SYMBOL = 16\n\n\nclass LDAPPasswordChanger:\n    \"\"\"Help validate and update passwords in LDAP\"\"\"\n\n    _source: LDAPSource\n\n    def __init__(self, source: LDAPSource) -> None:\n        self._source = source\n        self._connection = source.connection()\n\n    def get_domain_root_dn(self) -> str:\n        \"\"\"Attempt to get root DN via MS specific fields or generic LDAP fields\"\"\"\n        info = self._connection.server.info\n        if \"rootDomainNamingContext\" in info.other:\n            return info.other[\"rootDomainNamingContext\"][0]\n        naming_contexts = info.naming_contexts\n        naming_contexts.sort(key=len)\n        return naming_contexts[0]\n\n    def check_ad_password_complexity_enabled(self) -> bool:\n        \"\"\"Check if DOMAIN_PASSWORD_COMPLEX is enabled\"\"\"\n        root_dn = self.get_domain_root_dn()\n        try:\n            root_attrs = self._connection.extend.standard.paged_search(\n                search_base=root_dn,\n                search_filter=\"(objectClass=*)\",\n                search_scope=BASE,\n                attributes=[\"pwdProperties\"],\n            )\n            root_attrs = list(root_attrs)[0]\n        except (LDAPAttributeError, KeyError, IndexError):\n            return False\n        raw_pwd_properties = root_attrs.get(\"attributes\", {}).get(\"pwdProperties\", None)\n        if not raw_pwd_properties:\n            return False\n\n        try:\n            pwd_properties = PwdProperties(raw_pwd_properties)\n        except ValueError:\n            return False\n        if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties:\n            return True\n\n        return False\n\n    def change_password(self, user: User, password: str):\n        \"\"\"Change user's password\"\"\"\n        user_dn = user.attributes.get(LDAP_DISTINGUISHED_NAME, None)\n        if not user_dn:\n            LOGGER.info(f\"User has no {LDAP_DISTINGUISHED_NAME} set.\")\n            return\n        try:\n            self._connection.extend.microsoft.modify_password(user_dn, password)\n        except LDAPAttributeError:\n            self._connection.extend.standard.modify_password(user_dn, new_password=password)\n\n    def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:\n        \"\"\"Check if a password contains sAMAccount or displayName\"\"\"\n        users = list(\n            self._connection.extend.standard.paged_search(\n                search_base=user_dn,\n                search_filter=self._source.user_object_filter,\n                search_scope=BASE,\n                attributes=[\"displayName\", \"sAMAccountName\"],\n            )\n        )\n        if len(users) != 1:\n            raise AssertionError()\n        user_attributes = users[0][\"attributes\"]\n        # If sAMAccountName is longer than 3 chars, check if its contained in password\n        if len(user_attributes[\"sAMAccountName\"]) >= 3:\n            if password.lower() in user_attributes[\"sAMAccountName\"].lower():\n                return False\n        # No display name set, can't check any further\n        if len(user_attributes[\"displayName\"]) < 1:\n            return True\n        for display_name in user_attributes[\"displayName\"]:\n            display_name_tokens = split(RE_DISPLAYNAME_SEPARATORS, display_name)\n            for token in display_name_tokens:\n                # Ignore tokens under 3 chars\n                if len(token) < 3:\n                    continue\n                if token.lower() in password.lower():\n                    return False\n        return True\n\n    def ad_password_complexity(self, password: str, user: Optional[User] = None) -> bool:\n        \"\"\"Check if password matches Active directory password policies\n\n        https://docs.microsoft.com/en-us/windows/security/threat-protection/\n            security-policy-settings/password-must-meet-complexity-requirements\n        \"\"\"\n        if user:\n            # Check if password contains sAMAccountName or displayNames\n            if LDAP_DISTINGUISHED_NAME in user.attributes:\n                existing_user_check = self._ad_check_password_existing(\n                    password, user.attributes.get(LDAP_DISTINGUISHED_NAME)\n                )\n                if not existing_user_check:\n                    LOGGER.debug(\"Password failed name check\", user=user)\n                    return existing_user_check\n\n        # Step 2, match at least 3 of 5 categories\n        matched_categories = PasswordCategories.NONE\n        required = 3\n        for letter in password:\n            # Only match one category per letter,\n            if letter.islower():\n                matched_categories |= PasswordCategories.ALPHA_LOWER\n            elif letter.isupper():\n                matched_categories |= PasswordCategories.ALPHA_UPPER\n            elif not letter.isascii() and letter.isalpha():\n                # Not exactly matching microsoft's policy, but count it as \"Other unicode\" char\n                # when its alpha and not ascii\n                matched_categories |= PasswordCategories.ALPHA_OTHER\n            elif letter.isnumeric():\n                matched_categories |= PasswordCategories.NUMERIC\n            elif letter in NON_ALPHA:\n                matched_categories |= PasswordCategories.SYMBOL\n        if bin(matched_categories).count(\"1\") < required:\n            LOGGER.debug(\n                \"Password didn't match enough categories\",\n                has=matched_categories,\n                must=required,\n            )\n            return False\n        LOGGER.debug(\"Password matched categories\", has=matched_categories, must=required)\n        return True\n", "path": "authentik/sources/ldap/password.py"}], "after_files": [{"content": "\"\"\"Help validate and update passwords in LDAP\"\"\"\nfrom enum import IntFlag\nfrom re import split\nfrom typing import Optional\n\nfrom ldap3 import BASE\nfrom ldap3.core.exceptions import LDAPAttributeError, LDAPUnwillingToPerformResult\nfrom structlog.stdlib import get_logger\n\nfrom authentik.core.models import User\nfrom authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME\nfrom authentik.sources.ldap.models import LDAPSource\n\nLOGGER = get_logger()\n\nNON_ALPHA = r\"~!@#$%^&*_-+=`|\\(){}[]:;\\\"'<>,.?/\"\nRE_DISPLAYNAME_SEPARATORS = r\",\\.\u2013\u2014_\\s#\\t\"\n\n\nclass PwdProperties(IntFlag):\n    \"\"\"Possible values for the pwdProperties attribute\"\"\"\n\n    DOMAIN_PASSWORD_COMPLEX = 1\n    DOMAIN_PASSWORD_NO_ANON_CHANGE = 2\n    DOMAIN_PASSWORD_NO_CLEAR_CHANGE = 4\n    DOMAIN_LOCKOUT_ADMINS = 8\n    DOMAIN_PASSWORD_STORE_CLEARTEXT = 16\n    DOMAIN_REFUSE_PASSWORD_CHANGE = 32\n\n\nclass PasswordCategories(IntFlag):\n    \"\"\"Password categories as defined by Microsoft, a category can only be counted\n    once, hence intflag.\"\"\"\n\n    NONE = 0\n    ALPHA_LOWER = 1\n    ALPHA_UPPER = 2\n    ALPHA_OTHER = 4\n    NUMERIC = 8\n    SYMBOL = 16\n\n\nclass LDAPPasswordChanger:\n    \"\"\"Help validate and update passwords in LDAP\"\"\"\n\n    _source: LDAPSource\n\n    def __init__(self, source: LDAPSource) -> None:\n        self._source = source\n        self._connection = source.connection()\n\n    def get_domain_root_dn(self) -> str:\n        \"\"\"Attempt to get root DN via MS specific fields or generic LDAP fields\"\"\"\n        info = self._connection.server.info\n        if \"rootDomainNamingContext\" in info.other:\n            return info.other[\"rootDomainNamingContext\"][0]\n        naming_contexts = info.naming_contexts\n        naming_contexts.sort(key=len)\n        return naming_contexts[0]\n\n    def check_ad_password_complexity_enabled(self) -> bool:\n        \"\"\"Check if DOMAIN_PASSWORD_COMPLEX is enabled\"\"\"\n        root_dn = self.get_domain_root_dn()\n        try:\n            root_attrs = self._connection.extend.standard.paged_search(\n                search_base=root_dn,\n                search_filter=\"(objectClass=*)\",\n                search_scope=BASE,\n                attributes=[\"pwdProperties\"],\n            )\n            root_attrs = list(root_attrs)[0]\n        except (LDAPAttributeError, LDAPUnwillingToPerformResult, KeyError, IndexError):\n            return False\n        raw_pwd_properties = root_attrs.get(\"attributes\", {}).get(\"pwdProperties\", None)\n        if not raw_pwd_properties:\n            return False\n\n        try:\n            pwd_properties = PwdProperties(raw_pwd_properties)\n        except ValueError:\n            return False\n        if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties:\n            return True\n\n        return False\n\n    def change_password(self, user: User, password: str):\n        \"\"\"Change user's password\"\"\"\n        user_dn = user.attributes.get(LDAP_DISTINGUISHED_NAME, None)\n        if not user_dn:\n            LOGGER.info(f\"User has no {LDAP_DISTINGUISHED_NAME} set.\")\n            return\n        try:\n            self._connection.extend.microsoft.modify_password(user_dn, password)\n        except (LDAPAttributeError, LDAPUnwillingToPerformResult):\n            self._connection.extend.standard.modify_password(user_dn, new_password=password)\n\n    def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:\n        \"\"\"Check if a password contains sAMAccount or displayName\"\"\"\n        users = list(\n            self._connection.extend.standard.paged_search(\n                search_base=user_dn,\n                search_filter=self._source.user_object_filter,\n                search_scope=BASE,\n                attributes=[\"displayName\", \"sAMAccountName\"],\n            )\n        )\n        if len(users) != 1:\n            raise AssertionError()\n        user_attributes = users[0][\"attributes\"]\n        # If sAMAccountName is longer than 3 chars, check if its contained in password\n        if len(user_attributes[\"sAMAccountName\"]) >= 3:\n            if password.lower() in user_attributes[\"sAMAccountName\"].lower():\n                return False\n        # No display name set, can't check any further\n        if len(user_attributes[\"displayName\"]) < 1:\n            return True\n        for display_name in user_attributes[\"displayName\"]:\n            display_name_tokens = split(RE_DISPLAYNAME_SEPARATORS, display_name)\n            for token in display_name_tokens:\n                # Ignore tokens under 3 chars\n                if len(token) < 3:\n                    continue\n                if token.lower() in password.lower():\n                    return False\n        return True\n\n    def ad_password_complexity(self, password: str, user: Optional[User] = None) -> bool:\n        \"\"\"Check if password matches Active directory password policies\n\n        https://docs.microsoft.com/en-us/windows/security/threat-protection/\n            security-policy-settings/password-must-meet-complexity-requirements\n        \"\"\"\n        if user:\n            # Check if password contains sAMAccountName or displayNames\n            if LDAP_DISTINGUISHED_NAME in user.attributes:\n                existing_user_check = self._ad_check_password_existing(\n                    password, user.attributes.get(LDAP_DISTINGUISHED_NAME)\n                )\n                if not existing_user_check:\n                    LOGGER.debug(\"Password failed name check\", user=user)\n                    return existing_user_check\n\n        # Step 2, match at least 3 of 5 categories\n        matched_categories = PasswordCategories.NONE\n        required = 3\n        for letter in password:\n            # Only match one category per letter,\n            if letter.islower():\n                matched_categories |= PasswordCategories.ALPHA_LOWER\n            elif letter.isupper():\n                matched_categories |= PasswordCategories.ALPHA_UPPER\n            elif not letter.isascii() and letter.isalpha():\n                # Not exactly matching microsoft's policy, but count it as \"Other unicode\" char\n                # when its alpha and not ascii\n                matched_categories |= PasswordCategories.ALPHA_OTHER\n            elif letter.isnumeric():\n                matched_categories |= PasswordCategories.NUMERIC\n            elif letter in NON_ALPHA:\n                matched_categories |= PasswordCategories.SYMBOL\n        if bin(matched_categories).count(\"1\") < required:\n            LOGGER.debug(\n                \"Password didn't match enough categories\",\n                has=matched_categories,\n                must=required,\n            )\n            return False\n        LOGGER.debug(\"Password matched categories\", has=matched_categories, must=required)\n        return True\n", "path": "authentik/sources/ldap/password.py"}]}