sources/ldap: divide connector into password, sync and auth, add unittests for password
This commit is contained in:
parent
945d5bfaf6
commit
59e8dca499
|
@ -1,9 +1,12 @@
|
||||||
"""passbook LDAP Authentication Backend"""
|
"""passbook LDAP Authentication Backend"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import ldap3
|
||||||
from django.contrib.auth.backends import ModelBackend
|
from django.contrib.auth.backends import ModelBackend
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.sources.ldap.connector import Connector
|
from passbook.core.models import User
|
||||||
from passbook.sources.ldap.models import LDAPSource
|
from passbook.sources.ldap.models import LDAPSource
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
@ -18,7 +21,56 @@ class LDAPBackend(ModelBackend):
|
||||||
return None
|
return None
|
||||||
for source in LDAPSource.objects.filter(enabled=True):
|
for source in LDAPSource.objects.filter(enabled=True):
|
||||||
LOGGER.debug("LDAP Auth attempt", source=source)
|
LOGGER.debug("LDAP Auth attempt", source=source)
|
||||||
user = Connector(source).auth_user(**kwargs)
|
user = self.auth_user(source, **kwargs)
|
||||||
if user:
|
if user:
|
||||||
return user
|
return user
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def auth_user(
|
||||||
|
self, source: LDAPSource, password: str, **filters: str
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""Try to bind as either user_dn or mail with password.
|
||||||
|
Returns True on success, otherwise False"""
|
||||||
|
users = User.objects.filter(**filters)
|
||||||
|
if not users.exists():
|
||||||
|
return None
|
||||||
|
user: User = users.first()
|
||||||
|
if "distinguishedName" not in user.attributes:
|
||||||
|
LOGGER.debug(
|
||||||
|
"User doesn't have DN set, assuming not LDAP imported.", user=user
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
# Either has unusable password,
|
||||||
|
# or has a password, but couldn't be authenticated by ModelBackend.
|
||||||
|
# This means we check with a bind to see if the LDAP password has changed
|
||||||
|
if self.auth_user_by_bind(source, user, password):
|
||||||
|
# Password given successfully binds to LDAP, so we save it in our Database
|
||||||
|
LOGGER.debug("Updating user's password in DB", user=user)
|
||||||
|
user.set_password(password, signal=False)
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
# Password doesn't match
|
||||||
|
LOGGER.debug("Failed to bind, password invalid")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def auth_user_by_bind(
|
||||||
|
self, source: LDAPSource, user: User, password: str
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""Attempt authentication by binding to the LDAP server as `user`. This
|
||||||
|
method should be avoided as its slow to do the bind."""
|
||||||
|
# Try to bind as new user
|
||||||
|
LOGGER.debug("Attempting Binding as user", user=user)
|
||||||
|
try:
|
||||||
|
temp_connection = ldap3.Connection(
|
||||||
|
source.connection.server,
|
||||||
|
user=user.attributes.get("distinguishedName"),
|
||||||
|
password=password,
|
||||||
|
raise_exceptions=True,
|
||||||
|
)
|
||||||
|
temp_connection.bind()
|
||||||
|
return user
|
||||||
|
except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception:
|
||||||
|
LOGGER.debug("LDAPInvalidCredentialsResult", user=user, error=exception)
|
||||||
|
except ldap3.core.exceptions.LDAPException as exception:
|
||||||
|
LOGGER.warning(exception)
|
||||||
|
return None
|
||||||
|
|
|
@ -0,0 +1,155 @@
|
||||||
|
"""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
|
|
@ -10,8 +10,8 @@ from ldap3.core.exceptions import LDAPException
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.core.signals import password_changed
|
from passbook.core.signals import password_changed
|
||||||
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||||
from passbook.sources.ldap.connector import Connector
|
|
||||||
from passbook.sources.ldap.models import LDAPSource
|
from passbook.sources.ldap.models import LDAPSource
|
||||||
|
from passbook.sources.ldap.password import LDAPPasswordChanger
|
||||||
from passbook.sources.ldap.tasks import sync_single
|
from passbook.sources.ldap.tasks import sync_single
|
||||||
from passbook.stages.prompt.signals import password_validate
|
from passbook.stages.prompt.signals import password_validate
|
||||||
|
|
||||||
|
@ -32,9 +32,9 @@ def ldap_password_validate(sender, password: str, plan_context: Dict[str, Any],
|
||||||
if not sources.exists():
|
if not sources.exists():
|
||||||
return
|
return
|
||||||
source = sources.first()
|
source = sources.first()
|
||||||
connector = Connector(source)
|
changer = LDAPPasswordChanger(source)
|
||||||
if connector.check_ad_password_complexity_enabled():
|
if changer.check_ad_password_complexity_enabled():
|
||||||
passing = connector.ad_password_complexity(
|
passing = changer.ad_password_complexity(
|
||||||
password, plan_context.get(PLAN_CONTEXT_PENDING_USER, None)
|
password, plan_context.get(PLAN_CONTEXT_PENDING_USER, None)
|
||||||
)
|
)
|
||||||
if not passing:
|
if not passing:
|
||||||
|
@ -52,8 +52,8 @@ def ldap_sync_password(sender, user: User, password: str, **_):
|
||||||
if not sources.exists():
|
if not sources.exists():
|
||||||
return
|
return
|
||||||
source = sources.first()
|
source = sources.first()
|
||||||
connector = Connector(source)
|
changer = LDAPPasswordChanger(source)
|
||||||
try:
|
try:
|
||||||
connector.change_password(user, password)
|
changer.change_password(user, password)
|
||||||
except LDAPException as exc:
|
except LDAPException as exc:
|
||||||
raise ValidationError("Failed to set password") from exc
|
raise ValidationError("Failed to set password") from exc
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
"""Wrapper for ldap3 to easily manage user"""
|
"""Sync LDAP Users and groups into passbook"""
|
||||||
from enum import IntFlag
|
from typing import Any, Dict
|
||||||
from re import split
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
import ldap3
|
import ldap3
|
||||||
import ldap3.core.exceptions
|
import ldap3.core.exceptions
|
||||||
|
@ -14,23 +12,9 @@ from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
NON_ALPHA = r"~!@#$%^&*_-+=`|\(){}[]:;\"'<>,.?/"
|
|
||||||
RE_DISPLAYNAME_SEPARATORS = r",\.–—_\s#\t"
|
|
||||||
|
|
||||||
|
class LDAPSynchronizer:
|
||||||
class PwdProperties(IntFlag):
|
"""Sync LDAP Users and groups into passbook"""
|
||||||
"""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 Connector:
|
|
||||||
"""Wrapper for ldap3 to easily manage user authentication and creation"""
|
|
||||||
|
|
||||||
_source: LDAPSource
|
_source: LDAPSource
|
||||||
|
|
||||||
|
@ -198,151 +182,3 @@ class Connector:
|
||||||
"distinguishedName"
|
"distinguishedName"
|
||||||
)
|
)
|
||||||
return properties
|
return properties
|
||||||
|
|
||||||
def auth_user(self, password: str, **filters: str) -> Optional[User]:
|
|
||||||
"""Try to bind as either user_dn or mail with password.
|
|
||||||
Returns True on success, otherwise False"""
|
|
||||||
users = User.objects.filter(**filters)
|
|
||||||
if not users.exists():
|
|
||||||
return None
|
|
||||||
user: User = users.first()
|
|
||||||
if "distinguishedName" not in user.attributes:
|
|
||||||
LOGGER.debug(
|
|
||||||
"User doesn't have DN set, assuming not LDAP imported.", user=user
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
# Either has unusable password,
|
|
||||||
# or has a password, but couldn't be authenticated by ModelBackend.
|
|
||||||
# This means we check with a bind to see if the LDAP password has changed
|
|
||||||
if self.auth_user_by_bind(user, password):
|
|
||||||
# Password given successfully binds to LDAP, so we save it in our Database
|
|
||||||
LOGGER.debug("Updating user's password in DB", user=user)
|
|
||||||
user.set_password(password, signal=False)
|
|
||||||
user.save()
|
|
||||||
return user
|
|
||||||
# Password doesn't match
|
|
||||||
LOGGER.debug("Failed to bind, password invalid")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def auth_user_by_bind(self, user: User, password: str) -> Optional[User]:
|
|
||||||
"""Attempt authentication by binding to the LDAP server as `user`. This
|
|
||||||
method should be avoided as its slow to do the bind."""
|
|
||||||
# Try to bind as new user
|
|
||||||
LOGGER.debug("Attempting Binding as user", user=user)
|
|
||||||
try:
|
|
||||||
temp_connection = ldap3.Connection(
|
|
||||||
self._source.connection.server,
|
|
||||||
user=user.attributes.get("distinguishedName"),
|
|
||||||
password=password,
|
|
||||||
raise_exceptions=True,
|
|
||||||
)
|
|
||||||
temp_connection.bind()
|
|
||||||
return user
|
|
||||||
except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception:
|
|
||||||
LOGGER.debug("LDAPInvalidCredentialsResult", user=user, error=exception)
|
|
||||||
except ldap3.core.exceptions.LDAPException as exception:
|
|
||||||
LOGGER.warning(exception)
|
|
||||||
return None
|
|
||||||
|
|
||||||
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 = self._source.connection.extend.standard.paged_search(
|
|
||||||
search_base=user_dn,
|
|
||||||
search_filter="(objectClass=*)",
|
|
||||||
search_scope=ldap3.BASE,
|
|
||||||
attributes=["displayName", "sAMAccountName"],
|
|
||||||
)
|
|
||||||
if len(users) != 1:
|
|
||||||
raise AssertionError()
|
|
||||||
user = users[0]
|
|
||||||
# If sAMAccountName is longer than 3 chars, check if its contained in password
|
|
||||||
if len(user.sAMAccountName.value) >= 3:
|
|
||||||
if password.lower() in user.sAMAccountName.value.lower():
|
|
||||||
return False
|
|
||||||
display_name_tokens = split(RE_DISPLAYNAME_SEPARATORS, user.displayName.value)
|
|
||||||
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 = 0
|
|
||||||
required = 3
|
|
||||||
for letter in password:
|
|
||||||
# Only match one category per letter,
|
|
||||||
if letter.islower():
|
|
||||||
matched_categories += 1
|
|
||||||
elif letter.isupper():
|
|
||||||
matched_categories += 1
|
|
||||||
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 += 1
|
|
||||||
elif letter.isnumeric():
|
|
||||||
matched_categories += 1
|
|
||||||
elif letter in NON_ALPHA:
|
|
||||||
matched_categories += 1
|
|
||||||
if matched_categories < 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
|
|
|
@ -4,8 +4,8 @@ from time import time
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
from passbook.root.celery import CELERY_APP
|
from passbook.root.celery import CELERY_APP
|
||||||
from passbook.sources.ldap.connector import Connector
|
|
||||||
from passbook.sources.ldap.models import LDAPSource
|
from passbook.sources.ldap.models import LDAPSource
|
||||||
|
from passbook.sources.ldap.sync import LDAPSynchronizer
|
||||||
|
|
||||||
|
|
||||||
@CELERY_APP.task()
|
@CELERY_APP.task()
|
||||||
|
@ -19,9 +19,9 @@ def sync():
|
||||||
def sync_single(source_pk):
|
def sync_single(source_pk):
|
||||||
"""Sync a single source"""
|
"""Sync a single source"""
|
||||||
source: LDAPSource = LDAPSource.objects.get(pk=source_pk)
|
source: LDAPSource = LDAPSource.objects.get(pk=source_pk)
|
||||||
connector = Connector(source)
|
syncer = LDAPSynchronizer(source)
|
||||||
connector.sync_users()
|
syncer.sync_users()
|
||||||
connector.sync_groups()
|
syncer.sync_groups()
|
||||||
connector.sync_membership()
|
syncer.sync_membership()
|
||||||
cache_key = source.state_cache_prefix("last_sync")
|
cache_key = source.state_cache_prefix("last_sync")
|
||||||
cache.set(cache_key, time(), timeout=60 * 60)
|
cache.set(cache_key, time(), timeout=60 * 60)
|
||||||
|
|
|
@ -1,149 +0,0 @@
|
||||||
"""LDAP Source tests"""
|
|
||||||
from unittest.mock import Mock, PropertyMock, patch
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
|
|
||||||
|
|
||||||
from passbook.core.models import Group, User
|
|
||||||
from passbook.providers.oauth2.generators import generate_client_secret
|
|
||||||
from passbook.sources.ldap.auth import LDAPBackend
|
|
||||||
from passbook.sources.ldap.connector import Connector
|
|
||||||
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
|
||||||
from passbook.sources.ldap.tasks import sync
|
|
||||||
|
|
||||||
|
|
||||||
def _build_mock_connection() -> Connection:
|
|
||||||
"""Create mock connection"""
|
|
||||||
server = Server("my_fake_server", get_info=OFFLINE_AD_2012_R2)
|
|
||||||
_pass = "foo" # noqa # nosec
|
|
||||||
connection = Connection(
|
|
||||||
server,
|
|
||||||
user="cn=my_user,ou=test,o=lab",
|
|
||||||
password=_pass,
|
|
||||||
client_strategy=MOCK_SYNC,
|
|
||||||
)
|
|
||||||
connection.strategy.add_entry(
|
|
||||||
"cn=group1,ou=groups,ou=test,o=lab",
|
|
||||||
{
|
|
||||||
"name": "test-group",
|
|
||||||
"objectSid": "unique-test-group",
|
|
||||||
"objectCategory": "Group",
|
|
||||||
"distinguishedName": "cn=group1,ou=groups,ou=test,o=lab",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Group without SID
|
|
||||||
connection.strategy.add_entry(
|
|
||||||
"cn=group2,ou=groups,ou=test,o=lab",
|
|
||||||
{
|
|
||||||
"name": "test-group",
|
|
||||||
"objectCategory": "Group",
|
|
||||||
"distinguishedName": "cn=group2,ou=groups,ou=test,o=lab",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
connection.strategy.add_entry(
|
|
||||||
"cn=user0,ou=users,ou=test,o=lab",
|
|
||||||
{
|
|
||||||
"userPassword": LDAP_PASSWORD,
|
|
||||||
"sAMAccountName": "user0_sn",
|
|
||||||
"name": "user0_sn",
|
|
||||||
"revision": 0,
|
|
||||||
"objectSid": "user0",
|
|
||||||
"objectCategory": "Person",
|
|
||||||
"memberOf": "cn=group1,ou=groups,ou=test,o=lab",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# User without SID
|
|
||||||
connection.strategy.add_entry(
|
|
||||||
"cn=user1,ou=users,ou=test,o=lab",
|
|
||||||
{
|
|
||||||
"userPassword": "test1111",
|
|
||||||
"sAMAccountName": "user2_sn",
|
|
||||||
"name": "user1_sn",
|
|
||||||
"revision": 0,
|
|
||||||
"objectCategory": "Person",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Duplicate users
|
|
||||||
connection.strategy.add_entry(
|
|
||||||
"cn=user2,ou=users,ou=test,o=lab",
|
|
||||||
{
|
|
||||||
"userPassword": "test2222",
|
|
||||||
"sAMAccountName": "user2_sn",
|
|
||||||
"name": "user2_sn",
|
|
||||||
"revision": 0,
|
|
||||||
"objectSid": "unique-test2222",
|
|
||||||
"objectCategory": "Person",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
connection.strategy.add_entry(
|
|
||||||
"cn=user3,ou=users,ou=test,o=lab",
|
|
||||||
{
|
|
||||||
"userPassword": "test2222",
|
|
||||||
"sAMAccountName": "user2_sn",
|
|
||||||
"name": "user2_sn",
|
|
||||||
"revision": 0,
|
|
||||||
"objectSid": "unique-test2222",
|
|
||||||
"objectCategory": "Person",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
connection.bind()
|
|
||||||
return connection
|
|
||||||
|
|
||||||
|
|
||||||
LDAP_PASSWORD = generate_client_secret()
|
|
||||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection())
|
|
||||||
|
|
||||||
|
|
||||||
class LDAPSourceTests(TestCase):
|
|
||||||
"""LDAP Source tests"""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.source = LDAPSource.objects.create(
|
|
||||||
name="ldap",
|
|
||||||
slug="ldap",
|
|
||||||
base_dn="ou=test,o=lab",
|
|
||||||
additional_user_dn="ou=users",
|
|
||||||
additional_group_dn="ou=groups",
|
|
||||||
)
|
|
||||||
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
|
|
||||||
self.source.save()
|
|
||||||
|
|
||||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
|
||||||
def test_sync_users(self):
|
|
||||||
"""Test user sync"""
|
|
||||||
connector = Connector(self.source)
|
|
||||||
connector.sync_users()
|
|
||||||
self.assertTrue(User.objects.filter(username="user0_sn").exists())
|
|
||||||
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
|
||||||
|
|
||||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
|
||||||
def test_sync_groups(self):
|
|
||||||
"""Test group sync"""
|
|
||||||
connector = Connector(self.source)
|
|
||||||
connector.sync_groups()
|
|
||||||
connector.sync_membership()
|
|
||||||
group = Group.objects.filter(name="test-group")
|
|
||||||
self.assertTrue(group.exists())
|
|
||||||
|
|
||||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
|
||||||
def test_auth(self):
|
|
||||||
"""Test Cached auth"""
|
|
||||||
connector = Connector(self.source)
|
|
||||||
connector.sync_users()
|
|
||||||
|
|
||||||
user = User.objects.get(username="user0_sn")
|
|
||||||
auth_user_by_bind = Mock(return_value=user)
|
|
||||||
with patch(
|
|
||||||
"passbook.sources.ldap.connector.Connector.auth_user_by_bind",
|
|
||||||
auth_user_by_bind,
|
|
||||||
):
|
|
||||||
backend = LDAPBackend()
|
|
||||||
self.assertEqual(
|
|
||||||
backend.authenticate(None, username="user0_sn", password=LDAP_PASSWORD),
|
|
||||||
user,
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
|
||||||
def test_tasks(self):
|
|
||||||
"""Test Scheduled tasks"""
|
|
||||||
sync()
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""LDAP Source tests"""
|
||||||
|
from unittest.mock import Mock, PropertyMock, patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
|
from passbook.providers.oauth2.generators import generate_client_secret
|
||||||
|
from passbook.sources.ldap.auth import LDAPBackend
|
||||||
|
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||||
|
from passbook.sources.ldap.sync import LDAPSynchronizer
|
||||||
|
from passbook.sources.ldap.tests.utils import _build_mock_connection
|
||||||
|
|
||||||
|
LDAP_PASSWORD = generate_client_secret()
|
||||||
|
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPSyncTests(TestCase):
|
||||||
|
"""LDAP Sync tests"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.source = LDAPSource.objects.create(
|
||||||
|
name="ldap",
|
||||||
|
slug="ldap",
|
||||||
|
base_dn="DC=AD2012,DC=LAB",
|
||||||
|
additional_user_dn="ou=users",
|
||||||
|
additional_group_dn="ou=groups",
|
||||||
|
)
|
||||||
|
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
|
||||||
|
self.source.save()
|
||||||
|
|
||||||
|
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||||
|
def test_auth_synced_user(self):
|
||||||
|
"""Test Cached auth"""
|
||||||
|
syncer = LDAPSynchronizer(self.source)
|
||||||
|
syncer.sync_users()
|
||||||
|
|
||||||
|
user = User.objects.get(username="user0_sn")
|
||||||
|
auth_user_by_bind = Mock(return_value=user)
|
||||||
|
with patch(
|
||||||
|
"passbook.sources.ldap.auth.LDAPBackend.auth_user_by_bind",
|
||||||
|
auth_user_by_bind,
|
||||||
|
):
|
||||||
|
backend = LDAPBackend()
|
||||||
|
self.assertEqual(
|
||||||
|
backend.authenticate(None, username="user0_sn", password=LDAP_PASSWORD),
|
||||||
|
user,
|
||||||
|
)
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""LDAP Source tests"""
|
||||||
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
|
from passbook.providers.oauth2.generators import generate_client_secret
|
||||||
|
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||||
|
from passbook.sources.ldap.password import LDAPPasswordChanger
|
||||||
|
from passbook.sources.ldap.tests.utils import _build_mock_connection
|
||||||
|
|
||||||
|
LDAP_PASSWORD = generate_client_secret()
|
||||||
|
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPPasswordTests(TestCase):
|
||||||
|
"""LDAP Password tests"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.source = LDAPSource.objects.create(
|
||||||
|
name="ldap",
|
||||||
|
slug="ldap",
|
||||||
|
base_dn="DC=AD2012,DC=LAB",
|
||||||
|
additional_user_dn="ou=users",
|
||||||
|
additional_group_dn="ou=groups",
|
||||||
|
)
|
||||||
|
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
|
||||||
|
self.source.save()
|
||||||
|
|
||||||
|
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||||
|
def test_password_complexity(self):
|
||||||
|
"""Test password without user"""
|
||||||
|
pwc = LDAPPasswordChanger(self.source)
|
||||||
|
self.assertFalse(pwc.ad_password_complexity("test")) # 1 category
|
||||||
|
self.assertFalse(pwc.ad_password_complexity("test1")) # 2 categories
|
||||||
|
self.assertTrue(pwc.ad_password_complexity("test1!")) # 2 categories
|
||||||
|
|
||||||
|
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||||
|
def test_password_complexity_user(self):
|
||||||
|
"""test password with user"""
|
||||||
|
pwc = LDAPPasswordChanger(self.source)
|
||||||
|
user = User.objects.create(
|
||||||
|
username="test",
|
||||||
|
attributes={"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB"},
|
||||||
|
)
|
||||||
|
self.assertFalse(pwc.ad_password_complexity("test", user)) # 1 category
|
||||||
|
self.assertFalse(pwc.ad_password_complexity("test1", user)) # 2 categories
|
||||||
|
self.assertTrue(pwc.ad_password_complexity("test1!", user)) # 2 categories
|
||||||
|
self.assertFalse(
|
||||||
|
pwc.ad_password_complexity("erin!qewrqewr", user)
|
||||||
|
) # displayName token
|
||||||
|
self.assertFalse(
|
||||||
|
pwc.ad_password_complexity("hagens!qewrqewr", user)
|
||||||
|
) # displayName token
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""LDAP Source tests"""
|
||||||
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from passbook.core.models import Group, User
|
||||||
|
from passbook.providers.oauth2.generators import generate_client_secret
|
||||||
|
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||||
|
from passbook.sources.ldap.sync import LDAPSynchronizer
|
||||||
|
from passbook.sources.ldap.tasks import sync
|
||||||
|
from passbook.sources.ldap.tests.utils import _build_mock_connection
|
||||||
|
|
||||||
|
LDAP_PASSWORD = generate_client_secret()
|
||||||
|
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPSyncTests(TestCase):
|
||||||
|
"""LDAP Sync tests"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.source = LDAPSource.objects.create(
|
||||||
|
name="ldap",
|
||||||
|
slug="ldap",
|
||||||
|
base_dn="DC=AD2012,DC=LAB",
|
||||||
|
additional_user_dn="ou=users",
|
||||||
|
additional_group_dn="ou=groups",
|
||||||
|
)
|
||||||
|
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
|
||||||
|
self.source.save()
|
||||||
|
|
||||||
|
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||||
|
def test_sync_users(self):
|
||||||
|
"""Test user sync"""
|
||||||
|
syncer = LDAPSynchronizer(self.source)
|
||||||
|
syncer.sync_users()
|
||||||
|
self.assertTrue(User.objects.filter(username="user0_sn").exists())
|
||||||
|
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
||||||
|
|
||||||
|
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||||
|
def test_sync_groups(self):
|
||||||
|
"""Test group sync"""
|
||||||
|
syncer = LDAPSynchronizer(self.source)
|
||||||
|
syncer.sync_groups()
|
||||||
|
syncer.sync_membership()
|
||||||
|
group = Group.objects.filter(name="test-group")
|
||||||
|
self.assertTrue(group.exists())
|
||||||
|
|
||||||
|
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||||
|
def test_tasks(self):
|
||||||
|
"""Test Scheduled tasks"""
|
||||||
|
sync()
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""ldap testing utils"""
|
||||||
|
|
||||||
|
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mock_connection(password: str) -> Connection:
|
||||||
|
"""Create mock connection"""
|
||||||
|
server = Server("my_fake_server", get_info=OFFLINE_AD_2012_R2)
|
||||||
|
_pass = "foo" # noqa # nosec
|
||||||
|
connection = Connection(
|
||||||
|
server,
|
||||||
|
user="cn=my_user,DC=AD2012,DC=LAB",
|
||||||
|
password=_pass,
|
||||||
|
client_strategy=MOCK_SYNC,
|
||||||
|
)
|
||||||
|
# Entry for password checking
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=user,ou=users,DC=AD2012,DC=LAB",
|
||||||
|
{
|
||||||
|
"name": "test-user",
|
||||||
|
"objectSid": "unique-test-group",
|
||||||
|
"objectCategory": "Person",
|
||||||
|
"displayName": "Erin M. Hagens",
|
||||||
|
"sAMAccountName": "sAMAccountName",
|
||||||
|
"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=group1,ou=groups,DC=AD2012,DC=LAB",
|
||||||
|
{
|
||||||
|
"name": "test-group",
|
||||||
|
"objectSid": "unique-test-group",
|
||||||
|
"objectCategory": "Group",
|
||||||
|
"distinguishedName": "cn=group1,ou=groups,DC=AD2012,DC=LAB",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Group without SID
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=group2,ou=groups,DC=AD2012,DC=LAB",
|
||||||
|
{
|
||||||
|
"name": "test-group",
|
||||||
|
"objectCategory": "Group",
|
||||||
|
"distinguishedName": "cn=group2,ou=groups,DC=AD2012,DC=LAB",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=user0,ou=users,DC=AD2012,DC=LAB",
|
||||||
|
{
|
||||||
|
"userPassword": password,
|
||||||
|
"sAMAccountName": "user0_sn",
|
||||||
|
"name": "user0_sn",
|
||||||
|
"revision": 0,
|
||||||
|
"objectSid": "user0",
|
||||||
|
"objectCategory": "Person",
|
||||||
|
"memberOf": "cn=group1,ou=groups,DC=AD2012,DC=LAB",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# User without SID
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=user1,ou=users,DC=AD2012,DC=LAB",
|
||||||
|
{
|
||||||
|
"userPassword": "test1111",
|
||||||
|
"sAMAccountName": "user2_sn",
|
||||||
|
"name": "user1_sn",
|
||||||
|
"revision": 0,
|
||||||
|
"objectCategory": "Person",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Duplicate users
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=user2,ou=users,DC=AD2012,DC=LAB",
|
||||||
|
{
|
||||||
|
"userPassword": "test2222",
|
||||||
|
"sAMAccountName": "user2_sn",
|
||||||
|
"name": "user2_sn",
|
||||||
|
"revision": 0,
|
||||||
|
"objectSid": "unique-test2222",
|
||||||
|
"objectCategory": "Person",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=user3,ou=users,DC=AD2012,DC=LAB",
|
||||||
|
{
|
||||||
|
"userPassword": "test2222",
|
||||||
|
"sAMAccountName": "user2_sn",
|
||||||
|
"name": "user2_sn",
|
||||||
|
"revision": 0,
|
||||||
|
"objectSid": "unique-test2222",
|
||||||
|
"objectCategory": "Person",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.bind()
|
||||||
|
return connection
|
28
swagger.yaml
28
swagger.yaml
|
@ -5836,18 +5836,22 @@ definitions:
|
||||||
title: Action
|
title: Action
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- LOGIN
|
- login
|
||||||
- LOGIN_FAILED
|
- login_failed
|
||||||
- LOGOUT
|
- logout
|
||||||
- AUTHORIZE_APPLICATION
|
- sign_up
|
||||||
- SUSPICIOUS_REQUEST
|
- authorize_application
|
||||||
- SIGN_UP
|
- suspicious_request
|
||||||
- PASSWORD_RESET
|
- password_set
|
||||||
- INVITE_CREATED
|
- invitation_created
|
||||||
- INVITE_USED
|
- invitation_used
|
||||||
- IMPERSONATION_STARTED
|
- source_linked
|
||||||
- IMPERSONATION_ENDED
|
- impersonation_started
|
||||||
- CUSTOM
|
- impersonation_ended
|
||||||
|
- model_created
|
||||||
|
- model_updated
|
||||||
|
- model_deleted
|
||||||
|
- custom_
|
||||||
date:
|
date:
|
||||||
title: Date
|
title: Date
|
||||||
type: string
|
type: string
|
||||||
|
|
Reference in New Issue