From d817c646bd715cf8ab950a485c886abd9ee587ee Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Wed, 23 Oct 2024 17:58:29 +0200 Subject: [PATCH] sources: add Kerberos (#10815) * sources: introduce new property mappings per-user and group Signed-off-by: Marc 'risson' Schmitt * sources/ldap: migrate to new property mappings Signed-off-by: Marc 'risson' Schmitt * lint-fix and make gen Signed-off-by: Marc 'risson' Schmitt * web changes Signed-off-by: Marc 'risson' Schmitt * fix tests Signed-off-by: Marc 'risson' Schmitt * update tests Signed-off-by: Marc 'risson' Schmitt * remove flatten for generic implem Signed-off-by: Marc 'risson' Schmitt * rework migration Signed-off-by: Marc 'risson' Schmitt * lint-fix Signed-off-by: Marc 'risson' Schmitt * wip Signed-off-by: Marc 'risson' Schmitt * fix migrations Signed-off-by: Marc 'risson' Schmitt * re-add field migration to property mappings Signed-off-by: Marc 'risson' Schmitt * fix migrations Signed-off-by: Marc 'risson' Schmitt * more migrations fixes Signed-off-by: Marc 'risson' Schmitt * easy fixes Signed-off-by: Marc 'risson' Schmitt * migrate to propertymappingmanager Signed-off-by: Marc 'risson' Schmitt * ruff and small fixes Signed-off-by: Marc 'risson' Schmitt * move mapping things into a separate class Signed-off-by: Marc 'risson' Schmitt * migrations: use using(db_alias) Signed-off-by: Marc 'risson' Schmitt * migrations: use built-in variable Signed-off-by: Marc 'risson' Schmitt * add docs Signed-off-by: Marc 'risson' Schmitt * add release notes Signed-off-by: Marc 'risson' Schmitt * wip Signed-off-by: Marc 'risson' Schmitt * wip Signed-off-by: Marc 'risson' Schmitt * wip Signed-off-by: Marc 'risson' Schmitt * wip Signed-off-by: Marc 'risson' Schmitt * wip Signed-off-by: Marc 'risson' Schmitt * wip Signed-off-by: Marc 'risson' Schmitt * wip Signed-off-by: Marc 'risson' Schmitt * wip Signed-off-by: Marc 'risson' Schmitt * lint Signed-off-by: Marc 'risson' Schmitt * fix login reverse Signed-off-by: Marc 'risson' Schmitt * wip Signed-off-by: Marc 'risson' Schmitt * wip Signed-off-by: Marc 'risson' Schmitt * refactor source flow manager matching Signed-off-by: Marc 'risson' Schmitt * kerberos sync with mode matching Signed-off-by: Marc 'risson' Schmitt * fixup Signed-off-by: Marc 'risson' Schmitt * wip Signed-off-by: Marc 'risson' Schmitt * finish frontend Signed-off-by: Marc 'risson' Schmitt * Optimised images with calibre/image-actions * make web Signed-off-by: Marc 'risson' Schmitt * add test for internal password update Signed-off-by: Marc 'risson' Schmitt * fix sync tests Signed-off-by: Marc 'risson' Schmitt * fix filter Signed-off-by: Marc 'risson' Schmitt * switch to blueprints property mappings, improvements to frontend Signed-off-by: Marc 'risson' Schmitt * some more small fixes Signed-off-by: Marc 'risson' Schmitt * fix reverse Signed-off-by: Marc 'risson' Schmitt * properly deal with password changes signals Signed-off-by: Marc 'risson' Schmitt * actually deal with it properly Signed-off-by: Marc 'risson' Schmitt * fix Signed-off-by: Marc 'risson' Schmitt * update docs Signed-off-by: Marc 'risson' Schmitt * fix Signed-off-by: Marc 'risson' Schmitt * fix Signed-off-by: Marc 'risson' Schmitt * lint-fix Signed-off-by: Marc 'risson' Schmitt * blueprints: realm as group: make it non default Signed-off-by: Marc 'risson' Schmitt * small fixes and improvements Signed-off-by: Marc 'risson' Schmitt * wip Signed-off-by: Marc 'risson' Schmitt * fix title Signed-off-by: Marc 'risson' Schmitt * add password backend to default flow Signed-off-by: Marc 'risson' Schmitt * link docs page properly, add in admin interface, add suggestions for how to apply changes to a fleet of machines Signed-off-by: Marc 'risson' Schmitt * add troubleshooting Signed-off-by: Marc 'risson' Schmitt * fix default flow pass backend Signed-off-by: Marc 'risson' Schmitt * fix flaky spnego tests Signed-off-by: Marc 'risson' Schmitt * lint Signed-off-by: Marc 'risson' Schmitt * properly convert gssapi name to python str Signed-off-by: Marc 'risson' Schmitt * fix unpickable types Signed-off-by: Marc 'risson' Schmitt * make sure the last server token is returned to the client Signed-off-by: Marc 'risson' Schmitt * lint Signed-off-by: Marc 'risson' Schmitt * Update website/docs/developer-docs/setup/full-dev-environment.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/browser.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/browser.md Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/browser.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/browser.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/browser.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/browser.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/browser.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/browser.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/browser.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/browser.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/browser.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/browser.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/browser.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Signed-off-by: Marc 'risson' Schmitt * Update website/docs/users-sources/sources/protocols/kerberos/index.md Co-authored-by: Tana M Berry Signed-off-by: Marc 'risson' Schmitt * more docs review Signed-off-by: Marc 'risson' Schmitt * fix missing library Signed-off-by: Marc 'risson' Schmitt * fix missing library again Signed-off-by: Marc 'risson' Schmitt * fix web import Signed-off-by: Marc 'risson' Schmitt * fix sync Signed-off-by: Marc 'risson' Schmitt * fix sync v2 Signed-off-by: Marc 'risson' Schmitt * fix sync v3 Signed-off-by: Marc 'risson' Schmitt --------- Signed-off-by: Marc 'risson' Schmitt Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com> Co-authored-by: Tana M Berry --- .github/actions/setup/action.yml | 2 +- Dockerfile | 5 +- authentik/core/models.py | 6 +- authentik/lib/default.yml | 4 + authentik/root/settings.py | 1 + authentik/sources/kerberos/__init__.py | 0 authentik/sources/kerberos/api/__init__.py | 0 .../sources/kerberos/api/property_mappings.py | 31 + authentik/sources/kerberos/api/source.py | 114 ++ .../sources/kerberos/api/source_connection.py | 51 + authentik/sources/kerberos/apps.py | 13 + authentik/sources/kerberos/auth.py | 116 ++ authentik/sources/kerberos/krb5.conf | 4 + .../sources/kerberos/management/__init__.py | 0 .../kerberos/management/commands/__init__.py | 0 .../commands/kerberos_check_connection.py | 25 + .../management/commands/kerberos_sync.py | 25 + .../kerberos/migrations/0001_initial.py | 179 ++ .../sources/kerberos/migrations/__init__.py | 0 authentik/sources/kerberos/models.py | 376 ++++ authentik/sources/kerberos/settings.py | 18 + authentik/sources/kerberos/signals.py | 61 + authentik/sources/kerberos/sync.py | 167 ++ authentik/sources/kerberos/tasks.py | 68 + authentik/sources/kerberos/tests/__init__.py | 0 authentik/sources/kerberos/tests/test_auth.py | 57 + .../sources/kerberos/tests/test_spnego.py | 78 + authentik/sources/kerberos/tests/test_sync.py | 75 + authentik/sources/kerberos/tests/utils.py | 40 + authentik/sources/kerberos/urls.py | 22 + authentik/sources/kerberos/views.py | 181 ++ authentik/sources/ldap/auth.py | 2 +- authentik/sources/ldap/signals.py | 2 + authentik/stages/password/__init__.py | 1 + .../0010_alter_passwordstage_backends.py | 36 + authentik/stages/password/models.py | 11 +- .../flow-default-authentication-flow.yaml | 1 + blueprints/schema.json | 513 ++++- blueprints/system/sources-kerberos.yaml | 55 + lifecycle/ak | 5 +- poetry.lock | 80 +- pyproject.toml | 4 + schema.yml | 1673 +++++++++++++++++ web/authentik/sources/kerberos.png | Bin 0 -> 75303 bytes .../PropertyMappingListPage.ts | 1 + .../PropertyMappingSourceKerberosForm.ts | 40 + .../PropertyMappingWizard.ts | 1 + web/src/admin/sources/SourceListPage.ts | 1 + web/src/admin/sources/SourceViewPage.ts | 5 + web/src/admin/sources/SourceWizard.ts | 1 + .../kerberos/KerberosSourceConnectivity.ts | 39 + .../sources/kerberos/KerberosSourceForm.ts | 456 +++++ .../kerberos/KerberosSourceViewPage.ts | 213 +++ .../stages/password/PasswordStageForm.ts | 4 + .../setup/full-dev-environment.md | 2 +- website/docs/users-sources/sources/index.md | 2 +- .../sources/property-mappings/index.md | 1 + .../sources/protocols/kerberos/browser.md | 43 + .../sources/protocols/kerberos/index.md | 130 ++ website/sidebars.js | 11 + 60 files changed, 5037 insertions(+), 15 deletions(-) create mode 100644 authentik/sources/kerberos/__init__.py create mode 100644 authentik/sources/kerberos/api/__init__.py create mode 100644 authentik/sources/kerberos/api/property_mappings.py create mode 100644 authentik/sources/kerberos/api/source.py create mode 100644 authentik/sources/kerberos/api/source_connection.py create mode 100644 authentik/sources/kerberos/apps.py create mode 100644 authentik/sources/kerberos/auth.py create mode 100644 authentik/sources/kerberos/krb5.conf create mode 100644 authentik/sources/kerberos/management/__init__.py create mode 100644 authentik/sources/kerberos/management/commands/__init__.py create mode 100644 authentik/sources/kerberos/management/commands/kerberos_check_connection.py create mode 100644 authentik/sources/kerberos/management/commands/kerberos_sync.py create mode 100644 authentik/sources/kerberos/migrations/0001_initial.py create mode 100644 authentik/sources/kerberos/migrations/__init__.py create mode 100644 authentik/sources/kerberos/models.py create mode 100644 authentik/sources/kerberos/settings.py create mode 100644 authentik/sources/kerberos/signals.py create mode 100644 authentik/sources/kerberos/sync.py create mode 100644 authentik/sources/kerberos/tasks.py create mode 100644 authentik/sources/kerberos/tests/__init__.py create mode 100644 authentik/sources/kerberos/tests/test_auth.py create mode 100644 authentik/sources/kerberos/tests/test_spnego.py create mode 100644 authentik/sources/kerberos/tests/test_sync.py create mode 100644 authentik/sources/kerberos/tests/utils.py create mode 100644 authentik/sources/kerberos/urls.py create mode 100644 authentik/sources/kerberos/views.py create mode 100644 authentik/stages/password/migrations/0010_alter_passwordstage_backends.py create mode 100644 blueprints/system/sources-kerberos.yaml create mode 100644 web/authentik/sources/kerberos.png create mode 100644 web/src/admin/property-mappings/PropertyMappingSourceKerberosForm.ts create mode 100644 web/src/admin/sources/kerberos/KerberosSourceConnectivity.ts create mode 100644 web/src/admin/sources/kerberos/KerberosSourceForm.ts create mode 100644 web/src/admin/sources/kerberos/KerberosSourceViewPage.ts create mode 100644 website/docs/users-sources/sources/protocols/kerberos/browser.md create mode 100644 website/docs/users-sources/sources/protocols/kerberos/index.md diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 20501b3f0f..7166c72995 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -14,7 +14,7 @@ runs: run: | pipx install poetry || true sudo apt-get update - sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext + sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server - name: Setup python and restore poetry uses: actions/setup-python@v5 with: diff --git a/Dockerfile b/Dockerfile index f47f669ac8..c75fa81a52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -110,7 +110,7 @@ RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloa RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \ apt-get update && \ # Required for installing pip packages - apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev + apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev libkrb5-dev RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \ --mount=type=bind,target=./poetry.lock,src=./poetry.lock \ @@ -141,7 +141,7 @@ WORKDIR / # We cannot cache this layer otherwise we'll end up with a bigger image RUN apt-get update && \ # Required for runtime - apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates && \ + apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates libkrb5-3 libkadm5clnt-mit12 libkdb5-10 && \ # Required for bootstrap & healtcheck apt-get install -y --no-install-recommends runit && \ apt-get clean && \ @@ -161,6 +161,7 @@ COPY ./tests /tests COPY ./manage.py / COPY ./blueprints /blueprints COPY ./lifecycle/ /lifecycle +COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf COPY --from=go-builder /go/authentik /bin/authentik COPY --from=python-deps /ak-root/venv /ak-root/venv COPY --from=web-builder /work/web/dist/ /web/dist/ diff --git a/authentik/core/models.py b/authentik/core/models.py index 4c8e247e72..85e8901ed1 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -330,11 +330,13 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser): """superuser == staff user""" return self.is_superuser # type: ignore - def set_password(self, raw_password, signal=True): + def set_password(self, raw_password, signal=True, sender=None): if self.pk and signal: from authentik.core.signals import password_changed - password_changed.send(sender=self, user=self, password=raw_password) + if not sender: + sender = self + password_changed.send(sender=sender, user=self, password=raw_password) self.password_change_date = now() return super().set_password(raw_password) diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml index 7a6bff04a5..9d6d0dff1a 100644 --- a/authentik/lib/default.yml +++ b/authentik/lib/default.yml @@ -105,6 +105,10 @@ ldap: tls: ciphers: null +sources: + kerberos: + task_timeout_hours: 2 + reputation: expiry: 86400 diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 3b69f89d52..ad204f6ccc 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -91,6 +91,7 @@ TENANT_APPS = [ "authentik.providers.scim", "authentik.rbac", "authentik.recovery", + "authentik.sources.kerberos", "authentik.sources.ldap", "authentik.sources.oauth", "authentik.sources.plex", diff --git a/authentik/sources/kerberos/__init__.py b/authentik/sources/kerberos/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/sources/kerberos/api/__init__.py b/authentik/sources/kerberos/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/sources/kerberos/api/property_mappings.py b/authentik/sources/kerberos/api/property_mappings.py new file mode 100644 index 0000000000..ebeec41725 --- /dev/null +++ b/authentik/sources/kerberos/api/property_mappings.py @@ -0,0 +1,31 @@ +"""Kerberos Property Mapping API""" + +from rest_framework.viewsets import ModelViewSet + +from authentik.core.api.property_mappings import PropertyMappingFilterSet, PropertyMappingSerializer +from authentik.core.api.used_by import UsedByMixin +from authentik.sources.kerberos.models import KerberosSourcePropertyMapping + + +class KerberosSourcePropertyMappingSerializer(PropertyMappingSerializer): + """Kerberos PropertyMapping Serializer""" + + class Meta(PropertyMappingSerializer.Meta): + model = KerberosSourcePropertyMapping + + +class KerberosSourcePropertyMappingFilter(PropertyMappingFilterSet): + """Filter for KerberosSourcePropertyMapping""" + + class Meta(PropertyMappingFilterSet.Meta): + model = KerberosSourcePropertyMapping + + +class KerberosSourcePropertyMappingViewSet(UsedByMixin, ModelViewSet): + """KerberosSource PropertyMapping Viewset""" + + queryset = KerberosSourcePropertyMapping.objects.all() + serializer_class = KerberosSourcePropertyMappingSerializer + filterset_class = KerberosSourcePropertyMappingFilter + search_fields = ["name"] + ordering = ["name"] diff --git a/authentik/sources/kerberos/api/source.py b/authentik/sources/kerberos/api/source.py new file mode 100644 index 0000000000..b06f05b6d8 --- /dev/null +++ b/authentik/sources/kerberos/api/source.py @@ -0,0 +1,114 @@ +"""Source API Views""" + +from django.core.cache import cache +from drf_spectacular.utils import extend_schema +from guardian.shortcuts import get_objects_for_user +from rest_framework.decorators import action +from rest_framework.fields import BooleanField, SerializerMethodField +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from authentik.core.api.sources import SourceSerializer +from authentik.core.api.used_by import UsedByMixin +from authentik.core.api.utils import PassiveSerializer +from authentik.events.api.tasks import SystemTaskSerializer +from authentik.sources.kerberos.models import KerberosSource +from authentik.sources.kerberos.tasks import CACHE_KEY_STATUS + + +class KerberosSourceSerializer(SourceSerializer): + """Kerberos Source Serializer""" + + connectivity = SerializerMethodField() + + def get_connectivity(self, source: KerberosSource) -> dict[str, str] | None: + """Get cached source connectivity""" + return cache.get(CACHE_KEY_STATUS + source.slug, None) + + class Meta: + model = KerberosSource + fields = SourceSerializer.Meta.fields + [ + "group_matching_mode", + "realm", + "krb5_conf", + "sync_users", + "sync_users_password", + "sync_principal", + "sync_password", + "sync_keytab", + "sync_ccache", + "connectivity", + "spnego_server_name", + "spnego_keytab", + "spnego_ccache", + "password_login_update_internal_password", + ] + extra_kwargs = { + "sync_password": {"write_only": True}, + "sync_keytab": {"write_only": True}, + "spnego_keytab": {"write_only": True}, + } + + +class KerberosSyncStatusSerializer(PassiveSerializer): + """Kerberos Source sync status""" + + is_running = BooleanField(read_only=True) + tasks = SystemTaskSerializer(many=True, read_only=True) + + +class KerberosSourceViewSet(UsedByMixin, ModelViewSet): + """Kerberos Source Viewset""" + + queryset = KerberosSource.objects.all() + serializer_class = KerberosSourceSerializer + lookup_field = "slug" + filterset_fields = [ + "name", + "slug", + "enabled", + "realm", + "sync_users", + "sync_users_password", + "sync_principal", + "spnego_server_name", + "password_login_update_internal_password", + ] + search_fields = [ + "name", + "slug", + "realm", + "krb5_conf", + "sync_principal", + "spnego_server_name", + ] + ordering = ["name"] + + @extend_schema( + responses={ + 200: KerberosSyncStatusSerializer(), + } + ) + @action( + methods=["GET"], + detail=True, + pagination_class=None, + url_path="sync/status", + filter_backends=[], + ) + def sync_status(self, request: Request, slug: str) -> Response: + """Get source's sync status""" + source: KerberosSource = self.get_object() + tasks = list( + get_objects_for_user(request.user, "authentik_events.view_systemtask").filter( + name="kerberos_sync", + uid__startswith=source.slug, + ) + ) + with source.sync_lock as lock_acquired: + status = { + "tasks": tasks, + "is_running": not lock_acquired, + } + return Response(KerberosSyncStatusSerializer(status).data) diff --git a/authentik/sources/kerberos/api/source_connection.py b/authentik/sources/kerberos/api/source_connection.py new file mode 100644 index 0000000000..3dcbb53043 --- /dev/null +++ b/authentik/sources/kerberos/api/source_connection.py @@ -0,0 +1,51 @@ +"""Kerberos Source Serializer""" + +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.viewsets import ModelViewSet + +from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions +from authentik.core.api.sources import ( + GroupSourceConnectionSerializer, + GroupSourceConnectionViewSet, + UserSourceConnectionSerializer, +) +from authentik.core.api.used_by import UsedByMixin +from authentik.sources.kerberos.models import ( + GroupKerberosSourceConnection, + UserKerberosSourceConnection, +) + + +class UserKerberosSourceConnectionSerializer(UserSourceConnectionSerializer): + """Kerberos Source Serializer""" + + class Meta: + model = UserKerberosSourceConnection + fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"] + + +class UserKerberosSourceConnectionViewSet(UsedByMixin, ModelViewSet): + """Source Viewset""" + + queryset = UserKerberosSourceConnection.objects.all() + serializer_class = UserKerberosSourceConnectionSerializer + filterset_fields = ["source__slug"] + search_fields = ["source__slug"] + permission_classes = [OwnerSuperuserPermissions] + filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter] + ordering = ["source__slug"] + + +class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer): + """OAuth Group-Source connection Serializer""" + + class Meta(GroupSourceConnectionSerializer.Meta): + model = GroupKerberosSourceConnection + + +class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet): + """Group-source connection Viewset""" + + queryset = GroupKerberosSourceConnection.objects.all() + serializer_class = GroupKerberosSourceConnectionSerializer diff --git a/authentik/sources/kerberos/apps.py b/authentik/sources/kerberos/apps.py new file mode 100644 index 0000000000..751b522842 --- /dev/null +++ b/authentik/sources/kerberos/apps.py @@ -0,0 +1,13 @@ +"""authentik kerberos source config""" + +from authentik.blueprints.apps import ManagedAppConfig + + +class AuthentikSourceKerberosConfig(ManagedAppConfig): + """Authentik source kerberos app config""" + + name = "authentik.sources.kerberos" + label = "authentik_sources_kerberos" + verbose_name = "authentik Sources.Kerberos" + mountpoint = "source/kerberos/" + default = True diff --git a/authentik/sources/kerberos/auth.py b/authentik/sources/kerberos/auth.py new file mode 100644 index 0000000000..e1f51fb7d3 --- /dev/null +++ b/authentik/sources/kerberos/auth.py @@ -0,0 +1,116 @@ +"""authentik Kerberos Authentication Backend""" + +import gssapi +from django.http import HttpRequest +from structlog.stdlib import get_logger + +from authentik.core.auth import InbuiltBackend +from authentik.core.models import User +from authentik.lib.generators import generate_id +from authentik.sources.kerberos.models import ( + KerberosSource, + Krb5ConfContext, + UserKerberosSourceConnection, +) + +LOGGER = get_logger() + + +class KerberosBackend(InbuiltBackend): + """Authenticate users against Kerberos realm""" + + def authenticate(self, request: HttpRequest, **kwargs): + """Try to authenticate a user via kerberos""" + if "password" not in kwargs or "username" not in kwargs: + return None + username = kwargs.pop("username") + realm = None + if "@" in username: + username, realm = username.rsplit("@", 1) + + user, source = self.auth_user(username, realm, **kwargs) + if user: + self.set_method("kerberos", request, source=source) + return user + return None + + def auth_user( + self, username: str, realm: str | None, password: str, **filters + ) -> tuple[User | None, KerberosSource | None]: + sources = KerberosSource.objects.filter(enabled=True) + user = User.objects.filter(usersourceconnection__source__in=sources, **filters).first() + + if user is not None: + # User found, let's get its connections for the sources that are available + user_source_connections = UserKerberosSourceConnection.objects.filter( + user=user, source__in=sources + ) + elif realm is not None: + user_source_connections = UserKerberosSourceConnection.objects.filter( + source__in=sources, identifier=f"{username}@{realm}" + ) + # no realm specified, we can't do anything + else: + user_source_connections = UserKerberosSourceConnection.objects.none() + + if not user_source_connections.exists(): + LOGGER.debug("no kerberos source found for user", username=username) + return None, None + + for user_source_connection in user_source_connections.prefetch_related().select_related( + "source__kerberossource" + ): + # User either has an unusable password, + # or has a password, but couldn't be authenticated by ModelBackend + # This means we check with a kinit to see if the Kerberos password has changed + if self.auth_user_by_kinit(user_source_connection, password): + # Password was successful in kinit to Kerberos, so we save it in database + if ( + user_source_connection.source.kerberossource.password_login_update_internal_password + ): + LOGGER.debug( + "Updating user's password in DB", + source=user_source_connection.source, + user=user_source_connection.user, + ) + user_source_connection.user.set_password( + password, sender=user_source_connection.source + ) + user_source_connection.user.save() + return user, user_source_connection.source + # Password doesn't match, onto next source + LOGGER.debug( + "failed to kinit, password invalid", + source=user_source_connection.source, + user=user_source_connection.user, + ) + # No source with valid password found + LOGGER.debug("no valid kerberos source found for user", user=user) + return None, None + + def auth_user_by_kinit( + self, user_source_connection: UserKerberosSourceConnection, password: str + ) -> bool: + """Attempt authentication by kinit to the source.""" + LOGGER.debug( + "Attempting to kinit as user", + user=user_source_connection.user, + source=user_source_connection.source, + principal=user_source_connection.identifier, + ) + + with Krb5ConfContext(user_source_connection.source.kerberossource): + name = gssapi.raw.import_name( + user_source_connection.identifier.encode(), gssapi.raw.NameType.kerberos_principal + ) + try: + # Use a temporary credentials cache to not interfere with whatever is defined + # elsewhere + gssapi.raw.ext_krb5.krb5_ccache_name(f"MEMORY:{generate_id(12)}".encode()) + gssapi.raw.ext_password.acquire_cred_with_password(name, password.encode()) + # Restore the credentials cache to what it was before + gssapi.raw.ext_krb5.krb5_ccache_name(None) + return True + except gssapi.exceptions.GSSError as exc: + LOGGER.warning("failed to kinit", exc=exc) + return False diff --git a/authentik/sources/kerberos/krb5.conf b/authentik/sources/kerberos/krb5.conf new file mode 100644 index 0000000000..a7cb7af0ed --- /dev/null +++ b/authentik/sources/kerberos/krb5.conf @@ -0,0 +1,4 @@ +[libdefaults] + dns_canonicalize_hostname = false + dns_fallback = true + rnds = false diff --git a/authentik/sources/kerberos/management/__init__.py b/authentik/sources/kerberos/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/sources/kerberos/management/commands/__init__.py b/authentik/sources/kerberos/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/sources/kerberos/management/commands/kerberos_check_connection.py b/authentik/sources/kerberos/management/commands/kerberos_check_connection.py new file mode 100644 index 0000000000..33d75e57c3 --- /dev/null +++ b/authentik/sources/kerberos/management/commands/kerberos_check_connection.py @@ -0,0 +1,25 @@ +"""Kerberos Connection check""" + +from json import dumps + +from structlog.stdlib import get_logger + +from authentik.sources.kerberos.models import KerberosSource +from authentik.tenants.management import TenantCommand + +LOGGER = get_logger() + + +class Command(TenantCommand): + """Check connectivity to Kerberos servers for a source""" + + def add_arguments(self, parser): + parser.add_argument("source_slugs", nargs="?", type=str) + + def handle_per_tenant(self, **options): + sources = KerberosSource.objects.filter(enabled=True) + if options["source_slugs"]: + sources = KerberosSource.objects.filter(slug__in=options["source_slugs"]) + for source in sources.order_by("slug"): + status = source.check_connection() + self.stdout.write(dumps(status, indent=4)) diff --git a/authentik/sources/kerberos/management/commands/kerberos_sync.py b/authentik/sources/kerberos/management/commands/kerberos_sync.py new file mode 100644 index 0000000000..ff49dde4d1 --- /dev/null +++ b/authentik/sources/kerberos/management/commands/kerberos_sync.py @@ -0,0 +1,25 @@ +"""Kerberos Sync""" + +from structlog.stdlib import get_logger + +from authentik.sources.kerberos.models import KerberosSource +from authentik.sources.kerberos.sync import KerberosSync +from authentik.tenants.management import TenantCommand + +LOGGER = get_logger() + + +class Command(TenantCommand): + """Run sync for an Kerberos Source""" + + def add_arguments(self, parser): + parser.add_argument("source_slugs", nargs="+", type=str) + + def handle_per_tenant(self, **options): + for source_slug in options["source_slugs"]: + source = KerberosSource.objects.filter(slug=source_slug).first() + if not source: + LOGGER.warning("Source does not exist", slug=source_slug) + continue + user_count = KerberosSync(source).sync() + LOGGER.info(f"Synced {user_count} users", slug=source_slug) diff --git a/authentik/sources/kerberos/migrations/0001_initial.py b/authentik/sources/kerberos/migrations/0001_initial.py new file mode 100644 index 0000000000..a1968af043 --- /dev/null +++ b/authentik/sources/kerberos/migrations/0001_initial.py @@ -0,0 +1,179 @@ +# Generated by Django 5.0.9 on 2024-09-23 11:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="GroupKerberosSourceConnection", + fields=[ + ( + "groupsourceconnection_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.groupsourceconnection", + ), + ), + ], + options={ + "verbose_name": "Group Kerberos Source Connection", + "verbose_name_plural": "Group Kerberos Source Connections", + }, + bases=("authentik_core.groupsourceconnection",), + ), + migrations.CreateModel( + name="KerberosSource", + fields=[ + ( + "source_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.source", + ), + ), + ("realm", models.TextField(help_text="Kerberos realm", unique=True)), + ( + "krb5_conf", + models.TextField( + blank=True, + help_text="Custom krb5.conf to use. Uses the system one by default", + ), + ), + ( + "sync_users", + models.BooleanField( + db_index=True, + default=False, + help_text="Sync users from Kerberos into authentik", + ), + ), + ( + "sync_users_password", + models.BooleanField( + db_index=True, + default=True, + help_text="When a user changes their password, sync it back to Kerberos", + ), + ), + ( + "sync_principal", + models.TextField( + blank=True, help_text="Principal to authenticate to kadmin for sync." + ), + ), + ( + "sync_password", + models.TextField( + blank=True, help_text="Password to authenticate to kadmin for sync" + ), + ), + ( + "sync_keytab", + models.TextField( + blank=True, + help_text="Keytab to authenticate to kadmin for sync. Must be base64-encoded or in the form TYPE:residual", + ), + ), + ( + "sync_ccache", + models.TextField( + blank=True, + help_text="Credentials cache to authenticate to kadmin for sync. Must be in the form TYPE:residual", + ), + ), + ( + "spnego_server_name", + models.TextField( + blank=True, + help_text="Force the use of a specific server name for SPNEGO. Must be in the form HTTP@hostname", + ), + ), + ( + "spnego_keytab", + models.TextField( + blank=True, + help_text="SPNEGO keytab base64-encoded or path to keytab in the form FILE:path", + ), + ), + ( + "spnego_ccache", + models.TextField( + blank=True, + help_text="Credential cache to use for SPNEGO in form type:residual", + ), + ), + ( + "password_login_update_internal_password", + models.BooleanField( + default=False, + help_text="If enabled, the authentik-stored password will be updated upon login with the Kerberos password backend", + ), + ), + ], + options={ + "verbose_name": "Kerberos Source", + "verbose_name_plural": "Kerberos Sources", + }, + bases=("authentik_core.source",), + ), + migrations.CreateModel( + name="KerberosSourcePropertyMapping", + fields=[ + ( + "propertymapping_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.propertymapping", + ), + ), + ], + options={ + "verbose_name": "Kerberos Source Property Mapping", + "verbose_name_plural": "Kerberos Source Property Mappings", + }, + bases=("authentik_core.propertymapping",), + ), + migrations.CreateModel( + name="UserKerberosSourceConnection", + fields=[ + ( + "usersourceconnection_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.usersourceconnection", + ), + ), + ("identifier", models.TextField()), + ], + options={ + "verbose_name": "User Kerberos Source Connection", + "verbose_name_plural": "User Kerberos Source Connections", + }, + bases=("authentik_core.usersourceconnection",), + ), + ] diff --git a/authentik/sources/kerberos/migrations/__init__.py b/authentik/sources/kerberos/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/sources/kerberos/models.py b/authentik/sources/kerberos/models.py new file mode 100644 index 0000000000..b5656e4640 --- /dev/null +++ b/authentik/sources/kerberos/models.py @@ -0,0 +1,376 @@ +"""authentik Kerberos Source Models""" + +import os +from pathlib import Path +from tempfile import gettempdir +from typing import Any + +import gssapi +import kadmin +import pglock +from django.db import connection, models +from django.db.models.fields import b64decode +from django.http import HttpRequest +from django.shortcuts import reverse +from django.templatetags.static import static +from django.utils.translation import gettext_lazy as _ +from rest_framework.serializers import Serializer +from structlog.stdlib import get_logger + +from authentik.core.models import ( + GroupSourceConnection, + PropertyMapping, + Source, + UserSourceConnection, + UserTypes, +) +from authentik.core.types import UILoginButton, UserSettingSerializer +from authentik.flows.challenge import RedirectChallenge + +LOGGER = get_logger() + + +# python-kadmin leaks file descriptors. As such, this global is used to reuse +# existing kadmin connections instead of creating new ones, which results in less to no file +# descriptors leaks +_kadmin_connections: dict[str, Any] = {} + + +class KerberosSource(Source): + """Federate Kerberos realm with authentik""" + + realm = models.TextField(help_text=_("Kerberos realm"), unique=True) + krb5_conf = models.TextField( + blank=True, + help_text=_("Custom krb5.conf to use. Uses the system one by default"), + ) + + sync_users = models.BooleanField( + default=False, help_text=_("Sync users from Kerberos into authentik"), db_index=True + ) + sync_users_password = models.BooleanField( + default=True, + help_text=_("When a user changes their password, sync it back to Kerberos"), + db_index=True, + ) + sync_principal = models.TextField( + help_text=_("Principal to authenticate to kadmin for sync."), blank=True + ) + sync_password = models.TextField( + help_text=_("Password to authenticate to kadmin for sync"), blank=True + ) + sync_keytab = models.TextField( + help_text=_( + "Keytab to authenticate to kadmin for sync. " + "Must be base64-encoded or in the form TYPE:residual" + ), + blank=True, + ) + sync_ccache = models.TextField( + help_text=_( + "Credentials cache to authenticate to kadmin for sync. " + "Must be in the form TYPE:residual" + ), + blank=True, + ) + + spnego_server_name = models.TextField( + help_text=_( + "Force the use of a specific server name for SPNEGO. Must be in the form HTTP@hostname" + ), + blank=True, + ) + spnego_keytab = models.TextField( + help_text=_("SPNEGO keytab base64-encoded or path to keytab in the form FILE:path"), + blank=True, + ) + spnego_ccache = models.TextField( + help_text=_("Credential cache to use for SPNEGO in form type:residual"), + blank=True, + ) + + password_login_update_internal_password = models.BooleanField( + default=False, + help_text=_( + "If enabled, the authentik-stored password will be updated upon " + "login with the Kerberos password backend" + ), + ) + + class Meta: + verbose_name = _("Kerberos Source") + verbose_name_plural = _("Kerberos Sources") + + def __str__(self): + return f"Kerberos Source {self.name}" + + @property + def component(self) -> str: + return "ak-source-kerberos-form" + + @property + def serializer(self) -> type[Serializer]: + from authentik.sources.kerberos.api.source import KerberosSourceSerializer + + return KerberosSourceSerializer + + @property + def property_mapping_type(self) -> type[PropertyMapping]: + return KerberosSourcePropertyMapping + + @property + def icon_url(self) -> str: + icon = super().icon_url + if not icon: + return static("authentik/sources/kerberos.png") + return icon + + def ui_login_button(self, request: HttpRequest) -> UILoginButton: + return UILoginButton( + challenge=RedirectChallenge( + data={ + "to": reverse( + "authentik_sources_kerberos:spnego-login", + kwargs={"source_slug": self.slug}, + ), + } + ), + name=self.name, + icon_url=self.icon_url, + ) + + def ui_user_settings(self) -> UserSettingSerializer | None: + return UserSettingSerializer( + data={ + "title": self.name, + "component": "ak-user-settings-source-kerberos", + "configure_url": reverse( + "authentik_sources_kerberos:spnego-login", + kwargs={"source_slug": self.slug}, + ), + "icon_url": self.icon_url, + } + ) + + @property + def sync_lock(self) -> pglock.advisory: + """Redis lock for syncing Kerberos to prevent multiple parallel syncs happening""" + return pglock.advisory( + lock_id=f"goauthentik.io/{connection.schema_name}/sources/kerberos/sync/{self.slug}", + timeout=0, + side_effect=pglock.Return, + ) + + def get_base_user_properties(self, principal: str, **kwargs): + localpart, _ = principal.rsplit("@", 1) + + return { + "username": localpart, + "type": UserTypes.INTERNAL, + "path": self.get_user_path(), + } + + def get_base_group_properties(self, group_id: str, **kwargs): + return { + "name": group_id, + } + + @property + def tempdir(self) -> Path: + """Get temporary storage for Kerberos files""" + path = ( + Path(gettempdir()) + / "authentik" + / connection.schema_name + / "sources" + / "kerberos" + / str(self.pk) + ) + path.mkdir(mode=0o700, parents=True, exist_ok=True) + return path + + @property + def krb5_conf_path(self) -> str | None: + """Get krb5.conf path""" + if not self.krb5_conf: + return None + conf_path = self.tempdir / "krb5.conf" + conf_path.write_text(self.krb5_conf) + return str(conf_path) + + def _kadmin_init(self) -> "kadmin.KAdmin | None": + # kadmin doesn't use a ccache for its connection + # as such, we don't need to create a separate ccache for each source + if not self.sync_principal: + return None + if self.sync_password: + return kadmin.init_with_password( + self.sync_principal, + self.sync_password, + ) + if self.sync_keytab: + keytab = self.sync_keytab + if ":" not in keytab: + keytab_path = self.tempdir / "kadmin_keytab" + keytab_path.touch(mode=0o600) + keytab_path.write_bytes(b64decode(self.sync_keytab)) + keytab = f"FILE:{keytab_path}" + return kadmin.init_with_keytab( + self.sync_principal, + keytab, + ) + if self.sync_ccache: + return kadmin.init_with_ccache( + self.sync_principal, + self.sync_ccache, + ) + return None + + def connection(self) -> "kadmin.KAdmin | None": + """Get kadmin connection""" + if str(self.pk) not in _kadmin_connections: + kadm = self._kadmin_init() + if kadm is not None: + _kadmin_connections[str(self.pk)] = self._kadmin_init() + return _kadmin_connections.get(str(self.pk), None) + + def check_connection(self) -> dict[str, str]: + """Check Kerberos Connection""" + status = {"status": "ok"} + if not self.sync_users: + return status + with Krb5ConfContext(self): + try: + kadm = self.connection() + if kadm is None: + status["status"] = "no connection" + return status + status["principal_exists"] = kadm.principal_exists(self.sync_principal) + except kadmin.KAdminError as exc: + status["status"] = str(exc) + return status + + def get_gssapi_store(self) -> dict[str, str]: + """Get GSSAPI credentials store for this source""" + ccache = self.spnego_ccache + keytab = None + + if not ccache: + ccache_path = self.tempdir / "spnego_ccache" + ccache_path.touch(mode=0o600) + ccache = f"FILE:{ccache_path}" + + if self.spnego_keytab: + # Keytab is of the form type:residual, use as-is + if ":" in self.spnego_keytab: + keytab = self.spnego_keytab + # Parse the keytab and write it in the file + else: + keytab_path = self.tempdir / "spnego_keytab" + keytab_path.touch(mode=0o600) + keytab_path.write_bytes(b64decode(self.spnego_keytab)) + keytab = f"FILE:{keytab_path}" + + store = {"ccache": ccache} + if keytab is not None: + store["keytab"] = keytab + return store + + def get_gssapi_creds(self) -> gssapi.creds.Credentials | None: + """Get GSSAPI credentials for this source""" + try: + name = None + if self.spnego_server_name: + # pylint: disable=c-extension-no-member + name = gssapi.names.Name( + base=self.spnego_server_name, + name_type=gssapi.raw.types.NameType.hostbased_service, + ) + return gssapi.creds.Credentials( + usage="accept", name=name, store=self.get_gssapi_store() + ) + except gssapi.exceptions.GSSError as exc: + LOGGER.warn("GSSAPI credentials failure", exc=exc) + return None + + +class Krb5ConfContext: + """ + Context manager to set the path to the krb5.conf config file. + """ + + def __init__(self, source: KerberosSource): + self._source = source + self._path = self._source.krb5_conf_path + self._previous = None + + def __enter__(self): + if not self._path: + return + self._previous = os.environ.get("KRB5_CONFIG", None) + os.environ["KRB5_CONFIG"] = self._path + + def __exit__(self, *args, **kwargs): + if not self._path: + return + if self._previous: + os.environ["KRB5_CONFIG"] = self._previous + else: + del os.environ["KRB5_CONFIG"] + + +class KerberosSourcePropertyMapping(PropertyMapping): + """Map Kerberos Property to User object attribute""" + + @property + def component(self) -> str: + return "ak-property-mapping-source-kerberos-form" + + @property + def serializer(self) -> type[Serializer]: + from authentik.sources.kerberos.api.property_mappings import ( + KerberosSourcePropertyMappingSerializer, + ) + + return KerberosSourcePropertyMappingSerializer + + def __str__(self): + return str(self.name) + + class Meta: + verbose_name = _("Kerberos Source Property Mapping") + verbose_name_plural = _("Kerberos Source Property Mappings") + + +class UserKerberosSourceConnection(UserSourceConnection): + """Connection to configured Kerberos Sources.""" + + identifier = models.TextField() + + @property + def serializer(self) -> type[Serializer]: + from authentik.sources.kerberos.api.source_connection import ( + UserKerberosSourceConnectionSerializer, + ) + + return UserKerberosSourceConnectionSerializer + + class Meta: + verbose_name = _("User Kerberos Source Connection") + verbose_name_plural = _("User Kerberos Source Connections") + + +class GroupKerberosSourceConnection(GroupSourceConnection): + """Connection to configured Kerberos Sources.""" + + @property + def serializer(self) -> type[Serializer]: + from authentik.sources.kerberos.api.source_connection import ( + GroupKerberosSourceConnectionSerializer, + ) + + return GroupKerberosSourceConnectionSerializer + + class Meta: + verbose_name = _("Group Kerberos Source Connection") + verbose_name_plural = _("Group Kerberos Source Connections") diff --git a/authentik/sources/kerberos/settings.py b/authentik/sources/kerberos/settings.py new file mode 100644 index 0000000000..2eac46c175 --- /dev/null +++ b/authentik/sources/kerberos/settings.py @@ -0,0 +1,18 @@ +"""LDAP Settings""" + +from celery.schedules import crontab + +from authentik.lib.utils.time import fqdn_rand + +CELERY_BEAT_SCHEDULE = { + "sources_kerberos_sync": { + "task": "authentik.sources.kerberos.tasks.kerberos_sync_all", + "schedule": crontab(minute=fqdn_rand("sources_kerberos_sync"), hour="*/2"), + "options": {"queue": "authentik_scheduled"}, + }, + "sources_kerberos_connectivity_check": { + "task": "authentik.sources.kerberos.tasks.kerberos_connectivity_check", + "schedule": crontab(minute=fqdn_rand("sources_kerberos_connectivity_check"), hour="*"), + "options": {"queue": "authentik_scheduled"}, + }, +} diff --git a/authentik/sources/kerberos/signals.py b/authentik/sources/kerberos/signals.py new file mode 100644 index 0000000000..af3306a7a1 --- /dev/null +++ b/authentik/sources/kerberos/signals.py @@ -0,0 +1,61 @@ +"""authentik kerberos source signals""" + +import kadmin +from django.db.models.signals import post_save +from django.dispatch import receiver +from rest_framework.serializers import ValidationError +from structlog.stdlib import get_logger + +from authentik.core.models import User +from authentik.core.signals import password_changed +from authentik.events.models import Event, EventAction +from authentik.sources.kerberos.models import ( + KerberosSource, + Krb5ConfContext, + UserKerberosSourceConnection, +) +from authentik.sources.kerberos.tasks import kerberos_connectivity_check, kerberos_sync_single + +LOGGER = get_logger() + + +@receiver(post_save, sender=KerberosSource) +def sync_kerberos_source_on_save(sender, instance: KerberosSource, **_): + """Ensure that source is synced on save (if enabled)""" + if not instance.enabled or not instance.sync_users: + return + kerberos_sync_single.delay(instance.pk) + kerberos_connectivity_check.delay(instance.pk) + + +@receiver(password_changed) +def kerberos_sync_password(sender, user: User, password: str, **_): + """Connect to kerberos and update password.""" + user_source_connections = UserKerberosSourceConnection.objects.select_related( + "source__kerberossource" + ).filter( + user=user, + source__enabled=True, + source__kerberossource__sync_users=True, + source__kerberossource__sync_users_password=True, + ) + for user_source_connection in user_source_connections: + source = user_source_connection.source.kerberossource + if source.pk == getattr(sender, "pk", None): + continue + with Krb5ConfContext(source): + try: + source.connection().getprinc(user_source_connection.identifier).change_password( + password + ) + except kadmin.KAdminError as exc: + LOGGER.warning("failed to set Kerberos password", exc=exc, source=source) + Event.new( + EventAction.CONFIGURATION_ERROR, + message=( + "Failed to change password in Kerberos source due to remote error: " + f"{exc}" + ), + source=source, + ).set_user(user).save() + raise ValidationError("Failed to set password") from exc diff --git a/authentik/sources/kerberos/sync.py b/authentik/sources/kerberos/sync.py new file mode 100644 index 0000000000..6fcd87c538 --- /dev/null +++ b/authentik/sources/kerberos/sync.py @@ -0,0 +1,167 @@ +"""Sync Kerberos users into authentik""" + +from typing import Any + +import kadmin +from django.core.exceptions import FieldError +from django.db import IntegrityError, transaction +from structlog.stdlib import BoundLogger, get_logger + +from authentik.core.expression.exceptions import ( + PropertyMappingExpressionException, + SkipObjectException, +) +from authentik.core.models import Group, User, UserTypes +from authentik.core.sources.mapper import SourceMapper +from authentik.core.sources.matcher import Action, SourceMatcher +from authentik.events.models import Event, EventAction +from authentik.lib.sync.mapper import PropertyMappingManager +from authentik.lib.sync.outgoing.exceptions import StopSync +from authentik.sources.kerberos.models import ( + GroupKerberosSourceConnection, + KerberosSource, + Krb5ConfContext, + UserKerberosSourceConnection, +) + + +class KerberosSync: + """Sync Kerberos users into authentik""" + + _source: KerberosSource + _logger: BoundLogger + _connection: "kadmin.KAdmin" + mapper: SourceMapper + user_manager: PropertyMappingManager + group_manager: PropertyMappingManager + matcher: SourceMatcher + + def __init__(self, source: KerberosSource): + self._source = source + with Krb5ConfContext(self._source): + self._connection = self._source.connection() + self._messages = [] + self._logger = get_logger().bind(source=self._source, syncer=self.__class__.__name__) + self.mapper = SourceMapper(self._source) + self.user_manager = self.mapper.get_manager(User, ["principal"]) + self.group_manager = self.mapper.get_manager(Group, ["group_id", "principal"]) + self.matcher = SourceMatcher( + self._source, UserKerberosSourceConnection, GroupKerberosSourceConnection + ) + + @staticmethod + def name() -> str: + """UI name for the type of object this class synchronizes""" + return "users" + + @property + def messages(self) -> list[str]: + """Get all UI messages""" + return self._messages + + def message(self, *args, **kwargs): + """Add message that is later added to the System Task and shown to the user""" + formatted_message = " ".join(args) + self._messages.append(formatted_message) + self._logger.warning(*args, **kwargs) + + def _handle_principal(self, principal: str) -> bool: + try: + defaults = self.mapper.build_object_properties( + object_type=User, + manager=self.user_manager, + user=None, + request=None, + principal=principal, + ) + self._logger.debug("Writing user with attributes", **defaults) + if "username" not in defaults: + raise IntegrityError("Username was not set by propertymappings") + + action, connection = self.matcher.get_user_action(principal, defaults) + self._logger.debug("Action returned", action=action, connection=connection) + if action == Action.DENY: + return False + + group_properties = { + group_id: self.mapper.build_object_properties( + object_type=Group, + manager=self.group_manager, + user=None, + request=None, + group_id=group_id, + principal=principal, + ) + for group_id in defaults.pop("groups", []) + } + + if action == Action.ENROLL: + user = User.objects.create(**defaults) + if user.type == UserTypes.INTERNAL_SERVICE_ACCOUNT: + user.set_unusable_password() + user.save() + connection.user = user + connection.save() + elif action in (Action.AUTH, Action.LINK): + user = connection.user + user.update_attributes(defaults) + else: + return False + + groups: list[Group] = [] + for group_id, properties in group_properties.items(): + group = self._handle_group(group_id, properties) + if group: + groups.append(group) + + with transaction.atomic(): + user.ak_groups.remove( + *user.ak_groups.filter(groupsourceconnection__source=self._source) + ) + user.ak_groups.add(*groups) + + except PropertyMappingExpressionException as exc: + raise StopSync(exc, None, exc.mapping) from exc + except SkipObjectException: + return False + except (IntegrityError, FieldError, TypeError, AttributeError) as exc: + Event.new( + EventAction.CONFIGURATION_ERROR, + message=(f"Failed to create user: {str(exc)} "), + source=self._source, + principal=principal, + ).save() + return False + self._logger.debug("Synced User", user=user.username) + return True + + def _handle_group( + self, group_id: str, defaults: dict[str, Any | dict[str, Any]] + ) -> Group | None: + action, connection = self.matcher.get_group_action(group_id, defaults) + if action == Action.DENY: + return None + if action == Action.ENROLL: + group = Group.objects.create(**defaults) + connection.group = group + connection.save() + return group + if action in (Action.AUTH, Action.LINK): + group = connection.group + group.update_attributes(defaults) + connection.save() + return group + return None + + def sync(self) -> int: + """Iterate over all Kerberos users and create authentik_core.User instances""" + if not self._source.enabled or not self._source.sync_users: + self.message("Source is disabled or user syncing is disabled for this Source") + return -1 + + user_count = 0 + with Krb5ConfContext(self._source): + for principal in self._connection.principals(): + if self._handle_principal(principal): + user_count += 1 + return user_count diff --git a/authentik/sources/kerberos/tasks.py b/authentik/sources/kerberos/tasks.py new file mode 100644 index 0000000000..93570c11c4 --- /dev/null +++ b/authentik/sources/kerberos/tasks.py @@ -0,0 +1,68 @@ +"""Kerberos Sync tasks""" + +from django.core.cache import cache +from structlog.stdlib import get_logger + +from authentik.events.models import SystemTask as DBSystemTask +from authentik.events.models import TaskStatus +from authentik.events.system_tasks import SystemTask +from authentik.lib.config import CONFIG +from authentik.lib.sync.outgoing.exceptions import StopSync +from authentik.lib.utils.errors import exception_to_string +from authentik.root.celery import CELERY_APP +from authentik.sources.kerberos.models import KerberosSource +from authentik.sources.kerberos.sync import KerberosSync + +LOGGER = get_logger() +CACHE_KEY_STATUS = "goauthentik.io/sources/kerberos/status/" + + +@CELERY_APP.task() +def kerberos_sync_all(): + """Sync all sources""" + for source in KerberosSource.objects.filter(enabled=True, sync_users=True): + kerberos_sync_single.delay(str(source.pk)) + + +@CELERY_APP.task() +def kerberos_connectivity_check(pk: str | None = None): + """Check connectivity for Kerberos Sources""" + # 2 hour timeout, this task should run every hour + timeout = 60 * 60 * 2 + sources = KerberosSource.objects.filter(enabled=True) + if pk: + sources = sources.filter(pk=pk) + for source in sources: + status = source.check_connection() + cache.set(CACHE_KEY_STATUS + source.slug, status, timeout=timeout) + + +@CELERY_APP.task( + bind=True, + base=SystemTask, + # We take the configured hours timeout time by 2.5 as we run user and + # group in parallel and then membership, so 2x is to cover the serial tasks, + # and 0.5x on top of that to give some more leeway + soft_time_limit=(60 * 60 * CONFIG.get_int("sources.kerberos.task_timeout_hours")) * 2.5, + task_time_limit=(60 * 60 * CONFIG.get_int("sources.kerberos.task_timeout_hours")) * 2.5, +) +def kerberos_sync_single(self, source_pk: str): + """Sync a single source""" + source: KerberosSource = KerberosSource.objects.filter(pk=source_pk).first() + if not source or not source.enabled: + return + try: + with source.sync_lock as lock_acquired: + if not lock_acquired: + LOGGER.debug( + "Failed to acquire lock for Kerberos sync, skipping task", source=source.slug + ) + return + # Delete all sync tasks from the cache + DBSystemTask.objects.filter(name="kerberos_sync", uid__startswith=source.slug).delete() + syncer = KerberosSync(source) + syncer.sync() + self.set_status(TaskStatus.SUCCESSFUL, *syncer.messages) + except StopSync as exc: + LOGGER.warning(exception_to_string(exc)) + self.set_error(exc) diff --git a/authentik/sources/kerberos/tests/__init__.py b/authentik/sources/kerberos/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/sources/kerberos/tests/test_auth.py b/authentik/sources/kerberos/tests/test_auth.py new file mode 100644 index 0000000000..72db23e719 --- /dev/null +++ b/authentik/sources/kerberos/tests/test_auth.py @@ -0,0 +1,57 @@ +"""Kerberos Source Auth tests""" + +from django.contrib.auth.hashers import is_password_usable + +from authentik.core.models import User +from authentik.lib.generators import generate_id +from authentik.sources.kerberos.auth import KerberosBackend +from authentik.sources.kerberos.models import KerberosSource, UserKerberosSourceConnection +from authentik.sources.kerberos.tests.utils import KerberosTestCase + + +class TestKerberosAuth(KerberosTestCase): + """Kerberos Auth tests""" + + def setUp(self): + self.source = KerberosSource.objects.create( + name="kerberos", + slug="kerberos", + realm=self.realm.realm, + sync_users=False, + sync_users_password=False, + password_login_update_internal_password=True, + ) + self.user = User.objects.create(username=generate_id()) + self.user.set_unusable_password() + UserKerberosSourceConnection.objects.create( + source=self.source, user=self.user, identifier=self.realm.user_princ + ) + + def test_auth_username(self): + """Test auth username""" + backend = KerberosBackend() + self.assertEqual( + backend.authenticate( + None, username=self.user.username, password=self.realm.password("user") + ), + self.user, + ) + + def test_auth_principal(self): + """Test auth principal""" + backend = KerberosBackend() + self.assertEqual( + backend.authenticate( + None, username=self.realm.user_princ, password=self.realm.password("user") + ), + self.user, + ) + + def test_internal_password_update(self): + """Test internal password update""" + backend = KerberosBackend() + backend.authenticate( + None, username=self.realm.user_princ, password=self.realm.password("user") + ) + self.user.refresh_from_db() + self.assertTrue(is_password_usable(self.user.password)) diff --git a/authentik/sources/kerberos/tests/test_spnego.py b/authentik/sources/kerberos/tests/test_spnego.py new file mode 100644 index 0000000000..3d7f3ccf4a --- /dev/null +++ b/authentik/sources/kerberos/tests/test_spnego.py @@ -0,0 +1,78 @@ +"""Kerberos Source SPNEGO tests""" + +from base64 import b64decode, b64encode +from pathlib import Path + +import gssapi +from django.urls import reverse + +from authentik.core.tests.utils import create_test_admin_user +from authentik.sources.kerberos.models import KerberosSource +from authentik.sources.kerberos.tests.utils import KerberosTestCase + + +class TestSPNEGOSource(KerberosTestCase): + """Kerberos Source SPNEGO tests""" + + def setUp(self): + self.source = KerberosSource.objects.create( + name="test", + slug="test", + spnego_keytab=b64encode(Path(self.realm.http_keytab).read_bytes()).decode(), + ) + # Force store creation early + self.source.get_gssapi_store() + + def test_api_read(self): + """Test reading a source""" + self.client.force_login(create_test_admin_user()) + response = self.client.get( + reverse( + "authentik_api:kerberossource-detail", + kwargs={ + "slug": self.source.slug, + }, + ) + ) + self.assertEqual(response.status_code, 200) + + def test_source_login(self): + """test login view""" + response = self.client.get( + reverse( + "authentik_sources_kerberos:spnego-login", + kwargs={"source_slug": self.source.slug}, + ) + ) + self.assertEqual(response.status_code, 302) + + endpoint = response.headers["Location"] + + response = self.client.get(endpoint) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.headers["WWW-Authenticate"], "Negotiate") + + server_name = gssapi.names.Name("HTTP/testserver@") + client_creds = gssapi.creds.Credentials( + usage="initiate", store={"ccache": self.realm.ccache} + ) + client_ctx = gssapi.sec_contexts.SecurityContext( + name=server_name, usage="initiate", creds=client_creds + ) + + status = 401 + server_token = None + while status == 401 and not client_ctx.complete: # noqa: PLR2004 + client_token = client_ctx.step(server_token) + if not client_token: + break + response = self.client.get( + endpoint, + headers={"Authorization": f"Negotiate {b64encode(client_token).decode('ascii')}"}, + ) + status = response.status_code + if status == 401: # noqa: PLR2004 + server_token = b64decode(response.headers["WWW-Authenticate"][9:].strip()) + + # 400 because no enroll flow + self.assertEqual(status, 400) diff --git a/authentik/sources/kerberos/tests/test_sync.py b/authentik/sources/kerberos/tests/test_sync.py new file mode 100644 index 0000000000..546478acf2 --- /dev/null +++ b/authentik/sources/kerberos/tests/test_sync.py @@ -0,0 +1,75 @@ +"""Kerberos Source sync tests""" + +from authentik.blueprints.tests import apply_blueprint +from authentik.core.models import User +from authentik.lib.generators import generate_id +from authentik.sources.kerberos.models import KerberosSource, KerberosSourcePropertyMapping +from authentik.sources.kerberos.sync import KerberosSync +from authentik.sources.kerberos.tasks import kerberos_sync_all +from authentik.sources.kerberos.tests.utils import KerberosTestCase + + +class TestKerberosSync(KerberosTestCase): + """Kerberos Sync tests""" + + @apply_blueprint("system/sources-kerberos.yaml") + def setUp(self): + self.source: KerberosSource = KerberosSource.objects.create( + name="kerberos", + slug="kerberos", + realm=self.realm.realm, + sync_users=True, + sync_users_password=True, + sync_principal=self.realm.admin_princ, + sync_password=self.realm.password("admin"), + ) + self.source.user_property_mappings.set( + KerberosSourcePropertyMapping.objects.filter( + managed__startswith="goauthentik.io/sources/kerberos/user/default/" + ) + ) + + def test_default_mappings(self): + """Test default mappings""" + KerberosSync(self.source).sync() + + self.assertTrue( + User.objects.filter(username=self.realm.user_princ.rsplit("@", 1)[0]).exists() + ) + self.assertFalse( + User.objects.filter(username=self.realm.nfs_princ.rsplit("@", 1)[0]).exists() + ) + + def test_sync_mapping(self): + """Test property mappings""" + noop = KerberosSourcePropertyMapping.objects.create( + name=generate_id(), expression="return {}" + ) + email = KerberosSourcePropertyMapping.objects.create( + name=generate_id(), expression='return {"email": principal.lower()}' + ) + dont_sync_service = KerberosSourcePropertyMapping.objects.create( + name=generate_id(), + expression='if "/" in principal:\n return {"username": None}\nreturn {}', + ) + self.source.user_property_mappings.set([noop, email, dont_sync_service]) + + KerberosSync(self.source).sync() + + self.assertTrue( + User.objects.filter(username=self.realm.user_princ.rsplit("@", 1)[0]).exists() + ) + self.assertEqual( + User.objects.get(username=self.realm.user_princ.rsplit("@", 1)[0]).email, + self.realm.user_princ.lower(), + ) + self.assertFalse( + User.objects.filter(username=self.realm.nfs_princ.rsplit("@", 1)[0]).exists() + ) + + def test_tasks(self): + """Test Scheduled tasks""" + kerberos_sync_all.delay().get() + self.assertTrue( + User.objects.filter(username=self.realm.user_princ.rsplit("@", 1)[0]).exists() + ) diff --git a/authentik/sources/kerberos/tests/utils.py b/authentik/sources/kerberos/tests/utils.py new file mode 100644 index 0000000000..b88f686067 --- /dev/null +++ b/authentik/sources/kerberos/tests/utils.py @@ -0,0 +1,40 @@ +"""Kerberos Source test utils""" + +import os +from copy import deepcopy +from time import sleep + +from k5test import realm +from rest_framework.test import APITestCase + + +class KerberosTestCase(APITestCase): + """Kerberos Test Case""" + + @classmethod + def setUpClass(cls): + cls.realm = realm.K5Realm(start_kadmind=True) + + cls.realm.http_princ = f"HTTP/testserver@{cls.realm.realm}" + cls.realm.http_keytab = os.path.join(cls.realm.tmpdir, "http_keytab") + cls.realm.addprinc(cls.realm.http_princ) + cls.realm.extract_keytab(cls.realm.http_princ, cls.realm.http_keytab) + + cls._saved_env = deepcopy(os.environ) + for k, v in cls.realm.env.items(): + os.environ[k] = v + # Wait for everything to start correctly + # Otherwise leads to flaky tests + sleep(5) + + @classmethod + def tearDownClass(cls): + cls.realm.stop() + del cls.realm + + for k in deepcopy(os.environ): + if k in cls._saved_env: + os.environ[k] = cls._saved_env[k] + else: + del os.environ[k] + cls._saved_env = None diff --git a/authentik/sources/kerberos/urls.py b/authentik/sources/kerberos/urls.py new file mode 100644 index 0000000000..2ec47c1e6a --- /dev/null +++ b/authentik/sources/kerberos/urls.py @@ -0,0 +1,22 @@ +"""Kerberos Source urls""" + +from django.urls import path + +from authentik.sources.kerberos.api.property_mappings import KerberosSourcePropertyMappingViewSet +from authentik.sources.kerberos.api.source import KerberosSourceViewSet +from authentik.sources.kerberos.api.source_connection import ( + GroupKerberosSourceConnectionViewSet, + UserKerberosSourceConnectionViewSet, +) +from authentik.sources.kerberos.views import SPNEGOView + +urlpatterns = [ + path("/", SPNEGOView.as_view(), name="spnego-login"), +] + +api_urlpatterns = [ + ("propertymappings/source/kerberos", KerberosSourcePropertyMappingViewSet), + ("sources/user_connections/kerberos", UserKerberosSourceConnectionViewSet), + ("sources/group_connections/kerberos", GroupKerberosSourceConnectionViewSet), + ("sources/kerberos", KerberosSourceViewSet), +] diff --git a/authentik/sources/kerberos/views.py b/authentik/sources/kerberos/views.py new file mode 100644 index 0000000000..c34f841c06 --- /dev/null +++ b/authentik/sources/kerberos/views.py @@ -0,0 +1,181 @@ +"""Kerberos source SPNEGO views""" + +from base64 import b64decode, b64encode + +import gssapi +from django.core.cache import cache +from django.core.exceptions import SuspiciousOperation +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect, render, reverse +from django.utils.crypto import get_random_string +from django.utils.translation import gettext_lazy as _ +from django.views import View +from structlog.stdlib import get_logger + +from authentik.core.sources.flow_manager import SourceFlowManager +from authentik.sources.kerberos.models import ( + GroupKerberosSourceConnection, + KerberosSource, + Krb5ConfContext, + UserKerberosSourceConnection, +) + +LOGGER = get_logger() + +SPNEGO_REQUEST_STATUS = 401 +WWW_AUTHENTICATE = "WWW-Authenticate" +HTTP_AUTHORIZATION = "Authorization" +NEGOTIATE = "Negotiate" + +SPNEGO_STATE_CACHE_PREFIX = "goauthentik.io/sources/spnego" +SPNEGO_STATE_CACHE_TIMEOUT = 60 * 5 # 5 minutes + + +def add_negotiate_to_response( + response: HttpResponse, token: str | bytes | None = None +) -> HttpResponse: + if isinstance(token, str): + token = token.encode() + response[WWW_AUTHENTICATE] = ( + NEGOTIATE if token is None else f"{NEGOTIATE} {b64encode(token).decode('ascii')}" + ) + return response + + +class SPNEGOView(View): + """SPNEGO login""" + + source: KerberosSource + + def challenge(self, request, token: str | bytes | None = None) -> HttpResponse: + """Get SNPEGO challenge response""" + response = render( + request, + "if/error.html", + context={ + "title": _("SPNEGO authentication required"), + "message": _( + """ + Make sure you have valid tickets (obtainable via kinit) + and configured the browser correctly. + Please contact your administrator. + """ + ), + }, + status=401, + ) + return add_negotiate_to_response(response, token) + + def get_authstr(self, request) -> str | None: + """Get SPNEGO authentication string from headers""" + authorization_header = request.headers.get(HTTP_AUTHORIZATION, "") + if NEGOTIATE.lower() not in authorization_header.lower(): + return None + + auth_tuple = authorization_header.split(" ", 1) + if not auth_tuple or auth_tuple[0].lower() != NEGOTIATE.lower(): + return None + if len(auth_tuple) != 2: # noqa: PLR2004 + raise SuspiciousOperation("Malformed authorization header") + return auth_tuple[1] + + def new_state(self) -> str: + """Generate request state""" + return get_random_string(32) + + def get_server_ctx(self, key: str) -> gssapi.sec_contexts.SecurityContext | None: + """Get GSSAPI server context from cache or create it""" + server_creds = self.source.get_gssapi_creds() + if server_creds is None: + return None + + state = cache.get(f"{SPNEGO_STATE_CACHE_PREFIX}/{key}", None) + + if state: + # pylint: disable=c-extension-no-member + return gssapi.sec_contexts.SecurityContext( + base=gssapi.raw.sec_contexts.import_sec_context(state), + ) + + return gssapi.sec_contexts.SecurityContext(creds=server_creds, usage="accept") + + def set_server_ctx(self, key: str, ctx: gssapi.sec_contexts.SecurityContext): + """Store the GSSAPI server context in cache""" + cache.set(f"{SPNEGO_STATE_CACHE_PREFIX}/{key}", ctx.export(), SPNEGO_STATE_CACHE_TIMEOUT) + + # pylint: disable=too-many-return-statements + def dispatch(self, request, *args, **kwargs) -> HttpResponse: + """Process SPNEGO request""" + self.source: KerberosSource = get_object_or_404( + KerberosSource, + slug=kwargs.get("source_slug", ""), + enabled=True, + ) + + qstring = request.GET if request.method == "GET" else request.POST + state = qstring.get("state", None) + if not state: + return redirect( + reverse( + "authentik_sources_kerberos:spnego-login", + kwargs={"source_slug": self.source.slug}, + ) + + f"?state={self.new_state()}" + ) + + authstr = self.get_authstr(request) + if not authstr: + LOGGER.debug("authstr not present, sending challenge") + return self.challenge(request) + + try: + in_token = b64decode(authstr) + except (TypeError, ValueError): + return self.challenge(request) + + with Krb5ConfContext(self.source): + server_ctx = self.get_server_ctx(state) + if not server_ctx: + return self.challenge(request) + + try: + out_token = server_ctx.step(in_token) + except gssapi.exceptions.GSSError as exc: + LOGGER.debug("GSSAPI security context failure", exc=exc) + return self.challenge(request) + + if not server_ctx.complete or server_ctx.initiator_name is None: + self.set_server_ctx(state, server_ctx) + return self.challenge(request, out_token) + + def name_to_str(n: gssapi.names.Name) -> str: + return n.display_as(n.name_type) + + identifier = name_to_str(server_ctx.initiator_name) + context = { + "spnego_info": { + "initiator_name": name_to_str(server_ctx.initiator_name), + "target_name": name_to_str(server_ctx.target_name), + "mech": str(server_ctx.mech), + "actual_flags": server_ctx.actual_flags, + }, + } + + response = SPNEGOSourceFlowManager( + source=self.source, + request=request, + identifier=identifier, + user_info={ + "principal": identifier, + **context, + }, + policy_context=context, + ).get_flow() + return add_negotiate_to_response(response, out_token) + + +class SPNEGOSourceFlowManager(SourceFlowManager): + """Flow manager for Kerberos SPNEGO sources""" + + user_connection_type = UserKerberosSourceConnection + group_connection_type = GroupKerberosSourceConnection diff --git a/authentik/sources/ldap/auth.py b/authentik/sources/ldap/auth.py index f63242f80b..31ccc19de0 100644 --- a/authentik/sources/ldap/auth.py +++ b/authentik/sources/ldap/auth.py @@ -43,7 +43,7 @@ class LDAPBackend(InbuiltBackend): if source.password_login_update_internal_password: # Password given successfully binds to LDAP, so we save it in our Database LOGGER.debug("Updating user's password in DB", user=user) - user.set_password(password, signal=False) + user.set_password(password, sender=source) user.save() return user # Password doesn't match diff --git a/authentik/sources/ldap/signals.py b/authentik/sources/ldap/signals.py index a5c869f150..a2bad559bd 100644 --- a/authentik/sources/ldap/signals.py +++ b/authentik/sources/ldap/signals.py @@ -62,6 +62,8 @@ def ldap_sync_password(sender, user: User, password: str, **_): if not sources.exists(): return source = sources.first() + if source.pk == getattr(sender, "pk", None): + return if not LDAPPasswordChanger.should_check_user(user): return try: diff --git a/authentik/stages/password/__init__.py b/authentik/stages/password/__init__.py index 6c59258a05..33916581a0 100644 --- a/authentik/stages/password/__init__.py +++ b/authentik/stages/password/__init__.py @@ -3,3 +3,4 @@ BACKEND_INBUILT = "authentik.core.auth.InbuiltBackend" BACKEND_LDAP = "authentik.sources.ldap.auth.LDAPBackend" BACKEND_APP_PASSWORD = "authentik.core.auth.TokenBackend" # nosec +BACKEND_KERBEROS = "authentik.sources.kerberos.auth.KerberosBackend" diff --git a/authentik/stages/password/migrations/0010_alter_passwordstage_backends.py b/authentik/stages/password/migrations/0010_alter_passwordstage_backends.py new file mode 100644 index 0000000000..8ae6aab888 --- /dev/null +++ b/authentik/stages/password/migrations/0010_alter_passwordstage_backends.py @@ -0,0 +1,36 @@ +# Generated by Django 5.0.8 on 2024-08-07 22:17 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_password", "0009_passwordstage_allow_show_password"), + ] + + operations = [ + migrations.AlterField( + model_name="passwordstage", + name="backends", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.TextField( + choices=[ + ("authentik.core.auth.InbuiltBackend", "User database + standard password"), + ("authentik.core.auth.TokenBackend", "User database + app passwords"), + ( + "authentik.sources.ldap.auth.LDAPBackend", + "User database + LDAP password", + ), + ( + "authentik.sources.kerberos.auth.KerberosBackend", + "User database + Kerberos password", + ), + ] + ), + help_text="Selection of backends to test the password against.", + size=None, + ), + ), + ] diff --git a/authentik/stages/password/models.py b/authentik/stages/password/models.py index a88318b039..7fe44e45f3 100644 --- a/authentik/stages/password/models.py +++ b/authentik/stages/password/models.py @@ -8,7 +8,12 @@ from rest_framework.serializers import BaseSerializer from authentik.core.types import UserSettingSerializer from authentik.flows.models import ConfigurableStage, Stage -from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_INBUILT, BACKEND_LDAP +from authentik.stages.password import ( + BACKEND_APP_PASSWORD, + BACKEND_INBUILT, + BACKEND_KERBEROS, + BACKEND_LDAP, +) def get_authentication_backends(): @@ -26,6 +31,10 @@ def get_authentication_backends(): BACKEND_LDAP, _("User database + LDAP password"), ), + ( + BACKEND_KERBEROS, + _("User database + Kerberos password"), + ), ] diff --git a/blueprints/default/flow-default-authentication-flow.yaml b/blueprints/default/flow-default-authentication-flow.yaml index a7a7e3e16a..18137e3b98 100644 --- a/blueprints/default/flow-default-authentication-flow.yaml +++ b/blueprints/default/flow-default-authentication-flow.yaml @@ -19,6 +19,7 @@ entries: - attrs: backends: - authentik.core.auth.InbuiltBackend + - authentik.sources.kerberos.auth.KerberosBackend - authentik.sources.ldap.auth.LDAPBackend - authentik.core.auth.TokenBackend configure_flow: !Find [authentik_flows.flow, [slug, default-password-change]] diff --git a/blueprints/schema.json b/blueprints/schema.json index 033c6b3246..9b3b91eb74 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -1081,6 +1081,166 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_sources_kerberos.kerberossource" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "permissions": { + "$ref": "#/$defs/model_authentik_sources_kerberos.kerberossource_permissions" + }, + "attrs": { + "$ref": "#/$defs/model_authentik_sources_kerberos.kerberossource" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_sources_kerberos.kerberossource" + } + } + }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_sources_kerberos.kerberossourcepropertymapping" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "permissions": { + "$ref": "#/$defs/model_authentik_sources_kerberos.kerberossourcepropertymapping_permissions" + }, + "attrs": { + "$ref": "#/$defs/model_authentik_sources_kerberos.kerberossourcepropertymapping" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_sources_kerberos.kerberossourcepropertymapping" + } + } + }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_sources_kerberos.userkerberossourceconnection" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "permissions": { + "$ref": "#/$defs/model_authentik_sources_kerberos.userkerberossourceconnection_permissions" + }, + "attrs": { + "$ref": "#/$defs/model_authentik_sources_kerberos.userkerberossourceconnection" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_sources_kerberos.userkerberossourceconnection" + } + } + }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_sources_kerberos.groupkerberossourceconnection" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "permissions": { + "$ref": "#/$defs/model_authentik_sources_kerberos.groupkerberossourceconnection_permissions" + }, + "attrs": { + "$ref": "#/$defs/model_authentik_sources_kerberos.groupkerberossourceconnection" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_sources_kerberos.groupkerberossourceconnection" + } + } + }, { "type": "object", "required": [ @@ -4311,6 +4471,7 @@ "authentik.providers.scim", "authentik.rbac", "authentik.recovery", + "authentik.sources.kerberos", "authentik.sources.ldap", "authentik.sources.oauth", "authentik.sources.plex", @@ -4384,6 +4545,10 @@ "authentik_providers_scim.scimprovider", "authentik_providers_scim.scimmapping", "authentik_rbac.role", + "authentik_sources_kerberos.kerberossource", + "authentik_sources_kerberos.kerberossourcepropertymapping", + "authentik_sources_kerberos.userkerberossourceconnection", + "authentik_sources_kerberos.groupkerberossourceconnection", "authentik_sources_ldap.ldapsource", "authentik_sources_ldap.ldapsourcepropertymapping", "authentik_sources_oauth.oauthsource", @@ -6413,6 +6578,22 @@ "authentik_rbac.view_role", "authentik_rbac.view_system_info", "authentik_rbac.view_system_settings", + "authentik_sources_kerberos.add_groupkerberossourceconnection", + "authentik_sources_kerberos.add_kerberossource", + "authentik_sources_kerberos.add_kerberossourcepropertymapping", + "authentik_sources_kerberos.add_userkerberossourceconnection", + "authentik_sources_kerberos.change_groupkerberossourceconnection", + "authentik_sources_kerberos.change_kerberossource", + "authentik_sources_kerberos.change_kerberossourcepropertymapping", + "authentik_sources_kerberos.change_userkerberossourceconnection", + "authentik_sources_kerberos.delete_groupkerberossourceconnection", + "authentik_sources_kerberos.delete_kerberossource", + "authentik_sources_kerberos.delete_kerberossourcepropertymapping", + "authentik_sources_kerberos.delete_userkerberossourceconnection", + "authentik_sources_kerberos.view_groupkerberossourceconnection", + "authentik_sources_kerberos.view_kerberossource", + "authentik_sources_kerberos.view_kerberossourcepropertymapping", + "authentik_sources_kerberos.view_userkerberossourceconnection", "authentik_sources_ldap.add_ldapsource", "authentik_sources_ldap.add_ldapsourcepropertymapping", "authentik_sources_ldap.change_ldapsource", @@ -6660,6 +6841,319 @@ } } }, + "model_authentik_sources_kerberos.kerberossource": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name", + "description": "Source's display Name." + }, + "slug": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "pattern": "^[-a-zA-Z0-9_]+$", + "title": "Slug", + "description": "Internal source name, used in URLs." + }, + "enabled": { + "type": "boolean", + "title": "Enabled" + }, + "authentication_flow": { + "type": "string", + "format": "uuid", + "title": "Authentication flow", + "description": "Flow to use when authenticating existing users." + }, + "enrollment_flow": { + "type": "string", + "format": "uuid", + "title": "Enrollment flow", + "description": "Flow to use when enrolling new users." + }, + "user_property_mappings": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "title": "User property mappings" + }, + "group_property_mappings": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "title": "Group property mappings" + }, + "policy_engine_mode": { + "type": "string", + "enum": [ + "all", + "any" + ], + "title": "Policy engine mode" + }, + "user_matching_mode": { + "type": "string", + "enum": [ + "identifier", + "email_link", + "email_deny", + "username_link", + "username_deny" + ], + "title": "User matching mode", + "description": "How the source determines if an existing user should be authenticated or a new user enrolled." + }, + "user_path_template": { + "type": "string", + "minLength": 1, + "title": "User path template" + }, + "icon": { + "type": "string", + "minLength": 1, + "title": "Icon" + }, + "group_matching_mode": { + "type": "string", + "enum": [ + "identifier", + "name_link", + "name_deny" + ], + "title": "Group matching mode", + "description": "How the source determines if an existing group should be used or a new group created." + }, + "realm": { + "type": "string", + "minLength": 1, + "title": "Realm", + "description": "Kerberos realm" + }, + "krb5_conf": { + "type": "string", + "title": "Krb5 conf", + "description": "Custom krb5.conf to use. Uses the system one by default" + }, + "sync_users": { + "type": "boolean", + "title": "Sync users", + "description": "Sync users from Kerberos into authentik" + }, + "sync_users_password": { + "type": "boolean", + "title": "Sync users password", + "description": "When a user changes their password, sync it back to Kerberos" + }, + "sync_principal": { + "type": "string", + "title": "Sync principal", + "description": "Principal to authenticate to kadmin for sync." + }, + "sync_password": { + "type": "string", + "title": "Sync password", + "description": "Password to authenticate to kadmin for sync" + }, + "sync_keytab": { + "type": "string", + "title": "Sync keytab", + "description": "Keytab to authenticate to kadmin for sync. Must be base64-encoded or in the form TYPE:residual" + }, + "sync_ccache": { + "type": "string", + "title": "Sync ccache", + "description": "Credentials cache to authenticate to kadmin for sync. Must be in the form TYPE:residual" + }, + "spnego_server_name": { + "type": "string", + "title": "Spnego server name", + "description": "Force the use of a specific server name for SPNEGO" + }, + "spnego_keytab": { + "type": "string", + "title": "Spnego keytab", + "description": "SPNEGO keytab base64-encoded or path to keytab in the form FILE:path" + }, + "spnego_ccache": { + "type": "string", + "title": "Spnego ccache", + "description": "Credential cache to use for SPNEGO in form type:residual" + }, + "password_login_update_internal_password": { + "type": "boolean", + "title": "Password login update internal password", + "description": "If enabled, the authentik-stored password will be updated upon login with the Kerberos password backend" + } + }, + "required": [] + }, + "model_authentik_sources_kerberos.kerberossource_permissions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "add_kerberossource", + "change_kerberossource", + "delete_kerberossource", + "view_kerberossource" + ] + }, + "user": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + } + }, + "model_authentik_sources_kerberos.kerberossourcepropertymapping": { + "type": "object", + "properties": { + "managed": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "title": "Managed by authentik", + "description": "Objects that are managed by authentik. These objects are created and updated automatically. This flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update." + }, + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "expression": { + "type": "string", + "minLength": 1, + "title": "Expression" + } + }, + "required": [] + }, + "model_authentik_sources_kerberos.kerberossourcepropertymapping_permissions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "add_kerberossourcepropertymapping", + "change_kerberossourcepropertymapping", + "delete_kerberossourcepropertymapping", + "view_kerberossourcepropertymapping" + ] + }, + "user": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + } + }, + "model_authentik_sources_kerberos.userkerberossourceconnection": { + "type": "object", + "properties": { + "user": { + "type": "integer", + "title": "User" + }, + "identifier": { + "type": "string", + "minLength": 1, + "title": "Identifier" + }, + "icon": { + "type": "string", + "minLength": 1, + "title": "Icon" + } + }, + "required": [] + }, + "model_authentik_sources_kerberos.userkerberossourceconnection_permissions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "add_userkerberossourceconnection", + "change_userkerberossourceconnection", + "delete_userkerberossourceconnection", + "view_userkerberossourceconnection" + ] + }, + "user": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + } + }, + "model_authentik_sources_kerberos.groupkerberossourceconnection": { + "type": "object", + "properties": { + "icon": { + "type": "string", + "minLength": 1, + "title": "Icon" + } + }, + "required": [] + }, + "model_authentik_sources_kerberos.groupkerberossourceconnection_permissions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "add_groupkerberossourceconnection", + "change_groupkerberossourceconnection", + "delete_groupkerberossourceconnection", + "view_groupkerberossourceconnection" + ] + }, + "user": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + } + }, "model_authentik_sources_ldap.ldapsource": { "type": "object", "properties": { @@ -10544,7 +11038,8 @@ "enum": [ "authentik.core.auth.InbuiltBackend", "authentik.core.auth.TokenBackend", - "authentik.sources.ldap.auth.LDAPBackend" + "authentik.sources.ldap.auth.LDAPBackend", + "authentik.sources.kerberos.auth.KerberosBackend" ], "title": "Backends" }, @@ -12081,6 +12576,22 @@ "authentik_rbac.view_role", "authentik_rbac.view_system_info", "authentik_rbac.view_system_settings", + "authentik_sources_kerberos.add_groupkerberossourceconnection", + "authentik_sources_kerberos.add_kerberossource", + "authentik_sources_kerberos.add_kerberossourcepropertymapping", + "authentik_sources_kerberos.add_userkerberossourceconnection", + "authentik_sources_kerberos.change_groupkerberossourceconnection", + "authentik_sources_kerberos.change_kerberossource", + "authentik_sources_kerberos.change_kerberossourcepropertymapping", + "authentik_sources_kerberos.change_userkerberossourceconnection", + "authentik_sources_kerberos.delete_groupkerberossourceconnection", + "authentik_sources_kerberos.delete_kerberossource", + "authentik_sources_kerberos.delete_kerberossourcepropertymapping", + "authentik_sources_kerberos.delete_userkerberossourceconnection", + "authentik_sources_kerberos.view_groupkerberossourceconnection", + "authentik_sources_kerberos.view_kerberossource", + "authentik_sources_kerberos.view_kerberossourcepropertymapping", + "authentik_sources_kerberos.view_userkerberossourceconnection", "authentik_sources_ldap.add_ldapsource", "authentik_sources_ldap.add_ldapsourcepropertymapping", "authentik_sources_ldap.change_ldapsource", diff --git a/blueprints/system/sources-kerberos.yaml b/blueprints/system/sources-kerberos.yaml new file mode 100644 index 0000000000..d97e8eda53 --- /dev/null +++ b/blueprints/system/sources-kerberos.yaml @@ -0,0 +1,55 @@ +version: 1 +metadata: + labels: + blueprints.goauthentik.io/system: "true" + name: System - Kerberos Source - Mappings +entries: + - identifiers: + managed: goauthentik.io/sources/kerberos/user/default/multipart-principals-as-service-accounts + model: authentik_sources_kerberos.kerberossourcepropertymapping + attrs: + name: "authentik default Kerberos User Mapping: Multipart principals as service accounts" + expression: | + from authentik.core.models import USER_PATH_SERVICE_ACCOUNT, UserTypes + + localpart, _ = principal.rsplit("@", 1) + is_service_account = "/" in localpart + attrs = {} + if is_service_account: + attrs = { + "type": UserTypes.SERVICE_ACCOUNT, + "path": USER_PATH_SERVICE_ACCOUNT, + } + return attrs + - identifiers: + managed: goauthentik.io/sources/kerberos/user/default/ignore-other-realms + model: authentik_sources_kerberos.kerberossourcepropertymapping + attrs: + name: "authentik default Kerberos User Mapping: Ignore other realms" + expression: | + localpart, realm = principal.rsplit("@", 1) + if realm.upper() != source.realm.upper(): + raise SkipObject + return {} + - identifiers: + managed: goauthentik.io/sources/kerberos/user/default/ignore-system-principals + model: authentik_sources_kerberos.kerberossourcepropertymapping + attrs: + name: "authentik default Kerberos User Mapping: Ignore system principals" + expression: | + localpart, realm = principal.rsplit("@", 1) + denied_prefixes = ["kadmin/", "krbtgt/", "K/M", "WELLKNOWN/"] + for prefix in denied_prefixes: + if localpart.lower().startswith(prefix.lower()): + raise SkipObject + return {} + - identifiers: + managed: goauthentik.io/sources/kerberos/user/realm-as-group + model: authentik_sources_kerberos.kerberossourcepropertymapping + attrs: + name: "authentik default Kerberos User Mapping: Add realm as group" + expression: | + localpart, realm = principal.rsplit("@", 1) + return { + "groups": [realm.upper()] + } diff --git a/lifecycle/ak b/lifecycle/ak index 2ae5f4792f..2033951557 100755 --- a/lifecycle/ak +++ b/lifecycle/ak @@ -2,7 +2,7 @@ MODE_FILE="${TMPDIR}/authentik-mode" function log { - printf '{"event": "%s", "level": "info", "logger": "bootstrap"}\n' "$@" > /dev/stderr + printf '{"event": "%s", "level": "info", "logger": "bootstrap"}\n' "$@" >/dev/stderr } function wait_for_db { @@ -45,7 +45,7 @@ function run_authentik { } function set_mode { - echo $1 > $MODE_FILE + echo $1 >$MODE_FILE trap cleanup EXIT } @@ -54,6 +54,7 @@ function cleanup { } function prepare_debug { + apt-get install -y --no-install-recommends krb5-kdc krb5-user krb5-admin-server VIRTUAL_ENV=/ak-root/venv poetry install --no-ansi --no-interaction touch /unittest.xml chown authentik:authentik /unittest.xml diff --git a/poetry.lock b/poetry.lock index 48f712fb68..1c213fadfd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1157,6 +1157,17 @@ files = [ {file = "debugpy-1.8.7.zip", hash = "sha256:18b8f731ed3e2e1df8e9cdaa23fb1fc9c24e570cd0081625308ec51c82efe42e"}, ] +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + [[package]] name = "deepmerge" version = "2.0" @@ -1836,6 +1847,42 @@ protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4 [package.extras] grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] +[[package]] +name = "gssapi" +version = "1.8.3" +description = "Python GSSAPI Wrapper" +optional = false +python-versions = ">=3.7" +files = [ + {file = "gssapi-1.8.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4e4a83e9b275fe69b5d40be6d5479889866b80333a12c51a9243f2712d4f0554"}, + {file = "gssapi-1.8.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8d57d67547e18f4e44a688bfb20abbf176d1b8df547da2b31c3f2df03cfdc269"}, + {file = "gssapi-1.8.3-cp310-cp310-win32.whl", hash = "sha256:3a3f63105f39c4af29ffc8f7b6542053d87fe9d63010c689dd9a9f5571facb8e"}, + {file = "gssapi-1.8.3-cp310-cp310-win_amd64.whl", hash = "sha256:b031c0f186ab4275186da385b2c7470dd47c9b27522cb3b753757c9ac4bebf11"}, + {file = "gssapi-1.8.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b03d6b30f1fcd66d9a688b45a97e302e4dd3f1386d5c333442731aec73cdb409"}, + {file = "gssapi-1.8.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ca6ceb17fc15eda2a69f2e8c6cf10d11e2edb32832255e5d4c65b21b6db4680a"}, + {file = "gssapi-1.8.3-cp311-cp311-win32.whl", hash = "sha256:edc8ef3a9e397dbe18bb6016f8e2209969677b534316d20bb139da2865a38efe"}, + {file = "gssapi-1.8.3-cp311-cp311-win_amd64.whl", hash = "sha256:8fdb1ff130cee49bc865ec1624dee8cf445cd6c6e93b04bffef2c6f363a60cb9"}, + {file = "gssapi-1.8.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:19c373b3ba63ce19cd3163aa1495635e3d01b0de6cc4ff1126095eded1df6e01"}, + {file = "gssapi-1.8.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37f1a8046d695f2c9b8d640a6e385780d3945c0741571ed6fee6f94c31e431dc"}, + {file = "gssapi-1.8.3-cp312-cp312-win32.whl", hash = "sha256:338db18612e3e6ed64e92b6d849242a535fdc98b365f21122992fb8cae737617"}, + {file = "gssapi-1.8.3-cp312-cp312-win_amd64.whl", hash = "sha256:5731c5b40ecc3116cfe7fb7e1d1e128583ec8b3df1e68bf8cd12073160793acd"}, + {file = "gssapi-1.8.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e556878da197ad115a566d36e46a8082d0079731d9c24d1ace795132d725ff2a"}, + {file = "gssapi-1.8.3-cp37-cp37m-win32.whl", hash = "sha256:e2bb081f2db2111377effe7d40ba23f9a87359b9d2f4881552b731e9da88b36b"}, + {file = "gssapi-1.8.3-cp37-cp37m-win_amd64.whl", hash = "sha256:4d9ed83f2064cda60aad90e6840ae282096801b2c814b8cbd390bf0df4635aab"}, + {file = "gssapi-1.8.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7d91fe6e2a5c89b32102ea8e374b8ae13b9031d43d7b55f3abc1f194ddce820d"}, + {file = "gssapi-1.8.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d5b28237afc0668046934792756dd4b6b7e957b0d95a608d02f296734a2819ad"}, + {file = "gssapi-1.8.3-cp38-cp38-win32.whl", hash = "sha256:791e44f7bea602b8e3da1ec56fbdb383b8ee3326fdeb736f904c2aa9af13a67d"}, + {file = "gssapi-1.8.3-cp38-cp38-win_amd64.whl", hash = "sha256:5b4bf84d0a6d7779a4bf11dacfd3db57ae02dd53562e2aeadac4219a68eaee07"}, + {file = "gssapi-1.8.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e40efc88ccefefd6142f8c47b8af498731938958b808bad49990442a91f45160"}, + {file = "gssapi-1.8.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee74b9211c977b9181ff4652d886d7712c9a221560752a35393b58e5ea07887a"}, + {file = "gssapi-1.8.3-cp39-cp39-win32.whl", hash = "sha256:465c6788f2ac6ef7c738394ba8fde1ede6004e5721766f386add63891d8c90af"}, + {file = "gssapi-1.8.3-cp39-cp39-win_amd64.whl", hash = "sha256:8fb8ee70458f47b51ed881a6881f30b187c987c02af16cc0fff0079255d4d465"}, + {file = "gssapi-1.8.3.tar.gz", hash = "sha256:aa3c8d0b1526f52559552bb2c9d2d6be013d76a8e5db00b39a1db5727e93b0b0"}, +] + +[package.dependencies] +decorator = "*" + [[package]] name = "gunicorn" version = "23.0.0" @@ -2228,6 +2275,20 @@ files = [ cryptography = ">=3.4" typing-extensions = ">=4.5.0" +[[package]] +name = "k5test" +version = "0.10.4" +description = "A library for testing Python applications in self-contained Kerberos 5 environments" +optional = false +python-versions = ">=3.6" +files = [ + {file = "k5test-0.10.4-py2.py3-none-any.whl", hash = "sha256:33de7ff10bf99155fe8ee5d5976798ad1db6237214306dadf5a0ae9d6bb0ad03"}, + {file = "k5test-0.10.4.tar.gz", hash = "sha256:e152491e6602f6a93b3d533d387bd4590f2476093b6842170ff0b93de64bef30"}, +] + +[package.extras] +extension-test = ["gssapi"] + [[package]] name = "kombu" version = "5.3.7" @@ -3129,8 +3190,6 @@ files = [ {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:960db0e31c4e52fa0fc3ecbaea5b2d3b58f379e32a95ae6b0ebeaa25b93dfd34"}, {file = "orjson-3.10.6-cp312-none-win32.whl", hash = "sha256:a6ea7afb5b30b2317e0bee03c8d34c8181bc5a36f2afd4d0952f378972c4efd5"}, {file = "orjson-3.10.6-cp312-none-win_amd64.whl", hash = "sha256:874ce88264b7e655dde4aeaacdc8fd772a7962faadfb41abe63e2a4861abc3dc"}, - {file = "orjson-3.10.6-cp313-none-win32.whl", hash = "sha256:efdf2c5cde290ae6b83095f03119bdc00303d7a03b42b16c54517baa3c4ca3d0"}, - {file = "orjson-3.10.6-cp313-none-win_amd64.whl", hash = "sha256:8e190fe7888e2e4392f52cafb9626113ba135ef53aacc65cd13109eb9746c43e"}, {file = "orjson-3.10.6-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:66680eae4c4e7fc193d91cfc1353ad6d01b4801ae9b5314f17e11ba55e934183"}, {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff75b425db5ef8e8f23af93c80f072f97b4fb3afd4af44482905c9f588da28"}, {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3722fddb821b6036fd2a3c814f6bd9b57a89dc6337b9924ecd614ebce3271394"}, @@ -3888,6 +3947,21 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-kadmin" +version = "0.2.0" +description = "Python module for kerberos admin (kadm5)" +optional = false +python-versions = ">=3.8" +files = [] +develop = false + +[package.source] +type = "git" +url = "https://github.com/authentik-community/python-kadmin.git" +reference = "v0.2.0" +resolved_reference = "6f9ce6ee2427e3488b403a900a9211166c7569e1" + [[package]] name = "pytz" version = "2024.1" @@ -5488,4 +5562,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "~3.12" -content-hash = "f3bd82b8ae975dbb660a97fe248f118f780e43687d082d49f37a2d53b450adda" +content-hash = "10aa88f2f0e56cddd91adba8c39c52de92763429fb615a27c3dc218952cff808" diff --git a/pyproject.toml b/pyproject.toml index cb45912b09..47295a20f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,6 +115,7 @@ flower = "*" geoip2 = "*" google-api-python-client = "*" gunicorn = "*" +gssapi = "*" jsonpatch = "*" jwcrypto = "*" kubernetes = "*" @@ -130,6 +131,8 @@ pydantic-scim = "*" pyjwt = "*" pyrad = "*" python = "~3.12" +# Fork of python-kadmin with compilation fixes as it's unmaintained +python-kadmin = { git = "https://github.com/authentik-community/python-kadmin.git", tag = "v0.2.0" } pyyaml = "*" requests-oauthlib = "*" scim2-filter-parser = "*" @@ -162,6 +165,7 @@ debugpy = "*" drf-jsonschema-serializer = "*" freezegun = "*" importlib-metadata = "*" +k5test = "*" pdoc = "*" pytest = "*" pytest-django = "*" diff --git a/schema.yml b/schema.yml index 23bb4140bc..d4f3eb78ac 100644 --- a/schema.yml +++ b/schema.yml @@ -16466,6 +16466,287 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /propertymappings/source/kerberos/: + get: + operationId: propertymappings_source_kerberos_list + description: KerberosSource PropertyMapping Viewset + parameters: + - in: query + name: managed + schema: + type: array + items: + type: string + explode: true + style: form + - in: query + name: managed__isnull + schema: + type: boolean + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - propertymappings + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedKerberosSourcePropertyMappingList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: propertymappings_source_kerberos_create + description: KerberosSource PropertyMapping Viewset + tags: + - propertymappings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/KerberosSourcePropertyMappingRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/KerberosSourcePropertyMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /propertymappings/source/kerberos/{pm_uuid}/: + get: + operationId: propertymappings_source_kerberos_retrieve + description: KerberosSource PropertyMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Kerberos Source Property Mapping. + required: true + tags: + - propertymappings + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/KerberosSourcePropertyMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: propertymappings_source_kerberos_update + description: KerberosSource PropertyMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Kerberos Source Property Mapping. + required: true + tags: + - propertymappings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/KerberosSourcePropertyMappingRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/KerberosSourcePropertyMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: propertymappings_source_kerberos_partial_update + description: KerberosSource PropertyMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Kerberos Source Property Mapping. + required: true + tags: + - propertymappings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedKerberosSourcePropertyMappingRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/KerberosSourcePropertyMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: propertymappings_source_kerberos_destroy + description: KerberosSource PropertyMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Kerberos Source Property Mapping. + required: true + tags: + - propertymappings + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /propertymappings/source/kerberos/{pm_uuid}/used_by/: + get: + operationId: propertymappings_source_kerberos_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Kerberos Source Property Mapping. + required: true + tags: + - propertymappings + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /propertymappings/source/ldap/: get: operationId: propertymappings_source_ldap_list @@ -23067,6 +23348,10 @@ paths: - authentik_providers_scim.scimmapping - authentik_providers_scim.scimprovider - authentik_rbac.role + - authentik_sources_kerberos.groupkerberossourceconnection + - authentik_sources_kerberos.kerberossource + - authentik_sources_kerberos.kerberossourcepropertymapping + - authentik_sources_kerberos.userkerberossourceconnection - authentik_sources_ldap.ldapsource - authentik_sources_ldap.ldapsourcepropertymapping - authentik_sources_oauth.groupoauthsourceconnection @@ -23302,6 +23587,10 @@ paths: - authentik_providers_scim.scimmapping - authentik_providers_scim.scimprovider - authentik_rbac.role + - authentik_sources_kerberos.groupkerberossourceconnection + - authentik_sources_kerberos.kerberossource + - authentik_sources_kerberos.kerberossourcepropertymapping + - authentik_sources_kerberos.userkerberossourceconnection - authentik_sources_ldap.ldapsource - authentik_sources_ldap.ldapsourcepropertymapping - authentik_sources_oauth.groupoauthsourceconnection @@ -24610,6 +24899,237 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /sources/group_connections/kerberos/: + get: + operationId: sources_group_connections_kerberos_list + description: Group-source connection Viewset + parameters: + - in: query + name: group + schema: + type: string + format: uuid + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: source__slug + schema: + type: string + tags: + - sources + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedGroupKerberosSourceConnectionList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /sources/group_connections/kerberos/{id}/: + get: + operationId: sources_group_connections_kerberos_retrieve + description: Group-source connection Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Group Kerberos Source + Connection. + required: true + tags: + - sources + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/GroupKerberosSourceConnection' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: sources_group_connections_kerberos_update + description: Group-source connection Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Group Kerberos Source + Connection. + required: true + tags: + - sources + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/GroupKerberosSourceConnection' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: sources_group_connections_kerberos_partial_update + description: Group-source connection Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Group Kerberos Source + Connection. + required: true + tags: + - sources + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/GroupKerberosSourceConnection' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: sources_group_connections_kerberos_destroy + description: Group-source connection Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Group Kerberos Source + Connection. + required: true + tags: + - sources + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /sources/group_connections/kerberos/{id}/used_by/: + get: + operationId: sources_group_connections_kerberos_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Group Kerberos Source + Connection. + required: true + tags: + - sources + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /sources/group_connections/oauth/: get: operationId: sources_group_connections_oauth_list @@ -25340,6 +25860,336 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /sources/kerberos/: + get: + operationId: sources_kerberos_list + description: Kerberos Source Viewset + parameters: + - in: query + name: enabled + schema: + type: boolean + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: password_login_update_internal_password + schema: + type: boolean + - in: query + name: realm + schema: + type: string + - name: search + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: slug + schema: + type: string + - in: query + name: spnego_server_name + schema: + type: string + - in: query + name: sync_principal + schema: + type: string + - in: query + name: sync_users + schema: + type: boolean + - in: query + name: sync_users_password + schema: + type: boolean + tags: + - sources + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedKerberosSourceList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: sources_kerberos_create + description: Kerberos Source Viewset + tags: + - sources + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/KerberosSourceRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/KerberosSource' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /sources/kerberos/{slug}/: + get: + operationId: sources_kerberos_retrieve + description: Kerberos Source Viewset + 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/KerberosSource' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: sources_kerberos_update + description: Kerberos Source Viewset + parameters: + - in: path + name: slug + schema: + type: string + description: Internal source name, used in URLs. + required: true + tags: + - sources + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/KerberosSourceRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/KerberosSource' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: sources_kerberos_partial_update + description: Kerberos Source Viewset + parameters: + - in: path + name: slug + schema: + type: string + description: Internal source name, used in URLs. + required: true + tags: + - sources + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedKerberosSourceRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/KerberosSource' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: sources_kerberos_destroy + description: Kerberos Source Viewset + parameters: + - in: path + name: slug + schema: + type: string + description: Internal source name, used in URLs. + required: true + tags: + - sources + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /sources/kerberos/{slug}/sync/status/: + get: + operationId: sources_kerberos_sync_status_retrieve + description: Get source's sync status + 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/KerberosSyncStatus' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /sources/kerberos/{slug}/used_by/: + get: + operationId: sources_kerberos_used_by_list + description: Get a list of all objects that use this object + 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: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /sources/ldap/: get: operationId: sources_ldap_list @@ -27992,6 +28842,275 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /sources/user_connections/kerberos/: + get: + operationId: sources_user_connections_kerberos_list + description: Source Viewset + parameters: + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: source__slug + schema: + type: string + tags: + - sources + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedUserKerberosSourceConnectionList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: sources_user_connections_kerberos_create + description: Source Viewset + tags: + - sources + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserKerberosSourceConnectionRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/UserKerberosSourceConnection' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /sources/user_connections/kerberos/{id}/: + get: + operationId: sources_user_connections_kerberos_retrieve + description: Source Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this User Kerberos Source + Connection. + required: true + tags: + - sources + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserKerberosSourceConnection' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: sources_user_connections_kerberos_update + description: Source Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this User Kerberos Source + Connection. + required: true + tags: + - sources + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserKerberosSourceConnectionRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserKerberosSourceConnection' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: sources_user_connections_kerberos_partial_update + description: Source Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this User Kerberos Source + Connection. + required: true + tags: + - sources + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedUserKerberosSourceConnectionRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserKerberosSourceConnection' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: sources_user_connections_kerberos_destroy + description: Source Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this User Kerberos Source + Connection. + required: true + tags: + - sources + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /sources/user_connections/kerberos/{id}/used_by/: + get: + operationId: sources_user_connections_kerberos_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this User Kerberos Source + Connection. + required: true + tags: + - sources + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /sources/user_connections/oauth/: get: operationId: sources_user_connections_oauth_list @@ -36523,6 +37642,7 @@ components: - authentik.providers.scim - authentik.rbac - authentik.recovery + - authentik.sources.kerberos - authentik.sources.ldap - authentik.sources.oauth - authentik.sources.plex @@ -37840,6 +38960,7 @@ components: - authentik.core.auth.InbuiltBackend - authentik.core.auth.TokenBackend - authentik.sources.ldap.auth.LDAPBackend + - authentik.sources.kerberos.auth.KerberosBackend type: string BindingTypeEnum: enum: @@ -41117,6 +42238,35 @@ components: - pk - roles_obj - users_obj + GroupKerberosSourceConnection: + type: object + description: OAuth Group-Source connection Serializer + properties: + pk: + type: integer + readOnly: true + title: ID + group: + type: string + format: uuid + readOnly: true + source: + allOf: + - $ref: '#/components/schemas/Source' + readOnly: true + identifier: + type: string + readOnly: true + created: + type: string + format: date-time + readOnly: true + required: + - created + - group + - identifier + - pk + - source GroupMatchingModeEnum: enum: - identifier @@ -41684,6 +42834,316 @@ components: - global - per_provider type: string + KerberosSource: + type: object + description: Kerberos Source Serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Pbm uuid + name: + type: string + description: Source's display Name. + slug: + type: string + description: Internal source name, used in URLs. + maxLength: 50 + pattern: ^[-a-zA-Z0-9_]+$ + enabled: + type: boolean + authentication_flow: + type: string + format: uuid + nullable: true + description: Flow to use when authenticating existing users. + enrollment_flow: + type: string + format: uuid + nullable: true + description: Flow to use when enrolling new users. + user_property_mappings: + type: array + items: + type: string + format: uuid + group_property_mappings: + type: array + items: + type: string + format: uuid + component: + type: string + description: Get object component so that we know how to edit the object + readOnly: true + verbose_name: + type: string + description: Return object's verbose_name + readOnly: true + verbose_name_plural: + type: string + description: Return object's plural verbose_name + readOnly: true + meta_model_name: + type: string + description: Return internal model name + readOnly: true + policy_engine_mode: + $ref: '#/components/schemas/PolicyEngineMode' + user_matching_mode: + allOf: + - $ref: '#/components/schemas/UserMatchingModeEnum' + description: How the source determines if an existing user should be authenticated + or a new user enrolled. + managed: + type: string + nullable: true + title: Managed by authentik + description: Objects that are managed by authentik. These objects are created + and updated automatically. This flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the + API, but expect changes to be overwritten in a later update. + readOnly: true + user_path_template: + type: string + icon: + type: string + readOnly: true + group_matching_mode: + allOf: + - $ref: '#/components/schemas/GroupMatchingModeEnum' + description: How the source determines if an existing group should be used + or a new group created. + realm: + type: string + description: Kerberos realm + krb5_conf: + type: string + description: Custom krb5.conf to use. Uses the system one by default + sync_users: + type: boolean + description: Sync users from Kerberos into authentik + sync_users_password: + type: boolean + description: When a user changes their password, sync it back to Kerberos + sync_principal: + type: string + description: Principal to authenticate to kadmin for sync. + sync_ccache: + type: string + description: Credentials cache to authenticate to kadmin for sync. Must + be in the form TYPE:residual + connectivity: + type: object + additionalProperties: + type: string + nullable: true + description: Get cached source connectivity + readOnly: true + spnego_server_name: + type: string + description: Force the use of a specific server name for SPNEGO + spnego_ccache: + type: string + description: Credential cache to use for SPNEGO in form type:residual + password_login_update_internal_password: + type: boolean + description: If enabled, the authentik-stored password will be updated upon + login with the Kerberos password backend + required: + - component + - connectivity + - icon + - managed + - meta_model_name + - name + - pk + - realm + - slug + - verbose_name + - verbose_name_plural + KerberosSourcePropertyMapping: + type: object + description: Kerberos PropertyMapping Serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Pm uuid + managed: + type: string + nullable: true + title: Managed by authentik + description: Objects that are managed by authentik. These objects are created + and updated automatically. This flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the + API, but expect changes to be overwritten in a later update. + name: + type: string + expression: + type: string + component: + type: string + description: Get object's component so that we know how to edit the object + readOnly: true + verbose_name: + type: string + description: Return object's verbose_name + readOnly: true + verbose_name_plural: + type: string + description: Return object's plural verbose_name + readOnly: true + meta_model_name: + type: string + description: Return internal model name + readOnly: true + required: + - component + - expression + - meta_model_name + - name + - pk + - verbose_name + - verbose_name_plural + KerberosSourcePropertyMappingRequest: + type: object + description: Kerberos PropertyMapping Serializer + properties: + managed: + type: string + nullable: true + minLength: 1 + title: Managed by authentik + description: Objects that are managed by authentik. These objects are created + and updated automatically. This flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the + API, but expect changes to be overwritten in a later update. + name: + type: string + minLength: 1 + expression: + type: string + minLength: 1 + required: + - expression + - name + KerberosSourceRequest: + type: object + description: Kerberos Source Serializer + properties: + name: + type: string + minLength: 1 + description: Source's display Name. + slug: + type: string + minLength: 1 + description: Internal source name, used in URLs. + maxLength: 50 + pattern: ^[-a-zA-Z0-9_]+$ + enabled: + type: boolean + authentication_flow: + type: string + format: uuid + nullable: true + description: Flow to use when authenticating existing users. + enrollment_flow: + type: string + format: uuid + nullable: true + description: Flow to use when enrolling new users. + user_property_mappings: + type: array + items: + type: string + format: uuid + group_property_mappings: + type: array + items: + type: string + format: uuid + policy_engine_mode: + $ref: '#/components/schemas/PolicyEngineMode' + user_matching_mode: + allOf: + - $ref: '#/components/schemas/UserMatchingModeEnum' + description: How the source determines if an existing user should be authenticated + or a new user enrolled. + user_path_template: + type: string + minLength: 1 + group_matching_mode: + allOf: + - $ref: '#/components/schemas/GroupMatchingModeEnum' + description: How the source determines if an existing group should be used + or a new group created. + realm: + type: string + minLength: 1 + description: Kerberos realm + krb5_conf: + type: string + description: Custom krb5.conf to use. Uses the system one by default + sync_users: + type: boolean + description: Sync users from Kerberos into authentik + sync_users_password: + type: boolean + description: When a user changes their password, sync it back to Kerberos + sync_principal: + type: string + description: Principal to authenticate to kadmin for sync. + sync_password: + type: string + writeOnly: true + description: Password to authenticate to kadmin for sync + sync_keytab: + type: string + writeOnly: true + description: Keytab to authenticate to kadmin for sync. Must be base64-encoded + or in the form TYPE:residual + sync_ccache: + type: string + description: Credentials cache to authenticate to kadmin for sync. Must + be in the form TYPE:residual + spnego_server_name: + type: string + description: Force the use of a specific server name for SPNEGO + spnego_keytab: + type: string + writeOnly: true + description: SPNEGO keytab base64-encoded or path to keytab in the form + FILE:path + spnego_ccache: + type: string + description: Credential cache to use for SPNEGO in form type:residual + password_login_update_internal_password: + type: boolean + description: If enabled, the authentik-stored password will be updated upon + login with the Kerberos password backend + required: + - name + - realm + - slug + KerberosSyncStatus: + type: object + description: Kerberos Source sync status + properties: + is_running: + type: boolean + readOnly: true + tasks: + type: array + items: + $ref: '#/components/schemas/SystemTask' + readOnly: true + required: + - is_running + - tasks KubernetesServiceConnection: type: object description: KubernetesServiceConnection Serializer @@ -42867,6 +44327,10 @@ components: - authentik_providers_scim.scimprovider - authentik_providers_scim.scimmapping - authentik_rbac.role + - authentik_sources_kerberos.kerberossource + - authentik_sources_kerberos.kerberossourcepropertymapping + - authentik_sources_kerberos.userkerberossourceconnection + - authentik_sources_kerberos.groupkerberossourceconnection - authentik_sources_ldap.ldapsource - authentik_sources_ldap.ldapsourcepropertymapping - authentik_sources_oauth.oauthsource @@ -44426,6 +45890,18 @@ components: required: - pagination - results + PaginatedGroupKerberosSourceConnectionList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/GroupKerberosSourceConnection' + required: + - pagination + - results PaginatedGroupList: type: object properties: @@ -44510,6 +45986,30 @@ components: required: - pagination - results + PaginatedKerberosSourceList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/KerberosSource' + required: + - pagination + - results + PaginatedKerberosSourcePropertyMappingList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/KerberosSourcePropertyMapping' + required: + - pagination + - results PaginatedKubernetesServiceConnectionList: type: object properties: @@ -45326,6 +46826,18 @@ components: required: - pagination - results + PaginatedUserKerberosSourceConnectionList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/UserKerberosSourceConnection' + required: + - pagination + - results PaginatedUserList: type: object properties: @@ -46797,6 +48309,120 @@ components: description: If this flag is set, this Stage will jump to the next Stage when no Invitation is given. By default this Stage will cancel the Flow when no invitation is given. + PatchedKerberosSourcePropertyMappingRequest: + type: object + description: Kerberos PropertyMapping Serializer + properties: + managed: + type: string + nullable: true + minLength: 1 + title: Managed by authentik + description: Objects that are managed by authentik. These objects are created + and updated automatically. This flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the + API, but expect changes to be overwritten in a later update. + name: + type: string + minLength: 1 + expression: + type: string + minLength: 1 + PatchedKerberosSourceRequest: + type: object + description: Kerberos Source Serializer + properties: + name: + type: string + minLength: 1 + description: Source's display Name. + slug: + type: string + minLength: 1 + description: Internal source name, used in URLs. + maxLength: 50 + pattern: ^[-a-zA-Z0-9_]+$ + enabled: + type: boolean + authentication_flow: + type: string + format: uuid + nullable: true + description: Flow to use when authenticating existing users. + enrollment_flow: + type: string + format: uuid + nullable: true + description: Flow to use when enrolling new users. + user_property_mappings: + type: array + items: + type: string + format: uuid + group_property_mappings: + type: array + items: + type: string + format: uuid + policy_engine_mode: + $ref: '#/components/schemas/PolicyEngineMode' + user_matching_mode: + allOf: + - $ref: '#/components/schemas/UserMatchingModeEnum' + description: How the source determines if an existing user should be authenticated + or a new user enrolled. + user_path_template: + type: string + minLength: 1 + group_matching_mode: + allOf: + - $ref: '#/components/schemas/GroupMatchingModeEnum' + description: How the source determines if an existing group should be used + or a new group created. + realm: + type: string + minLength: 1 + description: Kerberos realm + krb5_conf: + type: string + description: Custom krb5.conf to use. Uses the system one by default + sync_users: + type: boolean + description: Sync users from Kerberos into authentik + sync_users_password: + type: boolean + description: When a user changes their password, sync it back to Kerberos + sync_principal: + type: string + description: Principal to authenticate to kadmin for sync. + sync_password: + type: string + writeOnly: true + description: Password to authenticate to kadmin for sync + sync_keytab: + type: string + writeOnly: true + description: Keytab to authenticate to kadmin for sync. Must be base64-encoded + or in the form TYPE:residual + sync_ccache: + type: string + description: Credentials cache to authenticate to kadmin for sync. Must + be in the form TYPE:residual + spnego_server_name: + type: string + description: Force the use of a specific server name for SPNEGO + spnego_keytab: + type: string + writeOnly: true + description: SPNEGO keytab base64-encoded or path to keytab in the form + FILE:path + spnego_ccache: + type: string + description: Credential cache to use for SPNEGO in form type:residual + password_login_update_internal_password: + type: boolean + description: If enabled, the authentik-stored password will be updated upon + login with the Kerberos password backend PatchedKubernetesServiceConnectionRequest: type: object description: KubernetesServiceConnection Serializer @@ -48457,6 +50083,15 @@ components: type: array items: $ref: '#/components/schemas/FlowSetRequest' + PatchedUserKerberosSourceConnectionRequest: + type: object + description: Kerberos Source Serializer + properties: + user: + type: integer + identifier: + type: string + minLength: 1 PatchedUserLoginStageRequest: type: object description: UserLoginStage Serializer @@ -53322,6 +54957,44 @@ components: additionalProperties: {} required: - name + UserKerberosSourceConnection: + type: object + description: Kerberos Source Serializer + properties: + pk: + type: integer + readOnly: true + title: ID + user: + type: integer + source: + allOf: + - $ref: '#/components/schemas/Source' + readOnly: true + created: + type: string + format: date-time + readOnly: true + identifier: + type: string + required: + - created + - identifier + - pk + - source + - user + UserKerberosSourceConnectionRequest: + type: object + description: Kerberos Source Serializer + properties: + user: + type: integer + identifier: + type: string + minLength: 1 + required: + - identifier + - user UserLoginChallenge: type: object description: Empty challenge diff --git a/web/authentik/sources/kerberos.png b/web/authentik/sources/kerberos.png new file mode 100644 index 0000000000000000000000000000000000000000..6edd6db4d2bb6696865f1cb003ab05736be049a9 GIT binary patch literal 75303 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%0Dsr^#cXTaBWDdV>~*IxzSz z;VVv@>4&CN*kX-AV8ik{e?+)rFjDy%RoShm11DSy7qDQM_0{eJ- zl8NQeuW9zbog8=ZHK?kijT~r&S_fpW;bSL^#`X`#lpFsxi;=dQ5@rOjBd-4H`4K;F zLk^>_fSh6N2N5U_(Ku#2CiMGO0hODtR1-8 zBstp9EHbxT&&8Wf|=o>-vvzX?OWwDLXKa;o6un zXe8S38fW0{6}HMl*5KL7pt{Lv{$Y7OjV1NPjn5TMuEfnZCv`LQoqP~;_pS#AK955f zbAHd>Fp7wz6Z1nhgNdBVa7wtli^^_~>x*UP-k6il;S$O&_TdMDu^$^zevd&|1oF8S zp2P1Ym}u}-wE4}t_3X}~!|;FGZ_05bJm5=}10~yHr80?QWpb&krbAG1BqGErA|ISa z-roG*x(&9_?-lQ4COhu$YNdJh7n@#v^Z8P*9U`IPjs|+UYXPXQDQ9u-P0ZcZm-X~FFWWVK@3^GD}i zZ_A$|Ct0piWbvfDK{2u_!=47wV`-bE69G;CYcRq;Ka<)aIWwWfV=jUj4)Zx9HPX+Me0k2sJ7< zMw4j)zfnaKY+7W)Bo%ziT7?Xz441694K&@M^)lVBnfqYCpIh<##j%yHPH5}>2^2>Y^h2v{ z9h1d1y}-NB*n;dsrCjlaK+(Wf`(0w|c8BOlSd_t6vR4>LmT-mf|Re;x}`HmZ&N zxTT`I8*Q#uwcMR&F$yl{o|(l;Ez(sa=w%{^xQ>v8cfdVp^_E1z~GqgFf*UvpL0w;W&HU=MAHMPmfs6@00<$_mDp?;KqxyY|1Kg zlTxW7j$baJe%Q%aN-IfCZJeVCG{XOwcdiI=;^eL|bP2$4r>iD#{ttN_auxZ{i_Nc0i$57=@b1cA$+`7ag`K{d+StzGg`hF8lgew({L3x)KBM@F`NPO zX-?^#k@Z6fDbvoEIe6lT(0zlKic2dnKe06Tj5D70+! zZg};4!pJ%kK@14G*H+bqY1v*C#k$QY{BF)ZQ92&~`}%x7q_+3MmEve1bOcU9+^)8H zwOT_)<@2o)hQQ+hNqrbOdAHd0?saxFXx?%4$97{MKFY%Y0gK=h=1QYpd|8-e_?%Pl zoOk~&HbaIN%CyxNeu|Dk^ssv_t^9N8t#D+GU~Xd^Cpc)OA6$avH6cB}SGtF07 zwGLLTRwd(J@O9qa#xB%YB62lg6aZJ|=lAyZNb)&XNW9lb@Eq%F3W*{&ZlBCXkk9^; zdTW^*K{n(+*!OiFYyDd|w6(rVZuXZsYnJ+m-)$e{9k>)R4IdWgs>N$Mi!AS49z)%A zaRl!21GT4D_wO%lu4NJ&&^Ks>OK_Sv1Enu1w^8)SvF?ePT*`^2&$mt|nZQn$P^X$C zq?Ka*{MOLnHeg+s8Bv?o?916t1s@_nwsgLQC+B#C*%s9ASUfes+FzucnnG>B#t#t! znPVvD?ty9ceh{k(8Iv{^xG}=%Q+0E>2|8%a{8Qm*&y{z=q-Qcgkx1%shn<#zIh>Ri zb;_bN>ixGBCrV!@o>p@23Xe%|f9#y!{6>hU)c)_?LXDKxW9ONmt(O)Iz_Lt)YuQ*J zv^I_htPN7ySSPOiINN$}BX)iWa(jNf3ASN4%{t;RS7lem;)oe{_H@FN4>Z6FI}6cR zku>;n09T+Rjn&q`z##uU9T4qsMTji#O_qU`NK`LbAfzb4TVwKVS^;c-26>V1R$}8F zS}0dE9yyqF{lNR>@KlCj*7&q*#-^3V+lw`j)K>oJT)?ELU+u9IXK1Svr+8RHh$&! z#%UWrVltJc)bo7BE@^f?E$in$IESrPc*{?9-B~=Z>-=8}UGh6^W!|9H>d(FA} zhtqhghv(USpoLtNgJ~7x!8s!Rq}=nMP0*$sQn}Nk&X>Y85BxWZOMR%nO7>7-3E|5} zs+5`@^H2Mn7DxUDd!yr&5{|l_w>bpFv`P_Fh z{w0JvEF!sk7@hIZGYw(BMdRx1$s4WwtnWid*nCo1V2gUs2dn(Zx(&&W`OEv`R@oc5 z;@>*|xGS3GnF7Z3w5=Ov(A`(UVmYU}4=XT8mwn&6+tzR3QP|zxt=aFzjY_Lkuq7OS zJex_+Y>gt(Y$=2FSwY@JQEpJbae(>eg`Kl*S2VuHbU32(@k{}oz6O<&5%L(>;Hk{xT%V zSFMOP(Ed-8J*x5$_&8eO)Ga}!g4dIxTrmo24X;rZO%I%#Aqy*J)i2o6 zJuj&A+#p=K&-sS>`sHX=FuQE3+Av8-Pg*SQpI*HK{w<8RW44(;+WKXOZYt(nR(23=5~=iY(*ACnduKkx1Nug9BSoRkQw{gkAVx$)MX28v1z zQdSL0YYG_Q(V*!k|E{Uc{t!sDBGc;S(ie445niSC<2G~=tYNS$xXrEpt9bpH$+1RG zuN0+Ij*T(R4!!OR>25Tt>e-!*%d~k&`)9NeN1pL~UO^(7h1EAL4x?S$l^T&_beV%W zy|4u@DEr`|%9hpXv~peWa)4>#dD|J?lvRI0SKRxoy7K0a1d8d`=SO8Z-utd2;}L#~ z;}vM3)OC^yM@MW?lyO}>?&p2hc$4F~HkJ0pZU-zzVL?#KX1f^CL&>dxHTTdBnU2X} zj)2-fyqMNu^PAV@O;?cj3@^?P{u9STovQJ=4hQrk`#$?$&aS9YOzxJ*a)aXwRWbvu6cFkc9V{KyYfc)qVrRcc zF&~vaz>W}G;v{T}D@UL&0zIh%s$(GTyJyC}*{cLUg{rYqPSHf%UlA8pbPSElacsFc z*(oeovJw;d-M93(JdeQ?R~Y%Hw~-@J2Wl9W-*X6T65$IzPgn8VJ--+XJ~?oia9GSQ z{ofwxl$sSX=ky@!J-1jxLKb>QvvCD2P`T^$BmELlU>_y-vO(sqj{qh#b(uKsPVS9% z$8hU{)7bp|#NVkOv_IV>7xk6ha&J@_I!Da zy~%b8mr0@p3PxhOC|G?ooekV)|MR+ESa>#FARiecj`+_kIM&R4V8JVv%Bh?~&Tv-) z$-_bGrJ_i_K>8}xzo(8PH6=io+zdeD{uRsW*kx=FB&IlynW5*@O?F;*ke#)93~QOu zym*d9emHoRvjZjVi8>Yx0K-hL3R;1h) z(fO|=mhJG8rrG-KPIik2|61r5j#Cei0w4B2X<5~Y$;EkRMe&c@U6@H40wld{jl`^_N zFD~QTo`T&(8vQLUcIomR&ax7<+-ARXOwDfd7iRYewPI<=dNn4~STm8Dq6Q*I1BymBFWxC`CQ{e+ z5I8YO;(#4meD~Q6bQXeW+Jb2oMUTXN|f1u(7jglU9`}XZhfWPv}D^)l6&ytAp`8;FOjj33)cp=7( z8;hH7zFE6=ZtK-kHkdBx(BXVEYt~#lbDA_g6TR>3ix;Q8g2}Srd-l2w4I4E0n&D=xr|*It0GUD~5{>lSEGzd==oH&CMu{$ENyzi>9F-w5ZnYma{ST#Z2k zEDjqJmVUrJ6A3oB0@W|ARR9?6cAJsw**O^kbMm zf4+t*$;rtos&2E{^x6EU-k0*I?8Qx7*#(x}<57^gOV*SPMUMTdFO?p8mxOS`RDzZC zSwd)rZyxh19r{IW?aTu&2KsQzi&0LkGWq*L3IFI&*hNG|P*?J7?cx1X8f~6E9Oj5I zNLqV?Dz~!+>lY}J9shx9Hj((BBi>f^nY6_&(78jV31bJH1W+0vV|z`jVLvccpb6)? z+>i9IdyufWqxLa6D9H5(7qF`v?d6wWj_a?#UbDyC-3iN1x6v|5X0c>oTlhAtS+hpZ zu&rCLH7pG4RM6pe`CWtcQjT>WQI;AS9SwJtxWj)ssp8c{1uaZ#n2k>?;0${rZj3;oSBZG3-iA zdh${{HuNmCX;}vsw`+=FeJ;o1&xavy?>sm%jqrHA*tcgdK6vvD+N-Acw|^NJU;Ad488Ai z+}7=Uv~Af~;>+46xWPXn`~OCxJswy85?roZw;tNGX^y^kcfjBW+TotwP0_q*Fq$>0 zi%#dX!l2u(#jBIXVg8(12;H~=-_QOQuS|RjJ+8Y3ZCbRHSUb>s(4j*I+;Yn;7&ves zCQh7)4?g%n1pp|ELj_5yY|xll1yC>i6XPxLSdkSs8>XETkQUlc9LZn_y&hN200qw} zR?l({!#>h#8J3X^McN~U2Me{Eb}-8gW#;Zw)jk=!?%K5rH{5Uo&N=5C%^vgr8AqKiR#cwX z*W2ZAdhJ#lj0PhNhBR4}(b&2*9GgP7AY%6(9FB_@<4qGoj>FRBD{#|IH;Cb$jq^HO zfQN?Mfs#Po5l)9o{zkaG`^~4iTD5dlB&p$_KXeeS1#UME)0Hw2Yvb65& zb~?PytTb4U%t1=nBS=`)1JRXQ3j+ysYqmbgy`1#;`uiTGf(bF2=z|c`;-r zM~zzsB1;?>wU^n=#!5+mPhQ;>_ULI6sGCtyQJ`~i zSZn2oLN)-9bRE#MKc@DuiYN%N%yU)@3>Jfw8YyV06l=>r@ug z?>jYJ6f{bD)X2z4^y<|MEn2kD#_o+9HzFe=b68-@zFEU||5|;tkkA=Qf?_>ld*k>mLxg#fTH@0c`M620fkFyah1B^PMfQ1n*+!>aq&-Kv>w6h zY=vV2$zxos*|*ZXRAe81OUc+2!{PudlB=nh$loDU4rGv-U3R0yby$M;)hbVEi+n~2 z+!?#aXd?j$6t;cOXnprovW)mp2|dZ>J@35paPGP1;_I)!R{bN!N~hyqQ)4MJGfOrO zix_OGzP}ep#N3d|-eDv?vU%c&^0wb@+7a#Y; z!HB+yKl})GZ+{Tqes&9b^*Bf1bHfuxQ|o`t82>_Sdm_HA<{^WFYgHL*b@D~k@1F3~ zjIV>l0|$C}e_r?f`N`g1HbM|W(}?-(y2!ohb?a>y^6(H$dubZpdV2;w`0zu_`c5Et z=xQ8}3P*DET9^~JBHO$lPU`_=#?L{<-l?!f%|Pa%_vL&?LbMn3Xww#fcGmwU^bl_elxL zhv=KujvV!>I1!Hu0s^6Z_N}mrktVLV42Kt>5~;HX+Bb9l566$cXwF zXQ@fXP%AYpJ~BWFn2qo!32d0V8tyOotCDu_=NN45u&8hKicmV=2{ zJ5AkmmtuVO7*bDG>#fA`iC-&YiE~u)E$cP&MzS@V-_yeez_Mc`9I|_HmMm+rl}G&o zo4pD*(l_-{OE6Yy3#xA`ry%~oxGoB^fnmTnV!a10kJlIpL`^(p4JWXh9GMRDU zz(KtB>U4Cw{@RlznIJX^oQV-59>tPH%aAS*IVSobo}4fqO`0@DqlR^5!PZB|j!n^} z%Q?8@;x@7woq_rdYFF`IJ*XP+95`QN^d zec^KdlA@5EX-2j+1-6uMWW+5)u6Z{+4g>P?Jt!$E63-e9SHkC79bv~A=i{cXxCZvf z=jCUS>ag;)xn%;ZCu==9ZP3+>1`^rjjhdtz0|UA{;0EJ9k6vzd&df*tFuS=@mn z#${TZMdvKlqLmLB+E;@6jmq;ps&FDfGtsIzAl{nV$EOAMi??Ocg5?BeC)uO=?6c2e z%a$#t+-Tiy@4EpnYlT_fQ))^gwuf)T6Hh#h%R6<@81OixY}B|BZn&{KKKbM`iEC34 z8yl?zj8V1j;=f&SFP231dP{Y>ei z%2S2})HH&Ef_~hD|0@vtr?g7K`w;P{wSxm^2-P)?_woMhy#%fm5*(tMS*=>O6c~L8 z`t=p>`_w3W`tiG1wrmMjEM1J9VJnbyC=_<%J~*<{;c>f}jfTU#3)YwqVcI1)lf&!bJMe^$FRjZh!?c-wz2_+JjcUIaWMX67h(ORt*?Ao;x zBZd#xju|Qn(uirafAoa(>PK#_6L#^k85T3r#QR4@?!xi~Utq+*9_VylYt*e(M;B(T zs;2h#0{y1H@*0wolW{0E2Gd@iCf@aefQ(GgFVs@2#fiZ`X?i~%=LY>ouX-Hf)|L&U zVZ#P!dPXxeZrlWQWy7F8RrM@&Fuh>yAoYwP$7O7Okro;BzFleziD=7r#&K=+d# zT{GQ5Aqt_}w`+x4x?hhWg9pj;AAz@CpN2I8pLcCpfau6ANH`LUbVDkPNjqUW`~$2B zOX15+fWOoyPAF6pu%tK^xnguU7!$TB8*f+AkT-o9hlQP5?Xt2$E8M1K+AEuL*n)&2d8NPoAoXR>=et&uA2YJt_TjsB0sqx_n^*+2!C*2V zI_40T2xNav7SgCr*P-hf=b%w7_Rj{&AhpG7G-+}Mo)J`V=H( zt0kURJ?n2az@L_ae!V0VRBZz|OFaEmS9ir-eeT9BH}}9r7hHt;Rb4~@*jh_7!}aSl zkTXE~2&skuejZ~7{n(Iyp()|`_kwEGfdgNeCoiYjEFPx79d$z!qDr-DB91eSuz-BM}1zGB4sTk@uEmgu0 z#rSOpCd0VxA?4-s|4-?HW`}=P%q!X$kel#{Y811tkPw+(lMTn5K4#zdjMiT>bmG`l z9XZI^D(sw~QR>`J>qhasUX=J!kem1=5|(ySPAI8loVjw^jMj79Xt!bb&>>oa=+x;_ zZ8XmkBVu;>R1hh$v$BwBx5G@rf9xU5oii8XM~uX+7hQ_mnzq9wb(*0;t-1kE8x&AJ ztRoBl(MKP}&Rx62a1Z0tPd|}(t(OjrKCYk`6cqGdXMum+Xn$Ip4N_)RqXzZR`m7eX z@rG^~GGquQPk8}vyzwTcPkR{?o_GRx-qBk#yJ}1lQMX@3m=t0%aEezMsdzgB|=0-wcr&u!lp7hcc-S6$T^{rcU9$&;SKoUh-< z!5vGGk$MnrS2jwE^WjWesLG;LTcI;6FZIA}T&hVbRz-?VbvF$rt7@XJ2xl%Yt4y_8t8@HX@TLMG9E__Z?u_ zIYv8uPHQPkjMkEofgR!D;%SF!8KG&jGw{OHsW9r0k zL_IX9TTc@<|DDkW|DqDbkGybj;PsgZYTdLkF70qOZtrnDroa4>P8nFeW(}6DScz3D zS7OZ_tmF9)As*2WjJdHLtC$i^q-h zWX#U;la`@E0%fKjUnBTOhOFXr^&8;44(HYAH0(@eyA$`&+95qZYNurgyNy9Vitq;7Fo5fe%@Tz_$nDpPR& zEE|j&o3ep`t$Qp^lM?1(r;dD&9Y5qHCoQ=MDXY5$yzFUakC5_aF&nXY<5~=Q=z#!} zN4&wPM<2tUy$4{m+Wt^Fqa#;5Y^DtsiL2S@P73ARxpUE_%av%`xEZdf-xwnsHpHEE z>!7_XqI%Ul4;NSe{sXXN$uh(qiW3<3J%$Y%Dn@&j!cNMk2L0C=;o$!tI#(G%)A|i@ z`z06Rxj}s~YsL#$y?g;StY4!>J8%91ELpk~YuByC;w4LELmY;tO`2%e5o6kr+Pu#} z0fS{fZ&2{BK=G5==inc~Yj5+@MOw`DDx-RGs1(R8&m9=l!axb#u-qZC@!zJs_u;C75O328` zji)xQ6{wqMS*QF;t6+3?^i#5uB^oBC*svZDbv7!kix8Sq#wa60#ddn)Vm7yz6@WBI zDk;W)e~y-mSV}tGq!9{cn~esnUOFH5-*@+kMcVzA9{6D9CrD0C`NI-YTHcWumgOCz z(TwDzB<0e+@WNEIY1PrlNWCUJ+<8`cCzfO#JwQ{;!GqYX0?g=j*R0Xk58f^92@6R*SK|k4Cg8p5+8}##R zHp^X&&uE0Jx?F*Q@~j^n_At8Na6Qg$)B4Am^q_z@W+JUkn{(0e{EN}yoDR6?{0%`_avN&2JLyz2C<^HSMC2z{*@WIrID z_daCqpQ25{Y!v34lZx^}9kNv_u$i>4w9R+vK%`2+`AkY>a!ByH9@>=bPMs^U@#&qr zL$!V;n~Z5GSTyTH+T7fYgM~ZCam= ztFF8ey>7V^H(qliuIYRg?(cUuzWe4K*o^xnuFXPmNrC2YtPxKrvSi7JJ-h4{awL2l z&|6NLb^6wMWtq1(>ro82*_lHv9m;cLgbznf{6_*64hnwHf;;6qO;B-CJ2jtFV@C{` zM2aLz$O!&tiu`nYS(n?r-05_9(^C`h-Fs92kG!vdi#lDupBWldKm`RGJF&aFYpqpR z*Ic_hv19G-E8qoVyK$oH(IZ@?S7P6R;8$`K{r^B-hFWN*h$#iJBY2Hg9i?xT8(OusTXf+N0wJV zD$AfMJq}C97}EFc)437$?3oT9FKa}GJHW@=9WP%#$J*7a(5XXvG;iKQIPBfHLFw|f zYnV83g6PnytINJQ(^UbfRCzq5r0OXB7H}1FS~DX9bf{GsEvi?6iJ?J3O)gVh+(B+u zzcQB28Uec(4-pv>hy=MD{&d(_Q{6p{o?ne>W`c;3*RZN!Vv2UH+hO3aLFh)nW_skZ z1#@us<`sm6Qt22cM|ygi@B#{R+6BKC69wmh8@0SS6ytYIG+RSHtU>CJ=F_LAMp?xM z+(D%K2}HUcL9E|xVMWQj;4}g)KcVC(N1^@7LRi6X7Yo6w5c{>lnVXApe@cHN+>QyS zywn75p&H;~RZnQrawnQo>Hl-k5)dOJ!rR19SFC@6Sx=@7tNS8hA-r3>0r4`1H84F{&xyw>L+KZmUdb;6GmyqCrz67sIOJGw~ zf^9V<7+pnzids^1YS#f9Hf_V(x9{-g^&1>MbQpE&))G#6#U)D=oqZNOPARJII@PO# zt*d6?)2ADd$MWv62OT_)cx3w!Q>RX$-&-~G>)Rh2)^EhE8#e`cf6}B0Fwie4>R3r3 zt>hwUu7tbN>L{V7CBCKtrPVbmRuYa0`lU*#946c-;_h|dPHnMu(Hv}FxdI~x3`7+I z9J8fF)Kr(kKu-r_2KU6Ra|aL=;)m>vbfLMOo|z6mKVLk&dk1S4FG82rtzbYuBR8qo ztGA|E4YX|C0u^~u^{0!#hGhhY#@6{}^^}`$m4q}`( z2fkm5_#ds@m=yCxZ25cM>m%l={x4=wzP?iI6UO*m7b{Dtau1;on;iZgvEEh)dNo@} zr@G(m3@7VGik;YK2V`a?BQEqQ{OAPY1^0lLQ;|epFZnO{Cj`nv1M%t2WBfX>9n5k! zVDc^=Qye^am~PH5h>DE` ze@%S+8^Glnxq@+7%pX4nFYa0+JuMQ6iSd+)1i=2|dstt!#*m+eqDnZWS~RMIE}hz7*nobRG;$P1 z4<9BJEttH)ooL~lK%ZOD#28EG&BEumFX83lg3|{NVQ7z@s9U7X7 zh2K{Pq$ejrAeR1Ri=8weH8~j`4j-_2@j^5;t15nf+%(hG)}=$#2#xABz{uglVQpmz zXL^i?U^m1BzMu$uKsf5Lc{=+pvo3N*QW$cw(uGWNK@b~d(Gy}2v!S!kqha*+7_kh@!)3NqVCT9>imXgq6ohHNo5;yXrr$~y z)vssZcB?a7uD24tvstPCd5-b7MlB55KuYT#U%!IR&FaEHS6w9vn3$BuvgIrA;% zU6ZJuy=Ef8yIvB!o+iQN88ZBACdHuUjWK)90h&ueJaj-f3utL+|FxP{xpSKv z0X3+`u34)(_U+k8H|}T3qUb=5i$_3!pYY|`zHKWyw(kg2s$JJutb>)+WjwRB!TT2n zVX=G|-T38E+Q5)Nq$ikCGMSoE`mOk04A_e0%Ho%w`s3_@9eDBN0ls{)hs)Qmuz&v+ z8!T3#Nt1>IT)kWo_LAt`y%Wx#K0%RBj+EpCxPGz2*@HVVut#T9HZ>7TAhV}W!;^>i zkw$>c&dMUtX6If|0GQsFmYjqaw{BtS_;ILIwhYvzib&2BP?q{xzhNEhKfH#&#~b*6 zxj>P1ECsc}Ldk`B^eq06krW|zY}t||Df){Lf8g!7AiL#1j%dL;u%Qm?;7h<{<%z_o zk3uSjQvgntS)D!P!)l>O!F6lkyZK_tJ^0-c;mjRCPm;+LQLaY?Gb$m-7J-yHJF`Q; z^YP%c`@bQ|Mn^^3M^c*l`R#KY-MSXFt5p;Y4CR}*w2&qp4NEI)!H#0!{*}fP@9uJ1 z%xixfU7x>rfhSL&;l}mr*t6qzv~Sx6^^8iRJJqZwsCL~+DcUHyF^%aOsv*G@s%;$x zQnXzt!Oay?tZpj7uPtj~Dn&Im<>JRXc<>+^n>U88o?bCGc)ry=mgX)g7@%v{PPldJ z2AKMpn2?BAc`RiO&bV^r0tO8nfX0o?(V=|@>_2cotirv1ei@GMR^rN;X;{8!B4$mW ziD}bjVk&{?*GZEx`j;^nHhie~+CF{yVBo<1m_KhCEH9jdkK0$o$)k~)l7h6x07*naRKBfv5$xrK>nBd4Yl{{rLk9w915CFZG@utQ zoH+p3PmdAkd<_8(dlB?ut#Bz|Ei-l-;4L6tnqjqzXbNPZ6u^9JhlxE~9sy-0{~)4H zwD%>7uxx;}RQSd650aUkCcI_Yjh&fJNmSd$_}L)R;{-xJZxoviyo$$~IlKhSd`wmW zV547758SS|g!5%HO0TyG8O#5Q)Hpm05p>1?9Kug!bHWEy2Vv3PN2IKaf+w_=EMNQkbbsOOgTdcHLDplzg z=L?V1`)kywhDj5~!r`MGWgZcTijGETNH88fe1N4(m%yZad13lBWBPQQKYtDnAKW02 zU4VzOCKZfJ6%GA+bJYO zyc3H8+-S{64yAkhi%`5^85>r2VYL<}9`U#|@kcSEveKfg!kqTNgT7g`>q$|w=1{0- zV_mkOv(1}j#rK~Qi=u|dmGHZ&}h(qdn@ zIXmMi0rt+_d$@J`HtyZNi4{v0qj?i^7?vu5{)QSjQB@OP8cJ}#ngkp5r5H>%&K6S% zK6jSDW2OWzCrNOwj|5xlPCLuY6uS6vB${T?&EtCmIpWIB$V8}@H+EBv+@W4= zXlrVshFK-7S~d%g_O|fz{R(-MpO96`%pkBP1tU4^4V8)8g%F(A-5zw`4;4=I(H^IT z>Z7t5-QVAUB2WfVs_RL0Yhv&VHqR3DLuM|q0W@#=2=gMUQRP0?BGu*54L>PP3mA~> zG{9=^%!uN8*TuRKoG#Z8owdZUxBusWWdKD{nj0J#2#5EtF#EU3FwoP5x~z~BdcMw) z24>HmODDFMpftwDe!tI5M0kW?Jh{2L;KTcOxI@wP+BF;4&~^Q)6(;^V9>&JyU|O*X zrgm+FN7I_ZZ&oR|4VB?}6H1FK$?&$B6k$Up@SP$Rji`h5Bv@NjimqieP`g$w965G` zqV`RkK6Mh~$Bjqr+O_|NnpU-Y+`C6NoIQRN{=Pnlj)@kds&y6?Xxp|ON|!E!<}I3I z!-n;+d;ea{oE<1-b#?xP#6*8&XT~$)3IL-LLKV=-LBYoqk+0zOoc#ZCKbD;(A}YVk z16+}Gm*2n&JJtQ+ek zHgZ~s#Q~oO1F7B}E@rbVI3D(OhhTpsg})clGEA6YcQH1JW}i=w>uto&Y{1(ENQ?9N zFBtPg+H=J8ba%tq69>_kY9d)-GPzWBHaTX@SW0Q1Q>0`{qx`!ljoft(3J4N*jvSS4 z-Mk5_%a>tgc|}CWqel*-Z{L2<)-8!f&Dvt!@`-S~JqOWNb&$A84~f4>knxg_oxrMRz|$hqxfd-&U-avR4(${d(N*E7TiVFD)hRKj`nnb5kyynam&X zY8KMybWBa~5!z24w>u;F<4Q`8KM3j}r@sMj=fL;bSh0fSNm11MZXeNA<~dcw*8@V3 zJlK8}zFw>fkGs7P?kZ-s|EE0TXt{iAIMrgjv&x;;;lqcbQpE}?y1jYU6%r-ynHDWt zVl71!CO||+N8887$N#Bl%j`xm8h+p zM{~iNqa1XVJ~v18?d-g0pUvSLp;R*6zIh$}1`oodNn>&TRz%`Lpw0gnRPt^`Q;O2;^yDPGdh!_SR;@t)f&E~ydM@5Sy9i&GrwI6R z7XEKnP(Yg_tU@@_vPhvYsAea~n6F|LHO}w0ARX}n4Fj6Z)>&UI;LTjZ!8k!!;^x)g zQ_qIJhR3ZgV)mLC@*LUeagcjk5@5#@aMuePOJVe!nT(9I7-4nKg*eZncsX7GIoMXw z>i)bzxQjWSEf2@@FTfx+OFKl_jNu(@#u>vtc= z*eSCxZ1`}TJaHUXFQ3D%Z5F6gy9TOOt%6=XI^yKsdHDG9DnbJtks3vZq~{>unHq9V z$$&jlWbc+B`J5C_=1H)uu>>9UD9SRhbaivx5mw;t?OOu0lO|3QvMNGm=6hH3cs{AF zhEn?af>Jq(0LvqsE0&h9wz9&RlPAOqARl&};_%8=aiFOupl)tkG=s8HC zv^c=s6^D22#Hb;I@Z03^6lJ%<=ZiJ`KVN{~n^l7RAL+Coyt2xrU~06Z5bBq6@Nz#c zGF4uL;cYnY$0OlGS*r+yk4Fn}2&c={C^}D}VEFv6V<9am1kt`WLP;XTZaFgI1E?TR zqEq4k-0yUw6XOU{VqJvJF*mDXd@RK&k`VM%Sa-1gE9*XG5r{L>qJ$?3TPwI;YYmT^ zoy5%cKT@y8#8^c}McGG0M##g%n8e@*cTW$vc)H__lQYiUdw>zYj6?0Zb)iEyx>QX= zX~Tp8tC_D5sJws48#7fZSH{K-8}Rz&OGL8IPh^xmM{jv-YY}aS}8yD?tf5RCIK;(bT*?RQ%KKz?`j_X{G|*^p?B}zSiWo-PMtUbO9JebE0);1cb{0aHz{unef^S( z=G5tvRKG?dCMFI^NvU+7WGO}09F=~P0FyGY0jj+z<>;pXVL`f_lcNg6oc#Kw6>WT&622`%QNXe_{}1Of7(i^ zfWkgOh_>Zq(C0e1EZ~W=X) zYb-Ez(H8U_w;t8Ze}ZwXb|_n`5lU9756x1>xxi(1j`}x2%a^`^0s0N-kG=c$!Qs>C@Xy2_T<}F%;ix5?Us6`aI1n`=~q z+hFtNtr#+N7|NG75!~hq70P2ZMfX?FpNlyu*RiRobbx1PD|LjG0G1a4^Xk>YNG<3b z<=4PDDiJnE=`m0cS#wmdo}W$^MCm-xD-obF(vg}NkJy+9_<6d*_BI`KF#>L7-W+J7lFq zBQ4fl>8aEt6bil#n2ci zEYtGkiz6OBw8i0rNAUB=(P+`C9n9*OqeheVFs(lX2DPTb(0m@Mb)SU>LnfkP>mev% zQYTlgGfzEGJzpsyzl$toU}&Ozg$fukdJK-AJ`1}~U*JyZwx3&gB7jL%^>*a-u3D(^nt<_a#_2T8dU?QfN{ppsT5cabw2f z`gKK3yK3cXbnMU(B}$a|zVw)W)(RC&MV)&5=urafbxMn^1qXZBu#qTZR32&?n$XhL zggHg`tCTMLczGjM9*4xlM6vavT)V13iKAj(nw(t_IScC5?EJ9~kV@~46@r0@339|l z#ULs&irw;%n3RaD?976g`7c15*JR3{!_tA0K?h58NHBr&Go0<;;>m;CICl5|e*JYk zWfFVg;^IvAR1gB)?!o7^1p;5s7baaSYM2!MMrc2|-|Qd+`BUUhB5G!)Cn!v-Q{_UO zpLM=76NBh`MF~s@GwXnxA{zTp>YVmhtudp$&MS0^MO3_j^t1>>QEe*1`HQJYkMk0Y z50*LQ2peLz3P~X^2*j=kciIKdhXWAgV1WesfwIzK#f;YP$uDrVX-28E)!$cm7y8Fz z1qQ5}yE|;3Ji(gv7U4`&<$`EBDD{8WdghP^DUJbniO^D>wd*;}@)O<>oycIe7+$ zj~&N8s{7_ITY-T;jX(qQCc+JXiRV=-SHZTeTVQ!bF_KxoZY{cW>4@^i`WV~Y9H*8| zhLiPagwf5Pl$a#M^Oy^Z)DJ-7R^-I@1L8UYu^o{e*A^Ku)e-5chx^;rF{!-_<>)zT z(sLd#WC%{2yFhj4RjgTSfiB&;qh$V8%%1}+D>ZcO))ian`Ch(w88!skgNKiyQ>QL4 zE@vVD%cTBF6-_X5Xnzr9LqkFl7aJ#{tY|*v5*(B}!m1tP!T?o@yy6|{ba@kCpCdf< z8D74)h0|v);28amZ(g^8?OyDcnm zeBT~eUb-L{WvsaI&h9;e2+&M94f=W!K95Gj<5qi0b&pXAm@Y<{q4sMiaCH_A1DsuE zWv0?|jS(uPvEDX__<9f_AJ>?R{ zYAk_)RIxAGrFVa9KX3wA_7~w#^k+e%Seht1V%*tu&j zex_R7q`WEA)e4$g?OQd$_I1nP>f|Wu*|@kkx^Xi_B+JPIq)HPwFQR6ND+lcPlq@df zq|n97C6UNZbEk~$F#gb#}7}!?CB$^cJ702-MV4q@WEKKVm{u!yoW$vcZ3B6 z5r{v*-tH~zKfD#$mXnJET)p1G%kL$8d>!E9=>~sCdjGq3ctqgbx#@STUbGAwSFXgZ zt5@;v%^Ts1#8R}r{{B?vL#MfHP4jOGMK+Q6hRj9ITN3jq|2dJ4xuJ zu_-kxvv4!Y|M?{NJ{==2;X;raBi2#FzibnlYjO&dOhhU^Xr1>9SAa-%vtxn;g?2tE zcoRSD(cZ0e?P5PXY ziYWRm*Enf`0=}|lknCRydMl|sX;U4)vk@b`wxhd{KC2O!Yr#ri{{YNkfCLH zZEPFc8SYoMBE|Ou-MjMKfWtR_3SDw~BNRxpLQdp(WPYlQXj^T(J0`>Yo)R=Rkw7C? zKdEW!E||A!E6z|fKXc|Z)~sBGI`qADbaei`0W^J1ty;B()Xj;LCvlYz7>cwLr~HPB zRjWgb((gR=LQORZ#t!QT+Zz`V65uN+jR^^H6lF6+^vljlr9^?LuJK4qOQ4iFkzU3k zDLD%9abX1TFvKT@P-J$c^wEWCK3BrA3vx1_Atm+{+}|&R&6!SEwa6GfIvb)=c^x{G z6u);}T`e@MTLarI7UTM*(*!yz$_h4M&8o$)plkECt@wTKdh9*CjLMirICXk6&Kx_8 zi#vB<@0wLuF#R|5>e>sFCrrliV-&1BJP;|CL89#$s8P`|R?#sE0!?D1i*O2Xx!Odm znzE|~(|YATSLq&~B>FZu{IZdi;#hdx>)}wu1wRz|d1{=$ApJ9Um{a7$$PbhX+@o)90l&v%Aosk4 z?2H78tX~oQaTP_?fnte(JII`?yWi>p=d0$Tqm~-)i@zVRQC4B$!La}I9@A&fM&0@i za{;R)l|s?hsA_%GZ{H6MTX#aG8V#YVUjb^``jDz=Ry04wD)KvG;m9VQ3` zRNG~R01>;8^{ELGua<=C8Y#~9lb~Zc3Cd^`JYOS|s_4{f2o4-S0ZWP??6fy!%4F1U z(BO}?i*t8c8#HVrY$J~yJ1XR1E?Zh+@!}@X-P@%G^sKr_4rfRjys8ZDg)oYrfZe25&H>i)6jm^=aRZH~k)dRDp&k!Pbywe;U z8eGKBHh-$Z>JwDnx!N=lfrxuQSs8I+F^7X>p?2${l4E>rV`QgVb1e zQEIy1?kseWlcGKe>ky989t7IJxATw~{H%D`NTLWD%y~AQ5)3>J_3Qw^&USt;CJC3k zV9Me7^kBvlF9O8VAA{A@V*_ppVl>yitdqmr`n)*cYSWxfm^Px3^>->%2;OjXlvQM8 z2tq^c@%9Y?;HTa&r9YOc&4uFT)GcX%(k7Kqsx+m_x+N*HX`+;t47%zPs4GR+!i0)~ zrj)D@Sij@SjnrnfWtkuwRjQ50nlAoFue#98a&`78-mH&I5zu5Q52zcJ^A~cL6+>RnS&Xo%KFfk@#rboy}3juTns5qId8p8Wte?+^T zX5z)xzYf+ov&e{W0xG5Lp@E2sazSw5RXlmP5M8@8KxxC0N@=c4jBON}LNc)nSxQeE zRm$nY+)NkEYHFdju?$9<1YD_1HKi@$LsMM9DxL#J-F9u;V)g2kxIi@`r>tzhXj-lU zs+(58=q@dBb$(B{ADo7S`-hO_?*=3%P&@SK))1{**QRJ*17)HO(k>uYxA#6)9bzSVEaaM ztX@$UojRJJa%DrIhN!DsLR@+!^iis;5y}`DQ+-WQw0dKuAz8POy_h0Wb`;+X`m(9qim#1N%1^W62O*^fr~Dl%|p#r?imgOOcfLJ3-4$C`#p+{>WV5Ym~0; z*|iJ5j2(lrrOTpIB~$F|*&3gg3`X=Nx)~fF0a1ZeyCy3&ank62Q3PTa0`oTH1h+<( zogN%^NU*7w1Xc7U`A&LLB|*A!rseVLlqtAmZ3Alp?J2yfTGq#zgFA#cnwOUwj-NP) z2@`+DjA@fGVf+}39yJWJrcK1Y-M_=~@&y9oGEDz%GN%4E3F|gkQ1su0!w2`%?_&o= z;!W7PX*ITNT#9vT=VI}q$@t}$QRvsdA3ApEfaXn`p+UWRs9UQR8rQ3jF7!UucHX*m zE2X{itN;KY07*naRPNrs16OAkxKWzx=IRCycMk*v1c3LJ`Qq1Ebb$4w{eAuDpbtU> z0ahOK=Vf0~6a57Zk%7x8tzx4s6mkBy;d6h0pfmFFeNJXNBHd2H>+vY6Z?B0iGfz!I z9Jq_TT&(G3P?Y8g3^#Z=6OC{_R1DCj#Y~OyxgtcV+;6t0C$#_(E{7neTs$F|2gDyl zM>st>L_w}d3`S0Rf^d6hCwTv-6BL&9?x%$tHj9{Z@$Gb>nplkIdF@{TY@CR&QF0$2 zKis`{k8ZBr^Zz4kgTE!SLnYyZV1i!7RgQ{j#F|cntY*;rC zR+g>s`nf6I+$n{l^RzI!whU!-3VXsxzfre-A7EvtVZ z`*8WvC7d~Z8k;t6#=t>CP_=6He1UMOibswF1|>^iP}ep%v402LoWD>sdyRejcVXtN z-$Y~Q+}U&Du`DlN#;X@E;Oyi~Y4am7JHB?~8eY77j*lPg@QKCjC=&AK6Z4rLJ$!^a z1VUbTxP18vJ>N4pa`XuH@86HzyLM8ly$!p5-;N`P4vNtkf1S6Nm+11w$mLUcX3r8r zfBPa$a;WgJ@+C0(J^MvOPY!6@EoNPBHj+*Ze=j6cLnsn^-0MxHXUqDcmsGr(HsYP=5OX@~dAldPSsvr{bcDQLF4#^SiQ_|`Az99A zcL4<4P!akQW4=(0I~;+pW{Dy^gQ72ko#i#Yo-+|`@4pIKRzURk55ntLZ?SOkV)W|W z9{u`N!J`Kq350zyeR@|I8X)HtEsL@>0MRKnLCeeyrZzbYK60OGqZFJ6YQA} zJXj`$jeZG)bZLsT!yAzkLeVrMC2v7RvC)!YPc`lqAkLgp=Q2pL)kE-aYPi@yhEbX_ z7^tZVbt?m`ETxcbJ9WeQ4V!TC2M>n6 zp+OOma+y-SZ>X=25q-Pk$lmRE_x1&zzj%Uk=g;8a!2@vm>Lj?{Jmd7G2*)EiPDPn1 z#>_ER?}&Bh zD~r1I%3;w0bNn`?38i;6Q9@5wsR<<$R79yv8|pfx(WbE(R!?b+M;Dvp&1F;E-X_7y zK@#+VNriJ<=gwUy)!a)drxh-oKZlvqrlYZWBZb8y0bnH6 zhWBe!M)>^M$ar`I^al{*1O;n{k()sg_C9jrCj-${knK_q@s}#Vp}PiF8%fZRn@AG1 z+zxlqCK|8PG4--ugZdaf_80t4HQ!YN>%DvTasS?3+`oHAxEXNMX~xW1Xx*lbsA1JK z)K$D{3tRvckI$XkQ3Lwm)Zu+_a{3}Do9vJ4=H`N+VD2|4+!&Zr8cN`0x!CU&#EXxO zH;a?Ub&sX5<5X6RVt8T@!3RLNc+8ks`G3ShlC3IZeJ%-A3eP(|sZ6{tB$razRQ=U7 z3UpoJakC@UsQuu@(-z7w5N{WqU~W1EzMd7eTEU|m{F42%G%VOQUl|zp^xmGp2nlKXb+t-oh=LMuD(eE%Tcl(T) zKJ+_HJ%OCq-pCFsk7S3^@I6r$r`xFECj$w}aQ#}yExqt+9k1oFz*~)4wb83jf6QI5 z6x(<0$B`3fv5i2yV$C{CnlcS7==qj4GFB=Pq($vPazR_h&=BK>55<*pXT(AV|DD3Z z6hMlO{(D^*{#*W~MB59qbZ#E8Vhi8b%qM0+H|91(IPMZ=b1`0*#5jn#3E32lv#4Zb zK}4qXvLa(@yaz?w0P#MjD>X%${9{g<)L1iql8NDO#5arhx=(od_)vb&3-he|#N0}r zRYf}PhTFB~aKG0>ED;nBu<>SLl=`^4yW=|5LBCG;73ECI34L2OYLYVBNunVlp@=fF zT#~;;(fr9*#%IY-nFRWjLYiq%WTnekO%df(YFOzvOrHvme*PqA)zmb^GW*C;qjCE5 z8C<1W^2njXm^ot>%9S&Q7Nx~yHMP-zvV&zk+T+dcwebIBhfqo-L;Wc2^9w^%Xf)zu zBaoitfUMXx$PP6}x?ed2y)eL|T_rKKsTB1}<+|tnA;F`ludKF?9_lu1iUGsMVA8C` z=s$ccT6gS5fUYZ;PzpKIf?8gxV#iUCd6X?(8o!PiC1i4hZgfP1P^si~M9Dw&A4l6r z=Y6>>sB?k^#WjrBY)g-2J!3I(VeSIYVlzb(Hxemw7ttf~C9L>79wFQ|xUQA^U4x5t zLxg_X^5eJPC@qkuCI%oW<|`6HUm-s5k;vV--c1aBi9m|PE>{~0pW3*9yTy7!(NxEJ zdwJu*gNLGFQnzkhp*AngAHU>oE! zlcr56$}WVJl@-;H)|fqO7HZb04ISN*sA^ai{miOkP19O9J*W+= zrwzis8I!PO_IzxdzY05+ZN}-nr(t{J7J?iuAjN(xvfflcyhANKzFZTFXI4PXiW<;X zS9Lma{RV_XiUga)y0u5(<$=qq0UK)h5_tSpMvMRiJ# z=}@9bYkB38NQHl-qu_tbzs&SlGgfQyc{oVeS*B4rsn{CIq=1zq1P)UuigGiGaloXC zNS7lBe!C1_x4XmrMjJtAWNVOgO08pq9>Dc_8-#uNy?CHaBjob}J3D_d<0C%s9s%x- zAWraBLahHyv7s;bu@dSPQ6A?I>3U3TCPTuWyLH z{rY3$#*L!EbL8k@3>rKLrAq3fym5I9?$iN$W=+Pub&GI$!DLtr?~UG#8=zI4`e<3d z3EDMoiJqN%;g=C3@%w@?xV5l3KFrm}gT>{rVstsQY;Fj>66!@vsifa*q)CftJt+g7 zfyzKu*=8!t@XE846q${NI1ozc>QVaNT&%B&+A=!k9}ilwC+&Oyo_Bj8=)+2?NzW2a z9tdGK7JcAo8uDqqu(f3U@1XZf1kobk)ol1Z{RM$<=L#YJ4BiGxixtj#9(Q^ON6F%? zxB1@|ZRs$(l7V0dVlSP~yaDQ0Owc2_3u=iOj3m5wh*30WcF$y7kABF06FRC_S>VmU%A)U+G4P{Wm4V6*U;#X1`nXDLIk1vNjOSUzQZXLqdlT`z^h;^5 z-2ax?_ThcpujeZw;Q3VHEbzV0CoCjP49GWc-i$_#8YvOorG*+kYD$#6!e+=)C8srM zKIs3PgWSIcsq~wG%@seRdX*Ikw{6=dR36TrIV*?@jT$z9zOEK}Hm`+K8#~hBI08X- zi*WzIQY;%h0kw=wDcx098r>*NkM*dAub`=h+L{tn)sRwct~jvNlny#JUN2 zQ;w88K#r!=SMGg9q{4DPN{b1QYze}eHe9==M12uNXqHK3XRwTTU(wp;T%A*D2K?6x zm4pofchSBp%2I8_?3D)(9$?a>NhmAyjWmkhRJk{$RLLP+Qb;{fx!YOLNKwyE8~;6U zrT>OpAuVE`!OxWiyZ7xoAczEK&+;f`2F%SHp_HyBI-6I)X^U2fvm1iQPjm79>NXr- zy#rGQ4MpF!tx5lwT1=X?X zY^a_PAa-kck|MU^qQ4-XN<>bf7!SpGo)>dn#|ssNc5>XG z1Z}K5W-6~nv3$#?FQ2h}#}0Jr+*w4fJaDSaB4x_;sq)RHRwVirfKWZRCe!#%&(?y@ zsZ{ClzvYvvuBJ(#t%-em58(Q>8#sIJ9A?g)1@k86(ACyNlUk-Yw4?*#Ur#`s>k4?h zKaD5X@8a@-130i|1y)a+gi!+qp?>YUVzecd7WR$J1FlqQMey5^U-kJF7q|rtDRoU9 z7?_!(UjJXwX3`pzZ!!`_wTGfvx9K=;bsP51&Vuy7`dmD&`zL~ycV%6!wWiW>x*!vA zZJN%qOvzz_cbpdEBmkTc{vPtcd!o+eMFGF(zfv&!8NO6XdOaFK0kRJ~?)QS%;}MAU zy;=;=#(7Xd+*PP0GRHEDiO)$PVv8Zx@0K7-@Nk$7q{F^!q0i&GceYsY^?5v6tnPU| z7zB^Iz2SDVHAUHqLV?l$`7|oj|8-5v^{c`kHbTzqmaopPxNyZ9z55M>iD`vgE^xt3 zT6j#O(oj+XQqgqr`yy@On_cm>Qi=K>916L*N?N+8SFfR<-SMdE)XCHMY2R@*8Wp zQB?XbGl1_)`HPkS(xSZpiu`JN+rAUMd}j0&Qcp&!<$0jKM|;QwJaq61VHeEAQ_T;kXm`P7)GXsf7* zC_zt)jAGVIFz!Eig!v0s!K`K-Xyw+jdAqK%Z`E#v>Q;&JsN$PxuH-w@Vrf3Zs%T@U z=+~AlV~ma+JLB--L$JAe6^9NSLhCl|U|?7p8XB6YU|Iz$=8lEu+uxBAITr~LYZ2me z3f^BH;qmFS*fC`urO?e`Y*0Jd+mM=J_$8mF636NhL})lw2dL>7Z=YW@ylR z20AS{i|&gqqQkgdsL^pa+V&od-)2n5{aw2z65wwK3*AvP`mQPEz4zuK?ga4|cSsma7_R!dV8 zI$B!dQp^&IrhQVCv`|^ESh*on{3euC1+agVf+}3&M+_f<<0p>c%H>Pgwq+}-Rjn@S zQ#DGR4Gha;(!^1C_GBC4V#Xsgc|4NC<{|jmUR+u*17qqmg|W6?5sMPxWUdTa$&UoB zEWdFllc=d=GU8{}&_@Z=CTQ~WN{ruj5etsoK*LchQL<(`7?v@`n9*a!&mIyS1bI}f z)jtlj*7foAVr979Xh%_WB0}CTL-^+{h@@0F+Q&+`2(V66dP1OB7GQz5^i*y}McQYj zMQ_bamv_(1_>11m3|h7~;Lfm!w3MeaI6bJ?j&?f*-X%;+qFOc~lm_ z#LI&b8xw-)$Y6wr`Xa>79)8ZZ;q7n%fgaBg66l6d=88r}v$u;?45dwRaj|Cc@qEd< zCnQA3Q&K#Up8gSOsUL9t`XP)MF$9K&2GG;fL3tA+R5h!N+I4E8R;?PsJeYT5mFKGx zsjN^MtduUx6dYukj3q?1a|r@!f6zVsb2oQkj~|JWAX?+b=9o2mCeEBaEf(GvE?6L> zM)QEj;BC{kIW}#c0zcn5$Vi%sWbaXMUELRJTh~Dy9c}1I3pJTkoDPbDHZLMd|4z_K zl{@8yzOPgbYC47}ZPpfpr!2*)nsM^Tqo z^LXhXEyk5VT?}GMTAZiw;9+I-w8S7G|HuWj*P~&G@w-pJ3=~#`32}0Y4&jK23`A6f zAELs%5f$n!7NEn!!{}rUgs;aJxPN{Omk-zAW`756j<1C`XlMvU(?S%twDefBjCA^b znI6c>c0_j8XMFi$g#!ndW6and7&mSJ7A%;G)oYew^JWWd-eiGUGp1tLkilr*ww+)Y zl`dl>WK9*(v~X%HQ_L0Bv?yv5V5NDC9xbI*SM5)XKmOHNM0EkZY177-J8uroo;!=< z$4_A4{Dop?HScp6h%E5kvsX);IyDA%w&UPuZH>rDZK%2LxtHT>Z>JpghN@1WS6N?C%dSzkWwmXJRUyRPvx1&<)agb=%q_5RR z)8@^vY}Inuef$8n_xxvpwh6o+4H8CMymy`$_DV=F`A|K}LJ+K%#&5F$ra9B(7fA51X^U8KB@jP{m)BPW2YOS~_JX6sJKVCl zirw3HVcL|L=-#cT5F#r>wWPK-d)5@Xp?PV5qpeylQ9{Z9*AX3LsY+w%d(-Da1pQpl zHfq!W)2B}pTQj_dw`#>In3$NTj81q#p-h=l81hqFte@2z`zLqC@XE$8)6`O`)4xT0 z$Rn0~Bhvl=&`NX1N_`g96y!n>e$#o#K(WzG9gwT+dVWS z9Ih^&c<|sc_Ut);QNw>hy*l-wtwT{&CRGBhtRTG<7lT%hZb;2s(5ig{v_%_H-y&2L zu4frcHEPtr&p-c+lP6EW(#i_kx9EDBKI%S?$BBT`-tNJ|Q$fEa)@9t$OS37$iIpe=kUn`LXAVu03FFf-UJIz{e* zl!QnE7uBXtw-D_26`o#hc>n1WZaln)<2UbN*UeYh|J)s?9RhLNGYrlVQE(6N#NE5r zXw#~N;0dd1s-vWi3>6LZ(6mwojB3#o`17n@K8kHPATTJ-(< z)sf)n6e;ZPYvb92ia5BZA^LW$g$m`%=F%ILD4)6GI9**mG;Y!iJ9g|sfWJR@`Tc($ zw2nJOP0M3W1}zI4$^&j9=;KP^z0CXtmXGE6Ej#EXN7yN@sLukZ8Oe0NXQxsrn1a+8 zXJHJ$&U)VuTDc2ct<4emY9>9nD@D!_g!wzd%jpq5JG{WnyEm}o@G(qZy%oLZ{EjBG zj-l0tN9cX*E2iEG!CA*xy!Q0R)93dvV)$TGtyTqUqR!0Su%SO73u%w()(Y1SZ-BR# z>kp}M0_nzh^x!_0FI|RC9Xi6$z(6#uq*7%HE^yj)te_SVwU7DST`8#cn% z&j%4v|F^!xe-~(3O4ZkPgjk@-Op6w7c{~o~5h?pQvkXnD+!+y0`>4L%B1HXJD?FOj zUxMx_R``U|fFt~#P7upC#Q?1{T&x-hMT*3TFNlr^K#1QL*uS#Dx;0DCvsX7%s8Sh5 zrlu%mYzEzO&7fCnC`z@Ni^{`xW6;*eIQ7gG&Yo^KbLJ#Q{X9ze-Eg#(N)?nl25!F= zRdH&?P&m9eOKJ6wamwb|AS);wJ9ZdDh7LekBNir;ev@)2EkXn2wLU%3NXeg1as**l zJvA)>S}l6N5k(YzPH_vFFe}nRbyTcS35ypkp%nQfPG3HQnHICqsGYgWVnWd-)AyS` ztEAl0!jd`P*n`bqfcRGx*T3q*uCtb>p3aFJGX+RXMKN#XAWlaP0Aiv?O|0x8@SPkpPmc!|VHLzz|O|-48jWT)#(UW&mOP48)L4yWh%jQj# zB45SD8|N|f=OK{F3fI@tKj1Kb2WVBmE6e9cOO>dI1-e;kx!v-@RL-IuaaDOYC6fRE zAOJ~3K~$z$u0S~*IyE&V7+6;uGy51~WM>mJtXC1*rA>sak3=d{0lu-ZKIYBphNllL zkQ8T&*w~Q&dC;=Px5xFi2z)(9i1qVg0Kbp>K`a}SksKx>D6hG4G>)MtoS7agWQ19< zk=OT<3ADWVlMwt2?zcJ$>EU95*2$_qftF5>a0f&Zpo07y@&1J+7S5Xr^G5XxDeo)O z1{DN`5{5>oRJ%S}cItw?G$lr3ko&I`lWyS3~KN8qn3&rr)cMVg{aP4=TP+1LaMPv1&zc zynC|*sVSETw7&RWaku|Q(DDi^k4F>3-U&(~YZ|k4WtihO;oZz;bD^KM2^l827hC7V z3*YA8_sikO4j!>Cf~*whcNZ?!=0XLv7@&0#lttdki4XsP7;Z!bdBE+nEi5nY#n7SM zpsCGKHLrHcTM#c$ieNW&b(S$HYm5^5`oe5jojz7xUk9D)8DQJbM!2_cEZjfb`j4Kr zK|z5yb?Ov`4<9ZhYpPVKf*RGUqj|HYXx^j+8q{hG(^6HSr(RNt@gvREnJQ3hkVL`*nt0{4)zX!C+QZ|L(&TI0iE>dV{>Y{etD)f68 z0EdrL;Ok?7PoEwM)yDtjy<1)w;N=>o^d?6-2&y7S-GCQUgo+DO7862Vid5O}DMeZz zONzAdhzqh6gbKgMqr_$$lbGb*SK)NIrXXSzZ%%u?75rXOO&j(W@$pn#M+76p_X|AT zo?^+;Us2jf3u>Cu0?<+vm1&kFpc*Kg>Uigs0nFgl(xAwu2@^wIbZ=Y%%YUwmYny7q z{^nc+dA$9Pr$|0NK0>Q$=FFKGGiD4XOqhV>%a>s3!nv42wP%~EjZjXfOhI#si3&1} zA~$_aV<{&0(Z(m+x^$Yhr~0Kmg1t*)-v)KGudjxR3ADpIc8e3HrvsY$9SMcY3IT9K7V3%K11z~s)HQsgusa$XE`9uHhxhNF;qmPqIQ9EL zjBH&F)eY4PHFsnx`?`G9uPnu!QQB~PZieKrHpofpjD&boJie}t^)n1GZQ?+ME|iru zZrr{Ni_II*rEd=y8keR+O|4)#y$ImGAG9)+b;Lp=qdilsZt{)QdMiu ze1SLVx4Y9yWED+1$g5N}!kp>NFn4+b3?FKS_U#*E%a+X|+OmS<{~~CkoDRVI{s2LU z;9!@Y=!bxp)8TQeGu5XZ;QL|%-0$^-^VP<=&8i1t#1rFnSp-F&r=x_5Ba24Fc$|aN zrE0=X_Pd`5qh83$aENy~gv4lP$fE)f7UD~n4~`$(hpt`Qp;XBd1vj<27W9oPpi`Pg98dT3Mg*NY@d0a)7XJX*>Hi%yO;mIMyxmXY z`RxJNGN%msG?&3hMeRRUp+YPrxlhsc~sEs$>j{!8P!Fdt1c37>%^ZodnpA|5bk|x%?J9TqogyRc)-8 zToN6s%TR%8_fj$zF_)l(nxbQ>md7iuCBcG;>UjBB9})i5kr~?**~xVf>240E2mSE; z)?wVcaZRXIOquZ;>NRRm2uKnY&`SSOLrPVFksE8wT+)@PL7PCMA(a-)j-`1E1+qV} zH>^soP^8ydxmRH$uDourQziQn55@cT@sgUdaCVx?veF(#(aC_xt zGt(s?eIPdWfJx*&rR^VdI1dII=x;`h7%pgKnFs7YWo5IqF3BOA5qW+zLXW>JIMi}d zBgE`^VR0S`i*iwv&!lbXVu*mr+nB_ysJKAPs3+f9g9M*%gfu40i7@dSYyB__1`BmP z){A1TO_n+k(gvYhpo=~Rt?~vEV{Vf*x`nf6j$!iTNodxrS()YpsZ0{$j}66bi$lrX z{JE2akk4u(<@RSNNW1o2eNaV(O62Ca!k~W#k$0QIYrhE&5-2wgA#lDV!{ROyCe)VD zhTfYMy{C5dWcXmH44XGdxanmDgSrlKGG0Wgb`F#gzr+9PQEb_`8so-|L$&JFD@bX` z%2CHu;4A*SaVsU zOK=b;CnF*%5=Y&RV#3?wVPDl=XqNsNXD+w-4WXYmHIgCdT?ZI^KcL{-?kCVA@&@--t98nSSEwVhcc|{BEvv48SH73)~zB#pO!MbH$jFyyCo=< z)sdb)94UtHp^Q0&>z>Ck@3UEWp=yGhj}`n6%37X2@ZBLv~NrY$p{l{TUH-l zu03(@a4)QyQ6C-a%3x(~tTW5xRwYd%X5}bz{tUDwHn5m`W*GrrvI#3Obwm)x<6Y6i{7f>hDpfop!|vhNpVwmWz*k^j zrAnoVko(daj&%E7TKn1(5au`{GhjLjGcP zaS~$v<+yuLjxFy498|X{F8dt7d z5u#oH70?!DCP967Efkj~BI?4M(1h<2?TOlu?ZV=!Zht3K2jabFi<)ih<@XTd@g5W& z?+E>#=rbb(UpUL@FbA71a-Wcc8AX);QOXkCOXej_Bl2>rm8JruawZC3u=3n7J&zMY0L8#A$kh5Hp9#wew3^WEh&($2Nq1HvQ~iVqS_UgKS){41(rL06aXz0T=H^1g z_-{o_;3;^VJAmcOzd?fr^+YzXQkFnk)#Wn0O&k0WZLZ|`HpuhniK4{yC@zY7EO#H=m%9rI9p0jua0WQgy^_;@Y3*ux*F?>&C7otcQV_;W%CI*QDBr29}| z+T;C}Avww&NpT^Fj0nMr6UXpkuNPrwU*$oeUtaEm;*v>c3mN9p20J<01kszSA^px& z6cs!_aV?7C$+Dgy7x}XiH@Oy&X@O$0vc)=ca_3Fqd0virGbJ>wB}coua!ej>0{7!q zh>98uy?!4I`eX38v>9J~H52t3))m}If1VNk3$Ce3RQp6phkj=Ms5j_({{Ef}!RO48 zl2Drf+YZI)y^)pF4dJ(5!1^z((XXRis07N(WPR^@Lnb9zevWf3OWfp=-^u?-FqLi0 zC=oXkpe;*4(A;M#B}>b4IqAyNNRK;@^aKwP&t__Tk*Yi+W^W_UjimcL znMm>R+d8=ina6}!qlCq^)D(EL@13UdM20>RI*kg639-2Ae;un>-k7RkogCSJb}Vr$imJ5(mMjW zMnZKRxs9NVbi(OFmRL5;1ZzH!;I`fxitvsU$IgRZ>xSg`QxxZai(b8Zz{bv|^4{Qw zlAl-BtWchS{f`No=H$7|A7KLDoz~Dq)gkw+DOuRI$Vq8~_^<{zf5aZsMw*~`-EyTR zQJyL7zM4FUai#Kt(Nbv(U#{^&l9!9KrAo!k{_K!)cCL%jW5(c`*EJ;lM+o0?(_@6R zCC|}vxhK~1eZl#iAUKY{5yI(-{>#PU+wnI(7gIm+Ub6&=8}}x2-?55*$1RJTtYRW_ z?)7KZm?wI1ncCap#NdW#p3pDM)}yGXkgQ`ixdJL^<8LD|#uv&69|ZVa!0sLEF?I5I zG;G{JOt(}RGN%vOnpSPvFr6qS9NobQ-^}QXGcKzU5#ouojK`a=%+1x$NJ~wEUZ*x> zq@_HS=JR3#Z*e|Z-Sl^XlwK5ncOom?2l-^l(p64~KWm2M(=w!b*Fc(jGR3?bp^kS$ zu)iBVo%J4UtJ;bBbft-$0JWSlmWw@`n{PAuDP|7FYBYOAo8v{y`1P6H?~PKW%9V z8!KzrTHDfhTKwI{Mu?;_Qw4KhnQ*OMD$TZGQqopQ?y++nj2itqyu7@Ha{PY*v{|}4 zNWA-lpkz{deI!~FQ_1pbLbizn6b}zDIU8dp(RTrXb+(|9QCu7+gzX~FjiT?bLvi6v zvBFLdK-8tT1=iTReX?LXT?t*(A%wdRCZBsFneA+{LPg*goy-c@m)=99_Yx=qPar15 z10i=WI!J3HJ*@^53Tt>?GsnJ7Cir@S46U4)bfA>3?0st}FP%V=U@NZ% zC)0Wqr&kgBLsA)Spt59j3|Qv55Jrx}a<4?&#LF2imr6E2gJv z*Q|{ywzlMbnTjm8k&I4yFg{q_$pNnn9)z>!FG8t||4-CvS!9dlJwn}Hgqmf(4rU492~x4t6Rcn30)u7h(zxoI&%_9IJo8(HKMXR3XWndF5Gm4^U*TCx|i z(juPx&|-!6RHZw@PQD^!4GP%O78bw|a})}CVbLD%ApY(~XyR`{oe)eXd?X?x1L1o7 z2&T=LiUtkq3pIcTmP1}@l&@qHn46iRzC#W4>(v(1rVYWj3ufS#Usm9R+g{we;R=n? zAB8y@zv7~@7PP29(}J)83tcHQz19_F+?9lQGSjXWKX=a3Q^}?7wi=AKD2Jm5!ql(@UIkB9uy4}Oy$Ib@dXsO zij|{Vm6xsY%YRxu_#6;tsIDOVL_eWs%_;4|LQYR_6Z|&Hn{$y8cN)3bTBN7zq17cL zF)0oSs%Y%lyA@+b4?~?g^x>PA(pi15K}(H#u~3z{gsN3dP^WHHbm`Ip<45fH8v@*qV(-?C=-jcLa1Uh_5|MuKk>D);HH#n#3e)vH%Wr_LQQdCElW-m?QYZh9j& zCI&3$QmHjEqzy<;R6`M^z-?b2Tp|zX_usc-?%a>izyC`@DS|n}EUl}cU56gnv+oc> z!y{lwNg43GE9|ipNa(Sii1YaZX^Gc_iX?laaTM3aT%HJhp+(W0A!hj|O~(~zn>DVQ2rvX6CYiwbj*pO=Od zjRFZ#zKDZH!czwhm)T`%QhNnm_D?9wnF&itHGw4jsO<-+f35RNSXj0c6 zBl@(#{ONI{S%d6;;6c=kUmW%4;TA@_`IHf2@h~M+f58jXgMb?l^w>X*0&W{w8|%=!qWPyJNzH30Sv&4ZOUs zQ0)15Szfg|5di@=v2FWmjGr(V4i2`c>s%WnM-0cAv#yAX3#ZUXx9WLzk#o`&qG^IN z$C{wcWI4k`9IEnNBC0Hjev8Psg^T`T)@x>lMea*h`F?|#tM3U8C|1!^Ui&~O4;Ug2 z&~pTGE9299¼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 { + docLink(): string { + return "/docs/sources/property-mappings/expressions?utm_source=authentik"; + } + + loadInstance(pk: string): Promise { + return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsSourceKerberosRetrieve({ + pmUuid: pk, + }); + } + + async send(data: KerberosSourcePropertyMapping): Promise { + if (this.instance) { + return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsSourceKerberosUpdate({ + pmUuid: this.instance.pk, + kerberosSourcePropertyMappingRequest: data, + }); + } else { + return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsSourceKerberosCreate({ + kerberosSourcePropertyMappingRequest: data, + }); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-property-mapping-source-kerberos-form": PropertyMappingSourceKerberosForm; + } +} diff --git a/web/src/admin/property-mappings/PropertyMappingWizard.ts b/web/src/admin/property-mappings/PropertyMappingWizard.ts index 537ab6347a..3ffe1fd78d 100644 --- a/web/src/admin/property-mappings/PropertyMappingWizard.ts +++ b/web/src/admin/property-mappings/PropertyMappingWizard.ts @@ -6,6 +6,7 @@ import "@goauthentik/admin/property-mappings/PropertyMappingProviderRadiusForm"; import "@goauthentik/admin/property-mappings/PropertyMappingProviderSAMLForm"; import "@goauthentik/admin/property-mappings/PropertyMappingProviderSCIMForm"; import "@goauthentik/admin/property-mappings/PropertyMappingProviderScopeForm"; +import "@goauthentik/admin/property-mappings/PropertyMappingSourceKerberosForm"; import "@goauthentik/admin/property-mappings/PropertyMappingSourceLDAPForm"; import "@goauthentik/admin/property-mappings/PropertyMappingSourceOAuthForm"; import "@goauthentik/admin/property-mappings/PropertyMappingSourcePlexForm"; diff --git a/web/src/admin/sources/SourceListPage.ts b/web/src/admin/sources/SourceListPage.ts index fc72eae8f8..a9af5d2336 100644 --- a/web/src/admin/sources/SourceListPage.ts +++ b/web/src/admin/sources/SourceListPage.ts @@ -1,4 +1,5 @@ import "@goauthentik/admin/sources/SourceWizard"; +import "@goauthentik/admin/sources/kerberos/KerberosSourceForm"; import "@goauthentik/admin/sources/ldap/LDAPSourceForm"; import "@goauthentik/admin/sources/oauth/OAuthSourceForm"; import "@goauthentik/admin/sources/plex/PlexSourceForm"; diff --git a/web/src/admin/sources/SourceViewPage.ts b/web/src/admin/sources/SourceViewPage.ts index 4c41033a51..5510640c93 100644 --- a/web/src/admin/sources/SourceViewPage.ts +++ b/web/src/admin/sources/SourceViewPage.ts @@ -1,3 +1,4 @@ +import "@goauthentik/admin/sources/kerberos/KerberosSourceViewPage"; import "@goauthentik/admin/sources/ldap/LDAPSourceViewPage"; import "@goauthentik/admin/sources/oauth/OAuthSourceViewPage"; import "@goauthentik/admin/sources/plex/PlexSourceViewPage"; @@ -36,6 +37,10 @@ export class SourceViewPage extends AKElement { return html``; } switch (this.source?.component) { + case "ak-source-kerberos-form": + return html``; case "ak-source-ldap-form": return html` + ${Object.keys(this.connectivity).map((serverKey) => { + return html`
  • ${serverKey}: ${this.connectivity![serverKey]}
  • `; + })} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-source-kerberos-connectivity": KerberosSourceConnectivity; + } +} diff --git a/web/src/admin/sources/kerberos/KerberosSourceForm.ts b/web/src/admin/sources/kerberos/KerberosSourceForm.ts new file mode 100644 index 0000000000..a388c9ef33 --- /dev/null +++ b/web/src/admin/sources/kerberos/KerberosSourceForm.ts @@ -0,0 +1,456 @@ +import "@goauthentik/admin/common/ak-flow-search/ak-source-flow-search"; +import { iconHelperText, placeholderHelperText } from "@goauthentik/admin/helperText"; +import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm"; +import { + GroupMatchingModeToLabel, + UserMatchingModeToLabel, +} from "@goauthentik/admin/sources/oauth/utils"; +import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; +import { first } from "@goauthentik/common/utils"; +import "@goauthentik/components/ak-switch-input"; +import "@goauthentik/components/ak-text-input"; +import "@goauthentik/components/ak-textarea-input"; +import { + CapabilitiesEnum, + WithCapabilitiesConfig, +} from "@goauthentik/elements/Interface/capabilitiesProvider"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import "@goauthentik/elements/forms/SearchSelect"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + FlowsInstancesListDesignationEnum, + GroupMatchingModeEnum, + KerberosSource, + KerberosSourcePropertyMapping, + KerberosSourceRequest, + PropertymappingsApi, + SourcesApi, + UserMatchingModeEnum, +} from "@goauthentik/api"; + +async function propertyMappingsProvider(page = 1, search = "") { + const propertyMappings = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsSourceKerberosList({ + ordering: "managed", + pageSize: 20, + search: search.trim(), + page, + }); + + return { + pagination: propertyMappings.pagination, + options: propertyMappings.results.map((m) => [m.pk, m.name, m.name, m]), + }; +} + +function makePropertyMappingsSelector(object: string, instanceMappings?: string[]) { + const localMappings = instanceMappings ? new Set(instanceMappings) : undefined; + return localMappings + ? ([pk, _]: DualSelectPair) => localMappings.has(pk) + : ([_0, _1, _2, mapping]: DualSelectPair) => + object == "user" && + mapping?.managed?.startsWith("goauthentik.io/sources/kerberos/user/default/"); +} + +@customElement("ak-source-kerberos-form") +export class KerberosSourceForm extends WithCapabilitiesConfig(BaseSourceForm) { + async loadInstance(pk: string): Promise { + const source = await new SourcesApi(DEFAULT_CONFIG).sourcesKerberosRetrieve({ + slug: pk, + }); + this.clearIcon = false; + return source; + } + + @state() + clearIcon = false; + + async send(data: KerberosSource): Promise { + let source: KerberosSource; + if (this.instance) { + source = await new SourcesApi(DEFAULT_CONFIG).sourcesKerberosPartialUpdate({ + slug: this.instance.slug, + patchedKerberosSourceRequest: data, + }); + } else { + source = await new SourcesApi(DEFAULT_CONFIG).sourcesKerberosCreate({ + kerberosSourceRequest: data as unknown as KerberosSourceRequest, + }); + } + const c = await config(); + if (c.capabilities.includes(CapabilitiesEnum.CanSaveMedia)) { + const icon = this.getFormFiles()["icon"]; + if (icon || this.clearIcon) { + await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({ + slug: source.slug, + file: icon, + clear: this.clearIcon, + }); + } + } else { + await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconUrlCreate({ + slug: source.slug, + filePathRequest: { + url: data.icon || "", + }, + }); + } + return source; + } + + renderForm(): TemplateResult { + return html` + + + + + + + ${msg("Realm settings")} +
    + + + + + + + + +
    +
    + + ${msg("Sync connection settings")} +
    + + + +

    + ${msg( + "Password used to authenticate to the KDC for syncing. Optional if Sync keytab or Sync credentials cache is provided.", + )} +

    +
    + + +

    + ${msg( + "Keytab used to authenticate to the KDC for syncing. Optional if Sync password or Sync credentials cache is provided. Must be base64 encoded or in the form TYPE:residual.", + )} +

    +
    + +
    +
    + + ${msg("SPNEGO settings")} +
    + + + +

    + ${msg( + "Keytab used for SPNEGO. Optional if SPNEGO credentials cache is provided. Must be base64 encoded or in the form TYPE:residual.", + )} +

    +
    + +
    +
    + + ${msg("Kerberos Attribute mapping")} +
    + + +

    + ${msg("Property mappings for user creation.")} +

    +
    + + +

    + ${msg("Property mappings for group creation.")} +

    +
    +
    +
    + + ${msg("Flow settings")} +
    + + +

    + ${msg("Flow to use when authenticating existing users.")} +

    +
    + + +

    + ${msg("Flow to use when enrolling new users.")} +

    +
    +
    +
    + + ${msg("Additional settings")} +
    + +
    + ${this.can(CapabilitiesEnum.CanSaveMedia) + ? html` + + ${this.instance?.icon + ? html` +

    + ${msg("Currently set to:")} ${this.instance?.icon} +

    + ` + : html``} +
    + ${this.instance?.icon + ? html` + + +

    + ${msg("Delete currently set icon.")} +

    +
    + ` + : html``}` + : html` + +

    ${iconHelperText}

    +
    `} +
    `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-source-kerberos-form": KerberosSourceForm; + } +} diff --git a/web/src/admin/sources/kerberos/KerberosSourceViewPage.ts b/web/src/admin/sources/kerberos/KerberosSourceViewPage.ts new file mode 100644 index 0000000000..94a7cebd41 --- /dev/null +++ b/web/src/admin/sources/kerberos/KerberosSourceViewPage.ts @@ -0,0 +1,213 @@ +import "@goauthentik/admin/rbac/ObjectPermissionsPage"; +import "@goauthentik/admin/sources/kerberos/KerberosSourceConnectivity"; +import "@goauthentik/admin/sources/kerberos/KerberosSourceForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { EVENT_REFRESH } from "@goauthentik/common/constants"; +import "@goauthentik/components/events/ObjectChangelog"; +import MDSourceKerberosBrowser from "@goauthentik/docs/users-sources/sources/protocols/kerberos/browser.md"; +import { AKElement } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/Markdown"; +import "@goauthentik/elements/SyncStatusCard"; +import "@goauthentik/elements/Tabs"; +import "@goauthentik/elements/buttons/ActionButton"; +import "@goauthentik/elements/buttons/SpinnerButton"; +import "@goauthentik/elements/forms/ModalForm"; + +import { msg } from "@lit/localize"; +import { CSSResult, TemplateResult, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFCard from "@patternfly/patternfly/components/Card/card.css"; +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; +import PFList from "@patternfly/patternfly/components/List/list.css"; +import PFPage from "@patternfly/patternfly/components/Page/page.css"; +import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { + KerberosSource, + RbacPermissionsAssignedByUsersListModelEnum, + SourcesApi, + SyncStatus, +} from "@goauthentik/api"; + +@customElement("ak-source-kerberos-view") +export class KerberosSourceViewPage extends AKElement { + @property({ type: String }) + set sourceSlug(slug: string) { + new SourcesApi(DEFAULT_CONFIG) + .sourcesKerberosRetrieve({ + slug: slug, + }) + .then((source) => { + this.source = source; + }); + } + + @property({ attribute: false }) + source!: KerberosSource; + + @state() + syncState?: SyncStatus; + + static get styles(): CSSResult[] { + return [PFBase, PFPage, PFButton, PFGrid, PFContent, PFCard, PFDescriptionList, PFList]; + } + + constructor() { + super(); + this.addEventListener(EVENT_REFRESH, () => { + if (!this.source?.slug) return; + this.sourceSlug = this.source?.slug; + }); + } + + load(): void { + new SourcesApi(DEFAULT_CONFIG) + .sourcesKerberosSyncStatusRetrieve({ + slug: this.source.slug, + }) + .then((state) => { + this.syncState = state; + }); + } + + renderSyncCards(): TemplateResult { + if (!this.source.syncUsers) { + return html``; + } + return html` +
    +
    +

    ${msg("Connectivity")}

    +
    +
    + +
    +
    +
    + { + return new SourcesApi(DEFAULT_CONFIG).sourcesKerberosSyncStatusRetrieve({ + slug: this.source?.slug, + }); + }} + .triggerSync=${() => { + return new SourcesApi(DEFAULT_CONFIG).sourcesKerberosPartialUpdate({ + slug: this.source?.slug || "", + patchedKerberosSourceRequest: {}, + }); + }} + > +
    + `; + } + + render(): TemplateResult { + if (!this.source) { + return html``; + } + return html` +
    { + this.load(); + }} + > +
    +
    +
    +
    +
    +
    + ${msg("Name")} +
    +
    +
    + ${this.source.name} +
    +
    +
    +
    +
    + ${msg("Realm")} +
    +
    +
    + ${this.source.realm} +
    +
    +
    +
    +
    + +
    + ${this.renderSyncCards()} +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + +
    `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-source-kerberos-view": KerberosSourceViewPage; + } +} diff --git a/web/src/admin/stages/password/PasswordStageForm.ts b/web/src/admin/stages/password/PasswordStageForm.ts index 76e43a2e55..84063a7de4 100644 --- a/web/src/admin/stages/password/PasswordStageForm.ts +++ b/web/src/admin/stages/password/PasswordStageForm.ts @@ -66,6 +66,10 @@ export class PasswordStageForm extends BaseStageForm { name: BackendsEnum.SourcesLdapAuthLdapBackend, label: msg("User database + LDAP password"), }, + { + name: BackendsEnum.SourcesKerberosAuthKerberosBackend, + label: msg("User database + Kerberos password"), + }, ]; return html` diff --git a/website/docs/developer-docs/setup/full-dev-environment.md b/website/docs/developer-docs/setup/full-dev-environment.md index 1f542156a6..6b6a95f53e 100644 --- a/website/docs/developer-docs/setup/full-dev-environment.md +++ b/website/docs/developer-docs/setup/full-dev-environment.md @@ -23,7 +23,7 @@ If you use locally installed databases, the PostgreSQL credentials given to auth ## Backend Setup :::info -Depending on your platform, some native dependencies might be required. On macOS, run `brew install libxmlsec1 libpq`, and for the CLI tools `brew install postgresql redis node@20` +Depending on your platform, some native dependencies might be required. On macOS, run `brew install libxmlsec1 libpq krb5`, and for the CLI tools `brew install postgresql redis node@20`. ::: 1. Create an isolated Python environment. To create the environment and install dependencies, run the following commands in the same directory as your local authentik git repository: diff --git a/website/docs/users-sources/sources/index.md b/website/docs/users-sources/sources/index.md index 0550a099cc..7dcb11c8d1 100644 --- a/website/docs/users-sources/sources/index.md +++ b/website/docs/users-sources/sources/index.md @@ -8,7 +8,7 @@ Sources allow you to connect authentik to an external user directory. Sources ca Sources are in the following general categories: -- **Protocols** ([LDAP](./protocols/ldap/index.md), [OAuth](./protocols/oauth/index.md), [SAML](./protocols/saml/index.md), and [SCIM](./protocols/scim/index.md)) +- **Protocols** ([Kerberos](./protocols/kerberos/index.md), [LDAP](./protocols/ldap/index.md), [OAuth](./protocols/oauth/index.md), [SAML](./protocols/saml/index.md), and [SCIM](./protocols/scim/index.md)) - [**Property mappings**](./property-mappings/index.md) or how to import data from a source - **Directory synchronization** (Active Directory, FreeIPA) - **Social logins** (Apple, Discord, Twitch, Twitter, and many others) diff --git a/website/docs/users-sources/sources/property-mappings/index.md b/website/docs/users-sources/sources/property-mappings/index.md index 3517404007..42206be38c 100644 --- a/website/docs/users-sources/sources/property-mappings/index.md +++ b/website/docs/users-sources/sources/property-mappings/index.md @@ -6,6 +6,7 @@ Source property mappings allow you to modify or gather extra information from so This page is an overview of how property mappings work. For information about specific protocol, please refer to each protocol page: +- [Kerberos](../protocols/kerberos/#kerberos-source-property-mappings) - [LDAP](../protocols/ldap/index.md#ldap-source-property-mappings) - [OAuth](../protocols/oauth/index.md#oauth-source-property-mappings) - [SAML](../protocols/saml/index.md#saml-source-property-mappings) diff --git a/website/docs/users-sources/sources/protocols/kerberos/browser.md b/website/docs/users-sources/sources/protocols/kerberos/browser.md new file mode 100644 index 0000000000..6d7897b352 --- /dev/null +++ b/website/docs/users-sources/sources/protocols/kerberos/browser.md @@ -0,0 +1,43 @@ +--- +title: Browser configuration for SPNEGO +--- + +You might need to configure your web browser to allow SPNEGO. Following are the instructions for major browsers. + +## Firefox + +1. In the address bar of Firefox, type `about:config` to display the list of current configuration options. +2. In the **Filter** field, type `negotiate` to restrict the list of options. +3. Double-click the `network.negotiate-auth.trusted-uris` entry to display the **Enter string value** dialog box. +4. Enter the name of the domain against which you want to authenticate. For example, `.example.com`. + +On Windows environments, to automate the deployment of this configuration use a [Group policy](https://support.mozilla.org/en-US/kb/customizing-firefox-using-group-policy-windows). On Linux or macOS systems, use [policies.json](https://support.mozilla.org/en-US/kb/customizing-firefox-using-policiesjson). + +## Chrome + +This section applies only for Chrome users on macOS and Linux machines. For Windows, see the instructions below. + +1. Make sure you have the necessary directory created by running: `mkdir -p /etc/opt/chrome/policies/managed/` +2. Create a new `/etc/opt/chrome/policies/managed/mydomain.json` file with write privileges limited to the system administrator or root, and include the following line: `{ "AuthServerWhitelist": "*.example.com" }`. + +**Note**: if using Chromium, use `/etc/chromium/policies/managed/` instead of `/etc/opt/chrome/policies/managed/`. + +To automate the deployment of this configuration use a [Group policy](https://support.google.com/chrome/a/answer/187202). + +## Windows / Internet Explorer + +Log into the Windows machine using an account of your Kerberos realm (or administrative domain). + +Open Internet Explorer, click **Tools** and then click **Internet Options**. You can also find **Internet Options** using the system search. + +1. Click the **Security** tab. +2. Click **Local intranet**. +3. Click **Sites**. +4. Click **Advanced**. +5. Add your domain to the list. +6. Click the **Security tab**. +7. Click **Local intranet**. +8. Click **Custom Level**. +9. Select **Automatic login only in Intranet zone**. + +To automate the deployment of this configuration use a [Group policy](https://learn.microsoft.com/en-us/previous-versions/troubleshoot/browsers/administration/how-to-configure-group-policy-preference-settings). diff --git a/website/docs/users-sources/sources/protocols/kerberos/index.md b/website/docs/users-sources/sources/protocols/kerberos/index.md new file mode 100644 index 0000000000..8ceb2741e7 --- /dev/null +++ b/website/docs/users-sources/sources/protocols/kerberos/index.md @@ -0,0 +1,130 @@ +--- +title: Kerberos +--- + +This source allows users to enroll themselves with an existing Kerberos identity. + +## Preparation + +The following placeholders will be used: + +- `REALM.COMPANY` is the Kerberos realm. +- `authentik.company` is the FQDN of the authentik install. + +Examples are shown for an MIT Krb5 KDC system; you might need to adapt them for you Kerberos installation. + +There are three ways to use the Kerberos source: + +- As a password backend, where users can log in to authentik with their Kerberos password. +- As a directory source, where users are synced from the KDC. +- With SPNEGO, where users can log in to authentik with their [browser](./browser.md) and their Kerberos credentials. + +You can choose to use one or several of those methods. + +## Common settings + +In the authentik Admin interface, under **Directory** -> **Federation and Social login**, create a new source of type Kerberos with these settings: + +- Name: a value of your choosing. This name is shown to users if you use the SPNEGO login method. +- Slug: `kerberos` +- Realm: `REALM.COMPANY` +- Kerberos 5 configuration: If you need to override the default Kerberos configuration, you can do it here. See [man krb5.conf(5)](https://web.mit.edu/kerberos/krb5-latest/doc/admin/conf_files/krb5_conf.html) for the expected format. +- User matching mode: define how Kerberos users get matched to authentik users. +- Group matching mode: define how Kerberos groups (specified via property mappings) get matched to authentik groups. +- User property mappings and group property mappings: see [Source property mappings](../../property-mappings/index.md) and the section below for details. + +## Password backend + +No extra configuration is required. Simply select the Kerberos backend in the password stage of your flow. + +Note that this only works on users that have been linked to this source, i.e. they must have been created via sync or via SPNEGO. + +## Sync + +The sync process uses the [Kerberos V5 administration system](https://web.mit.edu/kerberos/krb5-latest/doc/admin/database.html) to list users. Your KDC must support it to sync users with this source. + +You need to create both a principal (a unique identity that represents a user or service in a Kerberos network) for authentik and a keytab file: + +```bash +$ kadmin +> add_principal authentik/admin@REALM.COMPANY +> ktadd -k /tmp/authentik.keytab authentik/admin@REALM.COMPANY +> exit +$ cat /tmp/authentik.keytab | base64 +$ rm /tmp/authentik.keytab +``` + +In authentik, configure these extra options: + +- Sync users: enable it +- Sync principal: `authentik/admin@REALM.COMPANY` +- Sync keytab: the base64-encoded keytab created above. + +If you do not wish to use a keytab, you can also configure authentik to authenticate using a password, or an existing credentials cache. + +## SPNEGO + +You need to create both a principal (a unique identity that represents a user or service in a Kerberos network) for authentik and a keytab file: + +```bash +$ kadmin +> add_principal HTTP/authentik.company@REALM.COMPANY +> ktadd -k /tmp/authentik.keytab HTTP/authentik.company@REALM.COMPANY +> exit +$ cat /tmp/authentik.keytab | base64 +$ rm /tmp/authentik.keytab +``` + +In authentik, configure these extra options: + +- SPNEGO keytab: the base64-encoded keytab created above. + +If you do not wish to use a keytab, you can also configure authentik to use an existing credentials cache. + +You can also override the SPNEGO server name if needed. + +You might need to configure your web browser to allow SPNEGO. Check out [our documentation](./browser.md) on how to do so. You can now login to authentik using SPNEGO. + +### Custom server name + +If your authentik instance is accessed from multiple domains, you might want to force the use of a specific server name. You can do so with the **Custom server name** option. The value must be in the form of `HTTP@authentik.company`. + +If not specified, the server name defaults to trying out all entries in the keytab/credentials cache until a valid server name is found. + +## Extra settings + +There are some extra settings you can configure: + +- Update internal password on login: when a user logs in to authentik using the Kerberos source as a password backend, their internal authentik password will be updated to match the one from Kerberos. +- Use password writeback: when a user changes their password in authentik, their Kerberos password is automatically updated to match the one from authentik. This is only available if synchronization is configured. + +## Kerberos source property mappings + +See the [overview](../../property-mappings/index.md) for information on how property mappings work with external sources. + +By default, authentik ships with [pre-configured mappings](#built-in-property-mappings) for the most common Kerberos setups. These mappings can be found on the Kerberos Source Configuration page in the Admin interface. + +### Built-in property mappings + +Kerberos property mappings are used when you define a Kerberos source. These mappings define which Kerberos property maps to which authentik property. By default, the following mappings are created: + +- authentik default Kerberos User Mapping: Add realm as group + The realm of the user will be added as a group for that user. +- authentik default Kerberos User Mapping: Ignore other realms + Realms other than the one configured on the source are ignored, and log in is not allowed. +- authentik default Kerberos User Mapping: Ignore system principals + System principals such as `K/M` or `kadmin/admin` are ignored. +- authentik default Kerberos User Mapping: Multipart principals as service accounts + Multipart principals (for example: `HTTP/authentik.company`) have their user type set to **service account**. + +These property mappings are configured with the most common Kerberos setups. + +### Expression data + +The following variable is available to Kerberos source property mappings: + +- `principal`: a Python string containing the Kerberos principal. For example `alice@REALM.COMPANY` or `HTTP/authentik.company@REALM.COMPANY`. + +## Troubleshooting + +You can start authentik with the `KRB5_TRACE=/dev/stderr` environment variable for Kerberos to print errors in the logs. diff --git a/website/sidebars.js b/website/sidebars.js index f45c88cdb8..811420d7ca 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -477,6 +477,17 @@ export default { label: "Protocols", collapsed: true, items: [ + { + type: "category", + label: "Kerberos", + link: { + type: "doc", + id: "users-sources/sources/protocols/kerberos/index", + }, + items: [ + "users-sources/sources/protocols/kerberos/browser", + ], + }, "users-sources/sources/protocols/ldap/index", "users-sources/sources/protocols/oauth/index", "users-sources/sources/protocols/saml/index",