diff --git a/authentik/core/api/authenticated_sessions.py b/authentik/core/api/authenticated_sessions.py index 422a496b308e..d82646c1d185 100644 --- a/authentik/core/api/authenticated_sessions.py +++ b/authentik/core/api/authenticated_sessions.py @@ -2,18 +2,23 @@ from typing import TypedDict -from rest_framework import mixins +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 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 +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): @@ -52,6 +57,12 @@ class UserAgentDict(TypedDict): string: str +class BulkDeleteSessionSerializer(PassiveSerializer): + """Serializer for bulk deleting authenticated sessions by user""" + + user_pks = ListField(child=serializers.IntegerField(), help_text="List of user IDs to revoke all sessions for") + + class AuthenticatedSessionSerializer(ModelSerializer): """AuthenticatedSession Serializer""" @@ -115,3 +126,22 @@ class AuthenticatedSessionViewSet( filterset_fields = ["user__username", "session__last_ip", "session__last_user_agent"] ordering = ["user__username"] owner_field = "user" + + @permission_required("authentik_core.delete_authenticatedsession") + @extend_schema( + parameters=[BulkDeleteSessionSerializer], + 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""" + 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/schema.yml b/schema.yml index 3add0d112ecc..e998cb009340 100644 --- a/schema.yml +++ b/schema.yml @@ -3013,6 +3013,34 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' + /core/authenticated_sessions/bulk_delete/: + 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: [] + 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 +34665,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_pks Brand: type: object description: Brand Serializer @@ -53267,6 +53306,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 new file mode 100644 index 000000000000..1ce5b75d55a5 --- /dev/null +++ b/web/src/admin/users/UserBulkRevokeSessionsForm.ts @@ -0,0 +1,200 @@ +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 { + // 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({ + userPks: userIds, + }); + this.revokedCount = response.deleted || 0; + } + + 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 {