From 1e11947dbb9594a6035d842925f474e14e5b1990 Mon Sep 17 00:00:00 2001 From: Roney Dsilva Date: Wed, 3 Dec 2025 18:14:03 +0530 Subject: [PATCH 1/8] feat: add bulk session revocation functionality for users --- .../admin/users/UserBulkRevokeSessionsForm.ts | 207 ++++++++++++++++++ web/src/admin/users/UserListPage.ts | 82 +++---- 2 files changed, 251 insertions(+), 38 deletions(-) create mode 100644 web/src/admin/users/UserBulkRevokeSessionsForm.ts diff --git a/web/src/admin/users/UserBulkRevokeSessionsForm.ts b/web/src/admin/users/UserBulkRevokeSessionsForm.ts new file mode 100644 index 000000000000..9d4cade27706 --- /dev/null +++ b/web/src/admin/users/UserBulkRevokeSessionsForm.ts @@ -0,0 +1,207 @@ +import "#elements/buttons/SpinnerButton/index"; + +import { DEFAULT_CONFIG } from "#common/api/config"; +import { EVENT_REFRESH } from "#common/constants"; +import { MessageLevel } from "#common/messages"; + +import { ModalButton } from "#elements/buttons/ModalButton"; +import { showMessage } from "#elements/messages/MessageContainer"; +import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table"; +import { SlottedTemplateResult } from "#elements/types"; + +import { CoreApi, User } from "@goauthentik/api"; + +import { msg, str } from "@lit/localize"; +import { html, nothing, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +type UserMetadata = { key: string; value: string }[]; + +@customElement("ak-user-bulk-revoke-sessions-table") +export class UserBulkRevokeSessionsTable extends Table { + paginated = false; + + @property({ attribute: false }) + objects: User[] = []; + + @property({ attribute: false }) + metadata!: (item: User) => UserMetadata; + + @state() + sessionCounts: Map = new Map(); + + async apiEndpoint(): Promise> { + // Fetch session counts for each user + for (const user of this.objects) { + try { + const sessions = await new CoreApi(DEFAULT_CONFIG).coreAuthenticatedSessionsList({ + userUsername: user.username, + }); + this.sessionCounts.set(user.pk, sessions.pagination.count); + } catch { + this.sessionCounts.set(user.pk, 0); + } + } + this.requestUpdate(); + + return Promise.resolve({ + pagination: { + count: this.objects.length, + current: 1, + totalPages: 1, + startIndex: 1, + endIndex: this.objects.length, + next: 0, + previous: 0, + }, + results: this.objects, + }); + } + + protected override rowLabel(item: User): string | null { + return item.username || null; + } + + protected get columns(): TableColumn[] { + return [[msg("Username")], [msg("Name")], [msg("Active Sessions")]]; + } + + row(item: User): SlottedTemplateResult[] { + const sessionCount = this.sessionCounts.get(item.pk); + return [ + html`${item.username}`, + html`${item.name || msg("No name set")}`, + html`${sessionCount !== undefined ? sessionCount : html``}`, + ]; + } + + renderToolbarContainer(): SlottedTemplateResult { + return nothing; + } +} + +@customElement("ak-user-bulk-revoke-sessions") +export class UserBulkRevokeSessionsForm extends ModalButton { + @property({ attribute: false }) + users: User[] = []; + + @state() + isRevoking = false; + + @state() + revokedCount = 0; + + async confirm(): Promise { + this.isRevoking = true; + this.revokedCount = 0; + + try { + for (const user of this.users) { + // Get all sessions for this user + const sessions = await new CoreApi(DEFAULT_CONFIG).coreAuthenticatedSessionsList({ + userUsername: user.username, + pageSize: 1000, // Get all sessions + }); + + // Delete each session + for (const session of sessions.results) { + if (session.uuid) { + await new CoreApi(DEFAULT_CONFIG).coreAuthenticatedSessionsDestroy({ + uuid: session.uuid, + }); + this.revokedCount++; + } + } + } + + this.onSuccess(); + this.dispatchEvent( + new CustomEvent(EVENT_REFRESH, { + bubbles: true, + composed: true, + }), + ); + this.open = false; + } catch (e) { + this.onError(e as Error); + throw e; + } finally { + this.isRevoking = false; + } + } + + onSuccess(): void { + showMessage({ + message: msg( + str`Successfully revoked ${this.revokedCount} session(s) for ${this.users.length} user(s)`, + ), + level: MessageLevel.success, + }); + } + + onError(e: Error): void { + showMessage({ + message: msg(str`Failed to revoke sessions: ${e.toString()}`), + level: MessageLevel.error, + }); + } + + renderModalInner(): TemplateResult { + return html`
+
+

