diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index 700a32ad73..c862ddf892 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -36,6 +36,7 @@ from authentik.core.models import ( GroupSourceConnection, PropertyMapping, Provider, + Session, Source, User, UserSourceConnection, @@ -108,6 +109,7 @@ def excluded_models() -> list[type[Model]]: Policy, PolicyBindingModel, # Classes that have other dependencies + Session, AuthenticatedSession, # Classes which are only internally managed # FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin diff --git a/authentik/core/api/authenticated_sessions.py b/authentik/core/api/authenticated_sessions.py index 03742d82d8..422a496b30 100644 --- a/authentik/core/api/authenticated_sessions.py +++ b/authentik/core/api/authenticated_sessions.py @@ -5,6 +5,7 @@ from typing import TypedDict from rest_framework import mixins from rest_framework.fields import SerializerMethodField from rest_framework.request import Request +from rest_framework.serializers import CharField, DateTimeField, IPAddressField from rest_framework.viewsets import GenericViewSet from ua_parser import user_agent_parser @@ -54,6 +55,11 @@ class UserAgentDict(TypedDict): class AuthenticatedSessionSerializer(ModelSerializer): """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() user_agent = SerializerMethodField() geo_ip = SerializerMethodField() @@ -62,19 +68,19 @@ class AuthenticatedSessionSerializer(ModelSerializer): def get_current(self, instance: AuthenticatedSession) -> bool: """Check if session is currently active session""" 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: """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 """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 """Get ASN Data""" - return ASN_CONTEXT_PROCESSOR.asn_dict(instance.last_ip) + return ASN_CONTEXT_PROCESSOR.asn_dict(instance.session.last_ip) class Meta: model = AuthenticatedSession @@ -90,6 +96,7 @@ class AuthenticatedSessionSerializer(ModelSerializer): "last_used", "expires", ] + extra_args = {"uuid": {"read_only": True}} class AuthenticatedSessionViewSet( @@ -101,9 +108,10 @@ class AuthenticatedSessionViewSet( ): """AuthenticatedSession Viewset""" - queryset = AuthenticatedSession.objects.all() + lookup_field = "uuid" + queryset = AuthenticatedSession.objects.select_related("session").all() serializer_class = AuthenticatedSessionSerializer - search_fields = ["user__username", "last_ip", "last_user_agent"] - filterset_fields = ["user__username", "last_ip", "last_user_agent"] + search_fields = ["user__username", "session__last_ip", "session__last_user_agent"] + filterset_fields = ["user__username", "session__last_ip", "session__last_user_agent"] ordering = ["user__username"] owner_field = "user" diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index f6625b9506..6378965bf2 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -1,14 +1,11 @@ """User API Views""" from datetime import timedelta -from importlib import import_module from json import loads from typing import Any -from django.conf import settings from django.contrib.auth import update_session_auth_hash 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.transaction import atomic from django.db.utils import IntegrityError @@ -72,8 +69,8 @@ from authentik.core.middleware import ( from authentik.core.models import ( USER_ATTRIBUTE_TOKEN_EXPIRING, USER_PATH_SERVICE_ACCOUNT, - AuthenticatedSession, Group, + Session, Token, TokenIntents, User, @@ -92,7 +89,6 @@ from authentik.stages.email.tasks import send_mails from authentik.stages.email.utils import TemplateEmailMessage LOGGER = get_logger() -SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore class UserGroupSerializer(ModelSerializer): @@ -776,10 +772,6 @@ class UserViewSet(UsedByMixin, ModelViewSet): response = super().partial_update(request, *args, **kwargs) instance: User = self.get_object() if not instance.is_active: - sessions = AuthenticatedSession.objects.filter(user=instance) - session_ids = sessions.values_list("session_key", flat=True) - for session in session_ids: - SessionStore(session).delete() - sessions.delete() + Session.objects.filter(authenticatedsession__user=instance).delete() LOGGER.debug("Deleted user's sessions", user=instance.username) return response diff --git a/authentik/core/auth.py b/authentik/core/auth.py index 0eb5186ce6..bb7708e0bd 100644 --- a/authentik/core/auth.py +++ b/authentik/core/auth.py @@ -24,6 +24,15 @@ class InbuiltBackend(ModelBackend): self.set_method("password", request) 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): """Set method data on current flow, if possbiel""" if not request: diff --git a/authentik/core/management/commands/clearsessions.py b/authentik/core/management/commands/clearsessions.py new file mode 100644 index 0000000000..b06fdcbfaa --- /dev/null +++ b/authentik/core/management/commands/clearsessions.py @@ -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() diff --git a/authentik/core/middleware.py b/authentik/core/middleware.py index 811b1eceb2..e5b27136ad 100644 --- a/authentik/core/middleware.py +++ b/authentik/core/middleware.py @@ -2,9 +2,14 @@ from collections.abc import Callable from contextvars import ContextVar +from functools import partial 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.utils.deprecation import MiddlewareMixin +from django.utils.functional import SimpleLazyObject from django.utils.translation import override from sentry_sdk.api import set_tag 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) +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: """Middleware to impersonate users""" diff --git a/authentik/core/migrations/0044_session_and_more.py b/authentik/core/migrations/0044_session_and_more.py new file mode 100644 index 0000000000..a5f1e68584 --- /dev/null +++ b/authentik/core/migrations/0044_session_and_more.py @@ -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, + ), + ] diff --git a/authentik/core/migrations/0045_delete_oldauthenticatedsession.py b/authentik/core/migrations/0045_delete_oldauthenticatedsession.py new file mode 100644 index 0000000000..42c7389cc0 --- /dev/null +++ b/authentik/core/migrations/0045_delete_oldauthenticatedsession.py @@ -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", + ), + ] diff --git a/authentik/core/models.py b/authentik/core/models.py index 216bb0320f..e85a21c811 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -1,6 +1,7 @@ """authentik core models""" from datetime import datetime +from enum import StrEnum from hashlib import sha256 from typing import Any, Optional, Self 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.models import AbstractUser 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.models import Q, QuerySet, options 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""" IDENTIFIER = "identifier", _("Use the source-specific identifier") - EMAIL_LINK = "email_link", _( - "Link to a user with identical email address. Can have security implications " - "when a source doesn't validate email addresses." + EMAIL_LINK = ( + "email_link", + _( + "Link to a user with identical email address. Can have security implications " + "when a source doesn't validate email addresses." + ), ) - EMAIL_DENY = "email_deny", _( - "Use the user's email address, but deny enrollment when the email address already exists." + EMAIL_DENY = ( + "email_deny", + _( + "Use the user's email address, but deny enrollment when the email address already " + "exists." + ), ) - USERNAME_LINK = "username_link", _( - "Link to a user with identical username. Can have security implications " - "when a username is used with another source." + USERNAME_LINK = ( + "username_link", + _( + "Link to a user with identical username. Can have security implications " + "when a username is used with another source." + ), ) - USERNAME_DENY = "username_deny", _( - "Use the user's username, but deny enrollment when the username already exists." + USERNAME_DENY = ( + "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""" IDENTIFIER = "identifier", _("Use the source-specific identifier") - NAME_LINK = "name_link", _( - "Link to a group with identical name. Can have security implications " - "when a group name is used with another source." + NAME_LINK = ( + "name_link", + _( + "Link to a group with identical name. Can have security implications " + "when a group name is used with another source." + ), ) - NAME_DENY = "name_deny", _( - "Use the group name, but deny enrollment when the name already exists." + NAME_DENY = ( + "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, default=SourceGroupMatchingModes.IDENTIFIER, 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." ), ) @@ -1012,45 +1028,75 @@ class PropertyMapping(SerializerModel, ManagedModel): verbose_name_plural = _("Property Mappings") -class AuthenticatedSession(ExpiringModel): - """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. - """ +class Session(ExpiringModel, AbstractBaseSession): + """User session with extra fields for fast access""" - 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) - user = models.ForeignKey(User, on_delete=models.CASCADE) - - last_ip = models.TextField() + # Keep in sync with Session.Keys + last_ip = models.GenericIPAddressField() last_user_agent = models.TextField(blank=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: verbose_name = _("Authenticated Session") verbose_name_plural = _("Authenticated Sessions") - indexes = ExpiringModel.Meta.indexes + [ - models.Index(fields=["session_key"]), - ] def __str__(self) -> str: - return f"Authenticated Session {self.session_key[:10]}" + return f"Authenticated Session {str(self.pk)[:10]}" @staticmethod def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]: """Create a new session from a http request""" - from authentik.root.middleware import ClientIPMiddleware - - if not hasattr(request, "session") or not request.session.session_key: + if not hasattr(request, "session") or not request.session.exists( + request.session.session_key + ): return None return AuthenticatedSession( - session_key=request.session.session_key, + session=Session.objects.filter(session_key=request.session.session_key).first(), user=user, - last_ip=ClientIPMiddleware.get_client_ip(request), - last_user_agent=request.META.get("HTTP_USER_AGENT", ""), - expires=request.session.get_expiry_date(), ) diff --git a/authentik/core/sessions.py b/authentik/core/sessions.py new file mode 100644 index 0000000000..c48a74f7c2 --- /dev/null +++ b/authentik/core/sessions.py @@ -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) diff --git a/authentik/core/signals.py b/authentik/core/signals.py index 8632376ed9..c4c639c9bc 100644 --- a/authentik/core/signals.py +++ b/authentik/core/signals.py @@ -1,14 +1,10 @@ """authentik core signals""" -from importlib import import_module - -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.contrib.auth.signals import user_logged_in from django.core.cache import cache from django.core.signals import Signal 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.http.request import HttpRequest from structlog.stdlib import get_logger @@ -18,6 +14,7 @@ from authentik.core.models import ( AuthenticatedSession, BackchannelProvider, ExpiringModel, + Session, User, default_token_duration, ) @@ -28,7 +25,6 @@ password_changed = Signal() login_failed = Signal() LOGGER = get_logger() -SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore @receiver(post_save, sender=Application) @@ -53,18 +49,10 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_): session.save() -@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) +@receiver(post_delete, sender=AuthenticatedSession) def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_): """Delete session when authenticated session is deleted""" - SessionStore(instance.session_key).delete() + Session.objects.filter(session_key=instance.pk).delete() @receiver(pre_save) diff --git a/authentik/core/tasks.py b/authentik/core/tasks.py index c2e6929a20..80ca278960 100644 --- a/authentik/core/tasks.py +++ b/authentik/core/tasks.py @@ -2,22 +2,16 @@ 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 structlog.stdlib import get_logger from authentik.core.models import ( USER_ATTRIBUTE_EXPIRES, USER_ATTRIBUTE_GENERATED, - AuthenticatedSession, ExpiringModel, User, ) from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task -from authentik.lib.config import CONFIG from authentik.root.celery import CELERY_APP LOGGER = get_logger() @@ -38,40 +32,6 @@ def clean_expired_models(self: SystemTask): obj.expire_action() LOGGER.debug("Expired models", model=cls, amount=amount) 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) diff --git a/authentik/core/tests/test_authenticated_sessions_api.py b/authentik/core/tests/test_authenticated_sessions_api.py index a079dd5f84..c6b41e5639 100644 --- a/authentik/core/tests/test_authenticated_sessions_api.py +++ b/authentik/core/tests/test_authenticated_sessions_api.py @@ -5,7 +5,7 @@ from json import loads from django.urls.base import reverse 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 @@ -30,3 +30,18 @@ class TestAuthenticatedSessionsAPI(APITestCase): self.assertEqual(response.status_code, 200) body = loads(response.content.decode()) 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) diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index 94ec3a616e..ff5a28bec4 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -3,8 +3,6 @@ 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 rest_framework.test import APITestCase @@ -12,6 +10,7 @@ from authentik.brands.models import Brand from authentik.core.models import ( USER_ATTRIBUTE_TOKEN_EXPIRING, AuthenticatedSession, + Session, Token, User, UserTypes, @@ -381,12 +380,15 @@ class TestUsersAPI(APITestCase): """Ensure sessions are deleted when a user is deactivated""" user = create_test_admin_user() session_id = generate_id() - AuthenticatedSession.objects.create( - user=user, + session = Session.objects.create( 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) response = self.client.patch( @@ -397,5 +399,7 @@ class TestUsersAPI(APITestCase): ) self.assertEqual(response.status_code, 200) - self.assertIsNone(cache.get(KEY_PREFIX + session_id)) - self.assertFalse(AuthenticatedSession.objects.filter(session_key=session_id).exists()) + self.assertFalse(Session.objects.filter(session_key=session_id).exists()) + self.assertFalse( + AuthenticatedSession.objects.filter(session__session_key=session_id).exists() + ) diff --git a/authentik/core/urls.py b/authentik/core/urls.py index cc3d388e56..9f983c6359 100644 --- a/authentik/core/urls.py +++ b/authentik/core/urls.py @@ -1,7 +1,5 @@ """authentik URL Configuration""" -from channels.auth import AuthMiddleware -from channels.sessions import CookieMiddleware from django.conf import settings from django.contrib.auth.decorators import login_required from django.urls import path @@ -29,7 +27,7 @@ from authentik.core.views.interface import ( RootRedirectView, ) 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.middleware import ChannelsLoggingMiddleware @@ -99,9 +97,7 @@ api_urlpatterns = [ websocket_urlpatterns = [ path( "ws/client/", - ChannelsLoggingMiddleware( - CookieMiddleware(SessionMiddleware(AuthMiddleware(MessageConsumer.as_asgi()))) - ), + ChannelsLoggingMiddleware(AuthMiddlewareStack(MessageConsumer.as_asgi())), ), ] diff --git a/authentik/enterprise/providers/ssf/signals.py b/authentik/enterprise/providers/ssf/signals.py index b678aff246..d66e874859 100644 --- a/authentik/enterprise/providers/ssf/signals.py +++ b/authentik/enterprise/providers/ssf/signals.py @@ -102,7 +102,7 @@ def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSessi "format": "complex", "session": { "format": "opaque", - "id": sha256(instance.session_key.encode("ascii")).hexdigest(), + "id": sha256(instance.session.session_key.encode("ascii")).hexdigest(), }, "user": { "format": "email", diff --git a/authentik/events/signals.py b/authentik/events/signals.py index e3408c3b21..232ef605a8 100644 --- a/authentik/events/signals.py +++ b/authentik/events/signals.py @@ -59,7 +59,7 @@ def get_login_event(request_or_session: HttpRequest | AuthenticatedSession | Non session = request_or_session.session if isinstance(request_or_session, AuthenticatedSession): 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) diff --git a/authentik/lib/expression/evaluator.py b/authentik/lib/expression/evaluator.py index 3803572e90..c20cacff1a 100644 --- a/authentik/lib/expression/evaluator.py +++ b/authentik/lib/expression/evaluator.py @@ -18,7 +18,7 @@ from sentry_sdk import start_span from sentry_sdk.tracing import Span 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.lib.expression.exceptions import ControlFlowException from authentik.lib.utils.http import get_http_session @@ -203,9 +203,7 @@ class BaseEvaluator: provider = OAuth2Provider.objects.get(name=provider) session = None if hasattr(request, "session") and request.session.session_key: - session = AuthenticatedSession.objects.filter( - session_key=request.session.session_key - ).first() + session = request.session["authenticatedsession"] access_token = AccessToken( provider=provider, user=user, diff --git a/authentik/providers/oauth2/id_token.py b/authentik/providers/oauth2/id_token.py index 27c5f2600f..79ed0517b5 100644 --- a/authentik/providers/oauth2/id_token.py +++ b/authentik/providers/oauth2/id_token.py @@ -126,7 +126,7 @@ class IDToken: id_token.iat = int(now.timestamp()) id_token.auth_time = int(token.auth_time.timestamp()) 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 auth_event = get_login_event(token.session) diff --git a/authentik/providers/oauth2/migrations/0028_migrate_session.py b/authentik/providers/oauth2/migrations/0028_migrate_session.py new file mode 100644 index 0000000000..673793d367 --- /dev/null +++ b/authentik/providers/oauth2/migrations/0028_migrate_session.py @@ -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", + ), + ] diff --git a/authentik/providers/oauth2/signals.py b/authentik/providers/oauth2/signals.py index e7426b78f8..0e69f0ae1c 100644 --- a/authentik/providers/oauth2/signals.py +++ b/authentik/providers/oauth2/signals.py @@ -12,7 +12,9 @@ def user_logged_out_oauth_access_token(sender, request: HttpRequest, user: User, """Revoke access tokens upon user logout""" if not request.session or not request.session.session_key: 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) diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py index 74b3f98632..718d0afc8c 100644 --- a/authentik/providers/oauth2/views/authorize.py +++ b/authentik/providers/oauth2/views/authorize.py @@ -15,7 +15,7 @@ from django.utils import timezone from django.utils.translation import gettext as _ 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.signals import get_login_event from authentik.flows.challenge import ( @@ -316,9 +316,7 @@ class OAuthAuthorizationParams: expires=now + timedelta_from_string(self.provider.access_code_validity), scope=self.scope, nonce=self.nonce, - session=AuthenticatedSession.objects.filter( - session_key=request.session.session_key - ).first(), + session=request.session["authenticatedsession"], ) if self.code_challenge and self.code_challenge_method: @@ -615,9 +613,7 @@ class OAuthFulfillmentStage(StageView): expires=access_token_expiry, provider=self.provider, auth_time=auth_event.created if auth_event else now, - session=AuthenticatedSession.objects.filter( - session_key=self.request.session.session_key - ).first(), + session=self.request.session["authenticatedsession"], ) id_token = IDToken.new(self.provider, token, self.request) diff --git a/authentik/providers/proxy/signals.py b/authentik/providers/proxy/signals.py index 1a38eee53f..48b9f9794d 100644 --- a/authentik/providers/proxy/signals.py +++ b/authentik/providers/proxy/signals.py @@ -20,4 +20,4 @@ def logout_proxy_revoke_direct(sender: type[User], request: HttpRequest, **_): @receiver(pre_delete, sender=AuthenticatedSession) def logout_proxy_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_): """Catch logout by expiring sessions being deleted""" - proxy_on_logout.delay(instance.session_key) + proxy_on_logout.delay(instance.session.session_key) diff --git a/authentik/providers/rac/migrations/0007_migrate_session.py b/authentik/providers/rac/migrations/0007_migrate_session.py new file mode 100644 index 0000000000..6695d371c3 --- /dev/null +++ b/authentik/providers/rac/migrations/0007_migrate_session.py @@ -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", + ), + ] diff --git a/authentik/providers/rac/tests/test_models.py b/authentik/providers/rac/tests/test_models.py index 4a9fad4e67..67a6678e55 100644 --- a/authentik/providers/rac/tests/test_models.py +++ b/authentik/providers/rac/tests/test_models.py @@ -2,7 +2,7 @@ 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.lib.generators import generate_id from authentik.providers.rac.models import ( @@ -36,13 +36,15 @@ class TestModels(TransactionTestCase): def test_settings_merge(self): """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( provider=self.provider, endpoint=self.endpoint, - session=AuthenticatedSession.objects.create( - user=self.user, - session_key=generate_id(), - ), + session=auth_session, ) path = f"/tmp/connection/{token.token}" # nosec self.assertEqual( diff --git a/authentik/providers/rac/urls.py b/authentik/providers/rac/urls.py index 07e6f661cc..a9322f4400 100644 --- a/authentik/providers/rac/urls.py +++ b/authentik/providers/rac/urls.py @@ -1,7 +1,5 @@ """rac urls""" -from channels.auth import AuthMiddleware -from channels.sessions import CookieMiddleware from django.urls import path 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_outpost import RACOutpostConsumer 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 urlpatterns = [ @@ -31,9 +29,7 @@ urlpatterns = [ websocket_urlpatterns = [ path( "ws/rac//", - ChannelsLoggingMiddleware( - CookieMiddleware(SessionMiddleware(AuthMiddleware(RACClientConsumer.as_asgi()))) - ), + ChannelsLoggingMiddleware(AuthMiddlewareStack(RACClientConsumer.as_asgi())), ), path( "ws/outpost_rac//", diff --git a/authentik/providers/rac/views.py b/authentik/providers/rac/views.py index e5187079ac..c9a9613470 100644 --- a/authentik/providers/rac/views.py +++ b/authentik/providers/rac/views.py @@ -8,7 +8,7 @@ from django.urls import reverse from django.utils.timezone import now 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.events.models import Event, EventAction from authentik.flows.challenge import RedirectChallenge @@ -113,9 +113,7 @@ class RACFinalStage(RedirectStage): provider=self.provider, endpoint=self.endpoint, settings=self.executor.plan.context.get("connection_settings", {}), - session=AuthenticatedSession.objects.filter( - session_key=self.request.session.session_key - ).first(), + session=self.request.session["authenticatedsession"], expires=now() + timedelta_from_string(self.provider.connection_expiry), expiring=True, ) diff --git a/authentik/recovery/tests.py b/authentik/recovery/tests.py index b6cca0c59d..0a939a82a4 100644 --- a/authentik/recovery/tests.py +++ b/authentik/recovery/tests.py @@ -50,7 +50,7 @@ class TestRecovery(TestCase): ) token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user) 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): """Test recovery view with invalid token""" diff --git a/authentik/root/asgi_middleware.py b/authentik/root/asgi_middleware.py index cecdb54e05..939c425de3 100644 --- a/authentik/root/asgi_middleware.py +++ b/authentik/root/asgi_middleware.py @@ -1,8 +1,12 @@ """ASGI middleware""" +from channels.auth import UserLazyObject 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 SessionMiddleware as UpstreamSessionMiddleware +from django.contrib.auth.models import AnonymousUser from authentik.root.middleware import SessionMiddleware as HTTPSessionMiddleware @@ -33,3 +37,48 @@ class SessionMiddleware(UpstreamSessionMiddleware): await wrapper.resolve_session() 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))) diff --git a/authentik/root/middleware.py b/authentik/root/middleware.py index 96e9d73eef..73e8f73303 100644 --- a/authentik/root/middleware.py +++ b/authentik/root/middleware.py @@ -49,7 +49,7 @@ class SessionMiddleware(UpstreamSessionMiddleware): return False @staticmethod - def decode_session_key(key: str) -> str: + def decode_session_key(key: str | None) -> str | None: """Decode raw session cookie, and parse JWT""" # We need to support the standard django format of just a session key # for testing setups, where the session is directly set @@ -64,7 +64,11 @@ class SessionMiddleware(UpstreamSessionMiddleware): def process_request(self, request: HttpRequest): raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME) 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: """ diff --git a/authentik/root/sessions/__init__.py b/authentik/root/sessions/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/authentik/root/sessions/pickle.py b/authentik/root/sessions/pickle.py deleted file mode 100644 index a01eaaf8ad..0000000000 --- a/authentik/root/sessions/pickle.py +++ /dev/null @@ -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 diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 24f56e7b39..dd90c2d670 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -7,7 +7,6 @@ from pathlib import Path import orjson from celery.schedules import crontab -from django.conf import ImproperlyConfigured from sentry_sdk import set_tag from xmlsec import enable_debug_trace @@ -43,7 +42,6 @@ SESSION_COOKIE_DOMAIN = CONFIG.get("cookie_domain", None) APPEND_SLASH = False AUTHENTICATION_BACKENDS = [ - "django.contrib.auth.backends.ModelBackend", BACKEND_INBUILT, BACKEND_APP_PASSWORD, BACKEND_LDAP, @@ -229,17 +227,7 @@ CACHES = { DJANGO_REDIS_SCAN_ITERSIZE = 1000 DJANGO_REDIS_IGNORE_EXCEPTIONS = True DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True -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" +SESSION_ENGINE = "authentik.core.sessions" # Configured via custom SessionMiddleware # SESSION_COOKIE_SAMESITE = "None" # SESSION_COOKIE_SECURE = True @@ -256,7 +244,7 @@ MIDDLEWARE = [ "django_prometheus.middleware.PrometheusBeforeMiddleware", "authentik.root.middleware.ClientIPMiddleware", "authentik.stages.user_login.middleware.BoundSessionMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", + "authentik.core.middleware.AuthenticationMiddleware", "authentik.core.middleware.RequestIDMiddleware", "authentik.brands.middleware.BrandMiddleware", "authentik.events.middleware.AuditMiddleware", diff --git a/authentik/stages/authenticator_email/tests.py b/authentik/stages/authenticator_email/tests.py index aefa923998..8a3e9b2884 100644 --- a/authentik/stages/authenticator_email/tests.py +++ b/authentik/stages/authenticator_email/tests.py @@ -255,6 +255,7 @@ class TestAuthenticatorEmailStage(FlowTestCase): ) masked_email = mask_email(self.user.email) self.assertEqual(masked_email, response.json()["email"]) + self.client.logout() # Test without email self.client.force_login(self.user_noemail) diff --git a/authentik/stages/user_login/middleware.py b/authentik/stages/user_login/middleware.py index 3ecbbbc431..8d50ac376f 100644 --- a/authentik/stages/user_login/middleware.py +++ b/authentik/stages/user_login/middleware.py @@ -6,14 +6,12 @@ from django.contrib.auth.views import redirect_to_login from django.http.request import HttpRequest 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.geoip import GEOIP_CONTEXT_PROCESSOR from authentik.lib.sentry import SentryIgnoredException from authentik.root.middleware import ClientIPMiddleware, SessionMiddleware 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_GEO = "authentik/stages/user_login/binding/geo" LOGGER = get_logger() @@ -91,7 +89,7 @@ class BoundSessionMiddleware(SessionMiddleware): def recheck_session(self, request: HttpRequest): """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) # Check changed 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: # Only set the last IP in the session if there's a binding specified # (== basically requires the user to be logged in) - 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", "") - ) + request.session[request.session.model.Keys.LAST_IP] = new_ip def recheck_session_net(self, binding: NetworkBinding, last_ip: str, new_ip: str): """Check network/ASN binding""" diff --git a/authentik/stages/user_login/stage.py b/authentik/stages/user_login/stage.py index 5f180a977c..ac312f4db4 100644 --- a/authentik/stages/user_login/stage.py +++ b/authentik/stages/user_login/stage.py @@ -8,7 +8,7 @@ from django.http import HttpRequest, HttpResponse from django.utils.translation import gettext as _ 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.flows.challenge import ChallengeResponse, WithUserInfoChallenge 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 ( SESSION_KEY_BINDING_GEO, SESSION_KEY_BINDING_NET, - SESSION_KEY_LAST_IP, ) from authentik.stages.user_login.models import UserLoginStage @@ -73,7 +72,9 @@ class UserLoginStageView(ChallengeStageView): """Set the sessions' last IP and session bindings""" 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_GEO] = stage.geoip_binding @@ -112,7 +113,7 @@ class UserLoginStageView(ChallengeStageView): if not self.executor.plan.context.get(PLAN_CONTEXT_SOURCE, None): messages.success(self.request, _("Successfully logged in!")) if self.executor.current_stage.terminate_other_sessions: - AuthenticatedSession.objects.filter( - user=user, + Session.objects.filter( + authenticatedsession__user=user, ).exclude(session_key=self.request.session.session_key).delete() return self.executor.stage_ok() diff --git a/authentik/stages/user_login/tests.py b/authentik/stages/user_login/tests.py index 62c6fa1fac..01570c4b50 100644 --- a/authentik/stages/user_login/tests.py +++ b/authentik/stages/user_login/tests.py @@ -3,12 +3,10 @@ from time import sleep 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.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.flows.markers import StageMarker from authentik.flows.models import FlowDesignation, FlowStageBinding @@ -74,12 +72,13 @@ class TestUserLoginStage(FlowTestCase): session.save() 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, - session_key=key, - last_ip=ClientIPMiddleware.default_ip, ) - cache.set(f"{KEY_PREFIX}{other_session.session_key}", "foo") response = self.client.post( 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.assertStageRedirects(response, reverse("authentik_core:root-redirect")) - self.assertFalse(AuthenticatedSession.objects.filter(session_key=key)) - self.assertFalse(cache.has_key(f"{KEY_PREFIX}{key}")) + self.assertFalse(AuthenticatedSession.objects.filter(session__session_key=key)) + self.assertFalse(Session.objects.filter(session_key=key).exists()) def test_expiry(self): """Test with expiry""" @@ -108,7 +107,7 @@ class TestUserLoginStage(FlowTestCase): self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) self.assertNotEqual(list(self.client.session.keys()), []) 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( session.expires.timestamp() - before_request.timestamp(), 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.assertNotEqual(list(self.client.session.keys()), []) 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( session.expires.timestamp() - _now, timedelta_from_string(self.stage.session_duration).total_seconds() diff --git a/pyproject.toml b/pyproject.toml index cb84b5854b..b541c2ed29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,6 +129,7 @@ skip = [ "**/web/src/locales", "**/web/xliff", "./web/storybook-static", + "./web/custom-elements.json", "./website/build", "./gen-ts-api", "./gen-py-api", diff --git a/schema.yml b/schema.yml index 57ef35c42f..3fc28b2136 100644 --- a/schema.yml +++ b/schema.yml @@ -4276,14 +4276,6 @@ paths: operationId: core_authenticated_sessions_list description: AuthenticatedSession Viewset parameters: - - in: query - name: last_ip - schema: - type: string - - in: query - name: last_user_agent - schema: - type: string - name: ordering required: false in: query @@ -4308,6 +4300,14 @@ paths: description: A search term. schema: type: string + - in: query + name: session__last_ip + schema: + type: string + - in: query + name: session__last_user_agent + schema: + type: string - in: query name: user__username schema: @@ -4345,7 +4345,6 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this Authenticated Session. required: true tags: - core @@ -4379,7 +4378,6 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this Authenticated Session. required: true tags: - core @@ -4410,7 +4408,6 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this Authenticated Session. required: true tags: - core @@ -40150,8 +40147,10 @@ components: type: integer last_ip: type: string + readOnly: true last_user_agent: type: string + readOnly: true last_used: type: string format: date-time @@ -40159,13 +40158,15 @@ components: expires: type: string format: date-time - nullable: true + readOnly: true required: - asn - current + - expires - geo_ip - last_ip - last_used + - last_user_agent - user - user_agent AuthenticationEnum: diff --git a/website/docs/releases/2025/v2025.4.md b/website/docs/releases/2025/v2025.4.md new file mode 100644 index 0000000000..c135ddb4f4 --- /dev/null +++ b/website/docs/releases/2025/v2025.4.md @@ -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 + + + +## API Changes + +