Files
doktainer-upstream/src/server/services/auth-session.service.ts
T
KodekaTeam 0bd696d37f 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
2026-09-13 10:24:49 +07:00

320 lines
8.7 KiB
TypeScript

import crypto from "node:crypto";
import type { FastifyReply, FastifyRequest } from "fastify";
import { SessionEventType } from "@prisma/client";
import prisma from "../lib/prisma";
const DEVELOPMENT_SESSION_COOKIE = "doktainer_session";
const PRODUCTION_SESSION_COOKIE = "__Host-doktainer_session";
const DEFAULT_ABSOLUTE_SESSION_HOURS = 7 * 24;
const SESSION_TOUCH_INTERVAL_MS = 5 * 60 * 1000;
const MAX_USER_AGENT_LENGTH = 512;
const MAX_ACTIVE_SESSIONS_PER_USER = 20;
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
: DEVELOPMENT_SESSION_COOKIE;
}
function getAbsoluteSessionHours(env = process.env): number {
const parsed = Number.parseInt(env.SESSION_ABSOLUTE_TIMEOUT_HOURS ?? "", 10);
return Number.isFinite(parsed) && parsed > 0
? parsed
: DEFAULT_ABSOLUTE_SESSION_HOURS;
}
export function hashSessionToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export function normalizeSessionUserAgent(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, "").trim();
return normalized ? normalized.slice(0, MAX_USER_AGENT_LENGTH) : null;
}
export function getRequestUserAgent(req: FastifyRequest): string | null {
return normalizeSessionUserAgent(req.headers["user-agent"]);
}
function sessionCookieOptions(expires?: Date) {
return {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
priority: "high" as const,
...(expires ? { expires } : {}),
};
}
export function setSessionCookie(
reply: FastifyReply,
token: string,
expiresAt: Date,
): void {
reply.setCookie(getSessionCookieName(), token, sessionCookieOptions(expiresAt));
}
export function clearSessionCookie(reply: FastifyReply): void {
for (const cookieName of [
DEVELOPMENT_SESSION_COOKIE,
PRODUCTION_SESSION_COOKIE,
]) {
reply.clearCookie(cookieName, sessionCookieOptions());
}
}
export function getSessionToken(req: FastifyRequest): string | null {
return req.cookies?.[getSessionCookieName()]?.trim() || null;
}
export async function createUserSession(input: {
userId: string;
sessionTimeoutMinutes: number;
ipAddress: string | null;
userAgent: string | null;
}) {
const now = new Date();
const absoluteExpiresAt = new Date(
now.getTime() + getAbsoluteSessionHours() * 60 * 60 * 1000,
);
const idleExpiresAt =
input.sessionTimeoutMinutes > 0
? new Date(now.getTime() + input.sessionTimeoutMinutes * 60 * 1000)
: absoluteExpiresAt;
const rawToken = crypto.randomBytes(32).toString("base64url");
const session = await prisma.userSession.create({
data: {
userId: input.userId,
tokenHash: hashSessionToken(rawToken),
createdIp: input.ipAddress,
lastIp: input.ipAddress,
userAgent: input.userAgent,
idleExpiresAt,
absoluteExpiresAt,
events: {
create: {
type: SessionEventType.LOGIN,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
},
},
},
select: {
id: true,
absoluteExpiresAt: true,
},
});
const overflowSessions = await prisma.userSession.findMany({
where: {
userId: input.userId,
revokedAt: null,
idleExpiresAt: { gt: now },
absoluteExpiresAt: { gt: now },
},
orderBy: { createdAt: "desc" },
skip: MAX_ACTIVE_SESSIONS_PER_USER,
select: { id: true },
});
await Promise.all(
overflowSessions.map((overflowSession) =>
revokeUserSession({
sessionId: overflowSession.id,
userId: input.userId,
reason: "ACTIVE_SESSION_LIMIT",
ipAddress: input.ipAddress,
userAgent: input.userAgent,
}),
),
);
return { rawToken, session };
}
export async function expireUserSession(
sessionId: string,
ipAddress: string | null,
userAgent: string | null,
): Promise<void> {
await prisma.$transaction(async (tx) => {
const now = new Date();
const updated = await tx.userSession.updateMany({
where: { id: sessionId, revokedAt: null },
data: { revokedAt: now, revokeReason: "EXPIRED" },
});
if (updated.count > 0) {
await tx.userSessionEvent.create({
data: {
sessionId,
type: SessionEventType.EXPIRED,
ipAddress,
userAgent,
},
});
}
});
}
export async function revokeUserSession(input: {
sessionId: string;
userId: string;
reason: string;
eventType?: SessionEventType;
ipAddress?: string | null;
userAgent?: string | null;
}): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const now = new Date();
const updated = await tx.userSession.updateMany({
where: { id: input.sessionId, userId: input.userId, revokedAt: null },
data: { revokedAt: now, revokeReason: input.reason },
});
if (updated.count === 0) return false;
await tx.userSessionEvent.create({
data: {
sessionId: input.sessionId,
type: input.eventType ?? SessionEventType.REVOKED,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
details: { reason: input.reason },
},
});
return true;
});
}
export async function revokeOtherUserSessions(input: {
userId: string;
currentSessionId: string;
reason: string;
ipAddress?: string | null;
userAgent?: string | null;
}): Promise<number> {
return prisma.$transaction(async (tx) => {
const now = new Date();
const sessions = await tx.userSession.updateManyAndReturn({
where: {
userId: input.userId,
id: { not: input.currentSessionId },
revokedAt: null,
},
data: { revokedAt: now, revokeReason: input.reason },
select: { id: true },
});
if (sessions.length === 0) return 0;
await tx.userSessionEvent.createMany({
data: sessions.map((session) => ({
sessionId: session.id,
type: SessionEventType.REVOKED,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
details: { reason: input.reason },
})),
});
return sessions.length;
});
}
export async function changePasswordAndRevokeOtherSessions(input: {
userId: string;
currentSessionId: string;
passwordHash: string;
ipAddress?: string | null;
userAgent?: string | null;
}): Promise<number> {
return prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: input.userId },
data: { passwordHash: input.passwordHash },
});
const now = new Date();
const sessions = await tx.userSession.updateManyAndReturn({
where: {
userId: input.userId,
id: { not: input.currentSessionId },
revokedAt: null,
},
data: { revokedAt: now, revokeReason: "PASSWORD_CHANGED" },
select: { id: true },
});
if (sessions.length > 0) {
await tx.userSessionEvent.createMany({
data: sessions.map((session) => ({
sessionId: session.id,
type: SessionEventType.REVOKED,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
details: { reason: "PASSWORD_CHANGED" },
})),
});
}
return sessions.length;
});
}
export async function touchUserSession(input: {
sessionId: string;
lastSeenAt: Date;
sessionTimeoutMinutes: number;
absoluteExpiresAt: Date;
ipAddress: string | null;
}): Promise<void> {
const now = new Date();
if (now.getTime() - input.lastSeenAt.getTime() < SESSION_TOUCH_INTERVAL_MS) {
return;
}
await prisma.userSession.updateMany({
where: { id: input.sessionId, revokedAt: null },
data: {
lastSeenAt: now,
lastIp: input.ipAddress,
idleExpiresAt:
input.sessionTimeoutMinutes > 0
? new Date(
now.getTime() + input.sessionTimeoutMinutes * 60 * 1000,
)
: input.absoluteExpiresAt,
},
});
}