sources/ldap: add more flatten to user sync, start adding tests for OpenLDAP

This commit is contained in:
Jens Langhammer 2021-02-05 13:19:24 +01:00
parent fadf746234
commit 9c1ade59e9
9 changed files with 212 additions and 62 deletions

View file

@ -12,7 +12,7 @@ class EnsureOp:
"""Ensure operation, executed as part of an ObjectManager run""" """Ensure operation, executed as part of an ObjectManager run"""
_obj: Type[ManagedModel] _obj: Type[ManagedModel]
_match_fields: list[str] _match_fields: tuple[str, ...]
_kwargs: dict _kwargs: dict
def __init__(self, obj: Type[ManagedModel], *match_fields: str, **kwargs) -> None: def __init__(self, obj: Type[ManagedModel], *match_fields: str, **kwargs) -> None:
@ -34,11 +34,11 @@ class EnsureExists(EnsureOp):
"defaults": self._kwargs, "defaults": self._kwargs,
} }
for field in self._match_fields: 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._kwargs.setdefault("managed", True)
self._obj.objects.update_or_create( self._obj.objects.update_or_create(**update_kwargs)
**update_kwargs
)
class ObjectManager: class ObjectManager:

View file

@ -11,7 +11,7 @@ class LDAPProviderManager(ObjectManager):
EnsureExists( EnsureExists(
LDAPPropertyMapping, LDAPPropertyMapping,
"object_field", "object_field",
name="authentik default LDAP Mapping: Name", name="authentik default LDAP Mapping: name",
object_field="name", object_field="name",
expression="return ldap.get('name')", expression="return ldap.get('name')",
), ),
@ -22,9 +22,11 @@ class LDAPProviderManager(ObjectManager):
object_field="email", object_field="email",
expression="return ldap.get('mail')", expression="return ldap.get('mail')",
), ),
# Active Directory-specific mappings
EnsureExists( EnsureExists(
LDAPPropertyMapping, LDAPPropertyMapping,
"object_field", "object_field",
"expression",
name="authentik default Active Directory Mapping: sAMAccountName", name="authentik default Active Directory Mapping: sAMAccountName",
object_field="username", object_field="username",
expression="return ldap.get('sAMAccountName')", expression="return ldap.get('sAMAccountName')",
@ -36,4 +38,13 @@ class LDAPProviderManager(ObjectManager):
object_field="attributes.upn", object_field="attributes.upn",
expression="return ldap.get('userPrincipalName')", 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')",
),
] ]

View file

@ -1,4 +1,6 @@
"""Sync LDAP Users and groups into authentik""" """Sync LDAP Users and groups into authentik"""
from typing import Any
from structlog.stdlib import BoundLogger, get_logger from structlog.stdlib import BoundLogger, get_logger
from authentik.sources.ldap.models import LDAPSource from authentik.sources.ldap.models import LDAPSource
@ -33,3 +35,11 @@ class BaseLDAPSynchronizer:
def sync(self) -> int: def sync(self) -> int:
"""Sync function, implemented in subclass""" """Sync function, implemented in subclass"""
raise NotImplementedError() 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

View file

