sources/ldap: add more flatten to user sync, start adding tests for OpenLDAP
This commit is contained in:
parent
fadf746234
commit
9c1ade59e9
|
@ -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:
|
||||
|
|
|
@ -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')",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
81
authentik/sources/ldap/tests/mock_slapd.py
Normal file
81
authentik/sources/ldap/tests/mock_slapd.py
Normal 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
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
|
|
Reference in a new issue