${msg("Revoke Sessions")}

+
+
+
+
+

+ ${msg( + str`Are you sure you want to revoke all sessions for ${this.users.length} user(s)?`, + )} +

+

+ ${msg( + "This will force the selected users to re-authenticate on all their devices.", + )} +

+
+
+
+ { + return [ + { key: msg("Username"), value: item.username }, + { key: msg("Name"), value: item.name || "" }, + ]; + }} + > + +
+
+ { + return this.confirm(); + }} + class="pf-m-warning" + > + ${msg("Revoke Sessions")}   + { + this.open = false; + }} + class="pf-m-secondary" + > + ${msg("Cancel")} + +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-user-bulk-revoke-sessions-table": UserBulkRevokeSessionsTable; + "ak-user-bulk-revoke-sessions": UserBulkRevokeSessionsForm; + } +} diff --git a/web/src/admin/users/UserListPage.ts b/web/src/admin/users/UserListPage.ts index f617858b4f8c..3174711f0142 100644 --- a/web/src/admin/users/UserListPage.ts +++ b/web/src/admin/users/UserListPage.ts @@ -1,5 +1,6 @@ import "#admin/users/ServiceAccountForm"; import "#admin/users/UserActiveForm"; +import "#admin/users/UserBulkRevokeSessionsForm"; import "#admin/users/UserForm"; import "#admin/users/UserImpersonateForm"; import "#admin/users/UserPasswordForm"; @@ -162,45 +163,50 @@ export class UserListPage extends WithBrandConfig( const shouldShowWarning = this.selectedElements.find((el) => { return el.pk === currentUser?.pk || el.pk === originalUser?.pk; }); - return html` { - return [ - { key: msg("Username"), value: item.username }, - { key: msg("ID"), value: item.pk.toString() }, - { key: msg("UID"), value: item.uid }, - ]; - }} - .usedBy=${(item: User) => { - return new CoreApi(DEFAULT_CONFIG).coreUsersUsedByList({ - id: item.pk, - }); - }} - .delete=${(item: User) => { - return new CoreApi(DEFAULT_CONFIG).coreUsersDestroy({ - id: item.pk, - }); - }} - > - ${shouldShowWarning - ? html`
-
-
- + return html` + + + { + return [ + { key: msg("Username"), value: item.username }, + { key: msg("ID"), value: item.pk.toString() }, + { key: msg("UID"), value: item.uid }, + ]; + }} + .usedBy=${(item: User) => { + return new CoreApi(DEFAULT_CONFIG).coreUsersUsedByList({ + id: item.pk, + }); + }} + .delete=${(item: User) => { + return new CoreApi(DEFAULT_CONFIG).coreUsersDestroy({ + id: item.pk, + }); + }} + > + ${shouldShowWarning + ? html`
+
+
+ +
+

+ ${msg( + str`Warning: You're about to delete the user you're logged in as (${shouldShowWarning.username}). Proceed at your own risk.`, + )} +

-

- ${msg( - str`Warning: You're about to delete the user you're logged in as (${shouldShowWarning.username}). Proceed at your own risk.`, - )} -

