feat(auth): add session history retention and lifecycle updates

- Add daily session history cleanup with a 180-day retention period
- Run cleanup on startup and every 24 hours in batches of up to 1,000 sessions
- Add tests for session retention and derived session status
- Allow viewers to manage their own sessions while preserving global access for super admins
- Prevent the current browser session from being revoked through session management
- Derive active, expired, and revoked status without writing during list requests
- Count only active sessions toward the 20-session per-user limit
- Remove unused session event queries and payloads from the session listing endpoint
- Update the session management UI for the revised access and revocation policies
This commit is contained in:
KodekaTeam committed 2026-09-13 10:24:49 +07:00
1 parent 6ce3dd590a
commit 0bd696d37f
12 files changed
+271 -181

No files matched your search

+4 -1
View File
@@ -27,6 +27,9 @@ DATABASE_URL=postgresql://doktainer:doktainerdb@postgres:5432/doktainer?schema=p
# /* ------------------------------- App server ------------------------------ */
NODE_ENV=production
# Browser authentication in this production configuration requires HTTPS.
# Plain HTTP remains supported only by the local development configuration.
# Bind address for the backend process inside the container.
# This is NOT a public URL and must not be reused as FRONTEND_URL or NEXT_PUBLIC_PANEL_URL.
HOST=0.0.0.0
@@ -66,7 +69,7 @@ ENCRYPTION_KEY=dev-32-char-encryption-key-here!
# Optional comma-separated extra origins when the backend is exposed directly and CORS must allow them.
# Keep this explicit for security; this is not auto-derived from request origin.
# CORS_ORIGINS=https://panel.example.com,http://your-vps-ip:3128
# CORS_ORIGINS=https://panel.example.com
# Optional internal backend URL used by Next rewrites in monolith mode.
# This is internal-only and separate from the public panel URL above.
+41 -53
View File
@@ -6,12 +6,7 @@ import IssueDetailsSummary from "@/components/IssueDetailsSummary";
import SearchField from "@/components/SearchField";
import TablePagination from "@/components/TablePagination";
import { useCurrentUser } from "@/lib/auth-state";
import {
auth,
clearSessionState,
redirectToLogin,
type UserSessionRecord,
} from "@/lib/api";
import { auth, type UserSessionRecord } from "@/lib/api";
import { useTablePagination } from "@/lib/use-table-pagination";
import {
ArrowDown,
@@ -53,8 +48,7 @@ function describeDevice(userAgent: string | null) {
}
function getSessionStatus(session: UserSessionRecord): Exclude<StatusFilter, "all"> {
if (!session.revokedAt) return "active";
return session.revokeReason === "EXPIRED" ? "expired" : "revoked";
return session.status;
}
function getEffectiveExpiry(session: UserSessionRecord) {
@@ -230,14 +224,8 @@ export default function SessionsPage() {
setRevoking(true);
setError("");
try {
const response = await auth.revokeSession(pendingSession.id);
const revokedCurrent = response.data.revokedCurrent;
await auth.revokeSession(pendingSession.id);
setPendingSession(null);
if (revokedCurrent) {
clearSessionState();
redirectToLogin();
return;
}
await refresh();
} catch (revokeError) {
setError(revokeError instanceof Error ? revokeError.message : "Failed to revoke session");
@@ -250,17 +238,13 @@ export default function SessionsPage() {
<GuardedPage
route="/sessions"
title="Sessions"
subtitle="Manage login sessions across your organization"
subtitle="Review and revoke login sessions"
currentUser={currentUser}
>
<ConfirmActionDialog
open={pendingSession !== null}
title={pendingSession?.current ? "Sign out this session?" : "Revoke login session?"}
description={
pendingSession?.current
? "This browser will return to the login page immediately."
: `Revoke access for ${pendingSession?.user.name ?? "this user"} on ${describeDevice(pendingSession?.userAgent ?? null).browser}?`
}
title="Revoke login session?"
description={`Revoke access for ${pendingSession?.user.name ?? "this user"} on ${describeDevice(pendingSession?.userAgent ?? null).browser}?`}
confirmLabel={revoking ? "Revoking…" : "Revoke session"}
note="The affected browser must log in again. API keys are not changed."
onClose={() => !revoking && setPendingSession(null)}
@@ -300,20 +284,22 @@ export default function SessionsPage() {
<option value="expired">Expired</option>
<option value="revoked">Revoked</option>
</select>
<select
className="input users-toolbar-filter"
aria-label="Filter sessions by user"
value={userFilter}
onChange={(event) => setUserFilter(event.target.value)}
style={{ fontSize: 12 }}
>
<option value="all">All users</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name} ({user.email})
</option>
))}
</select>
{currentUser?.role === "SUPER_ADMIN" ? (
<select
className="input users-toolbar-filter"
aria-label="Filter sessions by user"
value={userFilter}
onChange={(event) => setUserFilter(event.target.value)}
style={{ fontSize: 12 }}
>
<option value="all">All users</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name} ({user.email})
</option>
))}
</select>
) : null}
<div className="ui-toolbar-actions">
<button
type="button"
@@ -400,23 +386,25 @@ export default function SessionsPage() {
<td>{formatDate(session.createdAt)}</td>
<td>{formatDate(getEffectiveExpiry(session))}</td>
<td style={{ textAlign: "right" }}>
<button
type="button"
className="btn btn-ghost"
style={{ padding: "6px 8px", color: session.canRevoke ? "#ef4444" : "var(--text-muted)" }}
onClick={() => setPendingSession(session)}
disabled={!session.canRevoke || revoking}
title={
session.canRevoke
? "Revoke session"
: status !== "active"
? "Session is no longer active"
: "Only a Super Admin can revoke another user's session"
}
aria-label={`Revoke session for ${session.user.name}`}
>
<LogOut size={14} aria-hidden="true" />
</button>
{session.current ? null : (
<button
type="button"
className="btn btn-ghost"
style={{ padding: "6px 8px", color: session.canRevoke ? "#ef4444" : "var(--text-muted)" }}
onClick={() => setPendingSession(session)}
disabled={!session.canRevoke || revoking}
title={
session.canRevoke
? "Revoke session"
: status !== "active"
? "Session is no longer active"
: "Only a Super Admin can revoke another user's session"
}
aria-label={`Revoke session for ${session.user.name}`}
>
<LogOut size={14} aria-hidden="true" />
</button>
)}
</td>
</tr>
);
+2 -17
View File
@@ -1,6 +1,6 @@
/**
* API Client — DOKTAINER
* Centralized HTTP client with auth token injection
* Centralized HTTP client with cookie-based browser authentication
*/
import { emitAuthStateChanged } from "@/lib/auth-events";
@@ -204,21 +204,6 @@ export interface UserInfo {
activeOrganizationId?: string | null;
}
export type UserSessionEventType =
| "LOGIN"
| "LOGOUT"
| "REVOKED"
| "EXPIRED";
export interface UserSessionEventRecord {
id: string;
type: UserSessionEventType;
ipAddress: string | null;
userAgent: string | null;
details: Record<string, unknown> | null;
createdAt: string;
}
export interface UserSessionRecord {
id: string;
createdIp: string | null;
@@ -230,6 +215,7 @@ export interface UserSessionRecord {
absoluteExpiresAt: string;
revokedAt: string | null;
revokeReason: string | null;
status: "active" | "expired" | "revoked";
current: boolean;
canRevoke: boolean;
user: {
@@ -238,7 +224,6 @@ export interface UserSessionRecord {
email: string;
role: string;
};
events: UserSessionEventRecord[];
}
export interface OrganizationRecord {
+1 -1
View File
@@ -186,7 +186,7 @@ export const navigation: NavSection[] = [
href: "/sessions",
icon: MonitorSmartphone,
label: "Sessions",
minRole: "OPERATOR",
minRole: "VIEWER",
keywords: ["login", "sessions", "devices", "security"],
},
{
+1 -1
View File
@@ -22,7 +22,7 @@ export function formatRoleLabel(role: string | undefined) {
}
export const managementRouteAccess = {
"/sessions": "OPERATOR",
"/sessions": "VIEWER",
"/users": "OPERATOR",
"/api-keys": "DEVELOPER",
"/settings": "OPERATOR",
+2
View File
@@ -30,6 +30,7 @@ import { gitProviderRoutes } from "./routes/git-providers";
import { storageDestinationRoutes } from "./routes/storage-destinations";
import { commitHistoryRoutes } from "./routes/commit-history";
import { startS3StorageRetentionScheduler } from "./services/s3-storage-retention.service";
import { startAuthSessionRetentionScheduler } from "./services/auth-session-retention.service";
const PORT = parseInt(process.env.PORT || "4000");
const HOST = process.env.HOST || "0.0.0.0";
@@ -193,6 +194,7 @@ async function start() {
// ── Start ─────────────────────────────────────────────
try {
await app.listen({ port: PORT, host: HOST });
startAuthSessionRetentionScheduler();
startS3StorageRetentionScheduler();
console.log(
`\n🚀 Doktainer Server Backend running on http://${HOST}:${PORT}`,
-16
View File
@@ -394,7 +394,6 @@ function enforceUserSecurityPolicies(
req: FastifyRequest,
reply: FastifyReply,
settings: UserSecuritySettings,
tokenIssuedAt?: number,
): boolean {
if (!settings) {
return true;
@@ -411,21 +410,6 @@ function enforceUserSecurityPolicies(
}
}
if (
typeof tokenIssuedAt === "number" &&
Number.isFinite(tokenIssuedAt) &&
settings.sessionTimeoutMinutes > 0
) {
const maxSessionAgeMs = settings.sessionTimeoutMinutes * 60 * 1000;
if (Date.now() - tokenIssuedAt * 1000 > maxSessionAgeMs) {
reply.status(401).send({
success: false,
error: "Unauthorized — session expired",
});
return false;
}
}
return true;
}
+35 -88
View File
@@ -23,7 +23,7 @@ import {
canRevokeManagedSession,
changePasswordAndRevokeOtherSessions,
createUserSession,
expireUserSession,
getManagedSessionStatus,
getRequestUserAgent,
revokeOtherUserSessions,
revokeUserSession,
@@ -645,52 +645,16 @@ export async function authRoutes(app: FastifyInstance) {
return reply.send({ success: true, message: "Logged out successfully" });
});
app.get("/sessions", { preHandler: [requireRole("OPERATOR")] }, async (req, reply) => {
app.get("/sessions", { preHandler: [requireRole("VIEWER")] }, async (req, reply) => {
if (!req.sessionId) {
return reply.status(403).send({
success: false,
error: "A browser login session is required",
});
}
if (!req.organizationId) {
return reply.status(400).send({
success: false,
error: "An active organization is required",
});
}
const organizationScope = {
user: {
organizationMemberships: {
some: { organizationId: req.organizationId },
},
},
};
const now = new Date();
const expiredSessions = await prisma.userSession.findMany({
where: {
...organizationScope,
revokedAt: null,
OR: [
{ idleExpiresAt: { lte: now } },
{ absoluteExpiresAt: { lte: now } },
],
},
take: 200,
select: { id: true },
});
await Promise.all(
expiredSessions.map((session) =>
expireUserSession(
session.id,
getClientIp(req) || null,
getRequestUserAgent(req),
),
),
);
const sessions = await prisma.userSession.findMany({
where: organizationScope,
where: req.userRole === "SUPER_ADMIN" ? {} : { userId: req.userId! },
orderBy: { createdAt: "desc" },
take: 200,
select: {
@@ -712,36 +676,34 @@ export async function authRoutes(app: FastifyInstance) {
role: true,
},
},
events: {
orderBy: { createdAt: "desc" },
take: 10,
select: {
id: true,
type: true,
ipAddress: true,
userAgent: true,
details: true,
createdAt: true,
},
},
},
});
return reply.send({
success: true,
data: sessions.map((session) => ({
...session,
current: session.id === req.sessionId,
canRevoke:
session.revokedAt === null &&
(session.user.id === req.userId || req.userRole === "SUPER_ADMIN"),
})),
data: sessions.map((session) => {
const status = getManagedSessionStatus(session, now);
return {
...session,
status,
current: session.id === req.sessionId,
canRevoke:
status === "active" &&
canRevokeManagedSession({
actorUserId: req.userId!,
actorRole: req.userRole!,
targetUserId: session.user.id,
currentSessionId: req.sessionId!,
targetSessionId: session.id,
}),
};
}),
});
});
app.delete(
"/sessions/:sessionId",
{ preHandler: [requireRole("OPERATOR")] },
{ preHandler: [requireRole("VIEWER")] },
async (req, reply) => {
if (!req.sessionId) {
return reply.status(403).send({
@@ -757,24 +719,24 @@ export async function authRoutes(app: FastifyInstance) {
.status(400)
.send({ success: false, error: params.error.flatten() });
}
if (!req.organizationId) {
if (params.data.sessionId === req.sessionId) {
return reply.status(400).send({
success: false,
error: "An active organization is required",
error: "The current session cannot be revoked; use logout instead",
});
}
const targetSession = await prisma.userSession.findFirst({
where: {
id: params.data.sessionId,
user: {
organizationMemberships: {
some: { organizationId: req.organizationId },
},
},
...(req.userRole === "SUPER_ADMIN" ? {} : { userId: req.userId! }),
},
select: {
userId: true,
revokedAt: true,
revokeReason: true,
idleExpiresAt: true,
absoluteExpiresAt: true,
user: { select: { email: true } },
},
});
@@ -783,27 +745,16 @@ export async function authRoutes(app: FastifyInstance) {
.status(404)
.send({ success: false, error: "Session not found" });
}
if (
!canRevokeManagedSession({
actorUserId: req.userId!,
actorRole: req.userRole!,
targetUserId: targetSession.userId,
})
) {
return reply.status(403).send({
success: false,
error: "Only a Super Admin can revoke another user's session",
});
if (getManagedSessionStatus(targetSession) !== "active") {
return reply
.status(404)
.send({ success: false, error: "Active session not found" });
}
const revoked = await revokeUserSession({
sessionId: params.data.sessionId,
userId: targetSession.userId,
reason:
params.data.sessionId === req.sessionId
? "CURRENT_SESSION_REVOKED"
: "USER_REVOKED",
reason: "USER_REVOKED",
ipAddress: getClientIp(req) || null,
userAgent: getRequestUserAgent(req),
});
@@ -813,23 +764,19 @@ export async function authRoutes(app: FastifyInstance) {
.send({ success: false, error: "Active session not found" });
}
const revokedCurrent = params.data.sessionId === req.sessionId;
if (revokedCurrent) clearSessionCookie(reply);
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "SESSION_REVOKE",
category: "AUTH",
level: "WARNING",
message: revokedCurrent
? "Current login session revoked"
: `Login session revoked for "${targetSession.user.email}"`,
message: `Login session revoked for "${targetSession.user.email}"`,
meta: {
sessionId: params.data.sessionId,
targetUserId: targetSession.userId,
},
});
return reply.send({ success: true, data: { revokedCurrent } });
return reply.send({ success: true, data: { revokedCurrent: false } });
},
);
@@ -0,0 +1,70 @@
import prisma from "../lib/prisma";
export const SESSION_HISTORY_RETENTION_DAYS = 180;
export const SESSION_RETENTION_BATCH_SIZE = 1000;
const SESSION_RETENTION_INTERVAL_MS = 24 * 60 * 60 * 1000;
let cleanupInProgress = false;
export function getSessionHistoryCutoff(now = new Date()): Date {
return new Date(
now.getTime() - SESSION_HISTORY_RETENTION_DAYS * 24 * 60 * 60 * 1000,
);
}
export function getSessionHistoryRetentionFilter(now = new Date()) {
const cutoff = getSessionHistoryCutoff(now);
return {
OR: [
{ revokedAt: { lte: cutoff } },
{
revokedAt: null,
OR: [
{ idleExpiresAt: { lte: cutoff } },
{ absoluteExpiresAt: { lte: cutoff } },
],
},
],
};
}
export async function runAuthSessionRetentionCleanup(now = new Date()) {
if (cleanupInProgress) return { skipped: true, deletedSessions: 0 };
cleanupInProgress = true;
try {
const sessions = await prisma.userSession.findMany({
where: getSessionHistoryRetentionFilter(now),
orderBy: { createdAt: "asc" },
take: SESSION_RETENTION_BATCH_SIZE,
select: { id: true },
});
if (sessions.length === 0) {
return { skipped: false, deletedSessions: 0 };
}
const deleted = await prisma.userSession.deleteMany({
where: { id: { in: sessions.map((session) => session.id) } },
});
return { skipped: false, deletedSessions: deleted.count };
} finally {
cleanupInProgress = false;
}
}
export function startAuthSessionRetentionScheduler() {
const runCleanup = async () => {
try {
await runAuthSessionRetentionCleanup();
} catch (error) {
console.error("[Session retention] Cleanup failed:", error);
}
};
const timer = setInterval(() => void runCleanup(), SESSION_RETENTION_INTERVAL_MS);
timer.unref?.();
void runCleanup();
return () => clearInterval(timer);
}
+28 -1
View File
@@ -14,13 +14,35 @@ export function canRevokeManagedSession(input: {
actorUserId: string;
actorRole: string;
targetUserId: string;
currentSessionId: string;
targetSessionId: string;
}): boolean {
if (input.currentSessionId === input.targetSessionId) return false;
return (
input.actorUserId === input.targetUserId ||
input.actorRole === "SUPER_ADMIN"
);
}
export type ManagedSessionStatus = "active" | "expired" | "revoked";
export function getManagedSessionStatus(
session: {
revokedAt: Date | null;
revokeReason: string | null;
idleExpiresAt: Date;
absoluteExpiresAt: Date;
},
now = new Date(),
): ManagedSessionStatus {
if (session.revokedAt) {
return session.revokeReason === "EXPIRED" ? "expired" : "revoked";
}
return session.idleExpiresAt <= now || session.absoluteExpiresAt <= now
? "expired"
: "active";
}
export function getSessionCookieName(env = process.env): string {
return env.NODE_ENV === "production"
? PRODUCTION_SESSION_COOKIE
@@ -120,7 +142,12 @@ export async function createUserSession(input: {
});
const overflowSessions = await prisma.userSession.findMany({
where: { userId: input.userId, revokedAt: null },
where: {
userId: input.userId,
revokedAt: null,
idleExpiresAt: { gt: now },
absoluteExpiresAt: { gt: now },
},
orderBy: { createdAt: "desc" },
skip: MAX_ACTIVE_SESSIONS_PER_USER,
select: { id: true },
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getSessionHistoryCutoff,
getSessionHistoryRetentionFilter,
SESSION_HISTORY_RETENTION_DAYS,
SESSION_RETENTION_BATCH_SIZE,
} from "../../src/server/services/auth-session-retention.service";
test("session history retention keeps 180 days and deletes in bounded batches", () => {
assert.equal(SESSION_HISTORY_RETENTION_DAYS, 180);
assert.equal(SESSION_RETENTION_BATCH_SIZE, 1000);
assert.equal(
getSessionHistoryCutoff(new Date("2026-09-12T00:00:00.000Z")).toISOString(),
"2026-03-16T00:00:00.000Z",
);
assert.deepEqual(
getSessionHistoryRetentionFilter(new Date("2026-09-12T00:00:00.000Z")),
{
OR: [
{ revokedAt: { lte: new Date("2026-03-16T00:00:00.000Z") } },
{
revokedAt: null,
OR: [
{ idleExpiresAt: { lte: new Date("2026-03-16T00:00:00.000Z") } },
{ absoluteExpiresAt: { lte: new Date("2026-03-16T00:00:00.000Z") } },
],
},
],
},
);
});
@@ -7,6 +7,7 @@ import {
} from "../../src/server/middleware/auth";
import {
canRevokeManagedSession,
getManagedSessionStatus,
getSessionCookieName,
hashSessionToken,
normalizeSessionUserAgent,
@@ -188,6 +189,8 @@ test("only owners and super admins can revoke a managed login session", () => {
actorUserId: "operator-1",
actorRole: "OPERATOR",
targetUserId: "user-2",
currentSessionId: "session-1",
targetSessionId: "session-2",
}),
false,
);
@@ -196,6 +199,8 @@ test("only owners and super admins can revoke a managed login session", () => {
actorUserId: "operator-1",
actorRole: "OPERATOR",
targetUserId: "operator-1",
currentSessionId: "session-1",
targetSessionId: "session-2",
}),
true,
);
@@ -204,9 +209,51 @@ test("only owners and super admins can revoke a managed login session", () => {
actorUserId: "admin-1",
actorRole: "SUPER_ADMIN",
targetUserId: "user-2",
currentSessionId: "session-1",
targetSessionId: "session-2",
}),
true,
);
assert.equal(
canRevokeManagedSession({
actorUserId: "admin-1",
actorRole: "SUPER_ADMIN",
targetUserId: "admin-1",
currentSessionId: "session-1",
targetSessionId: "session-1",
}),
false,
);
});
test("session status is derived without persisting expiry from list requests", () => {
const now = new Date("2026-09-12T12:00:00.000Z");
const activeSession = {
revokedAt: null,
revokeReason: null,
idleExpiresAt: new Date("2026-09-12T13:00:00.000Z"),
absoluteExpiresAt: new Date("2026-09-13T12:00:00.000Z"),
};
assert.equal(getManagedSessionStatus(activeSession, now), "active");
assert.equal(
getManagedSessionStatus(
{ ...activeSession, idleExpiresAt: new Date("2026-09-12T11:59:59.000Z") },
now,
),
"expired",
);
assert.equal(
getManagedSessionStatus(
{
...activeSession,
revokedAt: new Date("2026-09-12T10:00:00.000Z"),
revokeReason: "USER_REVOKED",
},
now,
),
"revoked",
);
});
test("session management is a dedicated protected page before Users and RBAC", () => {
@@ -217,12 +264,16 @@ test("session management is a dedicated protected page before Users and RBAC", (
const routes = readFileSync("src/server/routes/auth.ts", "utf8");
assert.ok(navigation.indexOf('href: "/sessions"') < navigation.indexOf('href: "/users"'));
assert.match(permissions, /"\/sessions": "OPERATOR"/);
assert.match(permissions, /"\/sessions": "VIEWER"/);
assert.doesNotMatch(dashboard, /LoginSessionsPanel/);
assert.match(page, /route="\/sessions"/);
assert.match(page, /All statuses/);
assert.match(page, /All users/);
assert.match(routes, /app\.get\("\/sessions", \{ preHandler: \[requireRole\("OPERATOR"\)\]/);
assert.match(routes, /"\/sessions\/:sessionId",\s+\{ preHandler: \[requireRole\("OPERATOR"\)\] \}/);
assert.match(routes, /app\.get\("\/sessions", \{ preHandler: \[requireRole\("VIEWER"\)\]/);
assert.match(routes, /"\/sessions\/:sessionId",\s+\{ preHandler: \[requireRole\("VIEWER"\)\] \}/);
assert.match(routes, /A browser login session is required/);
assert.doesNotMatch(routes, /expiredSessions/);
assert.doesNotMatch(routes, /events:\s*\{/);
assert.match(routes, /The current session cannot be revoked/);
assert.match(page, /session\.current \? null/);
});