From 9c1ade59e9c7364df28a252a24e1d2764484deca Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 5 Feb 2021 13:19:24 +0100 Subject: [PATCH] sources/ldap: add more flatten to user sync, start adding tests for OpenLDAP --- authentik/managed/manager.py | 10 +-- authentik/sources/ldap/managed.py | 13 ++- authentik/sources/ldap/sync/base.py | 10 +++ authentik/sources/ldap/sync/users.py | 20 +++-- .../ldap/tests/{utils.py => mock_ad.py} | 46 +++++----- authentik/sources/ldap/tests/mock_slapd.py | 81 ++++++++++++++++++ authentik/sources/ldap/tests/test_auth.py | 84 ++++++++++++++----- authentik/sources/ldap/tests/test_password.py | 6 +- authentik/sources/ldap/tests/test_sync.py | 4 +- 9 files changed, 212 insertions(+), 62 deletions(-) rename authentik/sources/ldap/tests/{utils.py => mock_ad.py} (60%) create mode 100644 authentik/sources/ldap/tests/mock_slapd.py diff --git a/authentik/managed/manager.py b/authentik/managed/manager.py index 654b3772d..e03927af5 100644 --- a/authentik/managed/manager.py +++ b/authentik/managed/manager.py @@ -12,7 +12,7 @@ class EnsureOp: """Ensure operation, executed as part of an ObjectManager run""" _obj: Type[ManagedModel] - _match_fields: list[str] + _match_fields: tuple[str, ...] _kwargs: dict def __init__(self, obj: Type[ManagedModel], *match_fields: str, **kwargs) -> None: @@ -34,11 +34,11 @@ class EnsureExists(EnsureOp): "defaults": self._kwargs, } for field in self._match_fields: - update_kwargs[field] = self._kwargs.get(field, None) + value = self._kwargs.get(field, None) + if value: + update_kwargs[field] = value self._kwargs.setdefault("managed", True) - self._obj.objects.update_or_create( - **update_kwargs - ) + self._obj.objects.update_or_create(**update_kwargs) class ObjectManager: diff --git a/authentik/sources/ldap/managed.py b/authentik/sources/ldap/managed.py index 37ad59dc9..f53c92df4 100644 --- a/authentik/sources/ldap/managed.py +++ b/authentik/sources/ldap/managed.py @@ -11,7 +11,7 @@ class LDAPProviderManager(ObjectManager): EnsureExists( LDAPPropertyMapping, "object_field", - name="authentik default LDAP Mapping: Name", + name="authentik default LDAP Mapping: name", object_field="name", expression="return ldap.get('name')", ), @@ -22,9 +22,11 @@ class LDAPProviderManager(ObjectManager): object_field="email", expression="return ldap.get('mail')", ), + # Active Directory-specific mappings EnsureExists( LDAPPropertyMapping, "object_field", + "expression", name="authentik default Active Directory Mapping: sAMAccountName", object_field="username", expression="return ldap.get('sAMAccountName')", @@ -36,4 +38,13 @@ class LDAPProviderManager(ObjectManager): object_field="attributes.upn", expression="return ldap.get('userPrincipalName')", ), + # OpenLDAP specific mappings + EnsureExists( + LDAPPropertyMapping, + "object_field", + "expression", + name="authentik default OpenLDAP Mapping: uid", + object_field="username", + expression="return ldap.get('uid')", + ), ] diff --git a/authentik/sources/ldap/sync/base.py b/authentik/sources/ldap/sync/base.py index d9405b0ad..38ce37b62 100644 --- a/authentik/sources/ldap/sync/base.py +++ b/authentik/sources/ldap/sync/base.py @@ -1,4 +1,6 @@ """Sync LDAP Users and groups into authentik""" +from typing import Any + from structlog.stdlib import BoundLogger, get_logger from authentik.sources.ldap.models import LDAPSource @@ -33,3 +35,11 @@ class BaseLDAPSynchronizer: def sync(self) -> int: """Sync function, implemented in subclass""" raise NotImplementedError() + + def _flatten(self, value: Any) -> Any: + """Flatten `value` if its a list""" + if isinstance(value, list): + if len(value) < 1: + return None + return value[0] + return value diff --git a/authentik/sources/ldap/sync/users.py b/authentik/sources/ldap/sync/users.py index b0f25d34e..fed63b9a1 100644 --- a/authentik/sources/ldap/sync/users.py +++ b/authentik/sources/ldap/sync/users.py @@ -28,8 +28,9 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): ) user_count = 0 for user in users: + self._logger.debug(user) attributes = user.get("attributes", {}) - user_dn = user.get("entryDN", "") + user_dn = self._flatten(user.get("entryDN", "")) if self._source.object_uniqueness_field not in attributes: self._logger.warning( "Cannot find uniqueness Field in attributes", @@ -37,9 +38,12 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): dn=user_dn, ) continue - uniq = attributes[self._source.object_uniqueness_field] + uniq = self._flatten(attributes[self._source.object_uniqueness_field]) try: defaults = self._build_object_properties(user_dn, **attributes) + self._logger.debug("Creating user with attributes", **defaults) + if "username" not in defaults: + raise IntegrityError("Username was not set by propertymappings") user, created = User.objects.update_or_create( **{ f"attributes__{LDAP_UNIQUENESS}": uniq, @@ -58,9 +62,7 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): if created: user.set_unusable_password() user.save() - self._logger.debug( - "Synced User", user=attributes.get("name", ""), created=created - ) + self._logger.debug("Synced User", user=user.username, created=created) user_count += 1 return user_count @@ -80,19 +82,21 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): continue object_field = mapping.object_field if object_field.startswith("attributes."): + # Because returning a list might desired, we can't + # rely on self._flatten here. Instead, just save the result as-is properties["attributes"][ object_field.replace("attributes.", "") ] = value else: - properties[object_field] = value + properties[object_field] = self._flatten(value) except PropertyMappingExpressionException as exc: self._logger.warning( "Mapping failed to evaluate", exc=exc, mapping=mapping ) continue if self._source.object_uniqueness_field in kwargs: - properties["attributes"][LDAP_UNIQUENESS] = kwargs.get( - self._source.object_uniqueness_field + properties["attributes"][LDAP_UNIQUENESS] = self._flatten( + kwargs.get(self._source.object_uniqueness_field) ) properties["attributes"][LDAP_DISTINGUISHED_NAME] = user_dn return properties diff --git a/authentik/sources/ldap/tests/utils.py b/authentik/sources/ldap/tests/mock_ad.py similarity index 60% rename from authentik/sources/ldap/tests/utils.py rename to authentik/sources/ldap/tests/mock_ad.py index b34a71eb0..c94519f0f 100644 --- a/authentik/sources/ldap/tests/utils.py +++ b/authentik/sources/ldap/tests/mock_ad.py @@ -9,88 +9,88 @@ def mock_ad_connection(password: str) -> Connection: _pass = "foo" # noqa # nosec connection = Connection( server, - user="cn=my_user,DC=AD2012,DC=LAB", + user="cn=my_user,dc=goauthentik,dc=io", password=_pass, client_strategy=MOCK_SYNC, ) # Entry for password checking connection.strategy.add_entry( - "cn=user,ou=users,DC=AD2012,DC=LAB", + "cn=user,ou=users,dc=goauthentik,dc=io", { "name": "test-user", "objectSid": "unique-test-group", - "objectCategory": "Person", + "objectClass": "person", "displayName": "Erin M. Hagens", "sAMAccountName": "sAMAccountName", - "distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB", + "distinguishedName": "cn=user,ou=users,dc=goauthentik,dc=io", }, ) connection.strategy.add_entry( - "cn=group1,ou=groups,DC=AD2012,DC=LAB", + "cn=group1,ou=groups,dc=goauthentik,dc=io", { "name": "test-group", "objectSid": "unique-test-group", - "objectCategory": "Group", - "distinguishedName": "cn=group1,ou=groups,DC=AD2012,DC=LAB", - "member": ["cn=user0,ou=users,DC=AD2012,DC=LAB"], + "objectClass": "group", + "distinguishedName": "cn=group1,ou=groups,dc=goauthentik,dc=io", + "member": ["cn=user0,ou=users,dc=goauthentik,dc=io"], }, ) # Group without SID connection.strategy.add_entry( - "cn=group2,ou=groups,DC=AD2012,DC=LAB", + "cn=group2,ou=groups,dc=goauthentik,dc=io", { "name": "test-group", - "objectCategory": "Group", - "distinguishedName": "cn=group2,ou=groups,DC=AD2012,DC=LAB", + "objectClass": "group", + "distinguishedName": "cn=group2,ou=groups,dc=goauthentik,dc=io", }, ) connection.strategy.add_entry( - "cn=user0,ou=users,DC=AD2012,DC=LAB", + "cn=user0,ou=users,dc=goauthentik,dc=io", { "userPassword": password, "sAMAccountName": "user0_sn", "name": "user0_sn", "revision": 0, "objectSid": "user0", - "objectCategory": "Person", - "distinguishedName": "cn=user0,ou=users,DC=AD2012,DC=LAB", + "objectClass": "person", + "distinguishedName": "cn=user0,ou=users,dc=goauthentik,dc=io", }, ) # User without SID connection.strategy.add_entry( - "cn=user1,ou=users,DC=AD2012,DC=LAB", + "cn=user1,ou=users,dc=goauthentik,dc=io", { "userPassword": "test1111", "sAMAccountName": "user2_sn", "name": "user1_sn", "revision": 0, - "objectCategory": "Person", - "distinguishedName": "cn=user1,ou=users,DC=AD2012,DC=LAB", + "objectClass": "person", + "distinguishedName": "cn=user1,ou=users,dc=goauthentik,dc=io", }, ) # Duplicate users connection.strategy.add_entry( - "cn=user2,ou=users,DC=AD2012,DC=LAB", + "cn=user2,ou=users,dc=goauthentik,dc=io", { "userPassword": "test2222", "sAMAccountName": "user2_sn", "name": "user2_sn", "revision": 0, "objectSid": "unique-test2222", - "objectCategory": "Person", - "distinguishedName": "cn=user2,ou=users,DC=AD2012,DC=LAB", + "objectClass": "person", + "distinguishedName": "cn=user2,ou=users,dc=goauthentik,dc=io", }, ) connection.strategy.add_entry( - "cn=user3,ou=users,DC=AD2012,DC=LAB", + "cn=user3,ou=users,dc=goauthentik,dc=io", { "userPassword": "test2222", "sAMAccountName": "user2_sn", "name": "user2_sn", "revision": 0, "objectSid": "unique-test2222", - "objectCategory": "Person", - "distinguishedName": "cn=user3,ou=users,DC=AD2012,DC=LAB", + "objectClass": "person", + "distinguishedName": "cn=user3,ou=users,dc=goauthentik,dc=io", }, ) connection.bind() diff --git a/authentik/sources/ldap/tests/mock_slapd.py b/authentik/sources/ldap/tests/mock_slapd.py new file mode 100644 index 000000000..e4e4af18a --- /dev/null +++ b/authentik/sources/ldap/tests/mock_slapd.py @@ -0,0 +1,81 @@ +"""ldap testing utils""" + +from ldap3 import MOCK_SYNC, OFFLINE_SLAPD_2_4, Connection, Server + + +def mock_slapd_connection(password: str) -> Connection: + """Create mock AD connection""" + server = Server("my_fake_server", get_info=OFFLINE_SLAPD_2_4) + _pass = "foo" # noqa # nosec + connection = Connection( + server, + user="cn=my_user,dc=goauthentik,dc=io", + password=_pass, + client_strategy=MOCK_SYNC, + ) + # Entry for password checking + connection.strategy.add_entry( + "cn=user,ou=users,dc=goauthentik,dc=io", + { + "name": "test-user", + "uid": "unique-test-group", + "objectClass": "person", + "displayName": "Erin M. Hagens", + }, + ) + connection.strategy.add_entry( + "cn=group1,ou=groups,dc=goauthentik,dc=io", + { + "name": "test-group", + "uid": "unique-test-group", + "objectClass": "group", + "member": ["cn=user0,ou=users,dc=goauthentik,dc=io"], + }, + ) + # Group without SID + connection.strategy.add_entry( + "cn=group2,ou=groups,dc=goauthentik,dc=io", + { + "name": "test-group", + "objectClass": "group", + }, + ) + connection.strategy.add_entry( + "cn=user0,ou=users,dc=goauthentik,dc=io", + { + "userPassword": password, + "name": "user0_sn", + "uid": "user0_sn", + "objectClass": "person", + }, + ) + # User without SID + connection.strategy.add_entry( + "cn=user1,ou=users,dc=goauthentik,dc=io", + { + "userPassword": "test1111", + "name": "user1_sn", + "objectClass": "person", + }, + ) + # Duplicate users + connection.strategy.add_entry( + "cn=user2,ou=users,dc=goauthentik,dc=io", + { + "userPassword": "test2222", + "name": "user2_sn", + "uid": "unique-test2222", + "objectClass": "person", + }, + ) + connection.strategy.add_entry( + "cn=user3,ou=users,dc=goauthentik,dc=io", + { + "userPassword": "test2222", + "name": "user2_sn", + "uid": "unique-test2222", + "objectClass": "person", + }, + ) + connection.bind() + return connection diff --git a/authentik/sources/ldap/tests/test_auth.py b/authentik/sources/ldap/tests/test_auth.py index 9dd89dc80..5492f281a 100644 --- a/authentik/sources/ldap/tests/test_auth.py +++ b/authentik/sources/ldap/tests/test_auth.py @@ -1,6 +1,7 @@ """LDAP Source tests""" from unittest.mock import Mock, PropertyMock, patch +from django.db.models import Q from django.test import TestCase from authentik.core.models import User @@ -9,10 +10,10 @@ from authentik.providers.oauth2.generators import generate_client_secret from authentik.sources.ldap.auth import LDAPBackend from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource from authentik.sources.ldap.sync.users import UserLDAPSynchronizer -from authentik.sources.ldap.tests.utils import mock_ad_connection +from authentik.sources.ldap.tests.mock_ad import mock_ad_connection +from authentik.sources.ldap.tests.mock_slapd import mock_slapd_connection LDAP_PASSWORD = generate_client_secret() -LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) class LDAPSyncTests(TestCase): @@ -23,27 +24,70 @@ class LDAPSyncTests(TestCase): self.source = LDAPSource.objects.create( name="ldap", slug="ldap", - base_dn="DC=AD2012,DC=LAB", + base_dn="dc=goauthentik,dc=io", additional_user_dn="ou=users", additional_group_dn="ou=groups", ) - self.source.property_mappings.set(LDAPPropertyMapping.objects.all()) - self.source.save() - @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) - def test_auth_synced_user(self): + def test_auth_synced_user_ad(self): """Test Cached auth""" - user_sync = UserLDAPSynchronizer(self.source) - user_sync.sync() - - user = User.objects.get(username="user0_sn") - auth_user_by_bind = Mock(return_value=user) - with patch( - "authentik.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, + self.source.property_mappings.set( + LDAPPropertyMapping.objects.filter( + Q(name__startswith="authentik default LDAP Mapping") + | Q(name__startswith="authentik default Active Directory Mapping") ) + ) + print( + LDAPPropertyMapping.objects.filter( + Q(name__startswith="authentik default LDAP Mapping") + | Q(name__startswith="authentik default Active Directory Mapping") + ) + ) + self.source.save() + connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) + with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + user_sync = UserLDAPSynchronizer(self.source) + user_sync.sync() + + user = User.objects.get(username="user0_sn") + auth_user_by_bind = Mock(return_value=user) + with patch( + "authentik.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, + ) + + def test_auth_synced_user_openldap(self): + """Test Cached auth""" + self.source.object_uniqueness_field = "uid" + self.source.property_mappings.set( + LDAPPropertyMapping.objects.filter( + Q(name__startswith="authentik default LDAP Mapping") + | Q(name__startswith="authentik default OpenLDAP Mapping") + ) + ) + self.source.save() + connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD)) + with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + user_sync = UserLDAPSynchronizer(self.source) + user_sync.sync() + + user = User.objects.get(username="user0_sn") + auth_user_by_bind = Mock(return_value=user) + with patch( + "authentik.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, + ) diff --git a/authentik/sources/ldap/tests/test_password.py b/authentik/sources/ldap/tests/test_password.py index d89adb7e4..91a32abf2 100644 --- a/authentik/sources/ldap/tests/test_password.py +++ b/authentik/sources/ldap/tests/test_password.py @@ -7,7 +7,7 @@ from authentik.core.models import User from authentik.providers.oauth2.generators import generate_client_secret from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource from authentik.sources.ldap.password import LDAPPasswordChanger -from authentik.sources.ldap.tests.utils import mock_ad_connection +from authentik.sources.ldap.tests.mock_ad import mock_ad_connection LDAP_PASSWORD = generate_client_secret() LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) @@ -20,7 +20,7 @@ class LDAPPasswordTests(TestCase): self.source = LDAPSource.objects.create( name="ldap", slug="ldap", - base_dn="DC=AD2012,DC=LAB", + base_dn="dc=goauthentik,dc=io", additional_user_dn="ou=users", additional_group_dn="ou=groups", ) @@ -41,7 +41,7 @@ class LDAPPasswordTests(TestCase): pwc = LDAPPasswordChanger(self.source) user = User.objects.create( username="test", - attributes={"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB"}, + attributes={"distinguishedName": "cn=user,ou=users,dc=goauthentik,dc=io"}, ) self.assertFalse(pwc.ad_password_complexity("test", user)) # 1 category self.assertFalse(pwc.ad_password_complexity("test1", user)) # 2 categories diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py index aa6cec8df..d680b8e45 100644 --- a/authentik/sources/ldap/tests/test_sync.py +++ b/authentik/sources/ldap/tests/test_sync.py @@ -11,7 +11,7 @@ from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer from authentik.sources.ldap.sync.users import UserLDAPSynchronizer from authentik.sources.ldap.tasks import ldap_sync_all -from authentik.sources.ldap.tests.utils import mock_ad_connection +from authentik.sources.ldap.tests.mock_ad import mock_ad_connection LDAP_PASSWORD = generate_client_secret() LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) @@ -25,7 +25,7 @@ class LDAPSyncTests(TestCase): self.source = LDAPSource.objects.create( name="ldap", slug="ldap", - base_dn="DC=AD2012,DC=LAB", + base_dn="dc=goauthentik,dc=io", additional_user_dn="ou=users", additional_group_dn="ou=groups", )