156 lines
		
	
	
		
			5.7 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			156 lines
		
	
	
		
			5.7 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| """Help validate and update passwords in LDAP"""
 | ||
| from enum import IntFlag
 | ||
| from re import split
 | ||
| from typing import Optional
 | ||
| 
 | ||
| import ldap3
 | ||
| import ldap3.core.exceptions
 | ||
| from structlog import get_logger
 | ||
| 
 | ||
| from passbook.core.models import User
 | ||
| from passbook.sources.ldap.models import LDAPSource
 | ||
| 
 | ||
| LOGGER = get_logger()
 | ||
| 
 | ||
| NON_ALPHA = r"~!@#$%^&*_-+=`|\(){}[]:;\"'<>,.?/"
 | ||
| RE_DISPLAYNAME_SEPARATORS = r",\.–—_\s#\t"
 | ||
| 
 | ||
| 
 | ||
| class PwdProperties(IntFlag):
 | ||
|     """Possible values for the pwdProperties attribute"""
 | ||
| 
 | ||
|     DOMAIN_PASSWORD_COMPLEX = 1
 | ||
|     DOMAIN_PASSWORD_NO_ANON_CHANGE = 2
 | ||
|     DOMAIN_PASSWORD_NO_CLEAR_CHANGE = 4
 | ||
|     DOMAIN_LOCKOUT_ADMINS = 8
 | ||
|     DOMAIN_PASSWORD_STORE_CLEARTEXT = 16
 | ||
|     DOMAIN_REFUSE_PASSWORD_CHANGE = 32
 | ||
| 
 | ||
| 
 | ||
| class PasswordCategories(IntFlag):
 | ||
|     """Password categories as defined by Microsoft, a category can only be counted
 | ||
|     once, hence intflag."""
 | ||
| 
 | ||
|     NONE = 0
 | ||
|     ALPHA_LOWER = 1
 | ||
|     ALPHA_UPPER = 2
 | ||
|     ALPHA_OTHER = 4
 | ||
|     NUMERIC = 8
 | ||
|     SYMBOL = 16
 | ||
| 
 | ||
| 
 | ||
| class LDAPPasswordChanger:
 | ||
|     """Help validate and update passwords in LDAP"""
 | ||
| 
 | ||
|     _source: LDAPSource
 | ||
| 
 | ||
|     def __init__(self, source: LDAPSource) -> None:
 | ||
|         self._source = source
 | ||
| 
 | ||
|     def get_domain_root_dn(self) -> str:
 | ||
|         """Attempt to get root DN via MS specific fields or generic LDAP fields"""
 | ||
|         info = self._source.connection.server.info
 | ||
|         if "rootDomainNamingContext" in info.other:
 | ||
|             return info.other["rootDomainNamingContext"][0]
 | ||
|         naming_contexts = info.naming_contexts
 | ||
|         naming_contexts.sort(key=len)
 | ||
|         return naming_contexts[0]
 | ||
| 
 | ||
|     def check_ad_password_complexity_enabled(self) -> bool:
 | ||
|         """Check if DOMAIN_PASSWORD_COMPLEX is enabled"""
 | ||
|         root_dn = self.get_domain_root_dn()
 | ||
|         root_attrs = self._source.connection.extend.standard.paged_search(
 | ||
|             search_base=root_dn,
 | ||
|             search_filter="(objectClass=*)",
 | ||
|             search_scope=ldap3.BASE,
 | ||
|             attributes=["pwdProperties"],
 | ||
|         )
 | ||
|         root_attrs = list(root_attrs)[0]
 | ||
|         pwd_properties = PwdProperties(root_attrs["attributes"]["pwdProperties"])
 | ||
|         if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties:
 | ||
|             return True
 | ||
| 
 | ||
|         return False
 | ||
| 
 | ||
|     def change_password(self, user: User, password: str):
 | ||
|         """Change user's password"""
 | ||
|         user_dn = user.attributes.get("distinguishedName", None)
 | ||
