diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py index 8349bb10d..3f6e24837 100644 --- a/authentik/sources/ldap/api.py +++ b/authentik/sources/ldap/api.py @@ -4,9 +4,10 @@ from typing import Any from django_filters.filters import AllValuesMultipleFilter from django_filters.filterset import FilterSet from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema, extend_schema_field +from drf_spectacular.utils import extend_schema, extend_schema_field, inline_serializer from rest_framework.decorators import action from rest_framework.exceptions import ValidationError +from rest_framework.fields import DictField, ListField from rest_framework.request import Request from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet @@ -104,11 +105,38 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet): results = [] for sync_class in SYNC_CLASSES: sync_name = sync_class.__name__.replace("LDAPSynchronizer", "").lower() - task = TaskInfo.by_name(f"ldap_sync/{source.slug}_{sync_name}") + task = TaskInfo.by_name(f"ldap_sync:{source.slug}:{sync_name}") if task: results.append(task) return Response(TaskSerializer(results, many=True).data) + @extend_schema( + responses={ + 200: inline_serializer( + "LDAPDebugSerializer", + fields={ + "user": ListField(child=DictField(), read_only=True), + "group": ListField(child=DictField(), read_only=True), + "membership": ListField(child=DictField(), read_only=True), + }, + ), + } + ) + @action(methods=["GET"], detail=True, pagination_class=None, filter_backends=[]) + def debug(self, request: Request, slug: str) -> Response: + """Get raw LDAP data to debug""" + source = self.get_object() + all_objects = {} + for sync_class in SYNC_CLASSES: + class_name = sync_class.__name__.replace("LDAPSynchronizer", "").lower() + all_objects.setdefault(class_name, []) + for obj in sync_class(source).get_objects(size_limit=10): + obj: dict + obj.pop("raw_attributes", None) + obj.pop("raw_dn", None) + all_objects[class_name].append(obj) + return Response(data=all_objects) + class LDAPPropertyMappingSerializer(PropertyMappingSerializer): """LDAP PropertyMapping Serializer""" diff --git a/authentik/sources/ldap/auth.py b/authentik/sources/ldap/auth.py index 608a27974..6a9bbd035 100644 --- a/authentik/sources/ldap/auth.py +++ b/authentik/sources/ldap/auth.py @@ -1,8 +1,9 @@ """authentik LDAP Authentication Backend""" from typing import Optional -import ldap3 from django.http import HttpRequest +from ldap3 import Connection +from ldap3.core.exceptions import LDAPException, LDAPInvalidCredentialsResult from structlog.stdlib import get_logger from authentik.core.auth import InbuiltBackend @@ -57,7 +58,7 @@ class LDAPBackend(InbuiltBackend): # Try to bind as new user LOGGER.debug("Attempting Binding as user", user=user) try: - temp_connection = ldap3.Connection( + temp_connection = Connection( source.server, user=user.attributes.get(LDAP_DISTINGUISHED_NAME), password=password, @@ -66,8 +67,8 @@ class LDAPBackend(InbuiltBackend): ) temp_connection.bind() return user - except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception: + except LDAPInvalidCredentialsResult as exception: LOGGER.debug("LDAPInvalidCredentialsResult", user=user, error=exception) - except ldap3.core.exceptions.LDAPException as exception: + except LDAPException as exception: LOGGER.warning(exception) return None diff --git a/authentik/sources/ldap/password.py b/authentik/sources/ldap/password.py index 8cd21245a..df210939e 100644 --- a/authentik/sources/ldap/password.py +++ b/authentik/sources/ldap/password.py @@ -3,7 +3,7 @@ from enum import IntFlag from re import split from typing import Optional -import ldap3 +from ldap3 import BASE from ldap3.core.exceptions import LDAPAttributeError from structlog.stdlib import get_logger @@ -64,7 +64,7 @@ class LDAPPasswordChanger: root_attrs = self._source.connection.extend.standard.paged_search( search_base=root_dn, search_filter="(objectClass=*)", - search_scope=ldap3.BASE, + search_scope=BASE, attributes=["pwdProperties"], ) root_attrs = list(root_attrs)[0] @@ -97,7 +97,7 @@ class LDAPPasswordChanger: self._source.connection.extend.standard.paged_search( search_base=user_dn, search_filter=self._source.user_object_filter, - search_scope=ldap3.BASE, + search_scope=BASE, attributes=["displayName", "sAMAccountName"], ) ) diff --git a/authentik/sources/ldap/sync/base.py b/authentik/sources/ldap/sync/base.py index f17c0066e..ebdfdede4 100644 --- a/authentik/sources/ldap/sync/base.py +++ b/authentik/sources/ldap/sync/base.py @@ -1,5 +1,5 @@ """Sync LDAP Users and groups into authentik""" -from typing import Any +from typing import Any, Generator from django.db.models.base import Model from django.db.models.query import QuerySet @@ -47,9 +47,16 @@ class BaseLDAPSynchronizer: def message(self, *args, **kwargs): """Add message that is later added to the System Task and shown to the user""" - self._messages.append(" ".join(args)) + formatted_message = " ".join(args) + if "dn" in kwargs: + formatted_message += f"; DN: {kwargs['dn']}" + self._messages.append(formatted_message) self._logger.warning(*args, **kwargs) + def get_objects(self, **kwargs) -> Generator: + """Get objects from LDAP, implemented in subclass""" + raise NotImplementedError() + def sync(self) -> int: """Sync function, implemented in subclass""" raise NotImplementedError() diff --git a/authentik/sources/ldap/sync/groups.py b/authentik/sources/ldap/sync/groups.py index df747ec5c..143c8a377 100644 --- a/authentik/sources/ldap/sync/groups.py +++ b/authentik/sources/ldap/sync/groups.py @@ -1,8 +1,9 @@ """Sync LDAP Users and groups into authentik""" -import ldap3 -import ldap3.core.exceptions +from typing import Generator + from django.core.exceptions import FieldError from django.db.utils import IntegrityError +from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE from authentik.core.models import Group from authentik.events.models import Event, EventAction @@ -12,19 +13,24 @@ from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchroniz class GroupLDAPSynchronizer(BaseLDAPSynchronizer): """Sync LDAP Users and groups into authentik""" + def get_objects(self, **kwargs) -> Generator: + return self._source.connection.extend.standard.paged_search( + search_base=self.base_dn_groups, + search_filter=self._source.group_object_filter, + search_scope=SUBTREE, + attributes=[ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES], + **kwargs, + ) + def sync(self) -> int: """Iterate over all LDAP Groups and create authentik_core.Group instances""" if not self._source.sync_groups: self.message("Group syncing is disabled for this Source") return -1 - groups = self._source.connection.extend.standard.paged_search( - search_base=self.base_dn_groups, - search_filter=self._source.group_object_filter, - search_scope=ldap3.SUBTREE, - attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES], - ) group_count = 0 - for group in groups: + for group in self.get_objects(): + if "attributes" not in group: + continue attributes = group.get("attributes", {}) group_dn = self._flatten(self._flatten(group.get("entryDN", group.get("dn")))) if self._source.object_uniqueness_field not in attributes: diff --git a/authentik/sources/ldap/sync/membership.py b/authentik/sources/ldap/sync/membership.py index 6343fbcef..a24cead56 100644 --- a/authentik/sources/ldap/sync/membership.py +++ b/authentik/sources/ldap/sync/membership.py @@ -1,9 +1,8 @@ """Sync LDAP Users and groups into authentik""" -from typing import Any, Optional +from typing import Any, Generator, Optional -import ldap3 -import ldap3.core.exceptions from django.db.models import Q +from ldap3 import SUBTREE from authentik.core.models import Group, User from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME @@ -20,23 +19,28 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): super().__init__(source) self.group_cache: dict[str, Group] = {} - def sync(self) -> int: - """Iterate over all Users and assign Groups using memberOf Field""" - if not self._source.sync_groups: - self.message("Group syncing is disabled for this Source") - return -1 - groups = self._source.connection.extend.standard.paged_search( + def get_objects(self, **kwargs) -> Generator: + return self._source.connection.extend.standard.paged_search( search_base=self.base_dn_groups, search_filter=self._source.group_object_filter, - search_scope=ldap3.SUBTREE, + search_scope=SUBTREE, attributes=[ self._source.group_membership_field, self._source.object_uniqueness_field, LDAP_DISTINGUISHED_NAME, ], + **kwargs, ) + + def sync(self) -> int: + """Iterate over all Users and assign Groups using memberOf Field""" + if not self._source.sync_groups: + self.message("Group syncing is disabled for this Source") + return -1 membership_count = 0 - for group in groups: + for group in self.get_objects(): + if "attributes" not in group: + continue members = group.get("attributes", {}).get(self._source.group_membership_field, []) ak_group = self.get_group(group) if not ak_group: diff --git a/authentik/sources/ldap/sync/users.py b/authentik/sources/ldap/sync/users.py index 2a0edf45b..739a09619 100644 --- a/authentik/sources/ldap/sync/users.py +++ b/authentik/sources/ldap/sync/users.py @@ -1,8 +1,9 @@ """Sync LDAP Users into authentik""" -import ldap3 -import ldap3.core.exceptions +from typing import Generator + from django.core.exceptions import FieldError from django.db.utils import IntegrityError +from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE from authentik.core.models import User from authentik.events.models import Event, EventAction @@ -14,19 +15,24 @@ from authentik.sources.ldap.sync.vendor.ms_ad import MicrosoftActiveDirectory class UserLDAPSynchronizer(BaseLDAPSynchronizer): """Sync LDAP Users into authentik""" + def get_objects(self, **kwargs) -> Generator: + return self._source.connection.extend.standard.paged_search( + search_base=self.base_dn_users, + search_filter=self._source.user_object_filter, + search_scope=SUBTREE, + attributes=[ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES], + **kwargs, + ) + def sync(self) -> int: """Iterate over all LDAP Users and create authentik_core.User instances""" if not self._source.sync_users: self.message("User syncing is disabled for this Source") return -1 - users = self._source.connection.extend.standard.paged_search( - search_base=self.base_dn_users, - search_filter=self._source.user_object_filter, - search_scope=ldap3.SUBTREE, - attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES], - ) user_count = 0 - for user in users: + for user in self.get_objects(): + if "attributes" not in user: + continue attributes = user.get("attributes", {}) user_dn = self._flatten(user.get("entryDN", user.get("dn"))) if self._source.object_uniqueness_field not in attributes: diff --git a/authentik/sources/ldap/sync/vendor/freeipa.py b/authentik/sources/ldap/sync/vendor/freeipa.py index 2eca55686..f71f9778e 100644 --- a/authentik/sources/ldap/sync/vendor/freeipa.py +++ b/authentik/sources/ldap/sync/vendor/freeipa.py @@ -1,6 +1,6 @@ """FreeIPA specific""" from datetime import datetime -from typing import Any +from typing import Any, Generator from pytz import UTC @@ -11,6 +11,9 @@ from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer class FreeIPA(BaseLDAPSynchronizer): """FreeIPA-specific LDAP""" + def get_objects(self, **kwargs) -> Generator: + yield None + def sync(self, attributes: dict[str, Any], user: User, created: bool): self.check_pwd_last_set(attributes, user, created) diff --git a/authentik/sources/ldap/sync/vendor/ms_ad.py b/authentik/sources/ldap/sync/vendor/ms_ad.py index 4effdc743..a78b5fddb 100644 --- a/authentik/sources/ldap/sync/vendor/ms_ad.py +++ b/authentik/sources/ldap/sync/vendor/ms_ad.py @@ -1,7 +1,7 @@ """Active Directory specific""" from datetime import datetime from enum import IntFlag -from typing import Any +from typing import Any, Generator from pytz import UTC @@ -42,6 +42,9 @@ class UserAccountControl(IntFlag): class MicrosoftActiveDirectory(BaseLDAPSynchronizer): """Microsoft-specific LDAP""" + def get_objects(self, **kwargs) -> Generator: + yield None + def sync(self, attributes: dict[str, Any], user: User, created: bool): self.ms_check_pwd_last_set(attributes, user, created) self.ms_check_uac(attributes, user) diff --git a/authentik/sources/ldap/tasks.py b/authentik/sources/ldap/tasks.py index d97ea4a5f..980018a9e 100644 --- a/authentik/sources/ldap/tasks.py +++ b/authentik/sources/ldap/tasks.py @@ -44,7 +44,7 @@ def ldap_sync(self: MonitoredTask, source_pk: str, sync_class: str): # to set the state with return sync = path_to_class(sync_class) - self.set_uid(f"{source.slug}_{sync.__name__.replace('LDAPSynchronizer', '').lower()}") + self.set_uid(f"{source.slug}:{sync.__name__.replace('LDAPSynchronizer', '').lower()}") try: sync_inst = sync(source) count = sync_inst.sync() diff --git a/schema.yml b/schema.yml index f6812efb5..45f19ea47 100644 --- a/schema.yml +++ b/schema.yml @@ -16287,6 +16287,40 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /sources/ldap/{slug}/debug/: + get: + operationId: sources_ldap_debug_retrieve + description: Get raw LDAP data to debug + parameters: + - in: path + name: slug + schema: + type: string + description: Internal source name, used in URLs. + required: true + tags: + - sources + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/LDAPDebug' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /sources/ldap/{slug}/sync_status/: get: operationId: sources_ldap_sync_status_list @@ -28618,6 +28652,31 @@ components: - direct - cached type: string + LDAPDebug: + type: object + properties: + user: + type: array + items: + type: object + additionalProperties: {} + readOnly: true + group: + type: array + items: + type: object + additionalProperties: {} + readOnly: true + membership: + type: array + items: + type: object + additionalProperties: {} + readOnly: true + required: + - group + - membership + - user LDAPOutpostConfig: type: object description: LDAPProvider Serializer