sources/ldap: implement LDAP password validation and syncing

This commit is contained in:
Jens Langhammer 2020-09-21 11:04:26 +02:00
parent 5007a6befe
commit f99eaa85ac
9 changed files with 210 additions and 11 deletions

View File

@ -22,6 +22,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
("passbook_core", "0002_auto_20200523_1133"), ("passbook_core", "0002_auto_20200523_1133"),
("passbook_sources_ldap", "0007_ldapsource_sync_users_password"),
] ]
operations = [ operations = [

View File

@ -90,8 +90,8 @@ class User(GuardianUserMixin, AbstractUser):
"""superuser == staff user""" """superuser == staff user"""
return self.is_superuser return self.is_superuser
def set_password(self, password): def set_password(self, password, signal=True):
if self.pk: if self.pk and signal:
password_changed.send(sender=self, user=self, password=password) password_changed.send(sender=self, user=self, password=password)
self.password_change_date = now() self.password_change_date = now()
return super().set_password(password) return super().set_password(password)

View File

@ -24,6 +24,7 @@ class LDAPSourceSerializer(ModelSerializer):
"user_group_membership_field", "user_group_membership_field",
"object_uniqueness_field", "object_uniqueness_field",
"sync_users", "sync_users",
"sync_users_password",
"sync_groups", "sync_groups",
"sync_parent_group", "sync_parent_group",
"property_mappings", "property_mappings",

View File

@ -1,4 +1,6 @@
"""Wrapper for ldap3 to easily manage user""" """Wrapper for ldap3 to easily manage user"""
from enum import IntFlag
from re import split
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import ldap3 import ldap3
@ -12,6 +14,20 @@ from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
LOGGER = get_logger() 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 Connector: class Connector:
"""Wrapper for ldap3 to easily manage user authentication and creation""" """Wrapper for ldap3 to easily manage user authentication and creation"""
@ -21,11 +37,6 @@ class Connector:
def __init__(self, source: LDAPSource): def __init__(self, source: LDAPSource):
self._source = source self._source = source
@staticmethod
def encode_pass(password: str) -> bytes:
"""Encodes a plain-text password so it can be used by AD"""
return '"{}"'.format(password).encode("utf-16-le")
@property @property
def base_dn_users(self) -> str: def base_dn_users(self) -> str:
"""Shortcut to get full base_dn for user lookups""" """Shortcut to get full base_dn for user lookups"""
@ -206,7 +217,7 @@ class Connector:
if self.auth_user_by_bind(user, password): if self.auth_user_by_bind(user, password):
# Password given successfully binds to LDAP, so we save it in our Database # Password given successfully binds to LDAP, so we save it in our Database
LOGGER.debug("Updating user's password in DB", user=user) LOGGER.debug("Updating user's password in DB", user=user)
user.set_password(password) user.set_password(password, signal=False)
user.save() user.save()
return user return user
# Password doesn't match # Password doesn't match
@ -232,3 +243,106 @@ class Connector:
except ldap3.core.exceptions.LDAPException as exception: except ldap3.core.exceptions.LDAPException as exception:
LOGGER.warning(exception) LOGGER.warning(exception)
return None 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

View File

@ -37,6 +37,7 @@ class LDAPSourceForm(forms.ModelForm):
"user_group_membership_field", "user_group_membership_field",
"object_uniqueness_field", "object_uniqueness_field",
"sync_users", "sync_users",
"sync_users_password",
"sync_groups", "sync_groups",
"sync_parent_group", "sync_parent_group",
"property_mappings", "property_mappings",

View File

@ -0,0 +1,22 @@
# Generated by Django 3.1.1 on 2020-09-21 09:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_sources_ldap", "0006_auto_20200915_1919"),
]
operations = [
migrations.AddField(
model_name="ldapsource",
name="sync_users_password",
field=models.BooleanField(
default=True,
help_text="When a user changes their password, sync it back to LDAP. This can only be enabled on a single LDAP source.",
unique=True,
),
),
]

View File

@ -6,7 +6,7 @@ from django.core.cache import cache
from django.db import models from django.db import models
from django.forms import ModelForm from django.forms import ModelForm
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from ldap3 import Connection, Server from ldap3 import ALL, Connection, Server
from passbook.core.models import Group, PropertyMapping, Source from passbook.core.models import Group, PropertyMapping, Source
from passbook.lib.models import DomainlessURLValidator from passbook.lib.models import DomainlessURLValidator
@ -52,6 +52,16 @@ class LDAPSource(Source):
) )
sync_users = models.BooleanField(default=True) sync_users = models.BooleanField(default=True)
sync_users_password = models.BooleanField(
default=True,
help_text=_(
(
"When a user changes their password, sync it back to LDAP. "
"This can only be enabled on a single LDAP source."
)
),
unique=True,
)
sync_groups = models.BooleanField(default=True) sync_groups = models.BooleanField(default=True)
sync_parent_group = models.ForeignKey( sync_parent_group = models.ForeignKey(
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
@ -82,7 +92,7 @@ class LDAPSource(Source):
def connection(self) -> Connection: def connection(self) -> Connection:
"""Get a fully connected and bound LDAP Connection""" """Get a fully connected and bound LDAP Connection"""
if not self._connection: if not self._connection:
server = Server(self.server_uri) server = Server(self.server_uri, get_info=ALL)
self._connection = Connection( self._connection = Connection(
server, server,
raise_exceptions=True, raise_exceptions=True,
@ -112,7 +122,7 @@ class LDAPPropertyMapping(PropertyMapping):
return LDAPPropertyMappingForm return LDAPPropertyMappingForm
def __str__(self): def __str__(self):
return f"LDAP Property Mapping {self.expression} -> {self.object_field}" return self.name
class Meta: class Meta:

View File

@ -1,9 +1,19 @@
"""passbook ldap source signals""" """passbook ldap source signals"""
from typing import Any, Dict
from django.core.exceptions import ValidationError
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from ldap3.core.exceptions import LDAPException
from passbook.core.models import User
from passbook.core.signals import password_changed
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.tasks import sync_single from passbook.sources.ldap.tasks import sync_single
from passbook.stages.prompt.signals import password_validate
@receiver(post_save, sender=LDAPSource) @receiver(post_save, sender=LDAPSource)
@ -12,3 +22,38 @@ def sync_ldap_source_on_save(sender, instance: LDAPSource, **_):
"""Ensure that source is synced on save (if enabled)""" """Ensure that source is synced on save (if enabled)"""
if instance.enabled: if instance.enabled:
sync_single.delay(instance.pk) sync_single.delay(instance.pk)
@receiver(password_validate)
# pylint: disable=unused-argument
def ldap_password_validate(sender, password: str, plan_context: Dict[str, Any], **__):
"""if there's an LDAP Source with enabled password sync, check the password"""
sources = LDAPSource.objects.filter(sync_users_password=True)
if not sources.exists():
return
source = sources.first()
connector = Connector(source)
if connector.check_ad_password_complexity_enabled():
passing = connector.ad_password_complexity(
password, plan_context.get(PLAN_CONTEXT_PENDING_USER, None)
)
if not passing:
raise ValidationError(
_("Password does not match Active Direcory Complexity.")
)
@receiver(password_changed)
# pylint: disable=unused-argument
def ldap_sync_password(sender, user: User, password: str, **_):
"""Connect to ldap and update password. We do this in the background to get
automatic retries on error."""
sources = LDAPSource.objects.filter(sync_users_password=True)
if not sources.exists():
return
source = sources.first()
connector = Connector(source)
try:
connector.change_password(user, password)
except LDAPException as exc:
raise ValidationError("Failed to set password") from exc

View File

@ -6945,6 +6945,11 @@ definitions:
sync_users: sync_users:
title: Sync users title: Sync users
type: boolean type: boolean
sync_users_password:
title: Sync users password
description: When a user changes their password, sync it back to LDAP. This
can only be enabled on a single LDAP source.
type: boolean
sync_groups: sync_groups:
title: Sync groups title: Sync groups
type: boolean type: boolean