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
|