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
 |