authentik.sources.ldap.password

Help validate and update passwords in LDAP

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

Possible values for the pwdProperties attribute

DOMAIN_PASSWORD_COMPLEX = <PwdProperties.DOMAIN_PASSWORD_COMPLEX: 1>
DOMAIN_PASSWORD_NO_ANON_CHANGE = <PwdProperties.DOMAIN_PASSWORD_NO_ANON_CHANGE: 2>
DOMAIN_PASSWORD_NO_CLEAR_CHANGE = <PwdProperties.DOMAIN_PASSWORD_NO_CLEAR_CHANGE: 4>
DOMAIN_LOCKOUT_ADMINS = <PwdProperties.DOMAIN_LOCKOUT_ADMINS: 8>
DOMAIN_PASSWORD_STORE_CLEARTEXT = <PwdProperties.DOMAIN_PASSWORD_STORE_CLEARTEXT: 16>
DOMAIN_REFUSE_PASSWORD_CHANGE = <PwdProperties.DOMAIN_REFUSE_PASSWORD_CHANGE: 32>
class PasswordCategories(enum.IntFlag):
37class PasswordCategories(IntFlag):
38    """Password categories as defined by Microsoft, a category can only be counted
39    once, hence intflag."""
40
41    NONE = 0
42    ALPHA_LOWER = 1
43    ALPHA_UPPER = 2
44    ALPHA_OTHER = 4
45    NUMERIC = 8
46    SYMBOL = 16

Password categories as defined by Microsoft, a category can only be counted once, hence intflag.

ALPHA_LOWER = <PasswordCategories.ALPHA_LOWER: 1>
ALPHA_UPPER = <PasswordCategories.ALPHA_UPPER: 2>
ALPHA_OTHER = <PasswordCategories.ALPHA_OTHER: 4>
SYMBOL = <PasswordCategories.SYMBOL: 16>
class LDAPPasswordChanger:
 49class LDAPPasswordChanger:
 50    """Help validate and update passwords in LDAP"""
 51
 52    _source: LDAPSource
 53
 54    def __init__(self, source: LDAPSource) -> None:
 55        self._source = source
 56        self._connection = source.connection()
 57
 58    @staticmethod
 59    def should_check_user(user: User) -> bool:
 60        """Check if the user has LDAP parameters and needs to be checked"""
 61        return LDAP_DISTINGUISHED_NAME in user.attributes
 62
 63    def get_domain_root_dn(self) -> str:
 64        """Attempt to get root DN via MS specific fields or generic LDAP fields"""
 65        info = self._connection.server.info
 66        if "rootDomainNamingContext" in info.other:
 67            return info.other["rootDomainNamingContext"][0]
 68        naming_contexts = info.naming_contexts
 69        naming_contexts.sort(key=len)
 70        return naming_contexts[0]
 71
 72    def check_ad_password_complexity_enabled(self) -> bool:
 73        """Check if DOMAIN_PASSWORD_COMPLEX is enabled"""
 74        root_dn = self.get_domain_root_dn()
 75        try:
 76            root_attrs = self._connection.extend.standard.paged_search(
 77                search_base=root_dn,
 78                search_filter="(objectClass=*)",
 79                search_scope=BASE,
 80                attributes=["pwdProperties"],
 81            )
 82            root_attrs = list(root_attrs)[0]
 83        except LDAPAttributeError, LDAPUnwillingToPerformResult, KeyError, IndexError:
 84            return False
 85        raw_pwd_properties = root_attrs.get("attributes", {}).get("pwdProperties", None)
 86        if not raw_pwd_properties:
 87            return False
 88
 89        try:
 90            pwd_properties = PwdProperties(raw_pwd_properties)
 91        except ValueError:
 92            return False
 93        if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties:
 94            return True
 95
 96        return False
 97
 98    def change_password(self, user: User, password: str):
 99        """Change user's password"""
