Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions authentik/core/api/authenticated_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"""

Expand Down Expand Up @@ -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)
45 changes: 45 additions & 0 deletions schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
202 changes: 202 additions & 0 deletions web/src/admin/users/UserBulkRevokeSessionsForm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
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<User> {
paginated = false;

@property({ attribute: false })
objects: User[] = [];

@property({ attribute: false })
metadata!: (item: User) => UserMetadata;

@state()
sessionCounts: Map<number, number> = new Map();

async apiEndpoint(): Promise<PaginatedResponse<User>> {
// 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`<ak-spinner size="sm"></ak-spinner>`}`,
];
}

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<void> {
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({
bulkDeleteSession: {
userIds: 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`<section class="pf-c-modal-box__header pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1 class="pf-c-title pf-m-2xl">${msg("Revoke Sessions")}</h1>
</div>
</section>
<section class="pf-c-modal-box__body pf-m-light">
<form class="pf-c-form pf-m-horizontal">
<p class="pf-c-title">
${msg(
str`Are you sure you want to revoke all sessions for ${this.users.length} user(s)?`,
)}
</p>
<p>
${msg(
"This will force the selected users to re-authenticate on all their devices.",
)}
</p>
</form>
</section>
<section class="pf-c-modal-box__body pf-m-light">
<ak-user-bulk-revoke-sessions-table
.objects=${this.users}
.metadata=${(item: User) => {
return [
{ key: msg("Username"), value: item.username },
{ key: msg("Name"), value: item.name || "" },
];
}}
>
</ak-user-bulk-revoke-sessions-table>
</section>
<footer class="pf-c-modal-box__footer">
<ak-spinner-button
.callAction=${() => {
return this.confirm();
}}
class="pf-m-warning"
>
${msg("Revoke Sessions")} </ak-spinner-button
>&nbsp;
<ak-spinner-button
.callAction=${async () => {
this.open = false;
}}
class="pf-m-secondary"
>
${msg("Cancel")}
</ak-spinner-button>
</footer>`;
}
}

declare global {
interface HTMLElementTagNameMap {
"ak-user-bulk-revoke-sessions-table": UserBulkRevokeSessionsTable;
"ak-user-bulk-revoke-sessions": UserBulkRevokeSessionsForm;
}
}
Loading
Loading