|         if not user_dn:
 | ||
|             raise AttributeError("User has no distinguishedName set.")
 | ||
|         self._source.connection.extend.microsoft.modify_password(user_dn, password)
 | ||
| 
 | ||
|     def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:
 | ||
|         """Check if a password contains sAMAccount or displayName"""
 | ||
|         users = list(
 | ||
|             self._source.connection.extend.standard.paged_search(
 | ||
|                 search_base=user_dn,
 | ||
|                 search_filter=self._source.user_object_filter,
 | ||
|                 search_scope=ldap3.BASE,
 | ||
|                 attributes=["displayName", "sAMAccountName"],
 | ||
|             )
 | ||
|         )
 | ||
|         if len(users) != 1:
 | ||
|             raise AssertionError()
 | ||
|         user_attributes = users[0]["attributes"]
 | ||
|         # If sAMAccountName is longer than 3 chars, check if its contained in password
 | ||
|         if len(user_attributes["sAMAccountName"]) >= 3:
 | ||
|             if password.lower() in user_attributes["sAMAccountName"].lower():
 | ||
|                 return False
 | ||
|         display_name_tokens = split(
 | ||
|             RE_DISPLAYNAME_SEPARATORS, user_attributes["displayName"]
 | ||
|         )
 | ||
|         for token in display_name_tokens:
 | ||
|             # Ignore tokens under 3 chars
 | ||
|             if len(token) < 3:
 | ||
|                 continue
 | ||
|             if token.lower() in password.lower():
 | ||
|                 return False
 | ||
|         return True
 | ||
| 
 | ||
|     def ad_password_complexity(
 | ||
|         self, password: str, user: Optional[User] = None
 | ||
|     ) -> bool:
 | ||
|         """Check if password matches Active direcotry password policies
 | ||
| 
 | ||
|         https://docs.microsoft.com/en-us/windows/security/threat-protection/
 | ||
|             security-policy-settings/password-must-meet-complexity-requirements
 | ||
|         """
 | ||
|         if user:
 | ||
|             # Check if password contains sAMAccountName or displayNames
 | ||
|             if "distinguishedName" in user.attributes:
 | ||
|                 existing_user_check = self._ad_check_password_existing(
 | ||
|                     password, user.attributes.get("distinguishedName")
 | ||
|                 )
 | ||
|                 if not existing_user_check:
 | ||
|                     LOGGER.debug("Password failed name check", user=user)
 | ||
|                     return existing_user_check
 | ||
| 
 | ||
|         # Step 2, match at least 3 of 5 categories
 | ||
|         matched_categories = PasswordCategories.NONE
 | ||
|         required = 3
 | ||
|         for letter in password:
 | ||
|             # Only match one category per letter,
 | ||
|             if letter.islower():
 | ||
|                 matched_categories |= PasswordCategories.ALPHA_LOWER
 | ||
|             elif letter.isupper():
 | ||
|                 matched_categories |= PasswordCategories.ALPHA_UPPER
 | ||
|             elif not letter.isascii() and letter.isalpha():
 | ||
|                 # Not exactly matching microsoft's policy, but count it as "Other unicode" char
 | ||
|                 # when its alpha and not ascii
 | ||
|                 matched_categories |= PasswordCategories.ALPHA_OTHER
 | ||
|             elif letter.isnumeric():
 | ||
|                 matched_categories |= PasswordCategories.NUMERIC
 | ||
|             elif letter in NON_ALPHA:
 | ||
|                 matched_categories |= PasswordCategories.SYMBOL
 | ||
|         if bin(matched_categories).count("1") < required:
 | ||
|             LOGGER.debug(
 | ||
|                 "Password didn't match enough categories",
 | ||
|                 has=matched_categories,
 | ||
|                 must=required,
 | ||
|             )
 | ||
|             return False
 | ||
|         LOGGER.debug(
 | ||
|             "Password matched categories", has=matched_categories, must=required
 | ||
|         )
 | ||
|         return True
 | 