100        user_dn = user.attributes.get(LDAP_DISTINGUISHED_NAME, None)
101        if not user_dn:
102            LOGGER.info(f"User has no {LDAP_DISTINGUISHED_NAME} set.")
103            return
104        try:
105            self._connection.extend.microsoft.modify_password(user_dn, password)
106        except LDAPAttributeError, LDAPUnwillingToPerformResult, LDAPNoSuchAttributeResult:
107            self._connection.extend.standard.modify_password(user_dn, new_password=password)
108
109    def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:
110        """Check if a password contains sAMAccount or displayName"""
111        users = list(
112            self._connection.extend.standard.paged_search(
113                search_base=user_dn,
114                search_filter=self._source.user_object_filter,
115                search_scope=BASE,
116                attributes=["displayName", "sAMAccountName"],
117            )
118        )
119        if len(users) != 1:
120            raise AssertionError()
121        user_attributes = users[0]["attributes"]
122        # If sAMAccountName is longer than 3 chars, check if its contained in password
123        if len(user_attributes["sAMAccountName"]) >= MIN_TOKEN_SIZE:
124            if password.lower() in user_attributes["sAMAccountName"].lower():
125                return False
126        # No display name set, can't check any further
127        if len(user_attributes["displayName"]) < 1:
128            return True
129        for display_name in user_attributes["displayName"]:
130            display_name_tokens = split(RE_DISPLAYNAME_SEPARATORS, display_name)
131            for token in display_name_tokens:
132                # Ignore tokens under 3 chars
133                if len(token) < MIN_TOKEN_SIZE:
134                    continue
135                if token.lower() in password.lower():
136                    return False
137        return True
138
139    def ad_password_complexity(self, password: str, user: User | None = None) -> bool:
140        """Check if password matches Active directory password policies
141
142        https://docs.microsoft.com/en-us/windows/security/threat-protection/
143            security-policy-settings/password-must-meet-complexity-requirements
144        """
145        if user:
146            # Check if password contains sAMAccountName or displayNames
147            if LDAP_DISTINGUISHED_NAME in user.attributes:
148                existing_user_check = self._ad_check_password_existing(
149                    password, user.attributes.get(LDAP_DISTINGUISHED_NAME)
150                )
151                if not existing_user_check:
152                    LOGGER.debug("Password failed name check", user=user)
153                    return existing_user_check
154
155        # Step 2, match at least 3 of 5 categories
156        matched_categories = PasswordCategories.NONE
157        required = 3
158        for letter in password:
159            # Only match one category per letter,
160            if letter.islower():
161                matched_categories |= PasswordCategories.ALPHA_LOWER
162            elif letter.isupper():
163                matched_categories |= PasswordCategories.ALPHA_UPPER
164            elif not letter.isascii() and letter.isalpha():
165                # Not exactly matching microsoft's policy, but count it as "Other unicode" char
166                # when its alpha and not ascii
167                matched_categories |= PasswordCategories.ALPHA_OTHER
168            elif letter.isnumeric():
169                matched_categories |= PasswordCategories.NUMERIC
170            elif letter in NON_ALPHA:
171                matched_categories |= PasswordCategories.SYMBOL
172        if bin(matched_categories).count("1") < required:
173            LOGGER.debug(
174                "Password didn't match enough categories",
175                has=matched_categories,
176                must=required,
177            )
178            return False
179        LOGGER.debug("Password matched categories", has=matched_categories, must=required)
180        return True

Help validate and update passwords in LDAP

LDAPPasswordChanger(source: authentik.sources.ldap.models.LDAPSource)
54    def __init__(self, source: LDAPSource) -> None:
55        self._source = source
56        self._connection = source.connection()
@staticmethod
def should_check_user(user: authentik.core.models.User) -> bool:
58    @staticmethod
59    def should_check_user(user: User) -> bool:
60        """Check if the user has LDAP parameters and needs to be checked"""
61        return LDAP_DISTINGUISHED_NAME in user.attributes

Check if the user has LDAP parameters and needs to be checked

def get_domain_root_dn(self) -> str:
63    def get_domain_root_dn(self) -> str:
64        """Attempt to get root DN via MS specific fields or generic LDAP fields"""
65        info = self._connection.server.info
66        if "rootDomainNamingContext" in info.other:
67            return info.other["rootDomainNamingContext"][0]
68        naming_contexts = info.naming_contexts
69        naming_contexts.sort(key=len)
70        return naming_contexts[0]

Attempt to get root DN via MS specific fields or generic LDAP fields

def check_ad_password_complexity_enabled(self) -> bool:
72    def check_ad_password_complexity_enabled(self) -> bool:
73        """Check if DOMAIN_PASSWORD_COMPLEX is enabled"""
74        root_dn = self.get_domain_root_dn()
75        try:
76            root_attrs = self._connection.extend.standard.paged_search(
77                search_base=root_dn,
78                search_filter="(objectClass=*)",
79                search_scope=BASE,
80                attributes=["pwdProperties"],
81            )
82            root_attrs = list(root_attrs)[0]
83        except LDAPAttributeError, LDAPUnwillingToPerformResult, KeyError, IndexError:
84            return False
85        raw_pwd_properties = root_attrs.get("attributes", {}).get("pwdProperties", None)
86        if not raw_pwd_properties:
87            return False
88
89        try:
90            pwd_properties = PwdProperties(raw_pwd_properties)
91        except ValueError:
92            return False
93        if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties:
94            return True
95
96        return False

Check if DOMAIN_PASSWORD_COMPLEX is enabled

def change_password(self, user: authentik.core.models.User, password: str):
 98    def change_password(self, user: User, password: str):
 99        """Change user's password"""
100        user_dn = user.attributes.get(LDAP_DISTINGUISHED_NAME, None)
101        if not user_dn:
102            LOGGER.info(f"User has no {LDAP_DISTINGUISHED_NAME} set.")
103            return
104        try:
105            self._connection.extend.microsoft.modify_password(user_dn, password)
106        except LDAPAttributeError, LDAPUnwillingToPerformResult, LDAPNoSuchAttributeResult:
107            self._connection.extend.standard.modify_password(user_dn, new_password=password)

Change user's password

def ad_password_complexity( self, password: str, user: authentik.core.models.User | None = None) -> bool:
139    def ad_password_complexity(self, password: str, user: User | None = None) -> bool:
140        """Check if password matches Active directory password policies
141
142        https://docs.microsoft.com/en-us/windows/security/threat-protection/
143            security-policy-settings/password-must-meet-complexity-requirements
144        """
145        if user:
146            # Check if password contains sAMAccountName or displayNames
147            if LDAP_DISTINGUISHED_NAME in user.attributes:
148                existing_user_check = self._ad_check_password_existing(
149                    password, user.attributes.get(LDAP_DISTINGUISHED_NAME)
150                )
151                if not existing_user_check:
152                    LOGGER.debug("Password failed name check", user=user)
153                    return existing_user_check
154
155        # Step 2, match at least 3 of 5 categories
156        matched_categories = PasswordCategories.NONE
157        required = 3
158        for letter in password:
159            # Only match one category per letter,
160            if letter.islower():
161                matched_categories |= PasswordCategories.ALPHA_LOWER
162            elif letter.isupper():
163                matched_categories |= PasswordCategories.ALPHA_UPPER
164            elif not letter.isascii() and letter.isalpha():
165                # Not exactly matching microsoft's policy, but count it as "Other unicode" char
166                # when its alpha and not ascii
167                matched_categories |= PasswordCategories.ALPHA_OTHER
168            elif letter.isnumeric():
169                matched_categories |= PasswordCategories.NUMERIC
170            elif letter in NON_ALPHA:
171                matched_categories |= PasswordCategories.SYMBOL
172        if bin(matched_categories).count("1") < required:
173            LOGGER.debug(
174                "Password didn't match enough categories",
175                has=matched_categories,
176                must=required,
177            )
178            return False
179        LOGGER.debug("Password matched categories", has=matched_categories, must=required)
180        return True

Check if password matches Active directory password policies

https://docs.microsoft.com/en-us/windows/security/threat-protection/ security-policy-settings/password-must-meet-complexity-requirements