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"""
_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:

View file

@ -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')",
),
]

View file

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

View file

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

View file

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

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"""
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,
)

View file

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

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.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",
)