sources/ldap: implement LDAP password validation and syncing
This commit is contained in:
parent
5007a6befe
commit
f99eaa85ac
|
@ -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 = [
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Reference in New Issue