import { cookies } from "next/headers"; import bcrypt from "bcryptjs"; import { prisma } from "@/lib/prisma"; import { SESSION_COOKIE, SESSION_MAX_AGE_SECONDS, ADMIN_SESSION_MAX_AGE_SECONDS, TWO_FACTOR_CHALLENGE_COOKIE, TWO_FACTOR_SETUP_COOKIE, TWO_FACTOR_BACKUP_CODES_COOKIE, ENTRA_OAUTH_COOKIE, signSessionToken, verifySessionToken, verifyTwoFactorChallenge, verifyTwoFactorSetup, verifyTwoFactorBackupCodesDisplay, verifyEntraOAuthState, type SessionRole, } from "@/lib/session"; import type { Customer } from "@/generated/prisma/client"; const BCRYPT_ROUNDS = 12; // Vorberechneter Hash, gegen den bei unbekannter E-Mail-Adresse verglichen // wird: so dauert ein Login-Versuch mit falscher E-Mail genauso lange wie // einer mit falschem Passwort (kein User-Enumeration ueber Timing). const dummyHashPromise: Promise = bcrypt.hash("wabenhain-timing-dummy", BCRYPT_ROUNDS); export async function hashPassword(password: string): Promise { return bcrypt.hash(password, BCRYPT_ROUNDS); } /** * Prueft ein Passwort gegen den Hash eines (moeglicherweise nicht * existierenden) Kontos in konstanter Zeit. Bei `hash === null` wird gegen * einen Dummy-Hash verglichen und immer false zurueckgegeben. */ export async function verifyPassword(password: string, hash: string | null): Promise { const effectiveHash = hash ?? (await dummyHashPromise); const matches = await bcrypt.compare(password, effectiveHash); return matches && hash !== null; } /** * Erzeugt das Session-JWT inkl. Rolle und sessionVersion. Hinweis: Rolle und * Version im Token dienen dem Proxy als schneller Vorfilter — autoritativ * ist immer die Datenbank (getCurrentCustomer/getCurrentAdmin). */ export async function createSession(customerId: string, role: SessionRole, sessionVersion: number): Promise { const token = await signSessionToken({ customerId, role, sessionVersion }); const cookieStore = await cookies(); cookieStore.set(SESSION_COOKIE, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: role === "ADMIN" ? ADMIN_SESSION_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS, }); } /** * Loescht nur das Session-Cookie auf diesem Geraet — erhoeht bewusst NICHT * sessionVersion (das ist global pro Kunde, nicht pro Geraet/Sitzung). Ein * bereits vor dem Logout kopiertes/gestohlenes JWT bliebe dadurch bis zum * natuerlichen Ablauf gueltig; das ist der bekannte Kompromiss der * Einzel-Zaehler-Architektur (kein separates Session-Token pro Geraet). * Fuer den Fall eines vermuteten Diebstahls existiert bewusst "Alle Geraete * abmelden" (erhoeht sessionVersion) als eigene, staerkere Aktion — wuerde * ein normales Logout dasselbe tun, waeren beide Aktionen identisch und man * koennte sich nicht mehr auf nur einem Geraet abmelden, ohne alle anderen * aktiven Sitzungen ebenfalls zu beenden. */ export async function destroySession(): Promise { const cookieStore = await cookies(); cookieStore.delete(SESSION_COOKIE); } export async function getCurrentCustomer(): Promise { const cookieStore = await cookies(); const token = cookieStore.get(SESSION_COOKIE)?.value; if (!token) return null; const payload = await verifySessionToken(token); if (!payload) return null; const customer = await prisma.customer.findUnique({ where: { id: payload.customerId } }); if (!customer) return null; // Session widerrufen (Passwort-Reset, "alle Geraete abmelden", 2FA-Aenderung)? // Das Token selbst ist noch gueltig/unmanipuliert, zaehlt aber nicht mehr. if (customer.sessionVersion !== payload.sessionVersion) return null; // Ohne diese Pruefung wuerde eine bestehende Session einer admin-gesperrten // Kundschaft weiter funktionieren, bis das Cookie natuerlich ablaeuft — // die Sperre in /admin/benutzer muss auch aktive Sitzungen sofort beenden. if (customer.blockedAt) return null; return customer; } /** * Autoritative Admin-Pruefung gegen die Datenbank (nicht nur das JWT-Claim). * Muss in jeder Admin-Server-Action und im Admin-Layout aufgerufen werden. */ export async function getCurrentAdmin(): Promise { const customer = await getCurrentCustomer(); return customer?.role === "ADMIN" ? customer : null; } /** customerId aus einem gueltigen 2FA-Zwischenschritt-Cookie (Passwort ok, Code steht noch aus). */ export async function getTwoFactorChallengeCustomerId(): Promise { const cookieStore = await cookies(); const token = cookieStore.get(TWO_FACTOR_CHALLENGE_COOKIE)?.value; if (!token) return null; return (await verifyTwoFactorChallenge(token))?.customerId ?? null; } export async function setTwoFactorChallengeCookie(token: string): Promise { const cookieStore = await cookies(); cookieStore.set(TWO_FACTOR_CHALLENGE_COOKIE, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 5 * 60, }); } export async function clearTwoFactorChallengeCookie(): Promise { const cookieStore = await cookies(); cookieStore.delete(TWO_FACTOR_CHALLENGE_COOKIE); } export async function setTwoFactorSetupCookie(token: string): Promise { const cookieStore = await cookies(); cookieStore.set(TWO_FACTOR_SETUP_COOKIE, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 10 * 60, }); } /** Liest secret + customerId aus dem laufenden 2FA-Einrichtungsversuch, ohne das Cookie zu verbrauchen. */ export async function getTwoFactorSetup(): Promise<{ customerId: string; secret: string } | null> { const cookieStore = await cookies(); const token = cookieStore.get(TWO_FACTOR_SETUP_COOKIE)?.value; if (!token) return null; return verifyTwoFactorSetup(token); } export async function clearTwoFactorSetupCookie(): Promise { const cookieStore = await cookies(); cookieStore.delete(TWO_FACTOR_SETUP_COOKIE); } export async function setTwoFactorBackupCodesDisplayCookie(token: string): Promise { const cookieStore = await cookies(); cookieStore.set(TWO_FACTOR_BACKUP_CODES_COOKIE, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 2 * 60, }); } /** * Liest die frisch generierten Backup-Codes aus (ohne das Cookie zu loeschen — * Cookies duerfen waehrend des Server-Component-Renderns nicht mutiert werden, * nur innerhalb einer Server Action/Route Handler). Das Cookie hat ohnehin nur * eine 2-Minuten-TTL; zusaetzlich raeumt clearTwoFactorBackupCodesDisplayCookie * es aktiv auf, sobald der Nutzer die Anzeige bestaetigt. */ export async function getTwoFactorBackupCodesDisplay(): Promise { const cookieStore = await cookies(); const token = cookieStore.get(TWO_FACTOR_BACKUP_CODES_COOKIE)?.value; if (!token) return null; const payload = await verifyTwoFactorBackupCodesDisplay(token); return payload?.codes ?? null; } export async function clearTwoFactorBackupCodesDisplayCookie(): Promise { const cookieStore = await cookies(); cookieStore.delete(TWO_FACTOR_BACKUP_CODES_COOKIE); } /** Setzt das kurzlebige Cookie mit PKCE-code_verifier + CSRF-state fuer den laufenden Entra-ID-SSO-Versuch. */ export async function setEntraOAuthCookie(token: string): Promise { const cookieStore = await cookies(); cookieStore.set(ENTRA_OAUTH_COOKIE, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", // "lax" reicht hier: der Redirect zurueck von login.microsoftonline.com // ist eine Top-Level-GET-Navigation, die "lax" bei Cross-Site-Redirects // ausdruecklich mitsendet (anders als bei einem POST/Formular-Cross-Site- // Request, wo "lax" das Cookie zurueckhaelt). sameSite: "lax", path: "/", maxAge: 10 * 60, }); } /** Liest state + code_verifier des laufenden Entra-ID-SSO-Versuchs, ohne das Cookie zu loeschen. */ export async function getEntraOAuthCookie(): Promise<{ state: string; codeVerifier: string } | null> { const cookieStore = await cookies(); const token = cookieStore.get(ENTRA_OAUTH_COOKIE)?.value; if (!token) return null; return verifyEntraOAuthState(token); } /** Einmal verwendet (erfolgreich oder nicht) wird das Cookie sofort geloescht — kein Replay eines alten code_verifier/state-Paars. */ export async function clearEntraOAuthCookie(): Promise { const cookieStore = await cookies(); cookieStore.delete(ENTRA_OAUTH_COOKIE); }