From 133fc38c05430a12befab94ffad37f3b06d75f0c Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 30 May 2021 00:10:50 +0200 Subject: [PATCH] core: initial authenticated sessions Signed-off-by: Jens Langhammer --- authentik/api/v2/urls.py | 2 + authentik/core/api/authenticated_sessions.py | 44 +++++ .../migrations/0022_authenticatedsession.py | 51 ++++++ authentik/core/models.py | 31 ++++ authentik/core/signals.py | 15 ++ schema.yml | 167 ++++++++++++++++++ 6 files changed, 310 insertions(+) create mode 100644 authentik/core/api/authenticated_sessions.py create mode 100644 authentik/core/migrations/0022_authenticatedsession.py diff --git a/authentik/api/v2/urls.py b/authentik/api/v2/urls.py index dade31d5e..6c8a185cf 100644 --- a/authentik/api/v2/urls.py +++ b/authentik/api/v2/urls.py @@ -11,6 +11,7 @@ from authentik.admin.api.workers import WorkerView from authentik.api.v2.config import ConfigView from authentik.api.views import APIBrowserView from authentik.core.api.applications import ApplicationViewSet +from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet from authentik.core.api.groups import GroupViewSet from authentik.core.api.propertymappings import PropertyMappingViewSet from authentik.core.api.providers import ProviderViewSet @@ -107,6 +108,7 @@ router = routers.DefaultRouter() router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks") router.register("admin/apps", AppsViewSet, basename="apps") +router.register("core/authenticated_sessions", AuthenticatedSessionViewSet) router.register("core/applications", ApplicationViewSet) router.register("core/groups", GroupViewSet) router.register("core/users", UserViewSet) diff --git a/authentik/core/api/authenticated_sessions.py b/authentik/core/api/authenticated_sessions.py new file mode 100644 index 000000000..3a5eab649 --- /dev/null +++ b/authentik/core/api/authenticated_sessions.py @@ -0,0 +1,44 @@ +"""AuthenticatedSessions API Viewset""" +from guardian.utils import get_anonymous_user +from rest_framework import mixins +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import GenericViewSet + +from authentik.core.models import AuthenticatedSession + + +class AuthenticatedSessionSerializer(ModelSerializer): + """AuthenticatedSession Serializer""" + + class Meta: + + model = AuthenticatedSession + fields = [ + "uuid", + "user", + "last_ip", + "last_user_agent", + "last_used", + "expires", + ] + + +class AuthenticatedSessionViewSet( + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + GenericViewSet, +): + """AuthenticatedSession Viewset""" + + queryset = AuthenticatedSession.objects.all() + serializer_class = AuthenticatedSessionSerializer + search_fields = ["user__username", "last_ip", "last_user_agent"] + filterset_fields = ["user__username", "last_ip", "last_user_agent"] + ordering = ["user__username"] + + def get_queryset(self): + user = self.request.user if self.request else get_anonymous_user() + if user.is_superuser: + return super().get_queryset() + return super().get_queryset().filter(user=user.pk) diff --git a/authentik/core/migrations/0022_authenticatedsession.py b/authentik/core/migrations/0022_authenticatedsession.py new file mode 100644 index 000000000..0b26abe07 --- /dev/null +++ b/authentik/core/migrations/0022_authenticatedsession.py @@ -0,0 +1,51 @@ +# Generated by Django 3.2.3 on 2021-05-29 22:14 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import authentik.core.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0021_alter_application_slug"), + ] + + operations = [ + migrations.CreateModel( + name="AuthenticatedSession", + fields=[ + ( + "expires", + models.DateTimeField( + default=authentik.core.models.default_token_duration + ), + ), + ("expiring", models.BooleanField(default=True)), + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, primary_key=True, serialize=False + ), + ), + ("session_key", models.CharField(max_length=40)), + ("last_ip", models.TextField()), + ("last_user_agent", models.TextField(blank=True)), + ("last_used", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/authentik/core/models.py b/authentik/core/models.py index 741f25cfa..71141f157 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -28,6 +28,7 @@ from authentik.flows.challenge import Challenge from authentik.flows.models import Flow from authentik.lib.config import CONFIG from authentik.lib.models import CreatedUpdatedModel, SerializerModel +from authentik.lib.utils.http import get_client_ip from authentik.managed.models import ManagedModel from authentik.policies.models import PolicyBindingModel @@ -452,3 +453,33 @@ class PropertyMapping(SerializerModel, ManagedModel): verbose_name = _("Property Mapping") 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. + """ + + uuid = models.UUIDField(default=uuid4, primary_key=True) + + session_key = models.CharField(max_length=40) + user = models.ForeignKey(User, on_delete=models.CASCADE) + + last_ip = models.TextField() + last_user_agent = models.TextField(blank=True) + last_used = models.DateTimeField(auto_now=True) + + @staticmethod + def from_request(request: HttpRequest, user: User) -> "AuthenticatedSession": + """Create a new session from a http request""" + return AuthenticatedSession( + session_key=request.session.session_key, + user=user, + last_ip=get_client_ip(request), + last_user_agent=request.META.get("HTTP_USER_AGENT", ""), + expires=request.session.get_expiry_date(), + ) diff --git a/authentik/core/signals.py b/authentik/core/signals.py index 089ed7bb1..7685c7255 100644 --- a/authentik/core/signals.py +++ b/authentik/core/signals.py @@ -1,9 +1,13 @@ """authentik core signals""" +from typing import TYPE_CHECKING + +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 from django.dispatch import receiver +from django.http.request import HttpRequest from prometheus_client import Gauge # Arguments: user: User, password: str @@ -13,6 +17,9 @@ GAUGE_MODELS = Gauge( "authentik_models", "Count of various objects", ["model_name", "app"] ) +if TYPE_CHECKING: + from authentik.core.models import User + @receiver(post_save) # pylint: disable=unused-argument @@ -33,3 +40,11 @@ def post_save_application(sender: type[Model], instance, created: bool, **_): # Also delete user application cache keys = cache.keys(user_app_cache_key("*")) cache.delete_many(keys) + + +@receiver(user_logged_in) +def user_logged_in_session(sender, request: HttpRequest, user: "User", **_): + """Create an AuthenticatedSession from request""" + from authentik.core.models import AuthenticatedSession + + AuthenticatedSession.from_request(request, user).save() diff --git a/schema.yml b/schema.yml index db673fce4..a73c9d5c2 100644 --- a/schema.yml +++ b/schema.yml @@ -1500,6 +1500,114 @@ paths: description: Bad request '403': $ref: '#/components/schemas/GenericError' + /api/v2beta/core/authenticated_sessions/: + get: + 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 + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: user__username + schema: + type: string + tags: + - core + security: + - authentik: [] + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedAuthenticatedSessionList' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + /api/v2beta/core/authenticated_sessions/{uuid}/: + get: + operationId: core_authenticated_sessions_retrieve + description: AuthenticatedSession Viewset + parameters: + - in: path + name: uuid + schema: + type: string + format: uuid + description: A UUID string identifying this authenticated session. + required: true + tags: + - core + security: + - authentik: [] + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatedSession' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + delete: + operationId: core_authenticated_sessions_destroy + description: AuthenticatedSession Viewset + parameters: + - in: path + name: uuid + schema: + type: string + format: uuid + description: A UUID string identifying this authenticated session. + required: true + tags: + - core + security: + - authentik: [] + - cookieAuth: [] + responses: + '204': + description: No response body + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' /api/v2beta/core/groups/: get: operationId: core_groups_list @@ -15454,6 +15562,30 @@ components: If empty, user will not be able to configure this stage. required: - name + AuthenticatedSession: + type: object + description: AuthenticatedSession Serializer + properties: + uuid: + type: string + format: uuid + user: + type: integer + last_ip: + type: string + last_user_agent: + type: string + last_used: + type: string + format: date-time + readOnly: true + expires: + type: string + format: date-time + required: + - last_ip + - last_used + - user AuthenticatorDuoChallenge: type: object description: Duo Challenge @@ -18894,6 +19026,41 @@ components: required: - pagination - results + PaginatedAuthenticatedSessionList: + type: object + properties: + pagination: + type: object + properties: + next: + type: number + previous: + type: number + count: + type: number + current: + type: number + total_pages: + type: number + start_index: + type: number + end_index: + type: number + required: + - next + - previous + - count + - current + - total_pages + - start_index + - end_index + results: + type: array + items: + $ref: '#/components/schemas/AuthenticatedSession' + required: + - pagination + - results PaginatedAuthenticatorDuoStageList: type: object properties: