import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import type { Role, User } from '@prisma/client'; import { prisma } from '../db.js'; import { env } from '../env.js'; export const SESSION_COOKIE = 'ccm_sid'; export const CSRF_COOKIE = 'ccm_csrf'; export const CSRF_HEADER = 'x-csrf-token'; export interface SessionUser { id: number; username: string; role: Role; mustChangePassword: boolean; totpEnabled: boolean; } function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } // Das CSRF-Token wird aus dem Session-Token abgeleitet statt separat gespeichert. // Ohne Kenntnis des httpOnly-Cookies und des SESSION_SECRET ist es nicht herstellbar. export function csrfTokenFor(sessionToken: string): string { return createHmac('sha256', env.SESSION_SECRET).update(sessionToken).digest('hex'); } export function csrfMatches(sessionToken: string, presented: string | undefined): boolean { if (!presented) return false; const expected = csrfTokenFor(sessionToken); const a = Buffer.from(expected); const b = Buffer.from(presented); if (a.length !== b.length) return false; return timingSafeEqual(a, b); } export function toSessionUser(user: User): SessionUser { return { id: user.id, username: user.username, role: user.role, mustChangePassword: user.mustChangePassword, totpEnabled: user.totpEnabled, }; } export async function createSession( userId: number, ip: string | undefined, userAgent: string | undefined, ): Promise<{ token: string; expiresAt: Date }> { const token = randomBytes(32).toString('base64url'); const expiresAt = new Date(Date.now() + env.SESSION_TTL_HOURS * 3600 * 1000); await prisma.session.create({ data: { tokenHash: hashToken(token), userId, expiresAt, ip: ip ?? null, userAgent: userAgent?.slice(0, 255) ?? null, }, }); return { token, expiresAt }; } export async function resolveSession(token: string): Promise { const session = await prisma.session.findUnique({ where: { tokenHash: hashToken(token) }, include: { user: true }, }); if (!session) return null; if (session.expiresAt.getTime() < Date.now()) { await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined); return null; } if (!session.user.isActive) { await prisma.session.deleteMany({ where: { userId: session.userId } }); return null; } // Nicht bei jedem Request schreiben, sonst erzeugt jede Anfrage einen Write. const staleAfter = 5 * 60 * 1000; if (Date.now() - session.lastSeenAt.getTime() > staleAfter) { await prisma.session .update({ where: { id: session.id }, data: { lastSeenAt: new Date() } }) .catch(() => undefined); } return toSessionUser(session.user); } export async function destroySession(token: string): Promise { await prisma.session.deleteMany({ where: { tokenHash: hashToken(token) } }); } export async function destroyAllSessionsFor(userId: number): Promise { await prisma.session.deleteMany({ where: { userId } }); } /** Beendet alle Sitzungen des Nutzers außer der gerade verwendeten. */ export async function destroyOtherSessionsFor(userId: number, keepToken: string): Promise { await prisma.session.deleteMany({ where: { userId, NOT: { tokenHash: hashToken(keepToken) } }, }); } export async function cleanupExpiredSessions(): Promise { const result = await prisma.session.deleteMany({ where: { expiresAt: { lt: new Date() } } }); return result.count; }