@ -28,8 +28,9 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
) )
user_count = 0 user_count = 0
for user in users: for user in users:
self._logger.debug(user)
attributes = user.get("attributes", {}) 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: if self._source.object_uniqueness_field not in attributes:
self._logger.warning( self._logger.warning(
"Cannot find uniqueness Field in attributes", "Cannot find uniqueness Field in attributes",
@ -37,9 +38,12 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
dn=user_dn, dn=user_dn,
) )
continue continue
uniq = attributes[self._source.object_uniqueness_field] uniq = self._flatten(attributes[self._source.object_uniqueness_field])
try: try:
defaults = self._build_object_properties(user_dn, **attributes) 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( user, created = User.objects.update_or_create(
**{ **{
f"attributes__{LDAP_UNIQUENESS}": uniq, f"attributes__{LDAP_UNIQUENESS}": uniq,
@ -58,9 +62,7 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
if created: if created:
user.set_unusable_password() user.set_unusable_password()
user.save() user.save()
self._logger.debug( self._logger.debug("Synced User", user=user.username, created=created)
"Synced User", user=attributes.get("name", ""), created=created
)
user_count += 1 user_count += 1
return user_count return user_count
@ -80,19 +82,21 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
continue continue
object_field = mapping.object_field object_field = mapping.object_field
if object_field.startswith("attributes."): 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"][ properties["attributes"][
object_field.replace("attributes.", "") object_field.replace("attributes.", "")
] = value ] = value
else: else:
properties[object_field] = value properties[object_field] = self._flatten(value)
except PropertyMappingExpressionException as exc: except PropertyMappingExpressionException as exc:
self._logger.warning( self._logger.warning(
"Mapping failed to evaluate", exc=exc, mapping=mapping "Mapping failed to evaluate", exc=exc, mapping=mapping
) )
continue continue
if self._source.object_uniqueness_field in kwargs: if self._source.object_uniqueness_field in kwargs:
properties["attributes"][LDAP_UNIQUENESS] = kwargs.get( properties["attributes"][LDAP_UNIQUENESS] = self._flatten(
self._source.object_uniqueness_field kwargs.get(self._source.object_uniqueness_field)
) )
properties["attributes"][LDAP_DISTINGUISHED_NAME] = user_dn properties["attributes"][LDAP_DISTINGUISHED_NAME] = user_dn
return properties return properties

View file

@ -9,88 +9,88 @@ def mock_ad_connection(password: str) -> Connection:
_pass = "foo" # noqa # nosec _pass = "foo" # noqa # nosec
connection = Connection( connection = Connection(
server, server,
user="cn=my_user,DC=AD2012,DC=LAB", user="cn=my_user,dc=goauthentik,dc=io",
password=_pass, password=_pass,
client_strategy=MOCK_SYNC, client_strategy=MOCK_SYNC,
) )
# Entry for password checking # Entry for password checking
connection.strategy.add_entry( connection.strategy.add_entry(
"cn=user,ou=users,DC=AD2012,DC=LAB", "cn=user,ou=users,dc=goauthentik,dc=io",
{ {
"name": "test-user", "name": "test-user",
"objectSid": "unique-test-group", "objectSid": "unique-test-group",
"objectCategory": "Person", "objectClass": "person",
"displayName": "Erin M. Hagens", "displayName": "Erin M. Hagens",
"sAMAccountName": "sAMAccountName", "sAMAccountName": "sAMAccountName",
"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB", "distinguishedName": "cn=user,ou=users,dc=goauthentik,dc=io",
}, },
) )
connection.strategy.add_entry( connection.strategy.add_entry(
"cn=group1,ou=groups,DC=AD2012,DC=LAB", "cn=group1,ou=groups,dc=goauthentik,dc=io",
{ {
"name": "test-group", "name": "test-group",
"objectSid": "unique-test-group", "objectSid": "unique-test-group",
"objectCategory": "Group", "objectClass": "group",
"distinguishedName": "cn=group1,ou=groups,DC=AD2012,DC=LAB", "distinguishedName": "cn=group1,ou=groups,dc=goauthentik,dc=io",
"member": ["cn=user0,ou=users,DC=AD2012,DC=LAB"], "member": ["cn=user0,ou=users,dc=goauthentik,dc=io"],
}, },
) )
# Group without SID # Group without SID
connection.strategy.add_entry( connection.strategy.add_entry(
"cn=group2,ou=groups,DC=AD2012,DC=LAB", "cn=group2,ou=groups,dc=goauthentik,dc=io",
{ {
"name": "test-group", "name": "test-group",
"objectCategory": "Group", "objectClass": "group",
"distinguishedName": "cn=group2,ou=groups,DC=AD2012,DC=LAB", "distinguishedName": "cn=group2,ou=groups,dc=goauthentik,dc=io",
}, },
) )
connection.strategy.add_entry( connection.strategy.add_entry(
"cn=user0,ou=users,DC=AD2012,DC=LAB", "cn=user0,ou=users,dc=goauthentik,dc=io",
{ {
"userPassword": password, "userPassword": password,
"sAMAccountName": "user0_sn", "sAMAccountName": "user0_sn",
"name": "user0_sn", "name": "user0_sn",
"revision": 0, "revision": 0,
"objectSid": "user0", "objectSid": "user0",
"objectCategory": "Person", "objectClass": "person",
"distinguishedName": "cn=user0,ou=users,DC=AD2012,DC=LAB", "distinguishedName": "cn=user0,ou=users,dc=goauthentik,dc=io",
}, },
) )
# User without SID # User without SID
connection.strategy.add_entry( connection.strategy.add_entry(
"cn=user1,ou=users,DC=AD2012,DC=LAB", "cn=user1,ou=users,dc=goauthentik,dc=io",
{ {
"userPassword": "test1111", "userPassword": "test1111",
"sAMAccountName": "user2_sn", "sAMAccountName": "user2_sn",
"name": "user1_sn", "name": "user1_sn",
"revision": 0, "revision": 0,
"objectCategory": "Person", "objectClass": "person",
"distinguishedName": "cn=user1,ou=users,DC=AD2012,DC=LAB", "distinguishedName": "cn=user1,ou=users,dc=goauthentik,dc=io",
}, },
) )
# Duplicate users # Duplicate users
connection.strategy.add_entry( connection.strategy.add_entry(
"cn=user2,ou=users,DC=AD2012,DC=LAB", "cn=user2,ou=users,dc=goauthentik,dc=io",
{ {
"userPassword": "test2222", "userPassword": "test2222",
"sAMAccountName": "user2_sn", "sAMAccountName": "user2_sn",
"name": "user2_sn", "name": "user2_sn",
"revision": 0, "revision": 0,
"objectSid": "unique-test2222", "objectSid": "unique-test2222",
"objectCategory": "Person", "objectClass": "person",
"distinguishedName": "cn=user2,ou=users,DC=AD2012,DC=LAB", "distinguishedName": "cn=user2,ou=users,dc=goauthentik,dc=io",
}, },
) )
connection.strategy.add_entry( connection.strategy.add_entry(
"cn=user3,ou=users,DC=AD2012,DC=LAB", "cn=user3,ou=users,dc=goauthentik,dc=io",
{ {
"userPassword": "test2222", "userPassword": "test2222",
"sAMAccountName": "user2_sn", "sAMAccountName": "user2_sn",
"name": "user2_sn", "name": "user2_sn",
"revision": 0, "revision": 0,
"objectSid": "unique-test2222", "objectSid": "unique-test2222",
"objectCategory": "Person", "objectClass": "person",
"distinguishedName": "cn=user3,ou=users,DC=AD2012,DC=LAB", "distinguishedName": "cn=user3,ou=users,dc=goauthentik,dc=io",
}, },
) )
connection.bind() connection.bind()

View file

@ -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

View file

@ -1,6 +1,7 @@
"""LDAP Source tests""" """LDAP Source tests"""
from unittest.mock import Mock, PropertyMock, patch from unittest.mock import Mock, PropertyMock, patch
from django.db.models import Q
from django.test import TestCase from django.test import TestCase
from authentik.core.models import User 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.auth import LDAPBackend
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer 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_PASSWORD = generate_client_secret()
LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
class LDAPSyncTests(TestCase): class LDAPSyncTests(TestCase):
@ -23,27 +24,70 @@ class LDAPSyncTests(TestCase):
self.source = LDAPSource.objects.create( self.source = LDAPSource.objects.create(
name="ldap", name="ldap",
slug="ldap", slug="ldap",
base_dn="DC=AD2012,DC=LAB", base_dn="dc=goauthentik,dc=io",
additional_user_dn="ou=users", additional_user_dn="ou=users",
additional_group_dn="ou=groups", 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_ad(self):
def test_auth_synced_user(self):
"""Test Cached auth""" """Test Cached auth"""
user_sync = UserLDAPSynchronizer(self.source) self.source.property_mappings.set(
user_sync.sync() LDAPPropertyMapping.objects.filter(
Q(name__startswith="authentik default LDAP Mapping")
user = User.objects.get(username="user0_sn") | Q(name__startswith="authentik default Active Directory Mapping")
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,
) )
)
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,
)

View file

@ -7,7 +7,7 @@ from authentik.core.models import User
from authentik.providers.oauth2.generators import generate_client_secret from authentik.providers.oauth2.generators import generate_client_secret
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
from authentik.sources.ldap.password import LDAPPasswordChanger 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_PASSWORD = generate_client_secret()
LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
@ -20,7 +20,7 @@ class LDAPPasswordTests(TestCase):
self.source = LDAPSource.objects.create( self.source = LDAPSource.objects.create(
name="ldap", name="ldap",
slug="ldap", slug="ldap",
base_dn="DC=AD2012,DC=LAB", base_dn="dc=goauthentik,dc=io",
additional_user_dn="ou=users", additional_user_dn="ou=users",
additional_group_dn="ou=groups", additional_group_dn="ou=groups",
) )
@ -41,7 +41,7 @@ class LDAPPasswordTests(TestCase):
pwc = LDAPPasswordChanger(self.source) pwc = LDAPPasswordChanger(self.source)
user = User.objects.create( user = User.objects.create(
username="test", 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("test", user)) # 1 category
self.assertFalse(pwc.ad_password_complexity("test1", user)) # 2 categories self.assertFalse(pwc.ad_password_complexity("test1", user)) # 2 categories

View file

@ -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.membership import MembershipLDAPSynchronizer
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
from authentik.sources.ldap.tasks import ldap_sync_all 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_PASSWORD = generate_client_secret()
LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
@ -25,7 +25,7 @@ class LDAPSyncTests(TestCase):
self.source = LDAPSource.objects.create( self.source = LDAPSource.objects.create(
name="ldap", name="ldap",
slug="ldap", slug="ldap",
base_dn="DC=AD2012,DC=LAB", base_dn="dc=goauthentik,dc=io",
additional_user_dn="ou=users", additional_user_dn="ou=users",
additional_group_dn="ou=groups", additional_group_dn="ou=groups",
) )