Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sources: add Kerberos #10815

Draft
wants to merge 50 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
0e1347e
sources: introduce new property mappings per-user and group
rissson Feb 29, 2024
a99b6ed
sources/ldap: migrate to new property mappings
rissson Feb 29, 2024
02fd08c
lint-fix and make gen
rissson Feb 29, 2024
5bbe030
web changes
rissson Feb 29, 2024
c8f0a4e
Merge branch 'main' into refactor-sources
rissson Mar 1, 2024
0f92e37
fix tests
rissson Mar 1, 2024
1dbd5eb
update tests
rissson Mar 1, 2024
7132d27
Merge branch 'main' into refactor-sources
rissson Mar 1, 2024
3df5c67
remove flatten for generic implem
rissson Mar 1, 2024
4927d2f
Merge branch 'main' into refactor-sources
rissson Mar 1, 2024
d276eb3
rework migration
rissson Mar 1, 2024
4323237
lint-fix
rissson Mar 1, 2024
924461b
wip
rissson Mar 1, 2024
99e2ce7
fix migrations
rissson Mar 1, 2024
44c92c2
re-add field migration to property mappings
rissson Mar 1, 2024
20b18bc
Merge branch 'main' into refactor-sources
rissson Apr 24, 2024
cd66792
fix migrations
rissson Apr 24, 2024
bddfb7f
more migrations fixes
rissson Apr 25, 2024
442248a
Merge branch 'main' into refactor-sources
rissson Jun 6, 2024
193e4ed
easy fixes
rissson Jun 6, 2024
8083ab2
migrate to propertymappingmanager
rissson Jun 6, 2024
a82b5b7
ruff and small fixes
rissson Jun 6, 2024
b9b3f55
Merge branch 'main' into refactor-sources
rissson Jul 15, 2024
dea6dc4
move mapping things into a separate class
rissson Jul 15, 2024
eb1b588
migrations: use using(db_alias)
rissson Jul 15, 2024
4d5a05d
migrations: use built-in variable
rissson Jul 15, 2024
8195352
add docs
rissson Jul 15, 2024
8c60f05
add release notes
rissson Jul 15, 2024
ac554e8
Merge branch 'main' into refactor-sources
rissson Jul 16, 2024
d23fc68
Merge branch 'main' into refactor-sources
rissson Jul 19, 2024
1a3c3da
wip
rissson Jul 22, 2024
964f103
Merge branch 'main' into kerberos-source-reworked
rissson Jul 29, 2024
2c00115
wip
rissson Jul 29, 2024
7e7db89
wip
rissson Jul 29, 2024
4895fed
Merge branch 'main' into kerberos-source-reworked
rissson Aug 7, 2024
98a76d0
wip
rissson Aug 7, 2024
354bea8
wip
rissson Aug 7, 2024
9a159a9
wip
rissson Aug 7, 2024
c74aba2
wip
rissson Aug 7, 2024
33464a4
wip
rissson Aug 7, 2024
893d31d
lint
rissson Aug 7, 2024
bfdc225
fix login reverse
rissson Aug 7, 2024
0df3379
Merge branch 'main' into kerberos-source-reworked
rissson Aug 8, 2024
6a1e193
wip
rissson Aug 8, 2024
fc2cf7e
wip
rissson Aug 12, 2024
e73b651
Merge branch 'main' into kerberos-source-reworked
rissson Sep 18, 2024
9439772
refactor source flow manager matching
rissson Sep 20, 2024
63c0c5f
kerberos sync with mode matching
rissson Sep 20, 2024
41521e2
fixup
rissson Sep 20, 2024
38e7df9
wip
rissson Sep 20, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,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 \
Expand Down Expand Up @@ -143,7 +143,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 && \
# Required for bootstrap & healtcheck
apt-get install -y --no-install-recommends runit && \
apt-get clean && \
Expand All @@ -163,6 +163,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/
Expand Down
4 changes: 4 additions & 0 deletions authentik/lib/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ ldap:
tls:
ciphers: null

sources:
kerberos:
task_timeout_hours: 2

reputation:
expiry: 86400

Expand Down
1 change: 1 addition & 0 deletions authentik/root/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"authentik.providers.scim",
"authentik.rbac",
"authentik.recovery",
"authentik.sources.kerberos",
"authentik.sources.ldap",
"authentik.sources.oauth",
"authentik.sources.plex",
Expand Down
Empty file.
Empty file.
31 changes: 31 additions & 0 deletions authentik/sources/kerberos/api/property_mappings.py
Original file line number Diff line number Diff line change
@@ -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 KerberosPropertyMapping


class KerberosPropertyMappingSerializer(PropertyMappingSerializer):
"""Kerberos PropertyMapping Serializer"""

class Meta(PropertyMappingSerializer.Meta):
model = KerberosPropertyMapping


class KerberosPropertyMappingFilter(PropertyMappingFilterSet):
"""Filter for KerberosPropertyMapping"""

class Meta(PropertyMappingFilterSet.Meta):
model = KerberosPropertyMapping


class KerberosPropertyMappingViewSet(UsedByMixin, ModelViewSet):
"""Kerberos PropertyMapping Viewset"""

queryset = KerberosPropertyMapping.objects.all()
serializer_class = KerberosPropertyMappingSerializer
filterset_class = KerberosPropertyMappingFilter
search_fields = ["name"]
ordering = ["name"]
111 changes: 111 additions & 0 deletions authentik/sources/kerberos/api/source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Source API Views"""
from typing import Optional

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) -> Optional[dict[str, str]]:
"""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_enabled",
"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_enabled",
"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)
45 changes: 45 additions & 0 deletions authentik/sources/kerberos/api/source_connection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""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 UserKerberosSourceConnection, GroupKerberosSourceConnection


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
13 changes: 13 additions & 0 deletions authentik/sources/kerberos/apps.py
Original file line number Diff line number Diff line change
@@ -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
117 changes: 117 additions & 0 deletions authentik/sources/kerberos/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""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, password_login_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
)
)
else:
# User not found, let's try to find it by identifier if the realm has been specified
if realm is not None:
user_source_connections = (
UserKerberosSourceConnection.objects.filter(
source__in=sources, identifier__iexact=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
LOGGER.debug(
"Updating user's password in DB",
source=user_source_connection.source,
user=user_source_connection.user,
)
if (
user_source_connection.source.kerberossource.password_login_update_internal_password
):
user_source_connection.user.set_password(password)
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
4 changes: 4 additions & 0 deletions authentik/sources/kerberos/krb5.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[libdefaults]
dns_canonicalize_hostname = false
dns_fallback = true
rnds = false
Empty file.
Empty file.
Loading
Loading