Compare commits

..

4 Commits

Author SHA1 Message Date
13c8cbf03a fix rac tests 2025-03-28 12:55:14 -03:00
1776981f29 Add schema.yml 2025-03-27 18:18:28 -03:00
5a4df95011 Fix tests, add more tests 2025-03-27 18:17:28 -03:00
f2927e5725 first approach 2025-03-27 18:03:25 -03:00
446 changed files with 4302 additions and 39022 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 2025.2.4 current_version = 2025.2.2
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
@ -17,8 +17,6 @@ optional_value = final
[bumpversion:file:pyproject.toml] [bumpversion:file:pyproject.toml]
[bumpversion:file:uv.lock]
[bumpversion:file:package.json] [bumpversion:file:package.json]
[bumpversion:file:docker-compose.yml] [bumpversion:file:docker-compose.yml]

View File

@ -30,6 +30,7 @@ jobs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version-file: "pyproject.toml" python-version-file: "pyproject.toml"
cache: "poetry"
- name: Generate API Client - name: Generate API Client
run: make gen-client-py run: make gen-client-py
- name: Publish package - name: Publish package

View File

@ -1,45 +0,0 @@
name: authentik-packages-npm-publish
on:
push:
branches: [main]
paths:
- packages/docusaurus-config
- packages/eslint-config
- packages/prettier-config
- packages/tsconfig
workflow_dispatch:
jobs:
publish:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package:
- docusaurus-config
- eslint-config
- prettier-config
- tsconfig
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4
with:
node-version-file: packages/${{ matrix.package }}/package.json
registry-url: "https://registry.npmjs.org"
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c
with:
files: |
packages/${{ matrix.package }}/package.json
- name: Publish package
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: packages/${{ matrix.package}}
run: |
npm ci
npm run build
npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}

5
.gitignore vendored
View File

@ -11,10 +11,6 @@ local_settings.py
db.sqlite3 db.sqlite3
media media
# Node
node_modules
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly. # in your Git repository. Update and uncomment the following line accordingly.
# <django-project-name>/staticfiles/ # <django-project-name>/staticfiles/
@ -37,7 +33,6 @@ eggs/
lib64/ lib64/
parts/ parts/
dist/ dist/
out/
sdist/ sdist/
var/ var/
wheels/ wheels/

View File

@ -1,47 +0,0 @@
# Prettier Ignorefile
## Static Files
**/LICENSE
authentik/stages/**/*
## Build asset directories
coverage
dist
out
.docusaurus
website/docs/developer-docs/api/**/*
## Environment
*.env
## Secrets
*.secrets
## Yarn
.yarn/**/*
## Node
node_modules
coverage
## Configs
*.log
*.yaml
*.yml
# Templates
# TODO: Rename affected files to *.template.* or similar.
*.html
*.mdx
*.md
## Import order matters
poly.ts
src/locale-codes.ts
src/locales/
# Storybook
storybook-static/
.storybook/css-import-maps*

View File

@ -23,8 +23,6 @@ docker-compose.yml @goauthentik/infrastructure
Makefile @goauthentik/infrastructure Makefile @goauthentik/infrastructure
.editorconfig @goauthentik/infrastructure .editorconfig @goauthentik/infrastructure
CODEOWNERS @goauthentik/infrastructure CODEOWNERS @goauthentik/infrastructure
# Web packages
packages/ @goauthentik/frontend
# Web # Web
web/ @goauthentik/frontend web/ @goauthentik/frontend
tests/wdio/ @goauthentik/frontend tests/wdio/ @goauthentik/frontend

View File

@ -43,7 +43,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
RUN npm run build RUN npm run build
# Stage 3: Build go proxy # Stage 3: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS go-builder
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
@ -76,7 +76,7 @@ COPY ./go.sum /go/src/goauthentik.io/go.sum
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
go build -o /go/authentik ./cmd/server go build -o /go/authentik ./cmd/server
# Stage 4: MaxMind GeoIP # Stage 4: MaxMind GeoIP
@ -94,9 +94,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" /bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Download uv # Stage 5: Download uv
FROM ghcr.io/astral-sh/uv:0.6.14 AS uv FROM ghcr.io/astral-sh/uv:0.6.10 AS uv
# Stage 6: Base python image # Stage 6: Base python image
FROM ghcr.io/goauthentik/fips-python:3.12.10-slim-bookworm-fips AS python-base FROM ghcr.io/goauthentik/fips-python:3.12.8-slim-bookworm-fips AS python-base
ENV VENV_PATH="/ak-root/.venv" \ ENV VENV_PATH="/ak-root/.venv" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \ PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \

View File

@ -2,7 +2,7 @@
from os import environ from os import environ
__version__ = "2025.2.4" __version__ = "2025.2.2"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -36,7 +36,6 @@ from authentik.core.models import (
GroupSourceConnection, GroupSourceConnection,
PropertyMapping, PropertyMapping,
Provider, Provider,
Session,
Source, Source,
User, User,
UserSourceConnection, UserSourceConnection,
@ -109,7 +108,6 @@ def excluded_models() -> list[type[Model]]:
Policy, Policy,
PolicyBindingModel, PolicyBindingModel,
# Classes that have other dependencies # Classes that have other dependencies
Session,
AuthenticatedSession, AuthenticatedSession,
# Classes which are only internally managed # Classes which are only internally managed
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin # FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin

View File

@ -46,7 +46,7 @@ LOGGER = get_logger()
def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str: def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str:
"""Cache key where application list for user is saved""" """Cache key where application list for user is saved"""
key = f"{CACHE_PREFIX}app_access/{user_pk}" key = f"{CACHE_PREFIX}/app_access/{user_pk}"
if page_number: if page_number:
key += f"/{page_number}" key += f"/{page_number}"
return key return key

View File

@ -5,7 +5,6 @@ from typing import TypedDict
from rest_framework import mixins from rest_framework import mixins
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.serializers import CharField, DateTimeField, IPAddressField
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from ua_parser import user_agent_parser from ua_parser import user_agent_parser
@ -55,11 +54,6 @@ class UserAgentDict(TypedDict):
class AuthenticatedSessionSerializer(ModelSerializer): class AuthenticatedSessionSerializer(ModelSerializer):
"""AuthenticatedSession Serializer""" """AuthenticatedSession Serializer"""
expires = DateTimeField(source="session.expires", read_only=True)
last_ip = IPAddressField(source="session.last_ip", read_only=True)
last_user_agent = CharField(source="session.last_user_agent", read_only=True)
last_used = DateTimeField(source="session.last_used", read_only=True)
current = SerializerMethodField() current = SerializerMethodField()
user_agent = SerializerMethodField() user_agent = SerializerMethodField()
geo_ip = SerializerMethodField() geo_ip = SerializerMethodField()
@ -68,19 +62,19 @@ class AuthenticatedSessionSerializer(ModelSerializer):
def get_current(self, instance: AuthenticatedSession) -> bool: def get_current(self, instance: AuthenticatedSession) -> bool:
"""Check if session is currently active session""" """Check if session is currently active session"""
request: Request = self.context["request"] request: Request = self.context["request"]
return request._request.session.session_key == instance.session.session_key return request._request.session.session_key == instance.session_key
def get_user_agent(self, instance: AuthenticatedSession) -> UserAgentDict: def get_user_agent(self, instance: AuthenticatedSession) -> UserAgentDict:
"""Get parsed user agent""" """Get parsed user agent"""
return user_agent_parser.Parse(instance.session.last_user_agent) return user_agent_parser.Parse(instance.last_user_agent)
def get_geo_ip(self, instance: AuthenticatedSession) -> GeoIPDict | None: # pragma: no cover def get_geo_ip(self, instance: AuthenticatedSession) -> GeoIPDict | None: # pragma: no cover
"""Get GeoIP Data""" """Get GeoIP Data"""
return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.session.last_ip) return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.last_ip)
def get_asn(self, instance: AuthenticatedSession) -> ASNDict | None: # pragma: no cover def get_asn(self, instance: AuthenticatedSession) -> ASNDict | None: # pragma: no cover
"""Get ASN Data""" """Get ASN Data"""
return ASN_CONTEXT_PROCESSOR.asn_dict(instance.session.last_ip) return ASN_CONTEXT_PROCESSOR.asn_dict(instance.last_ip)
class Meta: class Meta:
model = AuthenticatedSession model = AuthenticatedSession
@ -96,7 +90,6 @@ class AuthenticatedSessionSerializer(ModelSerializer):
"last_used", "last_used",
"expires", "expires",
] ]
extra_args = {"uuid": {"read_only": True}}
class AuthenticatedSessionViewSet( class AuthenticatedSessionViewSet(
@ -108,10 +101,9 @@ class AuthenticatedSessionViewSet(
): ):
"""AuthenticatedSession Viewset""" """AuthenticatedSession Viewset"""
lookup_field = "uuid" queryset = AuthenticatedSession.objects.all()
queryset = AuthenticatedSession.objects.select_related("session").all()
serializer_class = AuthenticatedSessionSerializer serializer_class = AuthenticatedSessionSerializer
search_fields = ["user__username", "session__last_ip", "session__last_user_agent"] search_fields = ["user__username", "last_ip", "last_user_agent"]
filterset_fields = ["user__username", "session__last_ip", "session__last_user_agent"] filterset_fields = ["user__username", "last_ip", "last_user_agent"]
ordering = ["user__username"] ordering = ["user__username"]
owner_field = "user" owner_field = "user"

View File

@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _
from django_filters.filters import BooleanFilter from django_filters.filters import BooleanFilter
from django_filters.filterset import FilterSet from django_filters.filterset import FilterSet
from rest_framework import mixins from rest_framework import mixins
from rest_framework.fields import ReadOnlyField, SerializerMethodField from rest_framework.fields import SerializerMethodField
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from authentik.core.api.object_types import TypesMixin from authentik.core.api.object_types import TypesMixin
@ -18,10 +18,10 @@ from authentik.core.models import Provider
class ProviderSerializer(ModelSerializer, MetaNameSerializer): class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"""Provider Serializer""" """Provider Serializer"""
assigned_application_slug = ReadOnlyField(source="application.slug") assigned_application_slug = SerializerMethodField()
assigned_application_name = ReadOnlyField(source="application.name") assigned_application_name = SerializerMethodField()
assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug") assigned_backchannel_application_slug = SerializerMethodField()
assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name") assigned_backchannel_application_name = SerializerMethodField()
component = SerializerMethodField() component = SerializerMethodField()
@ -31,6 +31,38 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
return "" return ""
return obj.component return obj.component
def get_assigned_application_slug(self, obj: Provider) -> str:
"""Get application slug, return empty string if no application exists"""
try:
return obj.application.slug
except Provider.application.RelatedObjectDoesNotExist:
return ""
def get_assigned_application_name(self, obj: Provider) -> str:
"""Get application name, return empty string if no application exists"""
try:
return obj.application.name
except Provider.application.RelatedObjectDoesNotExist:
return ""
def get_assigned_backchannel_application_slug(self, obj: Provider) -> str:
"""Get backchannel application slug.
Returns an empty string if no backchannel application exists.
"""
if not obj.backchannel_application:
return ""
return obj.backchannel_application.slug or ""
def get_assigned_backchannel_application_name(self, obj: Provider) -> str:
"""Get backchannel application name.
Returns an empty string if no backchannel application exists.
"""
if not obj.backchannel_application:
return ""
return obj.backchannel_application.name or ""
class Meta: class Meta:
model = Provider model = Provider
fields = [ fields = [

View File

@ -179,13 +179,10 @@ class UserSourceConnectionSerializer(SourceSerializer):
"user", "user",
"source", "source",
"source_obj", "source_obj",
"identifier",
"created", "created",
"last_updated",
] ]
extra_kwargs = { extra_kwargs = {
"created": {"read_only": True}, "created": {"read_only": True},
"last_updated": {"read_only": True},
} }
@ -202,7 +199,7 @@ class UserSourceConnectionViewSet(
queryset = UserSourceConnection.objects.all() queryset = UserSourceConnection.objects.all()
serializer_class = UserSourceConnectionSerializer serializer_class = UserSourceConnectionSerializer
filterset_fields = ["user", "source__slug"] filterset_fields = ["user", "source__slug"]
search_fields = ["user__username", "source__slug", "identifier"] search_fields = ["source__slug"]
ordering = ["source__slug", "pk"] ordering = ["source__slug", "pk"]
owner_field = "user" owner_field = "user"
@ -221,11 +218,9 @@ class GroupSourceConnectionSerializer(SourceSerializer):
"source_obj", "source_obj",
"identifier", "identifier",
"created", "created",
"last_updated",
] ]
extra_kwargs = { extra_kwargs = {
"created": {"read_only": True}, "created": {"read_only": True},
"last_updated": {"read_only": True},
} }
@ -242,5 +237,6 @@ class GroupSourceConnectionViewSet(
queryset = GroupSourceConnection.objects.all() queryset = GroupSourceConnection.objects.all()
serializer_class = GroupSourceConnectionSerializer serializer_class = GroupSourceConnectionSerializer
filterset_fields = ["group", "source__slug"] filterset_fields = ["group", "source__slug"]
search_fields = ["group__name", "source__slug", "identifier"] search_fields = ["source__slug"]
ordering = ["source__slug", "pk"] ordering = ["source__slug", "pk"]
owner_field = "user"

View File

@ -6,6 +6,8 @@ from typing import Any
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.db.models.functions import ExtractHour from django.db.models.functions import ExtractHour
from django.db.transaction import atomic from django.db.transaction import atomic
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
@ -69,8 +71,8 @@ from authentik.core.middleware import (
from authentik.core.models import ( from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING, USER_ATTRIBUTE_TOKEN_EXPIRING,
USER_PATH_SERVICE_ACCOUNT, USER_PATH_SERVICE_ACCOUNT,
AuthenticatedSession,
Group, Group,
Session,
Token, Token,
TokenIntents, TokenIntents,
User, User,
@ -224,7 +226,6 @@ class UserSerializer(ModelSerializer):
"name", "name",
"is_active", "is_active",
"last_login", "last_login",
"date_joined",
"is_superuser", "is_superuser",
"groups", "groups",
"groups_obj", "groups_obj",
@ -239,7 +240,6 @@ class UserSerializer(ModelSerializer):
] ]
extra_kwargs = { extra_kwargs = {
"name": {"allow_blank": True}, "name": {"allow_blank": True},
"date_joined": {"read_only": True},
"password_change_date": {"read_only": True}, "password_change_date": {"read_only": True},
} }
@ -373,7 +373,7 @@ class UsersFilter(FilterSet):
method="filter_attributes", method="filter_attributes",
) )
is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser") is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
uuid = UUIDFilter(field_name="uuid") uuid = UUIDFilter(field_name="uuid")
path = CharFilter(field_name="path") path = CharFilter(field_name="path")
@ -391,11 +391,6 @@ class UsersFilter(FilterSet):
queryset=Group.objects.all().order_by("name"), queryset=Group.objects.all().order_by("name"),
) )
def filter_is_superuser(self, queryset, name, value):
if value:
return queryset.filter(ak_groups__is_superuser=True).distinct()
return queryset.exclude(ak_groups__is_superuser=True).distinct()
def filter_attributes(self, queryset, name, value): def filter_attributes(self, queryset, name, value):
"""Filter attributes by query args""" """Filter attributes by query args"""
try: try:
@ -772,6 +767,9 @@ class UserViewSet(UsedByMixin, ModelViewSet):
response = super().partial_update(request, *args, **kwargs) response = super().partial_update(request, *args, **kwargs)
instance: User = self.get_object() instance: User = self.get_object()
if not instance.is_active: if not instance.is_active:
Session.objects.filter(authenticatedsession__user=instance).delete() sessions = AuthenticatedSession.objects.filter(user=instance)
session_ids = sessions.values_list("session_key", flat=True)
cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids)
sessions.delete()
LOGGER.debug("Deleted user's sessions", user=instance.username) LOGGER.debug("Deleted user's sessions", user=instance.username)
return response return response

View File

@ -24,15 +24,6 @@ class InbuiltBackend(ModelBackend):
self.set_method("password", request) self.set_method("password", request)
return user return user
async def aauthenticate(
self, request: HttpRequest, username: str | None, password: str | None, **kwargs: Any
) -> User | None:
user = await super().aauthenticate(request, username=username, password=password, **kwargs)
if not user:
return None
self.set_method("password", request)
return user
def set_method(self, method: str, request: HttpRequest | None, **kwargs): def set_method(self, method: str, request: HttpRequest | None, **kwargs):
"""Set method data on current flow, if possbiel""" """Set method data on current flow, if possbiel"""
if not request: if not request:

View File

@ -1,15 +0,0 @@
"""Change user type"""
from importlib import import_module
from django.conf import settings
from authentik.tenants.management import TenantCommand
class Command(TenantCommand):
"""Delete all sessions"""
def handle_per_tenant(self, **options):
engine = import_module(settings.SESSION_ENGINE)
engine.SessionStore.clear_expired()

View File

