diff --git a/passbook/core/migrations/0003_default_user.py b/passbook/core/migrations/0003_default_user.py index 56f9edd9c..41f4f8f57 100644 --- a/passbook/core/migrations/0003_default_user.py +++ b/passbook/core/migrations/0003_default_user.py @@ -22,6 +22,7 @@ class Migration(migrations.Migration): dependencies = [ ("passbook_core", "0002_auto_20200523_1133"), + ("passbook_sources_ldap", "0007_ldapsource_sync_users_password"), ] operations = [ diff --git a/passbook/core/models.py b/passbook/core/models.py index 9b1827454..545d05825 100644 --- a/passbook/core/models.py +++ b/passbook/core/models.py @@ -90,8 +90,8 @@ class User(GuardianUserMixin, AbstractUser): """superuser == staff user""" return self.is_superuser - def set_password(self, password): - if self.pk: + def set_password(self, password, signal=True): + if self.pk and signal: password_changed.send(sender=self, user=self, password=password) self.password_change_date = now() return super().set_password(password) diff --git a/passbook/sources/ldap/api.py b/passbook/sources/ldap/api.py index e5ad2677c..53fe13b3a 100644 --- a/passbook/sources/ldap/api.py +++ b/passbook/sources/ldap/api.py @@ -24,6 +24,7 @@ class LDAPSourceSerializer(ModelSerializer): "user_group_membership_field", "object_uniqueness_field", "sync_users", + "sync_users_password", "sync_groups", "sync_parent_group", "property_mappings", diff --git a/passbook/sources/ldap/connector.py b/passbook/sources/ldap/connector.py index 54c6f14e6..fc403890b 100644 --- a/passbook/sources/ldap/connector.py +++ b/passbook/sources/ldap/connector.py @@ -1,4 +1,6 @@ """Wrapper for ldap3 to easily manage user""" +from enum import IntFlag +from re import split from typing import Any, Dict, Optional import ldap3 @@ -12,6 +14,20 @@ from passbook.sources.ldap.models import LDAPPropertyMapping, 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 Connector: """Wrapper for ldap3 to easily manage user authentication and creation""" @@ -21,11 +37,6 @@ class Connector: def __init__(self, source: LDAPSource): 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 def base_dn_users(self) -> str: """Shortcut to get full base_dn for user lookups""" @@ -206,7 +217,7 @@ class Connector: 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) + user.set_password(password, signal=False) user.save() return user # Password doesn't match @@ -232,3 +243,106 @@ class Connector: 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 diff --git a/passbook/sources/ldap/forms.py b/passbook/sources/ldap/forms.py index 46ddbf27b..9804644d8 100644 --- a/passbook/sources/ldap/forms.py +++ b/passbook/sources/ldap/forms.py @@ -37,6 +37,7 @@ class LDAPSourceForm(forms.ModelForm): "user_group_membership_field", "object_uniqueness_field", "sync_users", + "sync_users_password", "sync_groups", "sync_parent_group", "property_mappings", diff --git a/passbook/sources/ldap/migrations/0007_ldapsource_sync_users_password.py b/passbook/sources/ldap/migrations/0007_ldapsource_sync_users_password.py new file mode 100644 index 000000000..aa10efbb8 --- /dev/null +++ b/passbook/sources/ldap/migrations/0007_ldapsource_sync_users_password.py @@ -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, + ), + ), + ] diff --git a/passbook/sources/ldap/models.py b/passbook/sources/ldap/models.py index eb5836641..20df911f6 100644 --- a/passbook/sources/ldap/models.py +++ b/passbook/sources/ldap/models.py @@ -6,7 +6,7 @@ from django.core.cache import cache from django.db import models from django.forms import ModelForm 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.lib.models import DomainlessURLValidator @@ -52,6 +52,16 @@ class LDAPSource(Source): ) 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_parent_group = models.ForeignKey( Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT @@ -82,7 +92,7 @@ class LDAPSource(Source): def connection(self) -> Connection: """Get a fully connected and bound LDAP Connection""" if not self._connection: - server = Server(self.server_uri) + server = Server(self.server_uri, get_info=ALL) self._connection = Connection( server, raise_exceptions=True, @@ -112,7 +122,7 @@ class LDAPPropertyMapping(PropertyMapping): return LDAPPropertyMappingForm def __str__(self): - return f"LDAP Property Mapping {self.expression} -> {self.object_field}" + return self.name class Meta: diff --git a/passbook/sources/ldap/signals.py b/passbook/sources/ldap/signals.py index 7e9b4cb69..4c5e0e1db 100644 --- a/passbook/sources/ldap/signals.py +++ b/passbook/sources/ldap/signals.py @@ -1,9 +1,19 @@ """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.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.tasks import sync_single +from passbook.stages.prompt.signals import password_validate @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)""" if instance.enabled: 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 diff --git a/swagger.yaml b/swagger.yaml index 4ac9d3311..d6959f482 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -6945,6 +6945,11 @@ definitions: sync_users: title: Sync users 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: title: Sync groups type: boolean