core: migrate all sessions to the database (#9736)

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Marc 'risson' Schmitt
2025-04-11 09:10:55 +02:00
committed by GitHub
parent 9917d81246
commit 395ad722b7
40 changed files with 974 additions and 236 deletions

View File

@ -36,6 +36,7 @@ from authentik.core.models import (
GroupSourceConnection, GroupSourceConnection,
PropertyMapping, PropertyMapping,
Provider, Provider,
Session,
Source, Source,
User, User,
UserSourceConnection, UserSourceConnection,
@ -108,6 +109,7 @@ 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

@ -5,6 +5,7 @@ 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
@ -54,6 +55,11 @@ 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()
@ -62,19 +68,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_key return request._request.session.session_key == instance.session.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.last_user_agent) return user_agent_parser.Parse(instance.session.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.last_ip) return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.session.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.last_ip) return ASN_CONTEXT_PROCESSOR.asn_dict(instance.session.last_ip)
class Meta: class Meta:
model = AuthenticatedSession model = AuthenticatedSession
@ -90,6 +96,7 @@ class AuthenticatedSessionSerializer(ModelSerializer):
"last_used", "last_used",
"expires", "expires",
] ]
extra_args = {"uuid": {"read_only": True}}
class AuthenticatedSessionViewSet( class AuthenticatedSessionViewSet(
@ -101,9 +108,10 @@ class AuthenticatedSessionViewSet(
): ):
"""AuthenticatedSession Viewset""" """AuthenticatedSession Viewset"""
queryset = AuthenticatedSession.objects.all() lookup_field = "uuid"
queryset = AuthenticatedSession.objects.select_related("session").all()
serializer_class = AuthenticatedSessionSerializer serializer_class = AuthenticatedSessionSerializer
search_fields = ["user__username", "last_ip", "last_user_agent"] search_fields = ["user__username", "session__last_ip", "session__last_user_agent"]
filterset_fields = ["user__username", "last_ip", "last_user_agent"] filterset_fields = ["user__username", "session__last_ip", "session__last_user_agent"]
ordering = ["user__username"] ordering = ["user__username"]
owner_field = "user" owner_field = "user"

View File

@ -1,14 +1,11 @@
"""User API Views""" """User API Views"""
from datetime import timedelta from datetime import timedelta
from importlib import import_module
from json import loads from json import loads
from typing import Any from typing import Any
from django.conf import settings
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.base import SessionBase
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
@ -72,8 +69,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,
@ -92,7 +89,6 @@ from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger() LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
class UserGroupSerializer(ModelSerializer): class UserGroupSerializer(ModelSerializer):
@ -776,10 +772,6 @@ 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:
sessions = AuthenticatedSession.objects.filter(user=instance) Session.objects.filter(authenticatedsession__user=instance).delete()
session_ids = sessions.values_list("session_key", flat=True)
for session in session_ids:
SessionStore(session).delete()
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,6 +24,15 @@ 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

@ -0,0 +1,15 @@
"""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,9 +2,14 @@
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
@ -20,6 +25,40 @@ 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

@ -0,0 +1,238 @@
# 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", "0043_alter_group_options"),
("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

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

View File

@ -1,6 +1,7 @@
"""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
@ -9,6 +10,7 @@ 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
@ -646,19 +648,30 @@ 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 = (
"Link to a user with identical email address. Can have security implications " "email_link",
"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 = (
"Use the user's email address, but deny enrollment when the email address already exists." "email_deny",
_(
"Use the user's email address, but deny enrollment when the email address already "
"exists."
),
) )
USERNAME_LINK = "username_link", _( USERNAME_LINK = (
"Link to a user with identical username. Can have security implications " "username_link",
"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 = (
"Use the user's username, but deny enrollment when the username already exists." "username_deny",
_("Use the user's username, but deny enrollment when the username already exists."),
) )
@ -666,12 +679,16 @@ 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 = (
"Link to a group with identical name. Can have security implications " "name_link",
"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 = (
"Use the group name, but deny enrollment when the name already exists." "name_deny",
_("Use the group name, but deny enrollment when the name already exists."),
) )
@ -730,8 +747,7 @@ 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 " "How the source determines if an existing group should be used or a new group created."
"a new group created."
), ),
) )
@ -1012,45 +1028,75 @@ class PropertyMapping(SerializerModel, ManagedModel):
verbose_name_plural = _("Property Mappings") verbose_name_plural = _("Property Mappings")
class AuthenticatedSession(ExpiringModel): class Session(ExpiringModel, AbstractBaseSession):
"""Additional session class for authenticated users. Augments the standard django session """User session with extra fields for fast access"""
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.
"""
uuid = models.UUIDField(default=uuid4, primary_key=True) # Remove upstream field because we're using our own ExpiringModel
expire_date = None
session_data = models.BinaryField(_("session data"))
session_key = models.CharField(max_length=40) # Keep in sync with Session.Keys
user = models.ForeignKey(User, on_delete=models.CASCADE) last_ip = models.GenericIPAddressField()
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 {self.session_key[:10]}" return f"Authenticated Session {str(self.pk)[: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"""
from authentik.root.middleware import ClientIPMiddleware if not hasattr(request, "session") or not request.session.exists(
request.session.session_key
if not hasattr(request, "session") or not request.session.session_key: ):
return None return None
return AuthenticatedSession( return AuthenticatedSession(
session_key=request.session.session_key, session=Session.objects.filter(session_key=request.session.session_key).first(),
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(),
) )

168
authentik/core/sessions.py Normal file
View File

@ -0,0 +1,168 @@
"""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,14 +1,10 @@
"""authentik core signals""" """authentik core signals"""
from importlib import import_module from django.contrib.auth.signals import user_logged_in
from django.conf import settings
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.contrib.sessions.backends.base import SessionBase
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_save, pre_delete, pre_save from django.db.models.signals import post_delete, post_save, 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
@ -18,6 +14,7 @@ from authentik.core.models import (
AuthenticatedSession, AuthenticatedSession,
BackchannelProvider, BackchannelProvider,
ExpiringModel, ExpiringModel,
Session,
User, User,
default_token_duration, default_token_duration,
) )
@ -28,7 +25,6 @@ password_changed = Signal()
login_failed = Signal() login_failed = Signal()
LOGGER = get_logger() LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
@receiver(post_save, sender=Application) @receiver(post_save, sender=Application)
@ -53,18 +49,10 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
session.save() session.save()
@receiver(user_logged_out) @receiver(post_delete, sender=AuthenticatedSession)
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"""
SessionStore(instance.session_key).delete() Session.objects.filter(session_key=instance.pk).delete()
@receiver(pre_save) @receiver(pre_save)

View File

@ -2,22 +2,16 @@
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()
@ -38,40 +32,6 @@ 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

@ -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 User from authentik.core.models import AuthenticatedSession, Session, User
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
@ -30,3 +30,18 @@ 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,8 +3,6 @@
from datetime import datetime from datetime import datetime
from json import loads 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
@ -12,6 +10,7 @@ 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,
@ -381,12 +380,15 @@ 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()
AuthenticatedSession.objects.create( session = Session.objects.create(
user=user,
session_key=session_id, session_key=session_id,
last_ip="", last_ip="255.255.255.255",
last_user_agent="",
)
AuthenticatedSession.objects.create(
session=session,
user=user,
) )
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(
@ -397,5 +399,7 @@ class TestUsersAPI(APITestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIsNone(cache.get(KEY_PREFIX + session_id)) self.assertFalse(Session.objects.filter(session_key=session_id).exists())
self.assertFalse(AuthenticatedSession.objects.filter(session_key=session_id).exists()) self.assertFalse(
AuthenticatedSession.objects.filter(session__session_key=session_id).exists()
)

View File

@ -1,7 +1,5 @@
"""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
@ -29,7 +27,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 SessionMiddleware from authentik.root.asgi_middleware import AuthMiddlewareStack
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
@ -99,9 +97,7 @@ api_urlpatterns = [
websocket_urlpatterns = [ websocket_urlpatterns = [
path( path(
"ws/client/", "ws/client/",
ChannelsLoggingMiddleware( ChannelsLoggingMiddleware(AuthMiddlewareStack(MessageConsumer.as_asgi())),
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_key.encode("ascii")).hexdigest(), "id": sha256(instance.session.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_key) session = SessionStore(request_or_session.session.session_key)
return session.get(SESSION_LOGIN_EVENT, None) return session.get(SESSION_LOGIN_EVENT, None)

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 AuthenticatedSession, User from authentik.core.models import 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,9 +203,7 @@ 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 = AuthenticatedSession.objects.filter( session = request.session["authenticatedsession"]
session_key=request.session.session_key
).first()
access_token = AccessToken( access_token = AccessToken(
provider=provider, provider=provider,
user=user, user=user,

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_key) id_token.sid = hash_session_key(token.session.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

@ -0,0 +1,116 @@
# 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", "0044_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

@ -12,7 +12,9 @@ def user_logged_out_oauth_access_token(sender, request: HttpRequest, user: User,
"""Revoke access 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(user=user, session__session_key=request.session.session_key).delete() AccessToken.objects.filter(
user=user, session__session__session_key=request.session.session_key
).delete()
@receiver(post_save, sender=User) @receiver(post_save, sender=User)

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, AuthenticatedSession from authentik.core.models import Application
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 (
@ -316,9 +316,7 @@ 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=AuthenticatedSession.objects.filter( session=request.session["authenticatedsession"],
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:
@ -615,9 +613,7 @@ 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=AuthenticatedSession.objects.filter( session=self.request.session["authenticatedsession"],
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_key) proxy_on_logout.delay(instance.session.session_key)

View File

@ -0,0 +1,60 @@
# 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", "0044_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

@ -2,7 +2,7 @@
from django.test import TransactionTestCase from django.test import TransactionTestCase
from authentik.core.models import Application, AuthenticatedSession from authentik.core.models import Application, AuthenticatedSession, Session
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,13 +36,15 @@ 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=AuthenticatedSession.objects.create( session=auth_session,
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,7 +1,5 @@
"""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
@ -12,7 +10,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 SessionMiddleware from authentik.root.asgi_middleware import AuthMiddlewareStack
from authentik.root.middleware import ChannelsLoggingMiddleware from authentik.root.middleware import ChannelsLoggingMiddleware
urlpatterns = [ urlpatterns = [
@ -31,9 +29,7 @@ urlpatterns = [
websocket_urlpatterns = [ websocket_urlpatterns = [
path( path(
"ws/rac/<str:token>/", "ws/rac/<str:token>/",
ChannelsLoggingMiddleware( ChannelsLoggingMiddleware(AuthMiddlewareStack(RACClientConsumer.as_asgi())),
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, AuthenticatedSession from authentik.core.models import Application
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
@ -113,9 +113,7 @@ 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=AuthenticatedSession.objects.filter( session=self.request.session["authenticatedsession"],
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

@ -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(int(self.client.session["_auth_user_id"]), token.user.pk) self.assertEqual(self.client.session["authenticatedsession"].user.pk, 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,8 +1,12 @@
"""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
@ -33,3 +37,48 @@ 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) -> str: def decode_session_key(key: str | None) -> str | None:
"""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,7 +64,11 @@ 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(session_key) request.session = self.SessionStore(
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

@ -1,23 +0,0 @@
"""
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,7 +7,6 @@ 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
@ -43,7 +42,6 @@ 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,
@ -229,17 +227,7 @@ 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
match CONFIG.get("session_storage", "cache"): SESSION_ENGINE = "authentik.core.sessions"
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
@ -256,7 +244,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",
"django.contrib.auth.middleware.AuthenticationMiddleware", "authentik.core.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

@ -255,6 +255,7 @@ 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)

View File

@ -6,14 +6,12 @@ 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()
@ -91,7 +89,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(SESSION_KEY_LAST_IP) last_ip = request.session.get(request.session.model.Keys.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:
@ -111,10 +109,7 @@ 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[SESSION_KEY_LAST_IP] = new_ip request.session[request.session.model.Keys.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 AuthenticatedSession, User from authentik.core.models import Session, 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,7 +20,6 @@ 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
@ -73,7 +72,9 @@ 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[SESSION_KEY_LAST_IP] = ClientIPMiddleware.get_client_ip(self.request) self.request.session[self.request.session.model.Keys.LAST_IP] = (
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
@ -112,7 +113,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:
AuthenticatedSession.objects.filter( Session.objects.filter(
user=user, authenticatedsession__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,12 +3,10 @@
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 from authentik.core.models import AuthenticatedSession, Session
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
@ -74,12 +72,13 @@ class TestUserLoginStage(FlowTestCase):
session.save() session.save()
key = generate_id() key = generate_id()
other_session = AuthenticatedSession.objects.create( 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})
@ -87,8 +86,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_key=key)) self.assertFalse(AuthenticatedSession.objects.filter(session__session_key=key))
self.assertFalse(cache.has_key(f"{KEY_PREFIX}{key}")) self.assertFalse(Session.objects.filter(session_key=key).exists())
def test_expiry(self): def test_expiry(self):
"""Test with expiry""" """Test with expiry"""
@ -108,7 +107,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 = AuthenticatedSession.objects.filter(session_key=session_key).first() session = Session.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(),
@ -143,7 +142,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 = AuthenticatedSession.objects.filter(session_key=session_key).first() session = Session.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

@ -129,6 +129,7 @@ skip = [
"**/web/src/locales", "**/web/src/locales",
"**/web/xliff", "**/web/xliff",
"./web/storybook-static", "./web/storybook-static",
"./web/custom-elements.json",
"./website/build", "./website/build",
"./gen-ts-api", "./gen-ts-api",
"./gen-py-api", "./gen-py-api",

View File

@ -4276,14 +4276,6 @@ paths:
operationId: core_authenticated_sessions_list operationId: core_authenticated_sessions_list
description: AuthenticatedSession Viewset description: AuthenticatedSession Viewset
parameters: parameters:
- in: query
name: last_ip
schema:
type: string
- in: query
name: last_user_agent
schema:
type: string
- name: ordering - name: ordering
required: false required: false
in: query in: query
@ -4308,6 +4300,14 @@ paths:
description: A search term. description: A search term.
schema: schema:
type: string type: string
- in: query
name: session__last_ip
schema:
type: string
- in: query
name: session__last_user_agent
schema:
type: string
- in: query - in: query
name: user__username name: user__username
schema: schema:
@ -4345,7 +4345,6 @@ paths:
schema: schema:
type: string type: string
format: uuid format: uuid
description: A UUID string identifying this Authenticated Session.
required: true required: true
tags: tags:
- core - core
@ -4379,7 +4378,6 @@ paths:
schema: schema:
type: string type: string
format: uuid format: uuid
description: A UUID string identifying this Authenticated Session.
required: true required: true
tags: tags:
- core - core
@ -4410,7 +4408,6 @@ paths:
schema: schema:
type: string type: string
format: uuid format: uuid
description: A UUID string identifying this Authenticated Session.
required: true required: true
tags: tags:
- core - core
@ -40150,8 +40147,10 @@ components:
type: integer type: integer
last_ip: last_ip:
type: string type: string
readOnly: true
last_user_agent: last_user_agent:
type: string type: string
readOnly: true
last_used: last_used:
type: string type: string
format: date-time format: date-time
@ -40159,13 +40158,15 @@ components:
expires: expires:
type: string type: string
format: date-time format: date-time
nullable: true readOnly: true
required: required:
- asn - asn
- current - current
- expires
- geo_ip - geo_ip
- last_ip - last_ip
- last_used - last_used
- last_user_agent
- user - user
- user_agent - user_agent
AuthenticationEnum: AuthenticationEnum:

View File

@ -0,0 +1,56 @@
---
title: Release 2025.4
slug: "/releases/2025.4"
---
:::::note
2025.4 has not been released yet! We're publishing these release notes as a preview of what's to come, and for our awesome beta testers trying out release candidates.
To try out the release candidate, replace your Docker image tag with the latest release candidate number, such as 2025.4.0-rc1. You can find the latest one in [the latest releases on GitHub](https://github.com/goauthentik/authentik/releases). If you don't find any, it means we haven't released one yet.
:::::
## Breaking changes
### Manual action may be required
#### Sessions are now stored in the database
Previously, sessions were stored by default in the cache. Now, they are stored in the database. This allows for numerous other performance improvements. On high traffic instances, requests to old instances after the upgrade has started will fail to authenticate.
## New features
## Upgrading
This release does not introduce any new requirements. You can follow the upgrade instructions below; for more detailed information about upgrading authentik, refer to our [Upgrade documentation](../../install-config/upgrade.mdx).
:::warning
When you upgrade, be aware that the version of the authentik instance and of any outposts must be the same. We recommended that you always upgrade any outposts at the same time you upgrade your authentik instance.
:::
### Docker Compose
To upgrade, download the new docker-compose file and update the Docker stack with the new version, using these commands:
```shell
wget -O docker-compose.yml https://goauthentik.io/version/xxxx.x/docker-compose.yml
docker compose up -d
```
The `-O` flag retains the downloaded file's name, overwriting any existing local file with the same name.
### Kubernetes
Upgrade the Helm Chart to the new version, using the following commands:
```shell
helm repo update
helm upgrade authentik authentik/authentik -f values.yaml --version ^2025.4
```
## Minor changes/fixes
<!-- _Insert the output of `make gen-changelog` here_ -->
## API Changes
<!-- _Insert output of `make gen-diff` here_ -->