@ -2,14 +2,9 @@
from collections.abc import Callable from collections.abc import Callable
from contextvars import ContextVar from contextvars import ContextVar
from functools import partial
from uuid import uuid4 from uuid import uuid4
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.deprecation import MiddlewareMixin
from django.utils.functional import SimpleLazyObject
from django.utils.translation import override from django.utils.translation import override
from sentry_sdk.api import set_tag from sentry_sdk.api import set_tag
from structlog.contextvars import STRUCTLOG_KEY_PREFIX from structlog.contextvars import STRUCTLOG_KEY_PREFIX
@ -25,40 +20,6 @@ CTX_HOST = ContextVar[str | None](STRUCTLOG_KEY_PREFIX + "host", default=None)
CTX_AUTH_VIA = ContextVar[str | None](STRUCTLOG_KEY_PREFIX + KEY_AUTH_VIA, default=None) CTX_AUTH_VIA = ContextVar[str | None](STRUCTLOG_KEY_PREFIX + KEY_AUTH_VIA, default=None)
def get_user(request):
if not hasattr(request, "_cached_user"):
user = None
if (authenticated_session := request.session.get("authenticatedsession", None)) is not None:
user = authenticated_session.user
request._cached_user = user or AnonymousUser()
return request._cached_user
async def aget_user(request):
if not hasattr(request, "_cached_user"):
user = None
if (
authenticated_session := await request.session.aget("authenticatedsession", None)
) is not None:
user = authenticated_session.user
request._cached_user = user or AnonymousUser()
return request._cached_user
class AuthenticationMiddleware(MiddlewareMixin):
def process_request(self, request):
if not hasattr(request, "session"):
raise ImproperlyConfigured(
"The Django authentication middleware requires session "
"middleware to be installed. Edit your MIDDLEWARE setting to "
"insert "
"'authentik.root.middleware.SessionMiddleware' before "
"'authentik.core.middleware.AuthenticationMiddleware'."
)
request.user = SimpleLazyObject(lambda: get_user(request))
request.auser = partial(aget_user, request)
class ImpersonateMiddleware: class ImpersonateMiddleware:
"""Middleware to impersonate users""" """Middleware to impersonate users"""

View File

@ -1,19 +0,0 @@
# Generated by Django 5.0.13 on 2025-04-07 14:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0043_alter_group_options"),
]
operations = [
migrations.AddField(
model_name="usersourceconnection",
name="new_identifier",
field=models.TextField(default=""),
preserve_default=False,
),
]

View File

@ -1,30 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0044_usersourceconnection_new_identifier"),
("authentik_sources_kerberos", "0003_migrate_userkerberossourceconnection_identifier"),
("authentik_sources_oauth", "0009_migrate_useroauthsourceconnection_identifier"),
("authentik_sources_plex", "0005_migrate_userplexsourceconnection_identifier"),
("authentik_sources_saml", "0019_migrate_usersamlsourceconnection_identifier"),
]
operations = [
migrations.RenameField(
model_name="usersourceconnection",
old_name="new_identifier",
new_name="identifier",
),
migrations.AddIndex(
model_name="usersourceconnection",
index=models.Index(fields=["identifier"], name="authentik_c_identif_59226f_idx"),
),
migrations.AddIndex(
model_name="usersourceconnection",
index=models.Index(
fields=["source", "identifier"], name="authentik_c_source__649e04_idx"
),
),
]

View File

@ -1,238 +0,0 @@
# Generated by Django 5.0.11 on 2025-01-27 12:58
import uuid
import pickle # nosec
from django.core import signing
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.utils.timezone import now, timedelta
from authentik.lib.migrations import progress_bar
from authentik.root.middleware import ClientIPMiddleware
SESSION_CACHE_ALIAS = "default"
class PickleSerializer:
"""
Simple wrapper around pickle to be used in signing.dumps()/loads() and
cache backends.
"""
def __init__(self, protocol=None):
self.protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
def dumps(self, obj):
"""Pickle data to be stored in redis"""
return pickle.dumps(obj, self.protocol)
def loads(self, data):
"""Unpickle data to be loaded from redis"""
return pickle.loads(data) # nosec
def _migrate_session(
apps,
db_alias,
session_key,
session_data,
expires,
):
Session = apps.get_model("authentik_core", "Session")
OldAuthenticatedSession = apps.get_model("authentik_core", "OldAuthenticatedSession")
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
old_auth_session = (
OldAuthenticatedSession.objects.using(db_alias).filter(session_key=session_key).first()
)
args = {
"session_key": session_key,
"expires": expires,
"last_ip": ClientIPMiddleware.default_ip,
"last_user_agent": "",
"session_data": {},
}
for k, v in session_data.items():
if k == "authentik/stages/user_login/last_ip":
args["last_ip"] = v
elif k in ["last_user_agent", "last_used"]:
args[k] = v
elif args in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY]:
pass
else:
args["session_data"][k] = v
if old_auth_session:
args["last_user_agent"] = old_auth_session.last_user_agent
args["last_used"] = old_auth_session.last_used
args["session_data"] = pickle.dumps(args["session_data"])
session = Session.objects.using(db_alias).create(**args)
if old_auth_session:
AuthenticatedSession.objects.using(db_alias).create(
session=session,
user=old_auth_session.user,
)
def migrate_redis_sessions(apps, schema_editor):
from django.core.cache import caches
db_alias = schema_editor.connection.alias
cache = caches[SESSION_CACHE_ALIAS]
# Not a redis cache, skipping
if not hasattr(cache, "keys"):
return
print("\nMigrating Redis sessions to database, this might take a couple of minutes...")
for key, session_data in progress_bar(cache.get_many(cache.keys(f"{KEY_PREFIX}*")).items()):
_migrate_session(
apps=apps,
db_alias=db_alias,
session_key=key.removeprefix(KEY_PREFIX),
session_data=session_data,
expires=now() + timedelta(seconds=cache.ttl(key)),
)
def migrate_database_sessions(apps, schema_editor):
DjangoSession = apps.get_model("sessions", "Session")
db_alias = schema_editor.connection.alias
print("\nMigration database sessions, this might take a couple of minutes...")
for django_session in progress_bar(DjangoSession.objects.using(db_alias).all()):
session_data = signing.loads(
django_session.session_data,
salt="django.contrib.sessions.SessionStore",
serializer=PickleSerializer,
)
_migrate_session(
apps=apps,
db_alias=db_alias,
session_key=django_session.session_key,
session_data=session_data,
expires=django_session.expire_date,
)
class Migration(migrations.Migration):
dependencies = [
("sessions", "0001_initial"),
("authentik_core", "0045_rename_new_identifier_usersourceconnection_identifier_and_more"),
("authentik_providers_oauth2", "0027_accesstoken_authentik_p_expires_9f24a5_idx_and_more"),
("authentik_providers_rac", "0006_connectiontoken_authentik_p_expires_91f148_idx_and_more"),
]
operations = [
# Rename AuthenticatedSession to OldAuthenticatedSession
migrations.RenameModel(
old_name="AuthenticatedSession",
new_name="OldAuthenticatedSession",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_expires_cf4f72_idx",
old_name="authentik_c_expires_08251d_idx",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_expirin_c1f17f_idx",
old_name="authentik_c_expirin_9cd839_idx",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_expirin_e04f5d_idx",
old_name="authentik_c_expirin_195a84_idx",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_session_a44819_idx",
old_name="authentik_c_session_d0f005_idx",
),
migrations.RunSQL(
sql="ALTER INDEX authentik_core_authenticatedsession_user_id_5055b6cf RENAME TO authentik_core_oldauthenticatedsession_user_id_5055b6cf",
reverse_sql="ALTER INDEX authentik_core_oldauthenticatedsession_user_id_5055b6cf RENAME TO authentik_core_authenticatedsession_user_id_5055b6cf",
),
# Create new Session and AuthenticatedSession models
migrations.CreateModel(
name="Session",
fields=[
(
"session_key",
models.CharField(
max_length=40, primary_key=True, serialize=False, verbose_name="session key"
),
),
("expires", models.DateTimeField(default=None, null=True)),
("expiring", models.BooleanField(default=True)),
("session_data", models.BinaryField(verbose_name="session data")),
("last_ip", models.GenericIPAddressField()),
("last_user_agent", models.TextField(blank=True)),
("last_used", models.DateTimeField(auto_now=True)),
],
options={
"default_permissions": [],
"verbose_name": "Session",
"verbose_name_plural": "Sessions",
},
),
migrations.AddIndex(
model_name="session",
index=models.Index(fields=["expires"], name="authentik_c_expires_d2f607_idx"),
),
migrations.AddIndex(
model_name="session",
index=models.Index(fields=["expiring"], name="authentik_c_expirin_7c2cfb_idx"),
),
migrations.AddIndex(
model_name="session",
index=models.Index(
fields=["expiring", "expires"], name="authentik_c_expirin_1ab2e4_idx"
),
),
migrations.AddIndex(
model_name="session",
index=models.Index(
fields=["expires", "session_key"], name="authentik_c_expires_c49143_idx"
),
),
migrations.CreateModel(
name="AuthenticatedSession",
fields=[
(
"session",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
serialize=False,
to="authentik_core.session",
),
),
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "Authenticated Session",
"verbose_name_plural": "Authenticated Sessions",
},
),
migrations.RunPython(
code=migrate_redis_sessions,
reverse_code=migrations.RunPython.noop,
),
migrations.RunPython(
code=migrate_database_sessions,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.11 on 2025-01-27 13:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0046_session_and_more"),
("authentik_providers_rac", "0007_migrate_session"),
("authentik_providers_oauth2", "0028_migrate_session"),
]
operations = [
migrations.DeleteModel(
name="OldAuthenticatedSession",
),
]

View File

@ -1,7 +1,6 @@
"""authentik core models""" """authentik core models"""
from datetime import datetime from datetime import datetime
from enum import StrEnum
from hashlib import sha256 from hashlib import sha256
from typing import Any, Optional, Self from typing import Any, Optional, Self
from uuid import uuid4 from uuid import uuid4
@ -10,7 +9,6 @@ from deepmerge import always_merger
from django.contrib.auth.hashers import check_password from django.contrib.auth.hashers import check_password
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as DjangoUserManager from django.contrib.auth.models import UserManager as DjangoUserManager
from django.contrib.sessions.base_session import AbstractBaseSession
from django.db import models from django.db import models
from django.db.models import Q, QuerySet, options from django.db.models import Q, QuerySet, options
from django.db.models.constants import LOOKUP_SEP from django.db.models.constants import LOOKUP_SEP
@ -648,30 +646,19 @@ class SourceUserMatchingModes(models.TextChoices):
"""Different modes a source can handle new/returning users""" """Different modes a source can handle new/returning users"""
IDENTIFIER = "identifier", _("Use the source-specific identifier") IDENTIFIER = "identifier", _("Use the source-specific identifier")
EMAIL_LINK = ( EMAIL_LINK = "email_link", _(
"email_link", "Link to a user with identical email address. Can have security implications "
_( "when a source doesn't validate email addresses."
"Link to a user with identical email address. Can have security implications "
"when a source doesn't validate email addresses."
),
) )
EMAIL_DENY = ( EMAIL_DENY = "email_deny", _(
"email_deny", "Use the user's email address, but deny enrollment when the email address already exists."
_(
"Use the user's email address, but deny enrollment when the email address already "
"exists."
),
) )
USERNAME_LINK = ( USERNAME_LINK = "username_link", _(
"username_link", "Link to a user with identical username. Can have security implications "
_( "when a username is used with another source."
"Link to a user with identical username. Can have security implications "
"when a username is used with another source."
),
) )
USERNAME_DENY = ( USERNAME_DENY = "username_deny", _(
"username_deny", "Use the user's username, but deny enrollment when the username already exists."
_("Use the user's username, but deny enrollment when the username already exists."),
) )
@ -679,16 +666,12 @@ class SourceGroupMatchingModes(models.TextChoices):
"""Different modes a source can handle new/returning groups""" """Different modes a source can handle new/returning groups"""
IDENTIFIER = "identifier", _("Use the source-specific identifier") IDENTIFIER = "identifier", _("Use the source-specific identifier")
NAME_LINK = ( NAME_LINK = "name_link", _(
"name_link", "Link to a group with identical name. Can have security implications "
_( "when a group name is used with another source."
"Link to a group with identical name. Can have security implications "
"when a group name is used with another source."
),
) )
NAME_DENY = ( NAME_DENY = "name_deny", _(
"name_deny", "Use the group name, but deny enrollment when the name already exists."
_("Use the group name, but deny enrollment when the name already exists."),
) )
@ -747,7 +730,8 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
choices=SourceGroupMatchingModes.choices, choices=SourceGroupMatchingModes.choices,
default=SourceGroupMatchingModes.IDENTIFIER, default=SourceGroupMatchingModes.IDENTIFIER,
help_text=_( help_text=_(
"How the source determines if an existing group should be used or a new group created." "How the source determines if an existing group should be used or "
"a new group created."
), ),
) )
@ -777,17 +761,11 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
@property @property
def component(self) -> str: def component(self) -> str:
"""Return component used to edit this object""" """Return component used to edit this object"""
if self.managed == self.MANAGED_INBUILT:
return ""
raise NotImplementedError raise NotImplementedError
@property @property
def property_mapping_type(self) -> "type[PropertyMapping]": def property_mapping_type(self) -> "type[PropertyMapping]":
"""Return property mapping type used by this object""" """Return property mapping type used by this object"""
if self.managed == self.MANAGED_INBUILT:
from authentik.core.models import PropertyMapping
return PropertyMapping
raise NotImplementedError raise NotImplementedError
def ui_login_button(self, request: HttpRequest) -> UILoginButton | None: def ui_login_button(self, request: HttpRequest) -> UILoginButton | None:
@ -802,14 +780,10 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
def get_base_user_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]: def get_base_user_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a user to build final properties upon.""" """Get base properties for a user to build final properties upon."""
if self.managed == self.MANAGED_INBUILT:
return {}
raise NotImplementedError raise NotImplementedError
def get_base_group_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]: def get_base_group_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a group to build final properties upon.""" """Get base properties for a group to build final properties upon."""
if self.managed == self.MANAGED_INBUILT:
return {}
raise NotImplementedError raise NotImplementedError
def __str__(self): def __str__(self):
@ -840,7 +814,6 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
source = models.ForeignKey(Source, on_delete=models.CASCADE) source = models.ForeignKey(Source, on_delete=models.CASCADE)
identifier = models.TextField()
objects = InheritanceManager() objects = InheritanceManager()
@ -854,10 +827,6 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
class Meta: class Meta:
unique_together = (("user", "source"),) unique_together = (("user", "source"),)
indexes = (
models.Index(fields=("identifier",)),
models.Index(fields=("source", "identifier")),
)
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel): class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):
@ -1028,75 +997,45 @@ class PropertyMapping(SerializerModel, ManagedModel):
verbose_name_plural = _("Property Mappings") verbose_name_plural = _("Property Mappings")
class Session(ExpiringModel, AbstractBaseSession): class AuthenticatedSession(ExpiringModel):
"""User session with extra fields for fast access""" """Additional session class for authenticated users. Augments the standard django session
to achieve the following:
- Make it queryable by user
- Have a direct connection to user objects
- Allow users to view their own sessions and terminate them
- Save structured and well-defined information.
"""
# Remove upstream field because we're using our own ExpiringModel uuid = models.UUIDField(default=uuid4, primary_key=True)
expire_date = None
session_data = models.BinaryField(_("session data"))
# Keep in sync with Session.Keys session_key = models.CharField(max_length=40)
last_ip = models.GenericIPAddressField() user = models.ForeignKey(User, on_delete=models.CASCADE)
last_ip = models.TextField()
last_user_agent = models.TextField(blank=True) last_user_agent = models.TextField(blank=True)
last_used = models.DateTimeField(auto_now=True) last_used = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("Session")
verbose_name_plural = _("Sessions")
indexes = ExpiringModel.Meta.indexes + [
models.Index(fields=["expires", "session_key"]),
]
default_permissions = []
def __str__(self):
return self.session_key
class Keys(StrEnum):
"""
Keys to be set with the session interface for the fields above to be updated.
If a field is added here that needs to be initialized when the session is initialized,
it must also be reflected in authentik.root.middleware.SessionMiddleware.process_request
and in authentik.core.sessions.SessionStore.__init__
"""
LAST_IP = "last_ip"
LAST_USER_AGENT = "last_user_agent"
LAST_USED = "last_used"
@classmethod
def get_session_store_class(cls):
from authentik.core.sessions import SessionStore
return SessionStore
def get_decoded(self):
raise NotImplementedError
class AuthenticatedSession(SerializerModel):
session = models.OneToOneField(Session, on_delete=models.CASCADE, primary_key=True)
# We use the session as primary key, but we need the API to be able to reference
# this object uniquely without exposing the session key
uuid = models.UUIDField(default=uuid4, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta: class Meta:
verbose_name = _("Authenticated Session") verbose_name = _("Authenticated Session")
verbose_name_plural = _("Authenticated Sessions") verbose_name_plural = _("Authenticated Sessions")
indexes = ExpiringModel.Meta.indexes + [
models.Index(fields=["session_key"]),
]
def __str__(self) -> str: def __str__(self) -> str:
return f"Authenticated Session {str(self.pk)[:10]}" return f"Authenticated Session {self.session_key[:10]}"
@staticmethod @staticmethod
def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]: def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]:
"""Create a new session from a http request""" """Create a new session from a http request"""
if not hasattr(request, "session") or not request.session.exists( from authentik.root.middleware import ClientIPMiddleware
request.session.session_key
): if not hasattr(request, "session") or not request.session.session_key:
return None return None
return AuthenticatedSession( return AuthenticatedSession(
session=Session.objects.filter(session_key=request.session.session_key).first(), session_key=request.session.session_key,
user=user, user=user,
last_ip=ClientIPMiddleware.get_client_ip(request),
last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
expires=request.session.get_expiry_date(),
) )

View File

