import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { prisma } from "@/server/db"; /** * SEC2 — Einmal-Tokens für Passwort-Reset und E-Mail-Änderung. * * Regeln (verbindlich, SEC2 §1/§6): * - Rohtoken = 32 Byte CSPRNG, base64url. Es existiert **nur** im Link und in * der Rückgabe von `issueToken()`; gespeichert wird ausschließlich der * SHA-256-Hash. Nie loggen, nie ins MailLog. * - Lookup über den Hash (unique). Der anschließende Vergleich läuft in * konstanter Zeit — auch wenn der Index-Lookup bereits eindeutig ist, hält * das die Prüfung frei von Timing-Unterschieden. * - **Single-use**: beim Einlösen wird `usedAt` gesetzt, und zwar über ein * bedingtes `updateMany` (nur wenn `usedAt` noch NULL ist). Zwei parallele * Einlösungen desselben Tokens können so nicht beide gewinnen. * - Beim Neuanfordern werden offene Tokens desselben Typs und Prinzipals * entwertet. * * Alle Zugriffe laufen über den **rohen** `prisma`-Client: Einlösung passiert * ohne Session (der Nutzer ist gerade nicht angemeldet) und damit ohne * Mandantenkontext. Die RLS-Policy auf `auth_tokens` bleibt als zweite * Verteidigungslinie bestehen. */ // Option C (WS4): der Mandanten-Prinzipal ist jetzt die GLOBALE Identity // (Passwort/Reset gehören der Identity, nicht mehr der per-Mandant-Mitgliedschaft). // "tenant_user" bleibt für Bestands-/Kompatibilität im Typ, wird aber nicht mehr // ausgestellt (principalTypeOf(tenant) → "identity"). export type PrincipalType = "identity" | "tenant_user" | "platform_admin"; export type TokenType = "password_reset" | "email_change" | "invitation"; /** Gültigkeitsdauer je Typ (SEC2 §1: Reset 30–60 Min, Verify 60 Min; Einladung 7 Tage). */ const TTL_MINUTES: Record = { password_reset: 60, email_change: 60, invitation: 7 * 24 * 60, }; function hashToken(raw: string): string { return createHash("sha256").update(raw).digest("hex"); } /** Konstante-Zeit-Vergleich zweier Hex-Hashes gleicher Länge. */ function hashesEqual(a: string, b: string): boolean { const bufA = Buffer.from(a, "hex"); const bufB = Buffer.from(b, "hex"); if (bufA.length !== bufB.length) return false; return timingSafeEqual(bufA, bufB); } export type IssuedToken = { raw: string; expiresAt: Date }; /** * Erzeugt ein neues Token und entwertet offene Tokens desselben Typs. * Gibt das **Rohtoken** zurück — der einzige Moment, in dem es existiert. */ export async function issueToken(input: { principalType: PrincipalType; principalId: string; tenantId?: string | null; type: TokenType; newEmail?: string; requestIp?: string | null; /** Überschreibt die Default-TTL des Typs (z. B. längerer Einladungslink). */ ttlMinutes?: number; }): Promise { // Offene Tokens desselben Typs entwerten: eine neue Anforderung macht die // vorherige ungültig (verhindert mehrere gleichzeitig gültige Links). await prisma.authToken.updateMany({ where: { principalType: input.principalType, principalId: input.principalId, type: input.type, usedAt: null, }, data: { usedAt: new Date() }, }); const raw = randomBytes(32).toString("base64url"); const expiresAt = new Date(Date.now() + (input.ttlMinutes ?? TTL_MINUTES[input.type]) * 60_000); await prisma.authToken.create({ data: { principalType: input.principalType, principalId: input.principalId, tenantId: input.tenantId ?? null, type: input.type, tokenHash: hashToken(raw), newEmail: input.newEmail, expiresAt, requestIp: input.requestIp ?? null, }, }); return { raw, expiresAt }; } export type ResolvedToken = { id: string; principalType: PrincipalType; principalId: string; tenantId: string | null; type: TokenType; newEmail: string | null; }; /** * Prüft ein Rohtoken, **ohne** es zu verbrauchen (für die Anzeige des * Reset-Formulars). Liefert `null` bei jedem Fehlerfall — der Aufrufer gibt * daraufhin eine generische Meldung aus, nie den konkreten Grund. */ export async function peekToken(raw: string, type: TokenType): Promise { if (!raw) return null; const hash = hashToken(raw); const row = await prisma.authToken.findUnique({ where: { tokenHash: hash } }); if (!row) return null; if (!hashesEqual(row.tokenHash, hash)) return null; if (row.type !== type) return null; if (row.usedAt) return null; if (row.expiresAt <= new Date()) return null; return { id: row.id, principalType: row.principalType as PrincipalType, principalId: row.principalId, tenantId: row.tenantId, type: row.type as TokenType, newEmail: row.newEmail, }; } /** * Löst ein Token ein und markiert es als verbraucht. * * Das `updateMany` mit `usedAt: null` in der Bedingung ist die eigentliche * Sperre: gewinnt ein paralleler Aufruf, ändert dieser Aufruf 0 Zeilen und * bekommt `null` — der Token wird garantiert nur einmal wirksam. */ export async function consumeToken(raw: string, type: TokenType): Promise { const token = await peekToken(raw, type); if (!token) return null; const result = await prisma.authToken.updateMany({ where: { id: token.id, usedAt: null }, data: { usedAt: new Date() }, }); if (result.count !== 1) return null; return token; } /** Aufräumen abgelaufener/verbrauchter Tokens (für einen späteren Wartungsjob). */ export async function purgeExpiredTokens(before: Date = new Date()): Promise { const { count } = await prisma.authToken.deleteMany({ where: { OR: [{ expiresAt: { lt: before } }, { usedAt: { not: null } }] }, }); return count; }