sources/ldap: implement LDAP password validation and syncing
This commit is contained in:
		| @ -22,6 +22,7 @@ class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("passbook_core", "0002_auto_20200523_1133"), | ||||
|         ("passbook_sources_ldap", "0007_ldapsource_sync_users_password"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|  | ||||
| @ -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) | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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.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: | ||||
|  | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens Langhammer
					Jens Langhammer