@ -1,168 +0,0 @@
"""authentik sessions engine"""
import pickle # nosec
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
from django.contrib.sessions.backends.db import SessionStore as SessionBase
from django.core.exceptions import SuspiciousOperation
from django.utils import timezone
from django.utils.functional import cached_property
from structlog.stdlib import get_logger
from authentik.root.middleware import ClientIPMiddleware
LOGGER = get_logger()
class SessionStore(SessionBase):
def __init__(self, session_key=None, last_ip=None, last_user_agent=""):
super().__init__(session_key)
self._create_kwargs = {
"last_ip": last_ip or ClientIPMiddleware.default_ip,
"last_user_agent": last_user_agent,
}
@classmethod
def get_model_class(cls):
from authentik.core.models import Session
return Session
@cached_property
def model_fields(self):
return [k.value for k in self.model.Keys]
def _get_session_from_db(self):
try:
return (
self.model.objects.select_related(
"authenticatedsession",
"authenticatedsession__user",
)
.prefetch_related(
"authenticatedsession__user__groups",
"authenticatedsession__user__user_permissions",
)
.get(
session_key=self.session_key,
expires__gt=timezone.now(),
)
)
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
if isinstance(exc, SuspiciousOperation):
LOGGER.warning(str(exc))
self._session_key = None
async def _aget_session_from_db(self):
try:
return (
await self.model.objects.select_related(
"authenticatedsession",
"authenticatedsession__user",
)
.prefetch_related(
"authenticatedsession__user__groups",
"authenticatedsession__user__user_permissions",
)
.aget(
session_key=self.session_key,
expires__gt=timezone.now(),
)
)
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
if isinstance(exc, SuspiciousOperation):
LOGGER.warning(str(exc))
self._session_key = None
def encode(self, session_dict):
return pickle.dumps(session_dict, protocol=pickle.HIGHEST_PROTOCOL)
def decode(self, session_data):
try:
return pickle.loads(session_data) # nosec
except pickle.PickleError:
# ValueError, unpickling exceptions. If any of these happen, just return an empty
# dictionary (an empty session)
pass
return {}
def load(self):
s = self._get_session_from_db()
if s:
return {
"authenticatedsession": getattr(s, "authenticatedsession", None),
**{k: getattr(s, k) for k in self.model_fields},
**self.decode(s.session_data),
}
else:
return {}
async def aload(self):
s = await self._aget_session_from_db()
if s:
return {
"authenticatedsession": getattr(s, "authenticatedsession", None),
**{k: getattr(s, k) for k in self.model_fields},
**self.decode(s.session_data),
}
else:
return {}
def create_model_instance(self, data):
args = {
"session_key": self._get_or_create_session_key(),
"expires": self.get_expiry_date(),
"session_data": {},
**self._create_kwargs,
}
for k, v in data.items():
# Don't save:
# - unused auth data
# - related models
if k in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY, "authenticatedsession"]:
pass
elif k in self.model_fields:
args[k] = v
else:
args["session_data"][k] = v
args["session_data"] = self.encode(args["session_data"])
return self.model(**args)
async def acreate_model_instance(self, data):
args = {
"session_key": await self._aget_or_create_session_key(),
"expires": await self.aget_expiry_date(),
"session_data": {},
**self._create_kwargs,
}
for k, v in data.items():
# Don't save:
# - unused auth data
# - related models
if k in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY, "authenticatedsession"]:
pass
elif k in self.model_fields:
args[k] = v
else:
args["session_data"][k] = v
args["session_data"] = self.encode(args["session_data"])
return self.model(**args)
@classmethod
def clear_expired(cls):
cls.get_model_class().objects.filter(expires__lt=timezone.now()).delete()
@classmethod
async def aclear_expired(cls):
await cls.get_model_class().objects.filter(expires__lt=timezone.now()).adelete()
def cycle_key(self):
data = self._session
key = self.session_key
self.create()
self._session_cache = data
if key:
self.delete(key)
if (authenticated_session := data.get("authenticatedsession")) is not None:
authenticated_session.session_id = self.session_key
authenticated_session.save(force_insert=True)

View File

@ -1,10 +1,11 @@
"""authentik core signals""" """authentik core signals"""
from django.contrib.auth.signals import user_logged_in from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache from django.core.cache import cache
from django.core.signals import Signal from django.core.signals import Signal
from django.db.models import Model from django.db.models import Model
from django.db.models.signals import post_delete, post_save, pre_save from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.http.request import HttpRequest from django.http.request import HttpRequest
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
@ -14,7 +15,6 @@ from authentik.core.models import (
AuthenticatedSession, AuthenticatedSession,
BackchannelProvider, BackchannelProvider,
ExpiringModel, ExpiringModel,
Session,
User, User,
default_token_duration, default_token_duration,
) )
@ -49,10 +49,19 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
session.save() session.save()
@receiver(post_delete, sender=AuthenticatedSession) @receiver(user_logged_out)
def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
"""Delete AuthenticatedSession if it exists"""
if not request.session or not request.session.session_key:
return
AuthenticatedSession.objects.filter(session_key=request.session.session_key).delete()
@receiver(pre_delete, sender=AuthenticatedSession)
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_): def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
"""Delete session when authenticated session is deleted""" """Delete session when authenticated session is deleted"""
Session.objects.filter(session_key=instance.pk).delete() cache_key = f"{KEY_PREFIX}{instance.session_key}"
cache.delete(cache_key)
@receiver(pre_save) @receiver(pre_save)

View File

@ -2,16 +2,22 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.conf import ImproperlyConfigured
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.contrib.sessions.backends.db import SessionStore as DBSessionStore
from django.core.cache import cache
from django.utils.timezone import now from django.utils.timezone import now
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import ( from authentik.core.models import (
USER_ATTRIBUTE_EXPIRES, USER_ATTRIBUTE_EXPIRES,
USER_ATTRIBUTE_GENERATED, USER_ATTRIBUTE_GENERATED,
AuthenticatedSession,
ExpiringModel, ExpiringModel,
User, User,
) )
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.lib.config import CONFIG
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
LOGGER = get_logger() LOGGER = get_logger()
@ -32,6 +38,40 @@ def clean_expired_models(self: SystemTask):
obj.expire_action() obj.expire_action()
LOGGER.debug("Expired models", model=cls, amount=amount) LOGGER.debug("Expired models", model=cls, amount=amount)
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}") messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
# Special case
amount = 0
for session in AuthenticatedSession.objects.all():
match CONFIG.get("session_storage", "cache"):
case "cache":
cache_key = f"{KEY_PREFIX}{session.session_key}"
value = None
try:
value = cache.get(cache_key)
except Exception as exc:
LOGGER.debug("Failed to get session from cache", exc=exc)
if not value:
session.delete()
amount += 1
case "db":
if not (
DBSessionStore.get_model_class()
.objects.filter(session_key=session.session_key, expire_date__gt=now())
.exists()
):
session.delete()
amount += 1
case _:
# Should never happen, as we check for other values in authentik/root/settings.py
raise ImproperlyConfigured(
"Invalid session_storage setting, allowed values are db and cache"
)
if CONFIG.get("session_storage", "cache") == "db":
DBSessionStore.clear_expired()
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
self.set_status(TaskStatus.SUCCESSFUL, *messages) self.set_status(TaskStatus.SUCCESSFUL, *messages)

View File

