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 0000000000..6edd6db4d2 Binary files /dev/null and b/web/authentik/sources/kerberos.png differ diff --git a/web/src/admin/property-mappings/PropertyMappingListPage.ts b/web/src/admin/property-mappings/PropertyMappingListPage.ts index 450cb368d8..76d25ec000 100644 --- a/web/src/admin/property-mappings/PropertyMappingListPage.ts +++ b/web/src/admin/property-mappings/PropertyMappingListPage.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/property-mappings/PropertyMappingSourceKerberosForm.ts b/web/src/admin/property-mappings/PropertyMappingSourceKerberosForm.ts new file mode 100644 index 0000000000..984818d687 --- /dev/null +++ b/web/src/admin/property-mappings/PropertyMappingSourceKerberosForm.ts @@ -0,0 +1,40 @@ +import { BasePropertyMappingForm } from "@goauthentik/admin/property-mappings/BasePropertyMappingForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/forms/HorizontalFormElement"; + +import { customElement } from "lit/decorators.js"; + +import { KerberosSourcePropertyMapping, PropertymappingsApi } from "@goauthentik/api"; + +@customElement("ak-property-mapping-source-kerberos-form") +export class PropertyMappingSourceKerberosForm extends BasePropertyMappingForm { + 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",