-
-
` - : nothing} - - `; +
` + : nothing} + + `; } renderToolbarAfter(): TemplateResult { From 64da5def26960c2f8ff890fdd32f99fde0036117 Mon Sep 17 00:00:00 2001 From: Roney Dsilva Date: Thu, 4 Dec 2025 09:51:16 +0530 Subject: [PATCH 2/8] feat: add bulk delete functionality for authenticated sessions - Implemented BulkDeleteSessionSerializer for handling bulk session deletions. - Added bulk_delete action to AuthenticatedSessionViewSet for revoking sessions by user IDs. - Updated API schema to include new endpoint for bulk session deletion. - Modified UserBulkRevokeSessionsForm to utilize the new bulk delete API. --- authentik/core/api/authenticated_sessions.py | 23 +++++++++- schema.yml | 45 +++++++++++++++++++ .../admin/users/UserBulkRevokeSessionsForm.ts | 27 +++++------ 3 files changed, 77 insertions(+), 18 deletions(-) diff --git a/authentik/core/api/authenticated_sessions.py b/authentik/core/api/authenticated_sessions.py index 422a496b308e..e2527c9befa6 100644 --- a/authentik/core/api/authenticated_sessions.py +++ b/authentik/core/api/authenticated_sessions.py @@ -2,10 +2,12 @@ from typing import TypedDict -from rest_framework import mixins +from rest_framework import mixins, serializers +from rest_framework.decorators import action from rest_framework.fields import SerializerMethodField from rest_framework.request import Request -from rest_framework.serializers import CharField, DateTimeField, IPAddressField +from rest_framework.response import Response +from rest_framework.serializers import CharField, DateTimeField, IPAddressField, ListField, UUIDField from rest_framework.viewsets import GenericViewSet from ua_parser import user_agent_parser @@ -52,6 +54,12 @@ class UserAgentDict(TypedDict): string: str +class BulkDeleteSessionSerializer(serializers.Serializer): + """Serializer for bulk deleting authenticated sessions by user""" + + user_ids = ListField(child=serializers.IntegerField(), help_text="List of user IDs to revoke all sessions for") + + class AuthenticatedSessionSerializer(ModelSerializer): """AuthenticatedSession Serializer""" @@ -115,3 +123,14 @@ class AuthenticatedSessionViewSet( filterset_fields = ["user__username", "session__last_ip", "session__last_user_agent"] ordering = ["user__username"] owner_field = "user" + + @action(detail=False, methods=["POST"], pagination_class=None, filter_backends=[]) + def bulk_delete(self, request: Request) -> Response: + """Bulk revoke all sessions for multiple users""" + serializer = BulkDeleteSessionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + user_ids = serializer.validated_data.get("user_ids", []) + deleted_count, _ = AuthenticatedSession.objects.filter(user_id__in=user_ids).delete() + + return Response({"deleted": deleted_count}, status=200) diff --git a/schema.yml b/schema.yml index da7874e3b80b..ea26b2175df5 100644 --- a/schema.yml +++ b/schema.yml @@ -3013,6 +3013,31 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' + /core/authenticated_sessions/bulk_delete/: + post: + operationId: core_authenticated_sessions_bulk_delete + description: Bulk revoke all sessions for multiple users + tags: + - core + security: + - authentik: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BulkDeleteSession' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionDeleteResponse' + description: '' + '400': + $ref: '#/components/responses/ValidationErrorResponse' + '403': + $ref: '#/components/responses/GenericErrorResponse' /core/authenticated_sessions/{uuid}/: get: operationId: core_authenticated_sessions_retrieve @@ -34637,6 +34662,17 @@ components: - orphaned - unknown type: string + BulkDeleteSession: + type: object + description: Serializer for bulk deleting authenticated sessions by user + properties: + user_ids: + type: array + items: + type: integer + description: List of user IDs to revoke all sessions for + required: + - user_ids Brand: type: object description: Brand Serializer @@ -53091,6 +53127,15 @@ components: required: - healthy - version + SessionDeleteResponse: + type: object + description: Response for bulk session deletion + properties: + deleted: + type: integer + description: Number of sessions deleted + required: + - deleted SessionEndChallenge: type: object description: Challenge for ending a session diff --git a/web/src/admin/users/UserBulkRevokeSessionsForm.ts b/web/src/admin/users/UserBulkRevokeSessionsForm.ts index 9d4cade27706..edbb9f095127 100644 --- a/web/src/admin/users/UserBulkRevokeSessionsForm.ts +++ b/web/src/admin/users/UserBulkRevokeSessionsForm.ts @@ -43,7 +43,7 @@ export class UserBulkRevokeSessionsTable extends Table { } } this.requestUpdate(); - + return Promise.resolve({ pagination: { count: this.objects.length, @@ -96,22 +96,17 @@ export class UserBulkRevokeSessionsForm extends ModalButton { this.revokedCount = 0; try { - for (const user of this.users) { - // Get all sessions for this user - const sessions = await new CoreApi(DEFAULT_CONFIG).coreAuthenticatedSessionsList({ - userUsername: user.username, - pageSize: 1000, // Get all sessions + // Get user IDs + const userIds = this.users.map((user) => user.pk).filter((pk): pk is number => pk !== undefined); + + // Delete all sessions for these users in a single API call + if (userIds.length > 0) { + const response = await new CoreApi(DEFAULT_CONFIG).coreAuthenticatedSessionsBulkDelete({ + bulkDeleteSession: { + userIds: userIds, + }, }); - - // Delete each session - for (const session of sessions.results) { - if (session.uuid) { - await new CoreApi(DEFAULT_CONFIG).coreAuthenticatedSessionsDestroy({ - uuid: session.uuid, - }); - this.revokedCount++; - } - } + this.revokedCount = response.deleted || 0; } this.onSuccess(); From df67f084b6bca84aaf897cb38dfd812ff13d0fba Mon Sep 17 00:00:00 2001 From: "CodeMax IT Solutions Pvt. Ltd." <137166088+cdmx-in@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:26:42 +0530 Subject: [PATCH 3/8] Update authentik/core/api/authenticated_sessions.py Co-authored-by: Marc 'risson' Schmitt Signed-off-by: CodeMax IT Solutions Pvt. Ltd. <137166088+cdmx-in@users.noreply.github.com> --- authentik/core/api/authenticated_sessions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/authentik/core/api/authenticated_sessions.py b/authentik/core/api/authenticated_sessions.py index e2527c9befa6..41d6e4c7badc 100644 --- a/authentik/core/api/authenticated_sessions.py +++ b/authentik/core/api/authenticated_sessions.py @@ -130,7 +130,7 @@ def bulk_delete(self, request: Request) -> Response: serializer = BulkDeleteSessionSerializer(data=request.data) serializer.is_valid(raise_exception=True) - user_ids = serializer.validated_data.get("user_ids", []) - deleted_count, _ = AuthenticatedSession.objects.filter(user_id__in=user_ids).delete() + user_pks = body.validated_data.get("user_pks", []) + deleted_count, _ = AuthenticatedSession.objects.filter(user_pk__in=user_pks).delete() return Response({"deleted": deleted_count}, status=200) From e4de5fa4f0a336b965a1cc3120738a735780be2d Mon Sep 17 00:00:00 2001 From: "CodeMax IT Solutions Pvt. Ltd." <137166088+cdmx-in@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:27:24 +0530 Subject: [PATCH 4/8] Update authentik/core/api/authenticated_sessions.py PassiveSerializer for BulkDeleteSessionSerializer Co-authored-by: Marc 'risson' Schmitt Signed-off-by: CodeMax IT Solutions Pvt. Ltd. <137166088+cdmx-in@users.noreply.github.com> --- authentik/core/api/authenticated_sessions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authentik/core/api/authenticated_sessions.py b/authentik/core/api/authenticated_sessions.py index 41d6e4c7badc..9b0bf312f17c 100644 --- a/authentik/core/api/authenticated_sessions.py +++ b/authentik/core/api/authenticated_sessions.py @@ -54,7 +54,7 @@ class UserAgentDict(TypedDict): string: str -class BulkDeleteSessionSerializer(serializers.Serializer): +class BulkDeleteSessionSerializer(PassiveSerializer): """Serializer for bulk deleting authenticated sessions by user""" user_ids = ListField(child=serializers.IntegerField(), help_text="List of user IDs to revoke all sessions for") From 02b3bf538893dfab53fe87280a3391027335af26 Mon Sep 17 00:00:00 2001 From: "CodeMax IT Solutions Pvt. Ltd." <137166088+cdmx-in@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:28:07 +0530 Subject: [PATCH 5/8] Update authentik/core/api/authenticated_sessions.py user_pks instead of user_ids Co-authored-by: Marc 'risson' Schmitt Signed-off-by: CodeMax IT Solutions Pvt. Ltd. <137166088+cdmx-in@users.noreply.github.com> --- authentik/core/api/authenticated_sessions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authentik/core/api/authenticated_sessions.py b/authentik/core/api/authenticated_sessions.py index 9b0bf312f17c..8d2e4a8afa36 100644 --- a/authentik/core/api/authenticated_sessions.py +++ b/authentik/core/api/authenticated_sessions.py @@ -57,7 +57,7 @@ class UserAgentDict(TypedDict): class BulkDeleteSessionSerializer(PassiveSerializer): """Serializer for bulk deleting authenticated sessions by user""" - user_ids = ListField(child=serializers.IntegerField(), help_text="List of user IDs to revoke all sessions for") + user_pks = ListField(child=serializers.IntegerField(), help_text="List of user IDs to revoke all sessions for") class AuthenticatedSessionSerializer(ModelSerializer): From 86eb07d978f826be96b82a2a5694e66977293fcd Mon Sep 17 00:00:00 2001 From: Roney Date: Thu, 4 Dec 2025 22:45:02 +0530 Subject: [PATCH 6/8] feat: enhance bulk delete functionality for authenticated sessions --- authentik/core/api/authenticated_sessions.py | 34 ++++++++++++++----- .../admin/users/UserBulkRevokeSessionsForm.ts | 6 ++-- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/authentik/core/api/authenticated_sessions.py b/authentik/core/api/authenticated_sessions.py index 8d2e4a8afa36..28a0226f7215 100644 --- a/authentik/core/api/authenticated_sessions.py +++ b/authentik/core/api/authenticated_sessions.py @@ -2,6 +2,7 @@ from typing import TypedDict +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, inline_serializer from rest_framework import mixins, serializers from rest_framework.decorators import action from rest_framework.fields import SerializerMethodField @@ -11,11 +12,13 @@ from rest_framework.viewsets import GenericViewSet from ua_parser import user_agent_parser +from authentik.api.validation import validate from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import ModelSerializer +from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.core.models import AuthenticatedSession from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict +from authentik.rbac.decorators import permission_required class UserAgentDeviceDict(TypedDict): @@ -124,13 +127,28 @@ class AuthenticatedSessionViewSet( ordering = ["user__username"] owner_field = "user" - @action(detail=False, methods=["POST"], pagination_class=None, filter_backends=[]) - def bulk_delete(self, request: Request) -> Response: + @permission_required("authentik_core.delete_authenticatedsession") + @extend_schema( + parameters=[ + OpenApiParameter( + "user_pks", + serializers.ListField(child=serializers.IntegerField()), + description="List of user IDs to revoke all sessions for", + required=True, + ), + ], + responses={ + 200: inline_serializer( + "BulkDeleteSessionResponse", + {"deleted": serializers.IntegerField()}, + ), + }, + ) + @validate(BulkDeleteSessionSerializer, location="query") + @action(detail=False, methods=["DELETE"], pagination_class=None, filter_backends=[]) + def bulk_delete(self, request: Request, *, query: BulkDeleteSessionSerializer) -> Response: """Bulk revoke all sessions for multiple users""" - serializer = BulkDeleteSessionSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - user_pks = body.validated_data.get("user_pks", []) - deleted_count, _ = AuthenticatedSession.objects.filter(user_pk__in=user_pks).delete() + user_pks = query.validated_data.get("user_pks", []) + deleted_count, _ = AuthenticatedSession.objects.filter(user_id__in=user_pks).delete() return Response({"deleted": deleted_count}, status=200) diff --git a/web/src/admin/users/UserBulkRevokeSessionsForm.ts b/web/src/admin/users/UserBulkRevokeSessionsForm.ts index edbb9f095127..1ce5b75d55a5 100644 --- a/web/src/admin/users/UserBulkRevokeSessionsForm.ts +++ b/web/src/admin/users/UserBulkRevokeSessionsForm.ts @@ -43,7 +43,7 @@ export class UserBulkRevokeSessionsTable extends Table { } } this.requestUpdate(); - + return Promise.resolve({ pagination: { count: this.objects.length, @@ -102,9 +102,7 @@ export class UserBulkRevokeSessionsForm extends ModalButton { // Delete all sessions for these users in a single API call if (userIds.length > 0) { const response = await new CoreApi(DEFAULT_CONFIG).coreAuthenticatedSessionsBulkDelete({ - bulkDeleteSession: { - userIds: userIds, - }, + userPks: userIds, }); this.revokedCount = response.deleted || 0; } From aa32ee63c0667dc0f93c1fb1acfaec610c1b4154 Mon Sep 17 00:00:00 2001 From: Roney Date: Fri, 5 Dec 2025 00:43:52 +0530 Subject: [PATCH 7/8] feat: update bulk delete endpoint for authenticated sessions to use DELETE method and query parameters --- schema.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/schema.yml b/schema.yml index ea26b2175df5..1400f7482d78 100644 --- a/schema.yml +++ b/schema.yml @@ -3014,19 +3014,22 @@ paths: '403': $ref: '#/components/responses/GenericErrorResponse' /core/authenticated_sessions/bulk_delete/: - post: + delete: operationId: core_authenticated_sessions_bulk_delete description: Bulk revoke all sessions for multiple users + parameters: + - in: query + name: user_pks + schema: + type: array + items: + type: integer + description: List of user IDs to revoke all sessions for + required: true tags: - core security: - authentik: [] - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/BulkDeleteSession' - required: true responses: '200': content: @@ -34672,7 +34675,7 @@ components: type: integer description: List of user IDs to revoke all sessions for required: - - user_ids + - user_pks Brand: type: object description: Brand Serializer From 2882bb5a791a58a50eb250a187f5eeb6a9783a02 Mon Sep 17 00:00:00 2001 From: "CodeMax IT Solutions Pvt. Ltd." <137166088+cdmx-in@users.noreply.github.com> Date: Sat, 6 Dec 2025 02:46:46 +0530 Subject: [PATCH 8/8] Update authentik/core/api/authenticated_sessions.py Co-authored-by: Marc 'risson' Schmitt Signed-off-by: CodeMax IT Solutions Pvt. Ltd. <137166088+cdmx-in@users.noreply.github.com> --- authentik/core/api/authenticated_sessions.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/authentik/core/api/authenticated_sessions.py b/authentik/core/api/authenticated_sessions.py index 28a0226f7215..d82646c1d185 100644 --- a/authentik/core/api/authenticated_sessions.py +++ b/authentik/core/api/authenticated_sessions.py @@ -129,14 +129,7 @@ class AuthenticatedSessionViewSet( @permission_required("authentik_core.delete_authenticatedsession") @extend_schema( - parameters=[ - OpenApiParameter( - "user_pks", - serializers.ListField(child=serializers.IntegerField()), - description="List of user IDs to revoke all sessions for", - required=True, - ), - ], + parameters=[BulkDeleteSessionSerializer], responses={ 200: inline_serializer( "BulkDeleteSessionResponse",