@ -133,6 +133,8 @@ class TestApplicationsAPI(APITestCase):
"provider_obj": { "provider_obj": {
"assigned_application_name": "allowed", "assigned_application_name": "allowed",
"assigned_application_slug": "allowed", "assigned_application_slug": "allowed",
"assigned_backchannel_application_name": "",
"assigned_backchannel_application_slug": "",
"authentication_flow": None, "authentication_flow": None,
"invalidation_flow": None, "invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk), "authorization_flow": str(self.provider.authorization_flow.pk),
@ -186,6 +188,8 @@ class TestApplicationsAPI(APITestCase):
"provider_obj": { "provider_obj": {
"assigned_application_name": "allowed", "assigned_application_name": "allowed",
"assigned_application_slug": "allowed", "assigned_application_slug": "allowed",
"assigned_backchannel_application_name": "",
"assigned_backchannel_application_slug": "",
"authentication_flow": None, "authentication_flow": None,
"invalidation_flow": None, "invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk), "authorization_flow": str(self.provider.authorization_flow.pk),

View File

@ -5,7 +5,7 @@ from json import loads
from django.urls.base import reverse from django.urls.base import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.models import AuthenticatedSession, Session, User from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
@ -30,18 +30,3 @@ class TestAuthenticatedSessionsAPI(APITestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
body = loads(response.content.decode()) body = loads(response.content.decode())
self.assertEqual(body["pagination"]["count"], 1) self.assertEqual(body["pagination"]["count"], 1)
def test_delete(self):
"""Test deletion"""
self.client.force_login(self.user)
self.assertEqual(AuthenticatedSession.objects.all().count(), 1)
self.assertEqual(Session.objects.all().count(), 1)
response = self.client.delete(
reverse(
"authentik_api:authenticatedsession-detail",
kwargs={"uuid": AuthenticatedSession.objects.first().uuid},
)
)
self.assertEqual(response.status_code, 204)
self.assertEqual(AuthenticatedSession.objects.all().count(), 0)
self.assertEqual(Session.objects.all().count(), 0)

View File

@ -3,7 +3,8 @@
from django.urls import reverse from django.urls import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.models import PropertyMapping from authentik.core.api.providers import ProviderSerializer
from authentik.core.models import Application, PropertyMapping, Provider
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
@ -24,3 +25,51 @@ class TestProvidersAPI(APITestCase):
reverse("authentik_api:provider-types"), reverse("authentik_api:provider-types"),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_provider_serializer_without_application(self):
"""Test that Provider serializer handles missing application gracefully"""
# Create a provider without an application
provider = Provider.objects.create(name="test-provider")
serializer = ProviderSerializer(instance=provider)
serialized_data = serializer.data
# Check that fields return empty strings when no application exists
self.assertEqual(serialized_data["assigned_application_slug"], "")
self.assertEqual(serialized_data["assigned_application_name"], "")
self.assertEqual(serialized_data["assigned_backchannel_application_slug"], "")
self.assertEqual(serialized_data["assigned_backchannel_application_name"], "")
def test_provider_serializer_with_application(self):
"""Test that Provider serializer correctly includes application data"""
# Create an application
app = Application.objects.create(name="Test App", slug="test-app")
# Create a provider with an application
provider = Provider.objects.create(name="test-provider-with-app")
app.provider = provider
app.save()
serializer = ProviderSerializer(instance=provider)
serialized_data = serializer.data
# Check that fields return correct values when application exists
self.assertEqual(serialized_data["assigned_application_slug"], "test-app")
self.assertEqual(serialized_data["assigned_application_name"], "Test App")
self.assertEqual(serialized_data["assigned_backchannel_application_slug"], "")
self.assertEqual(serialized_data["assigned_backchannel_application_name"], "")
def test_provider_api_response(self):
"""Test that the API response includes empty strings for missing applications"""
# Create a provider without an application
provider = Provider.objects.create(name="test-provider-api")
response = self.client.get(
reverse("authentik_api:provider-detail", kwargs={"pk": provider.pk}),
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["assigned_application_slug"], "")
self.assertEqual(response.data["assigned_application_name"], "")
self.assertEqual(response.data["assigned_backchannel_application_slug"], "")
self.assertEqual(response.data["assigned_backchannel_application_name"], "")

View File

@ -1,19 +0,0 @@
from django.apps import apps
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user
class TestSourceAPI(APITestCase):
def setUp(self) -> None:
self.user = create_test_admin_user()
self.client.force_login(self.user)
def test_builtin_source_used_by(self):
"""Test Providers's types endpoint"""
apps.get_app_config("authentik_core").source_inbuilt()
response = self.client.get(
reverse("authentik_api:source-used-by", kwargs={"slug": "authentik-built-in"}),
)
self.assertEqual(response.status_code, 200)

View File

@ -1,8 +1,9 @@
"""Test Users API""" """Test Users API"""
from datetime import datetime from datetime import datetime
from json import loads
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.urls.base import reverse from django.urls.base import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@ -10,17 +11,11 @@ from authentik.brands.models import Brand
from authentik.core.models import ( from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING, USER_ATTRIBUTE_TOKEN_EXPIRING,
AuthenticatedSession, AuthenticatedSession,
Session,
Token, Token,
User, User,
UserTypes, UserTypes,
) )
from authentik.core.tests.utils import ( from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
create_test_admin_user,
create_test_brand,
create_test_flow,
create_test_user,
)
from authentik.flows.models import FlowDesignation from authentik.flows.models import FlowDesignation
from authentik.lib.generators import generate_id, generate_key from authentik.lib.generators import generate_id, generate_key
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
@ -31,7 +26,7 @@ class TestUsersAPI(APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.admin = create_test_admin_user() self.admin = create_test_admin_user()
self.user = create_test_user() self.user = User.objects.create(username="test-user")
def test_filter_type(self): def test_filter_type(self):
"""Test API filtering by type""" """Test API filtering by type"""
@ -46,35 +41,6 @@ class TestUsersAPI(APITestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_filter_is_superuser(self):
"""Test API filtering by superuser status"""
User.objects.all().delete()
admin = create_test_admin_user()
self.client.force_login(admin)
# Test superuser
response = self.client.get(
reverse("authentik_api:user-list"),
data={
"is_superuser": True,
},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(len(body["results"]), 1)
self.assertEqual(body["results"][0]["username"], admin.username)
# Test non-superuser
user = create_test_user()
response = self.client.get(
reverse("authentik_api:user-list"),
data={
"is_superuser": False,
},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(len(body["results"]), 1, body)
self.assertEqual(body["results"][0]["username"], user.username)
def test_list_with_groups(self): def test_list_with_groups(self):
"""Test listing with groups""" """Test listing with groups"""
self.client.force_login(self.admin) self.client.force_login(self.admin)
@ -133,8 +99,6 @@ class TestUsersAPI(APITestCase):
def test_recovery_email_no_flow(self): def test_recovery_email_no_flow(self):
"""Test user recovery link (no recovery flow set)""" """Test user recovery link (no recovery flow set)"""
self.client.force_login(self.admin) self.client.force_login(self.admin)
self.user.email = ""
self.user.save()
response = self.client.post( response = self.client.post(
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk}) reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
) )
@ -380,15 +344,12 @@ class TestUsersAPI(APITestCase):
"""Ensure sessions are deleted when a user is deactivated""" """Ensure sessions are deleted when a user is deactivated"""
user = create_test_admin_user() user = create_test_admin_user()
session_id = generate_id() session_id = generate_id()
session = Session.objects.create(
session_key=session_id,
last_ip="255.255.255.255",
last_user_agent="",
)
AuthenticatedSession.objects.create( AuthenticatedSession.objects.create(
session=session,
user=user, user=user,
session_key=session_id,
last_ip="",
) )
cache.set(KEY_PREFIX + session_id, "foo")
self.client.force_login(self.admin) self.client.force_login(self.admin)
response = self.client.patch( response = self.client.patch(
@ -399,7 +360,5 @@ class TestUsersAPI(APITestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse(Session.objects.filter(session_key=session_id).exists()) self.assertIsNone(cache.get(KEY_PREFIX + session_id))
self.assertFalse( self.assertFalse(AuthenticatedSession.objects.filter(session_key=session_id).exists())
AuthenticatedSession.objects.filter(session__session_key=session_id).exists()
)

View File

@ -1,5 +1,7 @@
"""authentik URL Configuration""" """authentik URL Configuration"""
from channels.auth import AuthMiddleware
from channels.sessions import CookieMiddleware
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.urls import path from django.urls import path
@ -11,11 +13,7 @@ from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
from authentik.core.api.groups import GroupViewSet from authentik.core.api.groups import GroupViewSet
from authentik.core.api.property_mappings import PropertyMappingViewSet from authentik.core.api.property_mappings import PropertyMappingViewSet
from authentik.core.api.providers import ProviderViewSet from authentik.core.api.providers import ProviderViewSet
from authentik.core.api.sources import ( from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
GroupSourceConnectionViewSet,
SourceViewSet,
UserSourceConnectionViewSet,
)
from authentik.core.api.tokens import TokenViewSet from authentik.core.api.tokens import TokenViewSet
from authentik.core.api.transactional_applications import TransactionalApplicationView from authentik.core.api.transactional_applications import TransactionalApplicationView
from authentik.core.api.users import UserViewSet from authentik.core.api.users import UserViewSet
@ -27,7 +25,7 @@ from authentik.core.views.interface import (
RootRedirectView, RootRedirectView,
) )
from authentik.flows.views.interface import FlowInterfaceView from authentik.flows.views.interface import FlowInterfaceView
from authentik.root.asgi_middleware import AuthMiddlewareStack from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.messages.consumer import MessageConsumer from authentik.root.messages.consumer import MessageConsumer
from authentik.root.middleware import ChannelsLoggingMiddleware from authentik.root.middleware import ChannelsLoggingMiddleware
@ -83,7 +81,6 @@ api_urlpatterns = [
("core/tokens", TokenViewSet), ("core/tokens", TokenViewSet),
("sources/all", SourceViewSet), ("sources/all", SourceViewSet),
("sources/user_connections/all", UserSourceConnectionViewSet), ("sources/user_connections/all", UserSourceConnectionViewSet),
("sources/group_connections/all", GroupSourceConnectionViewSet),
("providers/all", ProviderViewSet), ("providers/all", ProviderViewSet),
("propertymappings/all", PropertyMappingViewSet), ("propertymappings/all", PropertyMappingViewSet),
("authenticators/all", DeviceViewSet, "device"), ("authenticators/all", DeviceViewSet, "device"),
@ -97,7 +94,9 @@ api_urlpatterns = [
websocket_urlpatterns = [ websocket_urlpatterns = [
path( path(
"ws/client/", "ws/client/",
ChannelsLoggingMiddleware(AuthMiddlewareStack(MessageConsumer.as_asgi())), ChannelsLoggingMiddleware(
CookieMiddleware(SessionMiddleware(AuthMiddleware(MessageConsumer.as_asgi())))
),
), ),
] ]

View File

@ -102,7 +102,7 @@ def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSessi
"format": "complex", "format": "complex",
"session": { "session": {
"format": "opaque", "format": "opaque",
"id": sha256(instance.session.session_key.encode("ascii")).hexdigest(), "id": sha256(instance.session_key.encode("ascii")).hexdigest(),
}, },
"user": { "user": {
"format": "email", "format": "email",

View File

@ -59,7 +59,7 @@ def get_login_event(request_or_session: HttpRequest | AuthenticatedSession | Non
session = request_or_session.session session = request_or_session.session
if isinstance(request_or_session, AuthenticatedSession): if isinstance(request_or_session, AuthenticatedSession):
SessionStore = _session_engine.SessionStore SessionStore = _session_engine.SessionStore
session = SessionStore(request_or_session.session.session_key) session = SessionStore(request_or_session.session_key)
return session.get(SESSION_LOGIN_EVENT, None) return session.get(SESSION_LOGIN_EVENT, None)

View File

@ -69,7 +69,6 @@ SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre"
SESSION_KEY_GET = "authentik/flows/get" SESSION_KEY_GET = "authentik/flows/get"
SESSION_KEY_POST = "authentik/flows/post" SESSION_KEY_POST = "authentik/flows/post"
SESSION_KEY_HISTORY = "authentik/flows/history" SESSION_KEY_HISTORY = "authentik/flows/history"
SESSION_KEY_AUTH_STARTED = "authentik/flows/auth_started"
QS_KEY_TOKEN = "flow_token" # nosec QS_KEY_TOKEN = "flow_token" # nosec
QS_QUERY = "query" QS_QUERY = "query"
@ -454,7 +453,6 @@ class FlowExecutorView(APIView):
SESSION_KEY_APPLICATION_PRE, SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_PLAN, SESSION_KEY_PLAN,
SESSION_KEY_GET, SESSION_KEY_GET,
SESSION_KEY_AUTH_STARTED,
# We might need the initial POST payloads for later requests # We might need the initial POST payloads for later requests
# SESSION_KEY_POST, # SESSION_KEY_POST,
# We don't delete the history on purpose, as a user might # We don't delete the history on purpose, as a user might

View File

@ -6,22 +6,14 @@ from django.shortcuts import get_object_or_404
from ua_parser.user_agent_parser import Parse from ua_parser.user_agent_parser import Parse
from authentik.core.views.interface import InterfaceView from authentik.core.views.interface import InterfaceView
from authentik.flows.models import Flow, FlowDesignation from authentik.flows.models import Flow
from authentik.flows.views.executor import SESSION_KEY_AUTH_STARTED
class FlowInterfaceView(InterfaceView): class FlowInterfaceView(InterfaceView):
"""Flow interface""" """Flow interface"""
def get_context_data(self, **kwargs: Any) -> dict[str, Any]: def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["flow"] = flow
if (
not self.request.user.is_authenticated
and flow.designation == FlowDesignation.AUTHENTICATION
):
self.request.session[SESSION_KEY_AUTH_STARTED] = True
self.request.session.save()
kwargs["inspector"] = "inspector" in self.request.GET kwargs["inspector"] = "inspector" in self.request.GET
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)

View File

@ -18,7 +18,7 @@ from sentry_sdk import start_span
from sentry_sdk.tracing import Span from sentry_sdk.tracing import Span
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import User from authentik.core.models import AuthenticatedSession, User
from authentik.events.models import Event from authentik.events.models import Event
from authentik.lib.expression.exceptions import ControlFlowException from authentik.lib.expression.exceptions import ControlFlowException
from authentik.lib.utils.http import get_http_session from authentik.lib.utils.http import get_http_session
@ -203,7 +203,9 @@ class BaseEvaluator:
provider = OAuth2Provider.objects.get(name=provider) provider = OAuth2Provider.objects.get(name=provider)
session = None session = None
if hasattr(request, "session") and request.session.session_key: if hasattr(request, "session") and request.session.session_key:
session = request.session["authenticatedsession"] session = AuthenticatedSession.objects.filter(
session_key=request.session.session_key
).first()
access_token = AccessToken( access_token = AccessToken(
provider=provider, provider=provider,
user=user, user=user,

View File

@ -18,15 +18,6 @@ class SerializerModel(models.Model):
@property @property
def serializer(self) -> type[BaseSerializer]: def serializer(self) -> type[BaseSerializer]:
"""Get serializer for this model""" """Get serializer for this model"""
# Special handling for built-in source
if (
hasattr(self, "managed")
and hasattr(self, "MANAGED_INBUILT")
and self.managed == self.MANAGED_INBUILT
):
from authentik.core.api.sources import SourceSerializer
return SourceSerializer
raise NotImplementedError raise NotImplementedError

View File

@ -35,4 +35,3 @@ class AuthentikPoliciesConfig(ManagedAppConfig):
label = "authentik_policies" label = "authentik_policies"
verbose_name = "authentik Policies" verbose_name = "authentik Policies"
default = True default = True
mountpoint = "policy/"

View File

@ -2,6 +2,7 @@
from django.contrib.auth.signals import user_logged_in from django.contrib.auth.signals import user_logged_in
from django.db import transaction from django.db import transaction
from django.db.models import F
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest from django.http import HttpRequest
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
@ -12,29 +13,20 @@ from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR
from authentik.policies.reputation.models import Reputation, reputation_expiry from authentik.policies.reputation.models import Reputation, reputation_expiry
from authentik.root.middleware import ClientIPMiddleware from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.identification.signals import identification_failed from authentik.stages.identification.signals import identification_failed
from authentik.tenants.utils import get_current_tenant
LOGGER = get_logger() LOGGER = get_logger()
def clamp(value, min, max):
return sorted([min, value, max])[1]
def update_score(request: HttpRequest, identifier: str, amount: int): def update_score(request: HttpRequest, identifier: str, amount: int):
"""Update score for IP and User""" """Update score for IP and User"""
remote_ip = ClientIPMiddleware.get_client_ip(request) remote_ip = ClientIPMiddleware.get_client_ip(request)
tenant = get_current_tenant()
new_score = clamp(amount, tenant.reputation_lower_limit, tenant.reputation_upper_limit)
with transaction.atomic(): with transaction.atomic():
reputation, created = Reputation.objects.select_for_update().get_or_create( reputation, created = Reputation.objects.select_for_update().get_or_create(
ip=remote_ip, ip=remote_ip,
identifier=identifier, identifier=identifier,
defaults={ defaults={
"score": clamp( "score": amount,
amount, tenant.reputation_lower_limit, tenant.reputation_upper_limit
),
"ip_geo_data": GEOIP_CONTEXT_PROCESSOR.city_dict(remote_ip) or {}, "ip_geo_data": GEOIP_CONTEXT_PROCESSOR.city_dict(remote_ip) or {},
"ip_asn_data": ASN_CONTEXT_PROCESSOR.asn_dict(remote_ip) or {}, "ip_asn_data": ASN_CONTEXT_PROCESSOR.asn_dict(remote_ip) or {},
"expires": reputation_expiry(), "expires": reputation_expiry(),
@ -42,15 +34,9 @@ def update_score(request: HttpRequest, identifier: str, amount: int):
) )
if not created: if not created:
new_score = clamp( reputation.score = F("score") + amount
reputation.score + amount,
tenant.reputation_lower_limit,
tenant.reputation_upper_limit,
)
reputation.score = new_score
reputation.save() reputation.save()
LOGGER.info("Updated score", amount=amount, for_user=identifier, for_ip=remote_ip)
LOGGER.info("Updated score", amount=new_score, for_user=identifier, for_ip=remote_ip)
@receiver(login_failed) @receiver(login_failed)

View File

@ -6,11 +6,9 @@ from authentik.core.models import User
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.policies.reputation.api import ReputationPolicySerializer from authentik.policies.reputation.api import ReputationPolicySerializer
from authentik.policies.reputation.models import Reputation, ReputationPolicy from authentik.policies.reputation.models import Reputation, ReputationPolicy
from authentik.policies.reputation.signals import update_score
from authentik.policies.types import PolicyRequest from authentik.policies.types import PolicyRequest
from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.stage import authenticate from authentik.stages.password.stage import authenticate
from authentik.tenants.models import DEFAULT_REPUTATION_LOWER_LIMIT, DEFAULT_REPUTATION_UPPER_LIMIT
class TestReputationPolicy(TestCase): class TestReputationPolicy(TestCase):
@ -19,48 +17,36 @@ class TestReputationPolicy(TestCase):
def setUp(self): def setUp(self):
self.request_factory = RequestFactory() self.request_factory = RequestFactory()
self.request = self.request_factory.get("/") self.request = self.request_factory.get("/")
self.ip = "127.0.0.1" self.test_ip = "127.0.0.1"
self.username = "username" self.test_username = "test"
self.password = generate_id()
# We need a user for the one-to-one in userreputation # We need a user for the one-to-one in userreputation
self.user = User.objects.create(username=self.username) self.user = User.objects.create(username=self.test_username)
self.user.set_password(self.password)
self.backends = [BACKEND_INBUILT] self.backends = [BACKEND_INBUILT]
def test_ip_reputation(self): def test_ip_reputation(self):
"""test IP reputation""" """test IP reputation"""
# Trigger negative reputation # Trigger negative reputation
authenticate(self.request, self.backends, username=self.username, password=self.username) authenticate(
self.assertEqual(Reputation.objects.get(ip=self.ip).score, -1) self.request, self.backends, username=self.test_username, password=self.test_username
)
self.assertEqual(Reputation.objects.get(ip=self.test_ip).score, -1)
def test_user_reputation(self): def test_user_reputation(self):
"""test User reputation""" """test User reputation"""
# Trigger negative reputation # Trigger negative reputation
authenticate(self.request, self.backends, username=self.username, password=self.username) authenticate(
self.assertEqual(Reputation.objects.get(identifier=self.username).score, -1) self.request, self.backends, username=self.test_username, password=self.test_username
)
self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, -1)
def test_update_reputation(self): def test_update_reputation(self):
"""test reputation update""" """test reputation update"""
Reputation.objects.create(identifier=self.username, ip=self.ip, score=4) Reputation.objects.create(identifier=self.test_username, ip=self.test_ip, score=43)
# Trigger negative reputation # Trigger negative reputation
authenticate(self.request, self.backends, username=self.username, password=self.username) authenticate(
self.assertEqual(Reputation.objects.get(identifier=self.username).score, 3) self.request, self.backends, username=self.test_username, password=self.test_username
def test_reputation_lower_limit(self):
"""test reputation lower limit"""
Reputation.objects.create(identifier=self.username, ip=self.ip)
update_score(self.request, identifier=self.username, amount=-1000)
self.assertEqual(
Reputation.objects.get(identifier=self.username).score, DEFAULT_REPUTATION_LOWER_LIMIT
)
def test_reputation_upper_limit(self):
"""test reputation upper limit"""
Reputation.objects.create(identifier=self.username, ip=self.ip)
update_score(self.request, identifier=self.username, amount=1000)
self.assertEqual(
Reputation.objects.get(identifier=self.username).score, DEFAULT_REPUTATION_UPPER_LIMIT
) )
self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, 42)
def test_policy(self): def test_policy(self):
"""Test Policy""" """Test Policy"""

View File

@ -1,89 +0,0 @@
{% extends 'login/base_full.html' %}
{% load static %}
{% load i18n %}
{% block head %}
{{ block.super }}
<script>
let redirecting = false;
const checkAuth = async () => {
if (redirecting) return true;
const url = "{{ check_auth_url }}";
console.debug("authentik/policies/buffer: Checking authentication...");
try {
const result = await fetch(url, {
method: "HEAD",
});
if (result.status >= 400) {
return false
}
console.debug("authentik/policies/buffer: Continuing");
redirecting = true;
if ("{{ auth_req_method }}" === "post") {
document.querySelector("form").submit();
} else {
window.location.assign("{{ continue_url|escapejs }}");
}
} catch {
return false;
}
};
let timeout = 100;
let offset = 20;
let attempt = 0;
const main = async () => {
attempt += 1;
await checkAuth();
console.debug(`authentik/policies/buffer: Waiting ${timeout}ms...`);
setTimeout(main, timeout);
timeout += (offset * attempt);
if (timeout >= 2000) {
timeout = 2000;
}
}
document.addEventListener("visibilitychange", async () => {
if (document.hidden) return;
console.debug("authentik/policies/buffer: Checking authentication on tab activate...");
await checkAuth();
});
main();
</script>
{% endblock %}
{% block title %}
{% trans 'Waiting for authentication...' %} - {{ brand.branding_title }}
{% endblock %}
{% block card_title %}
{% trans 'Waiting for authentication...' %}
{% endblock %}
{% block card %}
<form class="pf-c-form" method="{{ auth_req_method }}" action="{{ continue_url }}">
{% if auth_req_method == "post" %}
{% for key, value in auth_req_body.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}" />
{% endfor %}
{% endif %}
<div class="pf-c-empty-state">
<div class="pf-c-empty-state__content">
<div class="pf-c-empty-state__icon">
<span class="pf-c-spinner pf-m-xl" role="progressbar">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div>
<h1 class="pf-c-title pf-m-lg">
{% trans "You're already authenticating in another tab. This page will refresh once authentication is completed." %}
</h1>
</div>
</div>
<div class="pf-c-form__group pf-m-action">
<a href="{{ auth_req_url }}" class="pf-c-button pf-m-primary pf-m-block">
{% trans "Authenticate in this tab" %}
</a>
</div>
</form>
{% endblock %}

View File

@ -1,121 +0,0 @@
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.http import HttpResponse
from django.test import RequestFactory, TestCase
from django.urls import reverse
from authentik.core.models import Application, Provider
from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.flows.models import FlowDesignation
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import dummy_get_response
from authentik.policies.views import (
QS_BUFFER_ID,
SESSION_KEY_BUFFER,
BufferedPolicyAccessView,
BufferView,
PolicyAccessView,
)
class TestPolicyViews(TestCase):
"""Test PolicyAccessView"""
def setUp(self):
super().setUp()
self.factory = RequestFactory()
self.user = create_test_user()
def test_pav(self):
"""Test simple policy access view"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
class TestView(PolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/")
req.user = self.user
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.content, b"foo")
def test_pav_buffer(self):
"""Test simple policy access view"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
class TestView(BufferedPolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
req.session.save()
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 302)
self.assertTrue(res.url.startswith(reverse("authentik_policies:buffer")))
def test_pav_buffer_skip(self):
"""Test simple policy access view (skip buffer)"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
class TestView(BufferedPolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/?skip_buffer=true")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
req.session.save()
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 302)
self.assertTrue(res.url.startswith(reverse("authentik_flows:default-authentication")))
def test_buffer(self):
"""Test buffer view"""
uid = generate_id()
req = self.factory.get(f"/?{QS_BUFFER_ID}={uid}")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
ts = generate_id()
req.session[SESSION_KEY_BUFFER % uid] = {
"method": "get",
"body": {},
"url": f"/{ts}",
}
req.session.save()
res = BufferView.as_view()(req)
self.assertEqual(res.status_code, 200)
self.assertIn(ts, res.render().content.decode())

View File

@ -1,14 +1,7 @@
"""API URLs""" """API URLs"""
from django.urls import path
from authentik.policies.api.bindings import PolicyBindingViewSet from authentik.policies.api.bindings import PolicyBindingViewSet
from authentik.policies.api.policies import PolicyViewSet from authentik.policies.api.policies import PolicyViewSet
from authentik.policies.views import BufferView
urlpatterns = [
path("buffer", BufferView.as_view(), name="buffer"),
]
api_urlpatterns = [ api_urlpatterns = [
("policies/all", PolicyViewSet), ("policies/all", PolicyViewSet),

View File

@ -1,37 +1,23 @@
"""authentik access helper classes""" """authentik access helper classes"""
from typing import Any from typing import Any
from uuid import uuid4
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin from django.contrib.auth.mixins import AccessMixin
from django.contrib.auth.views import redirect_to_login from django.contrib.auth.views import redirect_to_login
from django.http import HttpRequest, HttpResponse, QueryDict from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import urlencode
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic.base import TemplateView, View from django.views.generic.base import View
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import Application, Provider, User from authentik.core.models import Application, Provider, User
from authentik.flows.models import Flow, FlowDesignation from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_POST
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import (
SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_AUTH_STARTED,
SESSION_KEY_PLAN,
SESSION_KEY_POST,
)
from authentik.lib.sentry import SentryIgnoredException from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.denied import AccessDeniedResponse from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyRequest, PolicyResult from authentik.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger() LOGGER = get_logger()
QS_BUFFER_ID = "af_bf_id"
QS_SKIP_BUFFER = "skip_buffer"
SESSION_KEY_BUFFER = "authentik/policies/pav_buffer/%s"
class RequestValidationError(SentryIgnoredException): class RequestValidationError(SentryIgnoredException):
@ -139,65 +125,3 @@ class PolicyAccessView(AccessMixin, View):
for message in result.messages: for message in result.messages:
messages.error(self.request, _(message)) messages.error(self.request, _(message))
return result return result
def url_with_qs(url: str, **kwargs):
"""Update/set querystring of `url` with the parameters in `kwargs`. Original query string
parameters are retained"""
if "?" not in url:
return url + f"?{urlencode(kwargs)}"
url, _, qs = url.partition("?")
qs = QueryDict(qs, mutable=True)
qs.update(kwargs)
return url + f"?{urlencode(qs.items())}"
class BufferView(TemplateView):
"""Buffer view"""
template_name = "policies/buffer.html"
def get_context_data(self, **kwargs):
buf_id = self.request.GET.get(QS_BUFFER_ID)
buffer: dict = self.request.session.get(SESSION_KEY_BUFFER % buf_id)
kwargs["auth_req_method"] = buffer["method"]
kwargs["auth_req_body"] = buffer["body"]
kwargs["auth_req_url"] = url_with_qs(buffer["url"], **{QS_SKIP_BUFFER: True})
kwargs["check_auth_url"] = reverse("authentik_api:user-me")
kwargs["continue_url"] = url_with_qs(buffer["url"], **{QS_BUFFER_ID: buf_id})
return super().get_context_data(**kwargs)
class BufferedPolicyAccessView(PolicyAccessView):
"""PolicyAccessView which buffers access requests in case the user is not logged in"""
def handle_no_permission(self):
plan: FlowPlan | None = self.request.session.get(SESSION_KEY_PLAN)
authenticating = self.request.session.get(SESSION_KEY_AUTH_STARTED)
if plan:
flow = Flow.objects.filter(pk=plan.flow_pk).first()
if not flow or flow.designation != FlowDesignation.AUTHENTICATION:
LOGGER.debug("Not buffering request, no flow or flow not for authentication")
return super().handle_no_permission()
if not plan and authenticating is None:
LOGGER.debug("Not buffering request, no flow plan active")
return super().handle_no_permission()
if self.request.GET.get(QS_SKIP_BUFFER):
LOGGER.debug("Not buffering request, explicit skip")
return super().handle_no_permission()
buffer_id = str(uuid4())
LOGGER.debug("Buffering access request", bf_id=buffer_id)
self.request.session[SESSION_KEY_BUFFER % buffer_id] = {
"body": self.request.POST,
"url": self.request.build_absolute_uri(self.request.get_full_path()),
"method": self.request.method.lower(),
}
return redirect(
url_with_qs(reverse("authentik_policies:buffer"), **{QS_BUFFER_ID: buffer_id})
)
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
if QS_BUFFER_ID in self.request.GET:
self.request.session.pop(SESSION_KEY_BUFFER % self.request.GET[QS_BUFFER_ID], None)
return response

View File

@ -126,7 +126,7 @@ class IDToken:
id_token.iat = int(now.timestamp()) id_token.iat = int(now.timestamp())
id_token.auth_time = int(token.auth_time.timestamp()) id_token.auth_time = int(token.auth_time.timestamp())
if token.session: if token.session:
id_token.sid = hash_session_key(token.session.session.session_key) id_token.sid = hash_session_key(token.session.session_key)
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time # We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
auth_event = get_login_event(token.session) auth_event = get_login_event(token.session)

View File

@ -1,116 +0,0 @@
# Generated by Django 5.0.11 on 2025-01-27 13:00
from django.db import migrations
import django.db.models.deletion
from django.db import migrations, models
from functools import partial
def migrate_sessions(apps, schema_editor, model):
Model = apps.get_model("authentik_providers_oauth2", model)
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
db_alias = schema_editor.connection.alias
for obj in Model.objects.using(db_alias).all():
if not obj.old_session:
continue
obj.session = (
AuthenticatedSession.objects.using(db_alias)
.filter(session__session_key=obj.old_session.session_key)
.first()
)
if obj.session:
obj.save()
else:
obj.delete()
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_oauth2", "0027_accesstoken_authentik_p_expires_9f24a5_idx_and_more"),
("authentik_core", "0046_session_and_more"),
]
operations = [
migrations.RenameField(
model_name="accesstoken",
old_name="session",
new_name="old_session",
),
migrations.RenameField(
model_name="authorizationcode",
old_name="session",
new_name="old_session",
),
migrations.RenameField(
model_name="devicetoken",
old_name="session",
new_name="old_session",
),
migrations.RenameField(
model_name="refreshtoken",
old_name="session",
new_name="old_session",
),
migrations.AddField(
model_name="accesstoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="authorizationcode",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="devicetoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="refreshtoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.authenticatedsession",
),
),
migrations.RunPython(code=partial(migrate_sessions, model="AccessToken")),
migrations.RunPython(code=partial(migrate_sessions, model="AuthorizationCode")),
migrations.RunPython(code=partial(migrate_sessions, model="DeviceToken")),
migrations.RunPython(code=partial(migrate_sessions, model="RefreshToken")),
migrations.RemoveField(
model_name="accesstoken",
name="old_session",
),
migrations.RemoveField(
model_name="authorizationcode",
name="old_session",
),
migrations.RemoveField(
model_name="devicetoken",
name="old_session",
),
migrations.RemoveField(
model_name="refreshtoken",
name="old_session",
),
]

View File

@ -1,30 +1,18 @@
from django.contrib.auth.signals import user_logged_out from django.contrib.auth.signals import user_logged_out
from django.db.models.signals import post_save, pre_delete from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest from django.http import HttpRequest
from authentik.core.models import AuthenticatedSession, User from authentik.core.models import User
from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken
@receiver(user_logged_out) @receiver(user_logged_out)
def user_logged_out_oauth_tokens_removal(sender, request: HttpRequest, user: User, **_): def user_logged_out_oauth_access_token(sender, request: HttpRequest, user: User, **_):
"""Revoke tokens upon user logout""" """Revoke access tokens upon user logout"""
if not request.session or not request.session.session_key: if not request.session or not request.session.session_key:
return return
AccessToken.objects.filter( AccessToken.objects.filter(user=user, session__session_key=request.session.session_key).delete()
user=user,
session__session__session_key=request.session.session_key,
).delete()
@receiver(pre_delete, sender=AuthenticatedSession)
def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_):
"""Revoke tokens upon user logout"""
AccessToken.objects.filter(
user=instance.user,
session__session__session_key=instance.session.session_key,
).delete()
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
@ -32,6 +20,6 @@ def user_deactivated(sender, instance: User, **_):
"""Remove user tokens when deactivated""" """Remove user tokens when deactivated"""
if instance.is_active: if instance.is_active:
return return
AccessToken.objects.filter(user=instance).delete() AccessToken.objects.filter(session__user=instance).delete()
RefreshToken.objects.filter(user=instance).delete() RefreshToken.objects.filter(session__user=instance).delete()
DeviceToken.objects.filter(user=instance).delete() DeviceToken.objects.filter(session__user=instance).delete()

View File

@ -7,13 +7,12 @@ from dataclasses import asdict
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from authentik.core.models import Application, AuthenticatedSession, Session from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import ( from authentik.providers.oauth2.models import (
AccessToken, AccessToken,
ClientTypes, ClientTypes,
DeviceToken,
IDToken, IDToken,
OAuth2Provider, OAuth2Provider,
RedirectURI, RedirectURI,
@ -21,7 +20,6 @@ from authentik.providers.oauth2.models import (
RefreshToken, RefreshToken,
) )
from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.tests.utils import OAuthTestCase
from authentik.root.middleware import ClientIPMiddleware
class TesOAuth2Revoke(OAuthTestCase): class TesOAuth2Revoke(OAuthTestCase):
@ -137,86 +135,3 @@ class TesOAuth2Revoke(OAuthTestCase):
}, },
) )
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
def test_revoke_logout(self):
"""Test revoke on logout"""
self.client.force_login(self.user)
AccessToken.objects.create(
provider=self.provider,
user=self.user,
session=self.client.session["authenticatedsession"],
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
self.client.logout()
self.assertEqual(AccessToken.objects.all().count(), 0)
def test_revoke_session_delete(self):
"""Test revoke on logout"""
session = AuthenticatedSession.objects.create(
session=Session.objects.create(
session_key=generate_id(),
last_ip=ClientIPMiddleware.default_ip,
),
user=self.user,
)
AccessToken.objects.create(
provider=self.provider,
user=self.user,
session=session,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
session.delete()
self.assertEqual(AccessToken.objects.all().count(), 0)
def test_revoke_user_deactivated(self):
"""Test revoke on logout"""
AccessToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
RefreshToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
DeviceToken.objects.create(
provider=self.provider,
user=self.user,
_scope="openid user profile",
)
self.user.is_active = False
self.user.save()
self.assertEqual(AccessToken.objects.all().count(), 0)
self.assertEqual(RefreshToken.objects.all().count(), 0)
self.assertEqual(DeviceToken.objects.all().count(), 0)

View File

@ -15,7 +15,7 @@ from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import Application from authentik.core.models import Application, AuthenticatedSession
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.events.signals import get_login_event from authentik.events.signals import get_login_event
from authentik.flows.challenge import ( from authentik.flows.challenge import (
@ -30,7 +30,7 @@ from authentik.flows.stage import StageView
from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.time import timedelta_from_string
from authentik.lib.views import bad_request_message from authentik.lib.views import bad_request_message
from authentik.policies.types import PolicyRequest from authentik.policies.types import PolicyRequest
from authentik.policies.views import BufferedPolicyAccessView, RequestValidationError from authentik.policies.views import PolicyAccessView, RequestValidationError
from authentik.providers.oauth2.constants import ( from authentik.providers.oauth2.constants import (
PKCE_METHOD_PLAIN, PKCE_METHOD_PLAIN,
PKCE_METHOD_S256, PKCE_METHOD_S256,
@ -316,7 +316,9 @@ class OAuthAuthorizationParams:
expires=now + timedelta_from_string(self.provider.access_code_validity), expires=now + timedelta_from_string(self.provider.access_code_validity),
scope=self.scope, scope=self.scope,
nonce=self.nonce, nonce=self.nonce,
session=request.session["authenticatedsession"], session=AuthenticatedSession.objects.filter(
session_key=request.session.session_key
).first(),
) )
if self.code_challenge and self.code_challenge_method: if self.code_challenge and self.code_challenge_method:
@ -326,7 +328,7 @@ class OAuthAuthorizationParams:
return code return code
class AuthorizationFlowInitView(BufferedPolicyAccessView): class AuthorizationFlowInitView(PolicyAccessView):
"""OAuth2 Flow initializer, checks access to application and starts flow""" """OAuth2 Flow initializer, checks access to application and starts flow"""
params: OAuthAuthorizationParams params: OAuthAuthorizationParams
@ -613,7 +615,9 @@ class OAuthFulfillmentStage(StageView):
expires=access_token_expiry, expires=access_token_expiry,
provider=self.provider, provider=self.provider,
auth_time=auth_event.created if auth_event else now, auth_time=auth_event.created if auth_event else now,
session=self.request.session["authenticatedsession"], session=AuthenticatedSession.objects.filter(
session_key=self.request.session.session_key
).first(),
) )
id_token = IDToken.new(self.provider, token, self.request) id_token = IDToken.new(self.provider, token, self.request)

View File

@ -20,4 +20,4 @@ def logout_proxy_revoke_direct(sender: type[User], request: HttpRequest, **_):
@receiver(pre_delete, sender=AuthenticatedSession) @receiver(pre_delete, sender=AuthenticatedSession)
def logout_proxy_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_): def logout_proxy_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
"""Catch logout by expiring sessions being deleted""" """Catch logout by expiring sessions being deleted"""
proxy_on_logout.delay(instance.session.session_key) proxy_on_logout.delay(instance.session_key)

View File

@ -1,60 +0,0 @@
# Generated by Django 5.0.11 on 2025-01-27 12:59
from django.db import migrations
import django.db.models.deletion
from django.db import migrations, models
def migrate_sessions(apps, schema_editor):
ConnectionToken = apps.get_model("authentik_providers_rac", "ConnectionToken")
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
db_alias = schema_editor.connection.alias
for token in ConnectionToken.objects.using(db_alias).all():
token.session = (
AuthenticatedSession.objects.using(db_alias)
.filter(session_key=token.old_session.session_key)
.first()
)
if token.session:
token.save()
else:
token.delete()
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_rac", "0006_connectiontoken_authentik_p_expires_91f148_idx_and_more"),
("authentik_core", "0046_session_and_more"),
]
operations = [
migrations.RenameField(
model_name="connectiontoken",
old_name="session",
new_name="old_session",
),
migrations.AddField(
model_name="connectiontoken",
name="session",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
migrations.RunPython(code=migrate_sessions),
migrations.AlterField(
model_name="connectiontoken",
name="session",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
migrations.RemoveField(
model_name="connectiontoken",
name="old_session",
),
]

View File

@ -8,7 +8,7 @@ from django.db.models.signals import post_delete, post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest from django.http import HttpRequest
from authentik.core.models import AuthenticatedSession, User from authentik.core.models import User
from authentik.providers.rac.api.endpoints import user_endpoint_cache_key from authentik.providers.rac.api.endpoints import user_endpoint_cache_key
from authentik.providers.rac.consumer_client import ( from authentik.providers.rac.consumer_client import (
RAC_CLIENT_GROUP_SESSION, RAC_CLIENT_GROUP_SESSION,
@ -32,18 +32,6 @@ def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
) )
@receiver(pre_delete, sender=AuthenticatedSession)
def user_session_deleted(sender, instance: AuthenticatedSession, **_):
layer = get_channel_layer()
async_to_sync(layer.group_send)(
RAC_CLIENT_GROUP_SESSION
% {
"session": instance.session.session_key,
},
{"type": "event.disconnect", "reason": "session_logout"},
)
@receiver(pre_delete, sender=ConnectionToken) @receiver(pre_delete, sender=ConnectionToken)
def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **_): def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **_):
"""Disconnect session when connection token is deleted""" """Disconnect session when connection token is deleted"""

View File

@ -74,6 +74,8 @@ class TestEndpointsAPI(APITestCase):
"component": "ak-provider-rac-form", "component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug, "assigned_application_slug": self.app.slug,
"assigned_application_name": self.app.name, "assigned_application_name": self.app.name,
"assigned_backchannel_application_slug": "",
"assigned_backchannel_application_name": "",
"verbose_name": "RAC Provider", "verbose_name": "RAC Provider",
"verbose_name_plural": "RAC Providers", "verbose_name_plural": "RAC Providers",
"meta_model_name": "authentik_providers_rac.racprovider", "meta_model_name": "authentik_providers_rac.racprovider",
@ -124,6 +126,8 @@ class TestEndpointsAPI(APITestCase):
"component": "ak-provider-rac-form", "component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug, "assigned_application_slug": self.app.slug,
"assigned_application_name": self.app.name, "assigned_application_name": self.app.name,
"assigned_backchannel_application_slug": "",
"assigned_backchannel_application_name": "",
"connection_expiry": "hours=8", "connection_expiry": "hours=8",
"delete_token_on_disconnect": False, "delete_token_on_disconnect": False,
"verbose_name": "RAC Provider", "verbose_name": "RAC Provider",
@ -153,6 +157,8 @@ class TestEndpointsAPI(APITestCase):
"component": "ak-provider-rac-form", "component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug, "assigned_application_slug": self.app.slug,
"assigned_application_name": self.app.name, "assigned_application_name": self.app.name,
"assigned_backchannel_application_slug": "",
"assigned_backchannel_application_name": "",
"connection_expiry": "hours=8", "connection_expiry": "hours=8",
"delete_token_on_disconnect": False, "delete_token_on_disconnect": False,
"verbose_name": "RAC Provider", "verbose_name": "RAC Provider",

View File

@ -2,7 +2,7 @@
from django.test import TransactionTestCase from django.test import TransactionTestCase
from authentik.core.models import Application, AuthenticatedSession, Session from authentik.core.models import Application, AuthenticatedSession
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.providers.rac.models import ( from authentik.providers.rac.models import (
@ -36,15 +36,13 @@ class TestModels(TransactionTestCase):
def test_settings_merge(self): def test_settings_merge(self):
"""Test settings merge""" """Test settings merge"""
session = Session.objects.create(
session_key=generate_id(),
last_ip="255.255.255.255",
)
auth_session = AuthenticatedSession.objects.create(session=session, user=self.user)
token = ConnectionToken.objects.create( token = ConnectionToken.objects.create(
provider=self.provider, provider=self.provider,
endpoint=self.endpoint, endpoint=self.endpoint,
session=auth_session, session=AuthenticatedSession.objects.create(
user=self.user,
session_key=generate_id(),
),
) )
path = f"/tmp/connection/{token.token}" # nosec path = f"/tmp/connection/{token.token}" # nosec
self.assertEqual( self.assertEqual(

View File

@ -1,5 +1,7 @@
"""rac urls""" """rac urls"""
from channels.auth import AuthMiddleware
from channels.sessions import CookieMiddleware
from django.urls import path from django.urls import path
from authentik.outposts.channels import TokenOutpostMiddleware from authentik.outposts.channels import TokenOutpostMiddleware
@ -10,7 +12,7 @@ from authentik.providers.rac.api.providers import RACProviderViewSet
from authentik.providers.rac.consumer_client import RACClientConsumer from authentik.providers.rac.consumer_client import RACClientConsumer
from authentik.providers.rac.consumer_outpost import RACOutpostConsumer from authentik.providers.rac.consumer_outpost import RACOutpostConsumer
from authentik.providers.rac.views import RACInterface, RACStartView from authentik.providers.rac.views import RACInterface, RACStartView
from authentik.root.asgi_middleware import AuthMiddlewareStack from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.middleware import ChannelsLoggingMiddleware from authentik.root.middleware import ChannelsLoggingMiddleware
urlpatterns = [ urlpatterns = [
@ -29,7 +31,9 @@ urlpatterns = [
websocket_urlpatterns = [ websocket_urlpatterns = [
path( path(
"ws/rac/<str:token>/", "ws/rac/<str:token>/",
ChannelsLoggingMiddleware(AuthMiddlewareStack(RACClientConsumer.as_asgi())), ChannelsLoggingMiddleware(
CookieMiddleware(SessionMiddleware(AuthMiddleware(RACClientConsumer.as_asgi())))
),
), ),
path( path(
"ws/outpost_rac/<str:channel>/", "ws/outpost_rac/<str:channel>/",

View File

@ -8,7 +8,7 @@ from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from authentik.core.models import Application from authentik.core.models import Application, AuthenticatedSession
from authentik.core.views.interface import InterfaceView from authentik.core.views.interface import InterfaceView
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.challenge import RedirectChallenge from authentik.flows.challenge import RedirectChallenge
@ -18,11 +18,11 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import RedirectStage from authentik.flows.stage import RedirectStage
from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
from authentik.policies.views import BufferedPolicyAccessView from authentik.policies.views import PolicyAccessView
from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider
class RACStartView(BufferedPolicyAccessView): class RACStartView(PolicyAccessView):
"""Start a RAC connection by checking access and creating a connection token""" """Start a RAC connection by checking access and creating a connection token"""
endpoint: Endpoint endpoint: Endpoint
@ -113,7 +113,9 @@ class RACFinalStage(RedirectStage):
provider=self.provider, provider=self.provider,
endpoint=self.endpoint, endpoint=self.endpoint,
settings=self.executor.plan.context.get("connection_settings", {}), settings=self.executor.plan.context.get("connection_settings", {}),
session=self.request.session["authenticatedsession"], session=AuthenticatedSession.objects.filter(
session_key=self.request.session.session_key
).first(),
expires=now() + timedelta_from_string(self.provider.connection_expiry), expires=now() + timedelta_from_string(self.provider.connection_expiry),
expiring=True, expiring=True,
) )

View File

@ -1,22 +0,0 @@
# Generated by Django 5.0.13 on 2025-03-31 13:50
import authentik.lib.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_saml", "0017_samlprovider_authn_context_class_ref_mapping"),
]
operations = [
migrations.AlterField(
model_name="samlprovider",
name="acs_url",
field=models.TextField(
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
verbose_name="ACS URL",
),
),
]

View File

@ -10,7 +10,6 @@ from structlog.stdlib import get_logger
from authentik.core.api.object_types import CreatableType from authentik.core.api.object_types import CreatableType
from authentik.core.models import PropertyMapping, Provider from authentik.core.models import PropertyMapping, Provider
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.lib.models import DomainlessURLValidator
from authentik.lib.utils.time import timedelta_string_validator from authentik.lib.utils.time import timedelta_string_validator
from authentik.sources.saml.processors.constants import ( from authentik.sources.saml.processors.constants import (
DSA_SHA1, DSA_SHA1,
@ -41,9 +40,7 @@ class SAMLBindings(models.TextChoices):
class SAMLProvider(Provider): class SAMLProvider(Provider):
"""SAML 2.0 Endpoint for applications which support SAML.""" """SAML 2.0 Endpoint for applications which support SAML."""
acs_url = models.TextField( acs_url = models.URLField(verbose_name=_("ACS URL"))
validators=[DomainlessURLValidator(schemes=("http", "https"))], verbose_name=_("ACS URL")
)
audience = models.TextField( audience = models.TextField(
default="", default="",
blank=True, blank=True,

View File

@ -15,7 +15,7 @@ from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.views.executor import SESSION_KEY_POST from authentik.flows.views.executor import SESSION_KEY_POST
from authentik.lib.views import bad_request_message from authentik.lib.views import bad_request_message
from authentik.policies.views import BufferedPolicyAccessView from authentik.policies.views import PolicyAccessView
from authentik.providers.saml.exceptions import CannotHandleAssertion from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLBindings, SAMLProvider from authentik.providers.saml.models import SAMLBindings, SAMLProvider
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
@ -35,7 +35,7 @@ from authentik.stages.consent.stage import (
LOGGER = get_logger() LOGGER = get_logger()
class SAMLSSOView(BufferedPolicyAccessView): class SAMLSSOView(PolicyAccessView):
"""SAML SSO Base View, which plans a flow and injects our final stage. """SAML SSO Base View, which plans a flow and injects our final stage.
Calls get/post handler.""" Calls get/post handler."""
@ -83,7 +83,7 @@ class SAMLSSOView(BufferedPolicyAccessView):
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""GET and POST use the same handler, but we can't """GET and POST use the same handler, but we can't
override .dispatch easily because BufferedPolicyAccessView's dispatch""" override .dispatch easily because PolicyAccessView's dispatch"""
return self.get(request, application_slug) return self.get(request, application_slug)

View File

@ -50,7 +50,7 @@ class TestRecovery(TestCase):
) )
token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user) token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user)
self.client.get(reverse("authentik_recovery:use-token", kwargs={"key": token.key})) self.client.get(reverse("authentik_recovery:use-token", kwargs={"key": token.key}))
self.assertEqual(self.client.session["authenticatedsession"].user.pk, token.user.pk) self.assertEqual(int(self.client.session["_auth_user_id"]), token.user.pk)
def test_recovery_view_invalid(self): def test_recovery_view_invalid(self):
"""Test recovery view with invalid token""" """Test recovery view with invalid token"""

View File

@ -1,12 +1,8 @@
"""ASGI middleware""" """ASGI middleware"""
from channels.auth import UserLazyObject
from channels.db import database_sync_to_async from channels.db import database_sync_to_async
from channels.middleware import BaseMiddleware
from channels.sessions import CookieMiddleware
from channels.sessions import InstanceSessionWrapper as UpstreamInstanceSessionWrapper from channels.sessions import InstanceSessionWrapper as UpstreamInstanceSessionWrapper
from channels.sessions import SessionMiddleware as UpstreamSessionMiddleware from channels.sessions import SessionMiddleware as UpstreamSessionMiddleware
from django.contrib.auth.models import AnonymousUser
from authentik.root.middleware import SessionMiddleware as HTTPSessionMiddleware from authentik.root.middleware import SessionMiddleware as HTTPSessionMiddleware
@ -37,48 +33,3 @@ class SessionMiddleware(UpstreamSessionMiddleware):
await wrapper.resolve_session() await wrapper.resolve_session()
return await self.inner(wrapper.scope, receive, wrapper.send) return await self.inner(wrapper.scope, receive, wrapper.send)
@database_sync_to_async
def get_user(scope):
"""
Return the user model instance associated with the given scope.
If no user is retrieved, return an instance of `AnonymousUser`.
"""
if "session" not in scope:
raise ValueError(
"Cannot find session in scope. You should wrap your consumer in SessionMiddleware."
)
user = None
if (authenticated_session := scope["session"].get("authenticated_session", None)) is not None:
user = authenticated_session.user
return user or AnonymousUser()
class AuthMiddleware(BaseMiddleware):
def populate_scope(self, scope):
# Make sure we have a session
if "session" not in scope:
raise ValueError(
"AuthMiddleware cannot find session in scope. SessionMiddleware must be above it."
)
# Add it to the scope if it's not there already
if "user" not in scope:
scope["user"] = UserLazyObject()
async def resolve_scope(self, scope):
scope["user"]._wrapped = await get_user(scope)
async def __call__(self, scope, receive, send):
scope = dict(scope)
# Scope injection/mutation per this middleware's needs.
self.populate_scope(scope)
# Grab the finalized/resolved scope
await self.resolve_scope(scope)
return await super().__call__(scope, receive, send)
# Handy shortcut for applying all three layers at once
def AuthMiddlewareStack(inner):
return CookieMiddleware(SessionMiddleware(AuthMiddleware(inner)))

View File

@ -49,7 +49,7 @@ class SessionMiddleware(UpstreamSessionMiddleware):
return False return False
@staticmethod @staticmethod
def decode_session_key(key: str | None) -> str | None: def decode_session_key(key: str) -> str:
"""Decode raw session cookie, and parse JWT""" """Decode raw session cookie, and parse JWT"""
# We need to support the standard django format of just a session key # We need to support the standard django format of just a session key
# for testing setups, where the session is directly set # for testing setups, where the session is directly set
@ -64,11 +64,7 @@ class SessionMiddleware(UpstreamSessionMiddleware):
def process_request(self, request: HttpRequest): def process_request(self, request: HttpRequest):
raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME) raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
session_key = SessionMiddleware.decode_session_key(raw_session) session_key = SessionMiddleware.decode_session_key(raw_session)
request.session = self.SessionStore( request.session = self.SessionStore(session_key)
session_key,
last_ip=ClientIPMiddleware.get_client_ip(request),
last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
)
def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse: def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse:
""" """

View File

@ -0,0 +1,23 @@
"""
Module for abstract serializer/unserializer base classes.
"""
import pickle # nosec
class PickleSerializer:
"""
Simple wrapper around pickle to be used in signing.dumps()/loads() and
cache backends.
"""
def __init__(self, protocol=None):
self.protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
def dumps(self, obj):
"""Pickle data to be stored in redis"""
return pickle.dumps(obj, self.protocol)
def loads(self, data):
"""Unpickle data to be loaded from redis"""
return pickle.loads(data) # nosec

View File

@ -7,6 +7,7 @@ from pathlib import Path
import orjson import orjson
from celery.schedules import crontab from celery.schedules import crontab
from django.conf import ImproperlyConfigured
from sentry_sdk import set_tag from sentry_sdk import set_tag
from xmlsec import enable_debug_trace from xmlsec import enable_debug_trace
@ -42,6 +43,7 @@ SESSION_COOKIE_DOMAIN = CONFIG.get("cookie_domain", None)
APPEND_SLASH = False APPEND_SLASH = False
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
BACKEND_INBUILT, BACKEND_INBUILT,
BACKEND_APP_PASSWORD, BACKEND_APP_PASSWORD,
BACKEND_LDAP, BACKEND_LDAP,
@ -227,7 +229,17 @@ CACHES = {
DJANGO_REDIS_SCAN_ITERSIZE = 1000 DJANGO_REDIS_SCAN_ITERSIZE = 1000
DJANGO_REDIS_IGNORE_EXCEPTIONS = True DJANGO_REDIS_IGNORE_EXCEPTIONS = True
DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True
SESSION_ENGINE = "authentik.core.sessions" match CONFIG.get("session_storage", "cache"):
case "cache":
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
case "db":
SESSION_ENGINE = "django.contrib.sessions.backends.db"
case _:
raise ImproperlyConfigured(
"Invalid session_storage setting, allowed values are db and cache"
)
SESSION_SERIALIZER = "authentik.root.sessions.pickle.PickleSerializer"
SESSION_CACHE_ALIAS = "default"
# Configured via custom SessionMiddleware # Configured via custom SessionMiddleware
# SESSION_COOKIE_SAMESITE = "None" # SESSION_COOKIE_SAMESITE = "None"
# SESSION_COOKIE_SECURE = True # SESSION_COOKIE_SECURE = True
@ -244,7 +256,7 @@ MIDDLEWARE = [
"django_prometheus.middleware.PrometheusBeforeMiddleware", "django_prometheus.middleware.PrometheusBeforeMiddleware",
"authentik.root.middleware.ClientIPMiddleware", "authentik.root.middleware.ClientIPMiddleware",
"authentik.stages.user_login.middleware.BoundSessionMiddleware", "authentik.stages.user_login.middleware.BoundSessionMiddleware",
"authentik.core.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"authentik.core.middleware.RequestIDMiddleware", "authentik.core.middleware.RequestIDMiddleware",
"authentik.brands.middleware.BrandMiddleware", "authentik.brands.middleware.BrandMiddleware",
"authentik.events.middleware.AuditMiddleware", "authentik.events.middleware.AuditMiddleware",

View File

@ -28,8 +28,8 @@ def pytest_report_header(*_, **__):
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
current_id = int(environ.get("CI_RUN_ID", "0")) - 1 current_id = int(environ.get("CI_RUN_ID", 0)) - 1
total_ids = int(environ.get("CI_TOTAL_RUNS", "0")) total_ids = int(environ.get("CI_TOTAL_RUNS", 0))
if total_ids: if total_ids:
num_tests = len(items) num_tests = len(items)

View File

@ -1,11 +1,13 @@
"""Kerberos Source Serializer"""
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import ( from authentik.core.api.sources import (
GroupSourceConnectionSerializer, GroupSourceConnectionSerializer,
GroupSourceConnectionViewSet, GroupSourceConnectionViewSet,
UserSourceConnectionSerializer, UserSourceConnectionSerializer,
UserSourceConnectionViewSet,
) )
from authentik.core.api.used_by import UsedByMixin
from authentik.sources.kerberos.models import ( from authentik.sources.kerberos.models import (
GroupKerberosSourceConnection, GroupKerberosSourceConnection,
UserKerberosSourceConnection, UserKerberosSourceConnection,
@ -13,20 +15,33 @@ from authentik.sources.kerberos.models import (
class UserKerberosSourceConnectionSerializer(UserSourceConnectionSerializer): class UserKerberosSourceConnectionSerializer(UserSourceConnectionSerializer):
class Meta(UserSourceConnectionSerializer.Meta): """Kerberos Source Serializer"""
class Meta:
model = UserKerberosSourceConnection model = UserKerberosSourceConnection
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"]
class UserKerberosSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet): class UserKerberosSourceConnectionViewSet(UsedByMixin, ModelViewSet):
"""Source Viewset"""
queryset = UserKerberosSourceConnection.objects.all() queryset = UserKerberosSourceConnection.objects.all()
serializer_class = UserKerberosSourceConnectionSerializer serializer_class = UserKerberosSourceConnectionSerializer
filterset_fields = ["source__slug"]
search_fields = ["source__slug"]
ordering = ["source__slug"]
owner_field = "user"
class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer): class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer):
"""OAuth Group-Source connection Serializer"""
class Meta(GroupSourceConnectionSerializer.Meta): class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupKerberosSourceConnection model = GroupKerberosSourceConnection
class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet): class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet):
"""Group-source connection Viewset"""
queryset = GroupKerberosSourceConnection.objects.all() queryset = GroupKerberosSourceConnection.objects.all()
serializer_class = GroupKerberosSourceConnectionSerializer serializer_class = GroupKerberosSourceConnectionSerializer

View File

@ -1,28 +0,0 @@
from django.db import migrations
def migrate_identifier(apps, schema_editor):
db_alias = schema_editor.connection.alias
UserKerberosSourceConnection = apps.get_model(
"authentik_sources_kerberos", "UserKerberosSourceConnection"
)
for connection in UserKerberosSourceConnection.objects.using(db_alias).all():
connection.new_identifier = connection.identifier
connection.save(using=db_alias)
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_kerberos", "0002_kerberossource_kadmin_type"),
("authentik_core", "0044_usersourceconnection_new_identifier"),
]
operations = [
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="userkerberossourceconnection",
name="identifier",
),
]

View File

@ -372,6 +372,8 @@ class KerberosSourcePropertyMapping(PropertyMapping):
class UserKerberosSourceConnection(UserSourceConnection): class UserKerberosSourceConnection(UserSourceConnection):
"""Connection to configured Kerberos Sources.""" """Connection to configured Kerberos Sources."""
identifier = models.TextField()
@property @property
def serializer(self) -> type[Serializer]: def serializer(self) -> type[Serializer]:
from authentik.sources.kerberos.api.source_connection import ( from authentik.sources.kerberos.api.source_connection import (

View File

@ -15,22 +15,11 @@ from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.property_mappings import PropertyMappingFilterSet, PropertyMappingSerializer from authentik.core.api.property_mappings import PropertyMappingFilterSet, PropertyMappingSerializer
from authentik.core.api.sources import ( from authentik.core.api.sources import SourceSerializer
GroupSourceConnectionSerializer,
GroupSourceConnectionViewSet,
SourceSerializer,
UserSourceConnectionSerializer,
UserSourceConnectionViewSet,
)
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.lib.sync.outgoing.api import SyncStatusSerializer from authentik.lib.sync.outgoing.api import SyncStatusSerializer
from authentik.sources.ldap.models import ( from authentik.sources.ldap.models import LDAPSource, LDAPSourcePropertyMapping
GroupLDAPSourceConnection,
LDAPSource,
LDAPSourcePropertyMapping,
UserLDAPSourceConnection,
)
from authentik.sources.ldap.tasks import CACHE_KEY_STATUS, SYNC_CLASSES from authentik.sources.ldap.tasks import CACHE_KEY_STATUS, SYNC_CLASSES
@ -110,7 +99,6 @@ class LDAPSourceSerializer(SourceSerializer):
"sync_groups", "sync_groups",
"sync_parent_group", "sync_parent_group",
"connectivity", "connectivity",
"lookup_groups_from_user",
] ]
extra_kwargs = {"bind_password": {"write_only": True}} extra_kwargs = {"bind_password": {"write_only": True}}
@ -146,7 +134,6 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
"sync_parent_group", "sync_parent_group",
"user_property_mappings", "user_property_mappings",
"group_property_mappings", "group_property_mappings",
"lookup_groups_from_user",
] ]
search_fields = ["name", "slug"] search_fields = ["name", "slug"]
ordering = ["name"] ordering = ["name"]
@ -232,23 +219,3 @@ class LDAPSourcePropertyMappingViewSet(UsedByMixin, ModelViewSet):
filterset_class = LDAPSourcePropertyMappingFilter filterset_class = LDAPSourcePropertyMappingFilter
search_fields = ["name"] search_fields = ["name"]
ordering = ["name"] ordering = ["name"]
class UserLDAPSourceConnectionSerializer(UserSourceConnectionSerializer):
class Meta(UserSourceConnectionSerializer.Meta):
model = UserLDAPSourceConnection
class UserLDAPSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
queryset = UserLDAPSourceConnection.objects.all()
serializer_class = UserLDAPSourceConnectionSerializer
class GroupLDAPSourceConnectionSerializer(GroupSourceConnectionSerializer):
class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupLDAPSourceConnection
class GroupLDAPSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
queryset = GroupLDAPSourceConnection.objects.all()
serializer_class = GroupLDAPSourceConnectionSerializer

View File

@ -1,24 +0,0 @@
# Generated by Django 5.0.13 on 2025-03-26 17:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_sources_ldap",
"0006_rename_ldappropertymapping_ldapsourcepropertymapping_and_more",
),
]
operations = [
migrations.AddField(
model_name="ldapsource",
name="lookup_groups_from_user",
field=models.BooleanField(
default=False,
help_text="Lookup group membership based on a user attribute instead of a group attribute. This allows nested group resolution on systems like FreeIPA and Active Directory",
),
),
]

View File

@ -1,57 +0,0 @@
# Generated by Django 5.0.14 on 2025-04-11 11:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0047_delete_oldauthenticatedsession"),
("authentik_sources_ldap", "0007_ldapsource_lookup_groups_from_user"),
]
operations = [
migrations.CreateModel(
name="GroupLDAPSourceConnection",
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 LDAP Source Connection",
"verbose_name_plural": "Group LDAP Source Connections",
},
bases=("authentik_core.groupsourceconnection",),
),
migrations.CreateModel(
name="UserLDAPSourceConnection",
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",
),
),
],
options={
"verbose_name": "User LDAP Source Connection",
"verbose_name_plural": "User LDAP Source Connections",
},
bases=("authentik_core.usersourceconnection",),
),
]

View File

@ -15,13 +15,7 @@ from ldap3 import ALL, NONE, RANDOM, Connection, Server, ServerPool, Tls
from ldap3.core.exceptions import LDAPException, LDAPInsufficientAccessRightsResult, LDAPSchemaError from ldap3.core.exceptions import LDAPException, LDAPInsufficientAccessRightsResult, LDAPSchemaError
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
from authentik.core.models import ( from authentik.core.models import Group, PropertyMapping, Source
Group,
GroupSourceConnection,
PropertyMapping,
Source,
UserSourceConnection,
)
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.models import DomainlessURLValidator from authentik.lib.models import DomainlessURLValidator
@ -129,14 +123,6 @@ class LDAPSource(Source):
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
) )
lookup_groups_from_user = models.BooleanField(
default=False,
help_text=_(
"Lookup group membership based on a user attribute instead of a group attribute. "
"This allows nested group resolution on systems like FreeIPA and Active Directory"
),
)
@property @property
def component(self) -> str: def component(self) -> str:
return "ak-source-ldap-form" return "ak-source-ldap-form"
@ -318,31 +304,3 @@ class LDAPSourcePropertyMapping(PropertyMapping):
class Meta: class Meta:
verbose_name = _("LDAP Source Property Mapping") verbose_name = _("LDAP Source Property Mapping")
verbose_name_plural = _("LDAP Source Property Mappings") verbose_name_plural = _("LDAP Source Property Mappings")
class UserLDAPSourceConnection(UserSourceConnection):
@property
def serializer(self) -> type[Serializer]:
from authentik.sources.ldap.api import (
UserLDAPSourceConnectionSerializer,
)
return UserLDAPSourceConnectionSerializer
class Meta:
verbose_name = _("User LDAP Source Connection")
verbose_name_plural = _("User LDAP Source Connections")
class GroupLDAPSourceConnection(GroupSourceConnection):
@property
def serializer(self) -> type[Serializer]:
from authentik.sources.ldap.api import (
GroupLDAPSourceConnectionSerializer,
)
return GroupLDAPSourceConnectionSerializer
class Meta:
verbose_name = _("Group LDAP Source Connection")
verbose_name_plural = _("Group LDAP Source Connections")

View File

@ -14,12 +14,7 @@ from authentik.core.models import Group
from authentik.core.sources.mapper import SourceMapper from authentik.core.sources.mapper import SourceMapper
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.lib.sync.outgoing.exceptions import StopSync from authentik.lib.sync.outgoing.exceptions import StopSync
from authentik.sources.ldap.models import ( from authentik.sources.ldap.models import LDAP_UNIQUENESS, LDAPSource, flatten
LDAP_UNIQUENESS,
GroupLDAPSourceConnection,
LDAPSource,
flatten,
)
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
@ -94,12 +89,6 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
defaults, defaults,
) )
self._logger.debug("Created group with attributes", **defaults) self._logger.debug("Created group with attributes", **defaults)
if not GroupLDAPSourceConnection.objects.filter(
source=self._source, identifier=uniq
):
GroupLDAPSourceConnection.objects.create(
source=self._source, group=ak_group, identifier=uniq
)
except SkipObjectException: except SkipObjectException:
continue continue
except PropertyMappingExpressionException as exc: except PropertyMappingExpressionException as exc:

View File

@ -28,17 +28,15 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
if not self._source.sync_groups: if not self._source.sync_groups:
self.message("Group syncing is disabled for this Source") self.message("Group syncing is disabled for this Source")
return iter(()) return iter(())
# If we are looking up groups from users, we don't need to fetch the group membership field
attributes = [self._source.object_uniqueness_field, LDAP_DISTINGUISHED_NAME]
if not self._source.lookup_groups_from_user:
attributes.append(self._source.group_membership_field)
return self.search_paginator( return self.search_paginator(
search_base=self.base_dn_groups, search_base=self.base_dn_groups,
search_filter=self._source.group_object_filter, search_filter=self._source.group_object_filter,
search_scope=SUBTREE, search_scope=SUBTREE,
attributes=attributes, attributes=[
self._source.group_membership_field,
self._source.object_uniqueness_field,
LDAP_DISTINGUISHED_NAME,
],
**kwargs, **kwargs,
) )
@ -49,24 +47,9 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
return -1 return -1
membership_count = 0 membership_count = 0
for group in page_data: for group in page_data:
if self._source.lookup_groups_from_user: if "attributes" not in group:
group_dn = group.get("dn", {}) continue
group_filter = f"({self._source.group_membership_field}={group_dn})" members = group.get("attributes", {}).get(self._source.group_membership_field, [])
group_members = self._source.connection().extend.standard.paged_search(
search_base=self.base_dn_users,
search_filter=group_filter,
search_scope=SUBTREE,
attributes=[self._source.object_uniqueness_field],
)
members = []
for group_member in group_members:
group_member_dn = group_member.get("dn", {})
members.append(group_member_dn)
else:
if "attributes" not in group:
continue
members = group.get("attributes", {}).get(self._source.group_membership_field, [])
ak_group = self.get_group(group) ak_group = self.get_group(group)
if not ak_group: if not ak_group:
continue continue
@ -85,7 +68,7 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
"ak_groups__in": [ak_group], "ak_groups__in": [ak_group],
} }
) )
).distinct() )
membership_count += 1 membership_count += 1
membership_count += users.count() membership_count += users.count()
ak_group.users.set(users) ak_group.users.set(users)

View File

@ -14,12 +14,7 @@ from authentik.core.models import User
from authentik.core.sources.mapper import SourceMapper from authentik.core.sources.mapper import SourceMapper
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.lib.sync.outgoing.exceptions import StopSync from authentik.lib.sync.outgoing.exceptions import StopSync
from authentik.sources.ldap.models import ( from authentik.sources.ldap.models import LDAP_UNIQUENESS, LDAPSource, flatten
LDAP_UNIQUENESS,
LDAPSource,
UserLDAPSourceConnection,
flatten,
)
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
from authentik.sources.ldap.sync.vendor.freeipa import FreeIPA from authentik.sources.ldap.sync.vendor.freeipa import FreeIPA
from authentik.sources.ldap.sync.vendor.ms_ad import MicrosoftActiveDirectory from authentik.sources.ldap.sync.vendor.ms_ad import MicrosoftActiveDirectory
@ -90,12 +85,6 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
ak_user, created = User.update_or_create_attributes( ak_user, created = User.update_or_create_attributes(
{f"attributes__{LDAP_UNIQUENESS}": uniq}, defaults {f"attributes__{LDAP_UNIQUENESS}": uniq}, defaults
) )
if not UserLDAPSourceConnection.objects.filter(
source=self._source, identifier=uniq
):
UserLDAPSourceConnection.objects.create(
source=self._source, user=ak_user, identifier=uniq
)
except PropertyMappingExpressionException as exc: except PropertyMappingExpressionException as exc:
raise StopSync(exc, None, exc.mapping) from exc raise StopSync(exc, None, exc.mapping) from exc
except SkipObjectException: except SkipObjectException:

View File

@ -96,26 +96,6 @@ def mock_freeipa_connection(password: str) -> Connection:
"objectClass": "posixAccount", "objectClass": "posixAccount",
}, },
) )
# User with groups in memberOf attribute
connection.strategy.add_entry(
"cn=user4,ou=users,dc=goauthentik,dc=io",
{
"name": "user4_sn",
"uid": "user4_sn",
"objectClass": "person",
"memberOf": [
"cn=reverse-lookup-group,ou=groups,dc=goauthentik,dc=io",
],
},
)
connection.strategy.add_entry(
"cn=reverse-lookup-group,ou=groups,dc=goauthentik,dc=io",
{
"cn": "reverse-lookup-group",
"uid": "reverse-lookup-group",
"objectClass": "groupOfNames",
},
)
# Locked out user # Locked out user
connection.strategy.add_entry( connection.strategy.add_entry(
"cn=user-nsaccountlock,ou=users,dc=goauthentik,dc=io", "cn=user-nsaccountlock,ou=users,dc=goauthentik,dc=io",

View File

@ -162,43 +162,6 @@ class LDAPSyncTests(TestCase):
self.assertFalse(User.objects.filter(username="user1_sn").exists()) self.assertFalse(User.objects.filter(username="user1_sn").exists())
self.assertFalse(User.objects.get(username="user-nsaccountlock").is_active) self.assertFalse(User.objects.get(username="user-nsaccountlock").is_active)
def test_sync_groups_freeipa_memberOf(self):
"""Test group sync when membership is derived from memberOf user attribute"""
self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.lookup_groups_from_user = True
self.source.group_membership_field = "memberOf"
self.source.user_property_mappings.set(
LDAPSourcePropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
)
)
self.source.group_property_mappings.set(
LDAPSourcePropertyMapping.objects.filter(
managed="goauthentik.io/sources/ldap/openldap-cn"
)
)
connection = MagicMock(return_value=mock_freeipa_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
user_sync = UserLDAPSynchronizer(self.source)
user_sync.sync_full()
group_sync = GroupLDAPSynchronizer(self.source)
group_sync.sync_full()
membership_sync = MembershipLDAPSynchronizer(self.source)
membership_sync.sync_full()
self.assertTrue(
User.objects.filter(username="user4_sn").exists(), "User does not exist"
)
# Test if membership mapping based on memberOf works.
memberof_group = Group.objects.filter(name="reverse-lookup-group")
self.assertTrue(memberof_group.exists(), "Group does not exist")
self.assertTrue(
memberof_group.first().users.filter(username="user4_sn").exists(),
"User not a member of the group",
)
def test_sync_groups_ad(self): def test_sync_groups_ad(self):
"""Test group sync""" """Test group sync"""
self.source.user_property_mappings.set( self.source.user_property_mappings.set(

View File

@ -1,15 +1,8 @@
"""API URLs""" """API URLs"""
from authentik.sources.ldap.api import ( from authentik.sources.ldap.api import LDAPSourcePropertyMappingViewSet, LDAPSourceViewSet
GroupLDAPSourceConnectionViewSet,
LDAPSourcePropertyMappingViewSet,
LDAPSourceViewSet,
UserLDAPSourceConnectionViewSet,
)
api_urlpatterns = [ api_urlpatterns = [
("propertymappings/source/ldap", LDAPSourcePropertyMappingViewSet), ("propertymappings/source/ldap", LDAPSourcePropertyMappingViewSet),
("sources/ldap", LDAPSourceViewSet), ("sources/ldap", LDAPSourceViewSet),
("sources/user_connections/ldap", UserLDAPSourceConnectionViewSet),
("sources/group_connections/ldap", GroupLDAPSourceConnectionViewSet),
] ]

View File

@ -1,3 +1,5 @@
"""OAuth Source Serializer"""
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import ( from authentik.core.api.sources import (
@ -10,9 +12,11 @@ from authentik.sources.oauth.models import GroupOAuthSourceConnection, UserOAuth
class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer): class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer):
"""OAuth Source Serializer"""
class Meta(UserSourceConnectionSerializer.Meta): class Meta(UserSourceConnectionSerializer.Meta):
model = UserOAuthSourceConnection model = UserOAuthSourceConnection
fields = UserSourceConnectionSerializer.Meta.fields + ["access_token"] fields = UserSourceConnectionSerializer.Meta.fields + ["identifier", "access_token"]
extra_kwargs = { extra_kwargs = {
**UserSourceConnectionSerializer.Meta.extra_kwargs, **UserSourceConnectionSerializer.Meta.extra_kwargs,
"access_token": {"write_only": True}, "access_token": {"write_only": True},
@ -20,15 +24,21 @@ class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer):
class UserOAuthSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet): class UserOAuthSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
"""Source Viewset"""
queryset = UserOAuthSourceConnection.objects.all() queryset = UserOAuthSourceConnection.objects.all()
serializer_class = UserOAuthSourceConnectionSerializer serializer_class = UserOAuthSourceConnectionSerializer
class GroupOAuthSourceConnectionSerializer(GroupSourceConnectionSerializer): class GroupOAuthSourceConnectionSerializer(GroupSourceConnectionSerializer):
"""OAuth Group-Source connection Serializer"""
class Meta(GroupSourceConnectionSerializer.Meta): class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupOAuthSourceConnection model = GroupOAuthSourceConnection
class GroupOAuthSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet): class GroupOAuthSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
"""Group-source connection Viewset"""
queryset = GroupOAuthSourceConnection.objects.all() queryset = GroupOAuthSourceConnection.objects.all()
serializer_class = GroupOAuthSourceConnectionSerializer serializer_class = GroupOAuthSourceConnectionSerializer

View File

@ -1,28 +0,0 @@
from django.db import migrations
def migrate_identifier(apps, schema_editor):
db_alias = schema_editor.connection.alias
UserOAuthSourceConnection = apps.get_model(
"authentik_sources_oauth", "UserOAuthSourceConnection"
)
for connection in UserOAuthSourceConnection.objects.using(db_alias).all():
connection.new_identifier = connection.identifier
connection.save(using=db_alias)
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_oauth", "0008_groupoauthsourceconnection_and_more"),
("authentik_core", "0044_usersourceconnection_new_identifier"),
]
operations = [
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="useroauthsourceconnection",
name="identifier",
),
]

View File

@ -286,6 +286,7 @@ class OAuthSourcePropertyMapping(PropertyMapping):
class UserOAuthSourceConnection(UserSourceConnection): class UserOAuthSourceConnection(UserSourceConnection):
"""Authorized remote OAuth provider.""" """Authorized remote OAuth provider."""
identifier = models.CharField(max_length=255)
access_token = models.TextField(blank=True, null=True, default=None) access_token = models.TextField(blank=True, null=True, default=None)
@property @property

View File

@ -1,3 +1,5 @@
"""Plex Source connection Serializer"""
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import ( from authentik.core.api.sources import (
@ -10,9 +12,14 @@ from authentik.sources.plex.models import GroupPlexSourceConnection, UserPlexSou
class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer): class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer):
"""Plex Source connection Serializer"""
class Meta(UserSourceConnectionSerializer.Meta): class Meta(UserSourceConnectionSerializer.Meta):
model = UserPlexSourceConnection model = UserPlexSourceConnection
fields = UserSourceConnectionSerializer.Meta.fields + ["plex_token"] fields = UserSourceConnectionSerializer.Meta.fields + [
"identifier",
"plex_token",
]
extra_kwargs = { extra_kwargs = {
**UserSourceConnectionSerializer.Meta.extra_kwargs, **UserSourceConnectionSerializer.Meta.extra_kwargs,
"plex_token": {"write_only": True}, "plex_token": {"write_only": True},
@ -20,15 +27,21 @@ class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer):
class UserPlexSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet): class UserPlexSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
"""Plex Source connection Serializer"""
queryset = UserPlexSourceConnection.objects.all() queryset = UserPlexSourceConnection.objects.all()
serializer_class = UserPlexSourceConnectionSerializer serializer_class = UserPlexSourceConnectionSerializer
class GroupPlexSourceConnectionSerializer(GroupSourceConnectionSerializer): class GroupPlexSourceConnectionSerializer(GroupSourceConnectionSerializer):
"""Plex Group-Source connection Serializer"""
class Meta(GroupSourceConnectionSerializer.Meta): class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupPlexSourceConnection model = GroupPlexSourceConnection
class GroupPlexSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet): class GroupPlexSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
"""Group-source connection Viewset"""
queryset = GroupPlexSourceConnection.objects.all() queryset = GroupPlexSourceConnection.objects.all()
serializer_class = GroupPlexSourceConnectionSerializer serializer_class = GroupPlexSourceConnectionSerializer

View File

@ -1,29 +0,0 @@
from django.db import migrations
def migrate_identifier(apps, schema_editor):
db_alias = schema_editor.connection.alias
UserPlexSourceConnection = apps.get_model("authentik_sources_plex", "UserPlexSourceConnection")
for connection in UserPlexSourceConnection.objects.using(db_alias).all():
connection.new_identifier = connection.identifier
connection.save(using=db_alias)
class Migration(migrations.Migration):
dependencies = [
(
"authentik_sources_plex",
"0004_groupplexsourceconnection_plexsourcepropertymapping_and_more",
),
("authentik_core", "0044_usersourceconnection_new_identifier"),
]
operations = [
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="userplexsourceconnection",
name="identifier",
),
]

View File

@ -141,6 +141,7 @@ class UserPlexSourceConnection(UserSourceConnection):
"""Connect user and plex source""" """Connect user and plex source"""
plex_token = models.TextField() plex_token = models.TextField()
identifier = models.TextField()
@property @property
def serializer(self) -> type[Serializer]: def serializer(self) -> type[Serializer]:

View File

@ -1,3 +1,5 @@
"""SAML Source Serializer"""
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import ( from authentik.core.api.sources import (
@ -10,20 +12,29 @@ from authentik.sources.saml.models import GroupSAMLSourceConnection, UserSAMLSou
class UserSAMLSourceConnectionSerializer(UserSourceConnectionSerializer): class UserSAMLSourceConnectionSerializer(UserSourceConnectionSerializer):
"""SAML Source Serializer"""
class Meta(UserSourceConnectionSerializer.Meta): class Meta(UserSourceConnectionSerializer.Meta):
model = UserSAMLSourceConnection model = UserSAMLSourceConnection
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"]
class UserSAMLSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet): class UserSAMLSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
"""Source Viewset"""
queryset = UserSAMLSourceConnection.objects.all() queryset = UserSAMLSourceConnection.objects.all()
serializer_class = UserSAMLSourceConnectionSerializer serializer_class = UserSAMLSourceConnectionSerializer
class GroupSAMLSourceConnectionSerializer(GroupSourceConnectionSerializer): class GroupSAMLSourceConnectionSerializer(GroupSourceConnectionSerializer):
"""OAuth Group-Source connection Serializer"""
class Meta(GroupSourceConnectionSerializer.Meta): class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupSAMLSourceConnection model = GroupSAMLSourceConnection
class GroupSAMLSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet): class GroupSAMLSourceConnectionViewSet(GroupSourceConnectionViewSet):
"""Group-source connection Viewset"""
queryset = GroupSAMLSourceConnection.objects.all() queryset = GroupSAMLSourceConnection.objects.all()
serializer_class = GroupSAMLSourceConnectionSerializer serializer_class = GroupSAMLSourceConnectionSerializer

View File

@ -1,35 +0,0 @@
# Generated by Django 5.0.13 on 2025-03-31 13:53
import authentik.lib.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_saml", "0017_fix_x509subjectname"),
]
operations = [
migrations.AlterField(
model_name="samlsource",
name="slo_url",
field=models.TextField(
blank=True,
default=None,
help_text="Optional URL if your IDP supports Single-Logout.",
null=True,
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
verbose_name="SLO URL",
),
),
migrations.AlterField(
model_name="samlsource",
name="sso_url",
field=models.TextField(
help_text="URL that the initial Login request is sent to.",
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
verbose_name="SSO URL",
),
),
]

View File

@ -1,26 +0,0 @@
from django.db import migrations
def migrate_identifier(apps, schema_editor):
db_alias = schema_editor.connection.alias
UserSAMLSourceConnection = apps.get_model("authentik_sources_saml", "UserSAMLSourceConnection")
for connection in UserSAMLSourceConnection.objects.using(db_alias).all():
connection.new_identifier = connection.identifier
connection.save(using=db_alias)
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_saml", "0018_alter_samlsource_slo_url_alter_samlsource_sso_url"),
("authentik_core", "0044_usersourceconnection_new_identifier"),
]
operations = [
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="usersamlsourceconnection",
name="identifier",
),
]

View File

@ -20,7 +20,6 @@ from authentik.crypto.models import CertificateKeyPair
from authentik.flows.challenge import RedirectChallenge from authentik.flows.challenge import RedirectChallenge
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.lib.expression.evaluator import BaseEvaluator from authentik.lib.expression.evaluator import BaseEvaluator
from authentik.lib.models import DomainlessURLValidator
from authentik.lib.utils.time import timedelta_string_validator from authentik.lib.utils.time import timedelta_string_validator
from authentik.sources.saml.processors.constants import ( from authentik.sources.saml.processors.constants import (
DSA_SHA1, DSA_SHA1,
@ -92,13 +91,11 @@ class SAMLSource(Source):
help_text=_("Also known as Entity ID. Defaults the Metadata URL."), help_text=_("Also known as Entity ID. Defaults the Metadata URL."),
) )
sso_url = models.TextField( sso_url = models.URLField(
validators=[DomainlessURLValidator(schemes=("http", "https"))],
verbose_name=_("SSO URL"), verbose_name=_("SSO URL"),
help_text=_("URL that the initial Login request is sent to."), help_text=_("URL that the initial Login request is sent to."),
) )
slo_url = models.TextField( slo_url = models.URLField(
validators=[DomainlessURLValidator(schemes=("http", "https"))],
default=None, default=None,
blank=True, blank=True,
null=True, null=True,
@ -318,6 +315,8 @@ class SAMLSourcePropertyMapping(PropertyMapping):
class UserSAMLSourceConnection(UserSourceConnection): class UserSAMLSourceConnection(UserSourceConnection):
"""Connection to configured SAML Sources.""" """Connection to configured SAML Sources."""
identifier = models.TextField()
@property @property
def serializer(self) -> Serializer: def serializer(self) -> Serializer:
from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionSerializer from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionSerializer

View File

@ -255,7 +255,6 @@ class TestAuthenticatorEmailStage(FlowTestCase):
) )
masked_email = mask_email(self.user.email) masked_email = mask_email(self.user.email)
self.assertEqual(masked_email, response.json()["email"]) self.assertEqual(masked_email, response.json()["email"])
self.client.logout()
# Test without email # Test without email
self.client.force_login(self.user_noemail) self.client.force_login(self.user_noemail)

File diff suppressed because one or more lines are too long

View File

@ -104,13 +104,6 @@ def send_mail(
# can't be converted to json) # can't be converted to json)
message_object.attach(logo_data()) message_object.attach(logo_data())
if (
message_object.to
and isinstance(message_object.to[0], str)
and "=?utf-8?" in message_object.to[0]
):
message_object.to = [message_object.to[0].split("<")[-1].replace(">", "")]
LOGGER.debug("Sending mail", to=message_object.to) LOGGER.debug("Sending mail", to=message_object.to)
backend.send_messages([message_object]) backend.send_messages([message_object])
Event.new( Event.new(

View File

@ -97,37 +97,6 @@ class TestEmailStageSending(FlowTestCase):
self.assertEqual(mail.outbox[0].subject, "authentik") self.assertEqual(mail.outbox[0].subject, "authentik")
self.assertEqual(mail.outbox[0].to, [f"Test User Many Words <{long_user.email}>"]) self.assertEqual(mail.outbox[0].to, [f"Test User Many Words <{long_user.email}>"])
def test_utf8_name(self):
"""Test with pending user"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
utf8_user = create_test_user()
utf8_user.name = "Cirilo ЉМНЊ el cirilico И̂ӢЙӤ "
utf8_user.email = "cyrillic@authentik.local"
utf8_user.save()
plan.context[PLAN_CONTEXT_PENDING_USER] = utf8_user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
Event.objects.filter(action=EventAction.EMAIL_SENT).delete()
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
with patch(
"authentik.stages.email.models.EmailStage.backend_class",
PropertyMock(return_value=EmailBackend),
):
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
self.flow,
response_errors={
"non_field_errors": [{"string": "email-sent", "code": "email-sent"}]
},
)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "authentik")
self.assertEqual(mail.outbox[0].to, [f"{utf8_user.email}"])
def test_pending_fake_user(self): def test_pending_fake_user(self):
"""Test with pending (fake) user""" """Test with pending (fake) user"""
self.flow.designation = FlowDesignation.RECOVERY self.flow.designation = FlowDesignation.RECOVERY

View File

@ -142,38 +142,35 @@ class IdentificationChallengeResponse(ChallengeResponse):
raise ValidationError("Failed to authenticate.") raise ValidationError("Failed to authenticate.")
self.pre_user = pre_user self.pre_user = pre_user
# Password check
if current_stage.password_stage:
password = attrs.get("password", None)
if not password:
self.stage.logger.warning("Password not set for ident+auth attempt")
try:
with start_span(
op="authentik.stages.identification.authenticate",
name="User authenticate call (combo stage)",
):
user = authenticate(
self.stage.request,
current_stage.password_stage.backends,
current_stage,
username=self.pre_user.username,
password=password,
)
if not user:
raise ValidationError("Failed to authenticate.")
self.pre_user = user
except PermissionDenied as exc:
raise ValidationError(str(exc)) from exc
# Captcha check # Captcha check
if captcha_stage := current_stage.captcha_stage: if captcha_stage := current_stage.captcha_stage:
captcha_token = attrs.get("captcha_token", None) captcha_token = attrs.get("captcha_token", None)
if not captcha_token: if not captcha_token:
self.stage.logger.warning("Token not set for captcha attempt") self.stage.logger.warning("Token not set for captcha attempt")
verify_captcha_token(captcha_stage, captcha_token, client_ip) verify_captcha_token(captcha_stage, captcha_token, client_ip)
# Password check
if not current_stage.password_stage:
# No password stage select, don't validate the password
return attrs
password = attrs.get("password", None)
if not password:
self.stage.logger.warning("Password not set for ident+auth attempt")
try:
with start_span(
op="authentik.stages.identification.authenticate",
name="User authenticate call (combo stage)",
):
user = authenticate(
self.stage.request,
current_stage.password_stage.backends,
current_stage,
username=self.pre_user.username,
password=password,
)
if not user:
raise ValidationError("Failed to authenticate.")
self.pre_user = user
except PermissionDenied as exc:
raise ValidationError(str(exc)) from exc
return attrs return attrs

View File

@ -6,12 +6,14 @@ from django.contrib.auth.views import redirect_to_login
from django.http.request import HttpRequest from django.http.request import HttpRequest
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import AuthenticatedSession
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR
from authentik.lib.sentry import SentryIgnoredException from authentik.lib.sentry import SentryIgnoredException
from authentik.root.middleware import ClientIPMiddleware, SessionMiddleware from authentik.root.middleware import ClientIPMiddleware, SessionMiddleware
from authentik.stages.user_login.models import GeoIPBinding, NetworkBinding from authentik.stages.user_login.models import GeoIPBinding, NetworkBinding
SESSION_KEY_LAST_IP = "authentik/stages/user_login/last_ip"
SESSION_KEY_BINDING_NET = "authentik/stages/user_login/binding/net" SESSION_KEY_BINDING_NET = "authentik/stages/user_login/binding/net"
SESSION_KEY_BINDING_GEO = "authentik/stages/user_login/binding/geo" SESSION_KEY_BINDING_GEO = "authentik/stages/user_login/binding/geo"
LOGGER = get_logger() LOGGER = get_logger()
@ -89,7 +91,7 @@ class BoundSessionMiddleware(SessionMiddleware):
def recheck_session(self, request: HttpRequest): def recheck_session(self, request: HttpRequest):
"""Check if a session is still valid with a changed IP""" """Check if a session is still valid with a changed IP"""
last_ip = request.session.get(request.session.model.Keys.LAST_IP) last_ip = request.session.get(SESSION_KEY_LAST_IP)
new_ip = ClientIPMiddleware.get_client_ip(request) new_ip = ClientIPMiddleware.get_client_ip(request)
# Check changed IP # Check changed IP
if new_ip == last_ip: if new_ip == last_ip:
@ -109,7 +111,10 @@ class BoundSessionMiddleware(SessionMiddleware):
if SESSION_KEY_BINDING_NET in request.session or SESSION_KEY_BINDING_GEO in request.session: if SESSION_KEY_BINDING_NET in request.session or SESSION_KEY_BINDING_GEO in request.session:
# Only set the last IP in the session if there's a binding specified # Only set the last IP in the session if there's a binding specified
# (== basically requires the user to be logged in) # (== basically requires the user to be logged in)
request.session[request.session.model.Keys.LAST_IP] = new_ip request.session[SESSION_KEY_LAST_IP] = new_ip
AuthenticatedSession.objects.filter(session_key=request.session.session_key).update(
last_ip=new_ip, last_user_agent=request.META.get("HTTP_USER_AGENT", "")
)
def recheck_session_net(self, binding: NetworkBinding, last_ip: str, new_ip: str): def recheck_session_net(self, binding: NetworkBinding, last_ip: str, new_ip: str):
"""Check network/ASN binding""" """Check network/ASN binding"""

View File

@ -8,7 +8,7 @@ from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from rest_framework.fields import BooleanField, CharField from rest_framework.fields import BooleanField, CharField
from authentik.core.models import Session, User from authentik.core.models import AuthenticatedSession, User
from authentik.events.middleware import audit_ignore from authentik.events.middleware import audit_ignore
from authentik.flows.challenge import ChallengeResponse, WithUserInfoChallenge from authentik.flows.challenge import ChallengeResponse, WithUserInfoChallenge
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_SOURCE from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_SOURCE
@ -20,6 +20,7 @@ from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
from authentik.stages.user_login.middleware import ( from authentik.stages.user_login.middleware import (
SESSION_KEY_BINDING_GEO, SESSION_KEY_BINDING_GEO,
SESSION_KEY_BINDING_NET, SESSION_KEY_BINDING_NET,
SESSION_KEY_LAST_IP,
) )
from authentik.stages.user_login.models import UserLoginStage from authentik.stages.user_login.models import UserLoginStage
@ -72,9 +73,7 @@ class UserLoginStageView(ChallengeStageView):
"""Set the sessions' last IP and session bindings""" """Set the sessions' last IP and session bindings"""
stage: UserLoginStage = self.executor.current_stage stage: UserLoginStage = self.executor.current_stage
self.request.session[self.request.session.model.Keys.LAST_IP] = ( self.request.session[SESSION_KEY_LAST_IP] = ClientIPMiddleware.get_client_ip(self.request)
ClientIPMiddleware.get_client_ip(self.request)
)
self.request.session[SESSION_KEY_BINDING_NET] = stage.network_binding self.request.session[SESSION_KEY_BINDING_NET] = stage.network_binding
self.request.session[SESSION_KEY_BINDING_GEO] = stage.geoip_binding self.request.session[SESSION_KEY_BINDING_GEO] = stage.geoip_binding
@ -113,7 +112,7 @@ class UserLoginStageView(ChallengeStageView):
if not self.executor.plan.context.get(PLAN_CONTEXT_SOURCE, None): if not self.executor.plan.context.get(PLAN_CONTEXT_SOURCE, None):
messages.success(self.request, _("Successfully logged in!")) messages.success(self.request, _("Successfully logged in!"))
if self.executor.current_stage.terminate_other_sessions: if self.executor.current_stage.terminate_other_sessions:
Session.objects.filter( AuthenticatedSession.objects.filter(
authenticatedsession__user=user, user=user,
).exclude(session_key=self.request.session.session_key).delete() ).exclude(session_key=self.request.session.session_key).delete()
return self.executor.stage_ok() return self.executor.stage_ok()

View File

@ -3,10 +3,12 @@
from time import sleep from time import sleep
from unittest.mock import patch from unittest.mock import patch
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from authentik.core.models import AuthenticatedSession, Session from authentik.core.models import AuthenticatedSession
from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.markers import StageMarker from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowDesignation, FlowStageBinding from authentik.flows.models import FlowDesignation, FlowStageBinding
@ -72,13 +74,12 @@ class TestUserLoginStage(FlowTestCase):
session.save() session.save()
key = generate_id() key = generate_id()
AuthenticatedSession.objects.create( other_session = AuthenticatedSession.objects.create(
session=Session.objects.create(
session_key=key,
last_ip=ClientIPMiddleware.default_ip,
),
user=self.user, user=self.user,
session_key=key,
last_ip=ClientIPMiddleware.default_ip,
) )
cache.set(f"{KEY_PREFIX}{other_session.session_key}", "foo")
response = self.client.post( response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
@ -86,8 +87,8 @@ class TestUserLoginStage(FlowTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
self.assertFalse(AuthenticatedSession.objects.filter(session__session_key=key)) self.assertFalse(AuthenticatedSession.objects.filter(session_key=key))
self.assertFalse(Session.objects.filter(session_key=key).exists()) self.assertFalse(cache.has_key(f"{KEY_PREFIX}{key}"))
def test_expiry(self): def test_expiry(self):
"""Test with expiry""" """Test with expiry"""
@ -107,7 +108,7 @@ class TestUserLoginStage(FlowTestCase):
self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
self.assertNotEqual(list(self.client.session.keys()), []) self.assertNotEqual(list(self.client.session.keys()), [])
session_key = self.client.session.session_key session_key = self.client.session.session_key
session = Session.objects.filter(session_key=session_key).first() session = AuthenticatedSession.objects.filter(session_key=session_key).first()
self.assertAlmostEqual( self.assertAlmostEqual(
session.expires.timestamp() - before_request.timestamp(), session.expires.timestamp() - before_request.timestamp(),
timedelta_from_string(self.stage.session_duration).total_seconds(), timedelta_from_string(self.stage.session_duration).total_seconds(),
@ -142,7 +143,7 @@ class TestUserLoginStage(FlowTestCase):
self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
self.assertNotEqual(list(self.client.session.keys()), []) self.assertNotEqual(list(self.client.session.keys()), [])
session_key = self.client.session.session_key session_key = self.client.session.session_key
session = Session.objects.filter(session_key=session_key).first() session = AuthenticatedSession.objects.filter(session_key=session_key).first()
self.assertAlmostEqual( self.assertAlmostEqual(
session.expires.timestamp() - _now, session.expires.timestamp() - _now,
timedelta_from_string(self.stage.session_duration).total_seconds() timedelta_from_string(self.stage.session_duration).total_seconds()

View File

@ -20,8 +20,6 @@ class SettingsSerializer(ModelSerializer):
"default_user_change_email", "default_user_change_email",
"default_user_change_username", "default_user_change_username",
"event_retention", "event_retention",
"reputation_lower_limit",
"reputation_upper_limit",
"footer_links", "footer_links",
"gdpr_compliance", "gdpr_compliance",
"impersonation", "impersonation",

View File

@ -1,32 +0,0 @@
# Generated by Django 5.0.14 on 2025-04-14 07:50
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_tenants", "0004_tenant_impersonation_require_reason"),
]
operations = [
migrations.AddField(
model_name="tenant",
name="reputation_lower_limit",
field=models.IntegerField(
default=-5,
help_text="Reputation cannot decrease lower than this value. Zero or negative.",
validators=[django.core.validators.MaxValueValidator(0)],
),
),
migrations.AddField(
model_name="tenant",
name="reputation_upper_limit",
field=models.IntegerField(
default=5,
help_text="Reputation cannot increase higher than this value. Zero or positive.",
validators=[django.core.validators.MinValueValidator(0)],
),
),
]

View File

@ -5,7 +5,7 @@ from uuid import uuid4
from django.apps import apps from django.apps import apps
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.dispatch import receiver from django.dispatch import receiver
@ -25,8 +25,6 @@ VALID_SCHEMA_NAME = re.compile(r"^t_[a-z0-9]{1,61}$")
DEFAULT_TOKEN_DURATION = "days=1" # nosec DEFAULT_TOKEN_DURATION = "days=1" # nosec
DEFAULT_TOKEN_LENGTH = 60 DEFAULT_TOKEN_LENGTH = 60
DEFAULT_REPUTATION_LOWER_LIMIT = -5
DEFAULT_REPUTATION_UPPER_LIMIT = 5
def _validate_schema_name(name): def _validate_schema_name(name):
@ -72,16 +70,6 @@ class Tenant(TenantMixin, SerializerModel):
"Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2)." "Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2)."
), ),
) )
reputation_lower_limit = models.IntegerField(
help_text=_("Reputation cannot decrease lower than this value. Zero or negative."),
default=DEFAULT_REPUTATION_LOWER_LIMIT,
validators=[MaxValueValidator(0)],
)
reputation_upper_limit = models.IntegerField(
help_text=_("Reputation cannot increase higher than this value. Zero or positive."),
default=DEFAULT_REPUTATION_UPPER_LIMIT,
validators=[MinValueValidator(0)],
)
footer_links = models.JSONField( footer_links = models.JSONField(
help_text=_("The option configures the footer links on the flow executor pages."), help_text=_("The option configures the footer links on the flow executor pages."),
default=list, default=list,

Some files were not shown because too many files have changed in this diff Show More