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.
NONE =
<PasswordCategories.NONE: 0>
ALPHA_LOWER =
<PasswordCategories.ALPHA_LOWER: 1>
ALPHA_UPPER =
<PasswordCategories.ALPHA_UPPER: 2>
ALPHA_OTHER =
<PasswordCategories.ALPHA_OTHER: 4>
NUMERIC =
<PasswordCategories.NUMERIC: 8>
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)
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
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