import { createHash, randomBytes } from "node:crypto"; import { cachified, clearCacheKey } from "~/utils/cache.server"; import { db } from "~/utils/db.server"; // R-P2a S4 — serverseitige Sessions. Postgres ist Source-of-Truth (Tabelle UserSession), // davor ein kurzlebiger Cache (LRU heute, Redis-swappable per cache.server, AD-4). Das // Login-Cookie traegt einen opaken Zufalls-Token; persistiert wird nur dessen SHA-256-Hash. const SESSION_TOKEN_BYTES = 32; const DEFAULT_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage, deckt das Cookie-Max-Age const SESSION_CACHE_TTL_MS = 60 * 1000; // kurzer Cache vor dem Postgres-SoT const TOUCH_THROTTLE_MS = 5 * 60 * 1000; // lastSeenAt hoechstens alle 5min fortschreiben export type UserSessionRow = Awaited>; type Identified = { userId: string; sessionId: string }; export function generateSessionToken(): string { return randomBytes(SESSION_TOKEN_BYTES).toString("base64url"); } export function hashSessionToken(rawToken: string): string { return createHash("sha256").update(rawToken).digest("hex"); } function sessionCacheKey(tokenHash: string): string { return `user-session:${tokenHash}`; } function isActive(session: Pick | null, now = Date.now()): boolean { return Boolean(session && session.revokedAt === null && session.expiresAt.getTime() > now); } export type CreateSessionInput = { userId: string; // Wenn nicht gesetzt, wird ein Token generiert. Der rohe Token gehoert ins Cookie. rawToken?: string; expiresAt?: Date; // AD-22: gehashte IP (hashIp), NICHT Klartext. ipHash?: string | null; userAgent?: string | null; deviceLabel?: string | null; approxLocation?: string | null; browserFingerprint?: string | null; trusted?: boolean; }; export async function createUserSession(input: CreateSessionInput): Promise<{ session: UserSessionRow; rawToken: string }> { const rawToken = input.rawToken ?? generateSessionToken(); const now = Date.now(); const session = await db.userSession.create({ data: { userId: input.userId, tokenHash: hashSessionToken(rawToken), expiresAt: input.expiresAt ?? new Date(now + DEFAULT_SESSION_TTL_MS), ipHash: input.ipHash ?? null, userAgent: input.userAgent ?? null, deviceLabel: input.deviceLabel ?? null, approxLocation: input.approxLocation ?? null, browserFingerprint: input.browserFingerprint ?? null, trustedAt: input.trusted ? new Date(now) : null, }, }); return { session, rawToken }; } // Liefert die aktive Session zum Cookie-Token oder null (revoked/abgelaufen/unbekannt). Der // Cache wird bei Revoke gezielt invalidiert; die Ablauf-Pruefung laeuft zusaetzlich live gegen now. export async function getActiveSessionByToken(rawToken: string | undefined | null): Promise { if (!rawToken) { return null; } const tokenHash = hashSessionToken(rawToken); const session = await cachified({ key: sessionCacheKey(tokenHash), ttl: SESSION_CACHE_TTL_MS, getFreshValue: async () => db.userSession.findUnique({ where: { tokenHash } }), }); if (!isActive(session)) { return null; } return session; } // Schreibt lastSeenAt fort, aber gedrosselt (hoechstens alle TOUCH_THROTTLE_MS), um den // Hot-Path nicht mit Writes zu belasten. export async function touchSession(session: Pick): Promise { const now = Date.now(); if (now - session.lastSeenAt.getTime() < TOUCH_THROTTLE_MS) { return; } await db.userSession.update({ where: { id: session.id }, data: { lastSeenAt: new Date(now) } }); clearCacheKey(sessionCacheKey(session.tokenHash)); } export async function listActiveUserSessions(userId: string): Promise { return db.userSession.findMany({ where: { userId, revokedAt: null, expiresAt: { gt: new Date() } }, orderBy: { lastSeenAt: "desc" }, }); } // Revoke einzeln (Geraete-Liste). Verifiziert die Eigentuemerschaft ueber userId, damit ein // Nutzer nur eigene Sessions beenden kann, und invalidiert den Cache. export async function revokeUserSession({ userId, sessionId }: Identified): Promise { const session = await db.userSession.findFirst({ where: { id: sessionId, userId }, select: { id: true, tokenHash: true, revokedAt: true } }); if (!session || session.revokedAt !== null) { return false; } await db.userSession.update({ where: { id: session.id }, data: { revokedAt: new Date() } }); clearCacheKey(sessionCacheKey(session.tokenHash)); return true; } // "Ueberall abmelden": alle aktiven Sessions des Nutzers ausser der aktuellen beenden. export async function revokeAllUserSessionsExcept({ userId, sessionId }: Identified): Promise { const sessions = await db.userSession.findMany({ where: { userId, revokedAt: null, id: { not: sessionId } }, select: { id: true, tokenHash: true }, }); if (sessions.length === 0) { return 0; } const now = new Date(); await db.userSession.updateMany({ where: { userId, revokedAt: null, id: { not: sessionId } }, data: { revokedAt: now }, }); for (const session of sessions) { clearCacheKey(sessionCacheKey(session.tokenHash)); } return sessions.length; } // Revoke ueber den Cookie-Token (Logout): beendet genau die aktuelle Session. export async function revokeSessionByToken(rawToken: string | undefined | null): Promise { if (!rawToken) { return false; } const tokenHash = hashSessionToken(rawToken); const session = await db.userSession.findUnique({ where: { tokenHash }, select: { id: true, revokedAt: true } }); if (!session || session.revokedAt !== null) { return false; } await db.userSession.update({ where: { id: session.id }, data: { revokedAt: new Date() } }); clearCacheKey(sessionCacheKey(tokenHash)); return true; } // Trust-Device (S5/2FA): markiert eine eigene Session als vertrauenswuerdig. export async function markSessionTrusted({ userId, sessionId }: Identified): Promise { const result = await db.userSession.updateMany({ where: { id: sessionId, userId, revokedAt: null }, data: { trustedAt: new Date() }, }); return result.count > 0; }