diff --git a/.env.docker b/.env.docker index 16291b2..7030b20 100644 --- a/.env.docker +++ b/.env.docker @@ -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. diff --git a/src/app/sessions/page.tsx b/src/app/sessions/page.tsx index 6cd65b4..100fe11 100644 --- a/src/app/sessions/page.tsx +++ b/src/app/sessions/page.tsx @@ -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 { - 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() { !revoking && setPendingSession(null)} @@ -300,20 +284,22 @@ export default function SessionsPage() { - + {currentUser?.role === "SUPER_ADMIN" ? ( + + ) : null}
+ {session.current ? null : ( + + )} ); diff --git a/src/lib/api.ts b/src/lib/api.ts index 1913ff8..32cb83b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 | 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 { diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 9973f13..cc3d087 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -186,7 +186,7 @@ export const navigation: NavSection[] = [ href: "/sessions", icon: MonitorSmartphone, label: "Sessions", - minRole: "OPERATOR", + minRole: "VIEWER", keywords: ["login", "sessions", "devices", "security"], }, { diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 7af188d..302a1d9 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -22,7 +22,7 @@ export function formatRoleLabel(role: string | undefined) { } export const managementRouteAccess = { - "/sessions": "OPERATOR", + "/sessions": "VIEWER", "/users": "OPERATOR", "/api-keys": "DEVELOPER", "/settings": "OPERATOR", diff --git a/src/server/index.ts b/src/server/index.ts index d4742e1..e4157d6 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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}`, diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index ff0959f..a7f4dcd 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -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; } diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index 33558a5..44125c8 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -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 } }); }, ); diff --git a/src/server/services/auth-session-retention.service.ts b/src/server/services/auth-session-retention.service.ts new file mode 100644 index 0000000..8e5a371 --- /dev/null +++ b/src/server/services/auth-session-retention.service.ts @@ -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); +} diff --git a/src/server/services/auth-session.service.ts b/src/server/services/auth-session.service.ts index 7660538..1a79d42 100644 --- a/src/server/services/auth-session.service.ts +++ b/src/server/services/auth-session.service.ts @@ -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 }, diff --git a/tests/backend/auth-session-retention.test.ts b/tests/backend/auth-session-retention.test.ts new file mode 100644 index 0000000..2e77f2b --- /dev/null +++ b/tests/backend/auth-session-retention.test.ts @@ -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") } }, + ], + }, + ], + }, + ); +}); diff --git a/tests/backend/database-session-security.test.ts b/tests/backend/database-session-security.test.ts index 6aad139..8c624e9 100644 --- a/tests/backend/database-session-security.test.ts +++ b/tests/backend/database-session-security.test.ts @@ -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/); });