feat(sessions): implement user session management with session tracking and revocation
- Introduced new database schema for user sessions and session events. - Implemented session management page with filtering, sorting, and revocation capabilities. - Added backend services for creating, revoking, and expiring user sessions. - Implemented session cookie handling and security measures. - Created tests for session management functionalities and security features.
This commit is contained in:
1 parent
b731194014
commit
87fdbb493b
33 files changed
+2878
-1296
No files matched your search
@@ -0,0 +1,292 @@
|
||||
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;
|
||||
}): boolean {
|
||||
return (
|
||||
input.actorUserId === input.targetUserId ||
|
||||
input.actorRole === "SUPER_ADMIN"
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user