import { SignJWT, jwtVerify } from "jose"; // Bewusst ohne Prisma-/next-headers-Importe: dieses Modul wird auch vom // Proxy (src/proxy.ts) geladen, der schlank bleiben muss und keinen // Datenbankzugriff haben soll. export const SESSION_COOKIE = "wabenhain_session"; export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 Tage // Admin-Konten haben Zugriff auf Bestellungen, Rueckerstattungen und // Kundendaten — eine kuerzere Session-Laufzeit begrenzt das Zeitfenster, // in dem ein gestohlenes/vergessenes Admin-Cookie missbraucht werden kann. export const ADMIN_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 Tage export const TWO_FACTOR_CHALLENGE_COOKIE = "wabenhain_2fa_challenge"; const TWO_FACTOR_CHALLENGE_TTL_SECONDS = 5 * 60; // 5 Minuten, nach Passwort-Schritt bis 2FA-Code export const TWO_FACTOR_SETUP_COOKIE = "wabenhain_2fa_setup"; const TWO_FACTOR_SETUP_TTL_SECONDS = 10 * 60; // 10 Minuten, um die Einrichtung abzuschliessen const JWT_ISSUER = "wabenhain"; const JWT_AUDIENCE = "wabenhain-web"; const MIN_SECRET_LENGTH = 32; export type SessionRole = "KUNDE" | "ADMIN"; export interface SessionPayload { customerId: string; role: SessionRole; sessionVersion: number; } function getSecretKey(): Uint8Array { const secret = process.env.SESSION_SECRET; if (!secret) { throw new Error("SESSION_SECRET ist nicht gesetzt. Bitte in der .env-Datei konfigurieren."); } if (process.env.NODE_ENV === "production" && secret.length < MIN_SECRET_LENGTH) { throw new Error( `SESSION_SECRET muss in Produktion mindestens ${MIN_SECRET_LENGTH} Zeichen lang sein (z.B. via "openssl rand -base64 32" erzeugen).` ); } return new TextEncoder().encode(secret); } export async function signSessionToken(payload: SessionPayload): Promise { const maxAge = payload.role === "ADMIN" ? ADMIN_SESSION_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS; return new SignJWT({ role: payload.role, sessionVersion: payload.sessionVersion }) .setProtectedHeader({ alg: "HS256", typ: "JWT" }) .setSubject(payload.customerId) .setIssuer(JWT_ISSUER) .setAudience(JWT_AUDIENCE) .setIssuedAt() .setExpirationTime(`${maxAge}s`) .sign(getSecretKey()); } export async function verifySessionToken(token: string): Promise { try { const { payload } = await jwtVerify(token, getSecretKey(), { issuer: JWT_ISSUER, audience: JWT_AUDIENCE, algorithms: ["HS256"], }); if (!payload.sub) return null; // Alle Sondertoken in dieser Datei — 2FA-Challenge, 2FA-Einrichtung, // Backup-Code-Anzeige, Entra-OAuth-State — sind mit DEMSELBEN Geheimnis // signiert und tragen denselben Aussteller und dieselbe Zielgruppe. Ihr // einziger Unterschied ist das "type"-Feld. Ohne diese Zeile besteht ein // solcher Token die Pruefung hier und gilt als vollwertige Anmeldung: // das 2FA-Challenge-Cookie wird ausgestellt, NACHDEM das Passwort stimmt, // aber BEVOR der zweite Faktor geprueft ist — wer es in das // Session-Cookie kopiert, umgeht damit die Zwei-Faktor-Pruefung. // // Praktisch scheiterte das bisher nur zufaellig daran, dass diese Token // keine sessionVersion tragen (also 0) und getCurrentCustomer die Version // gegen die Datenbank prueft — wer 2FA aktiviert, steht dort bereits bei // mindestens 1 (siehe konto/actions/two-factor.ts). Das ist eine Sperre, // die aus einem voellig anderen Grund existiert; sie faellt lautlos weg, // sobald sich an Zaehler oder Token etwas aendert. // // Das Session-Token selbst setzt kein "type" (siehe signSessionToken). // Deshalb wird hier auf ABWESENHEIT geprueft statt auf einen bestimmten // Wert: so bleiben alle bereits ausgestellten Cookies gueltig, niemand // wird durch das Ausrollen abgemeldet. if (payload.type !== undefined) return null; const role: SessionRole = payload.role === "ADMIN" ? "ADMIN" : "KUNDE"; const sessionVersion = typeof payload.sessionVersion === "number" ? payload.sessionVersion : 0; return { customerId: payload.sub, role, sessionVersion }; } catch { // Abgelaufene oder manipulierte Tokens werden wie "nicht angemeldet" behandelt. return null; } } // --- Kurzlebiger Zwischenschritt-Token: Passwort korrekt, 2FA-Code steht noch aus. --- // Bewusst ein eigener, separat benannter Token-Typ (nicht das normale Session-JWT): // so kann dieser Zustand niemals versehentlich als vollstaendig angemeldet durchgehen. interface TwoFactorChallengePayload { customerId: string; } export async function signTwoFactorChallenge(customerId: string): Promise { return new SignJWT({ type: "2fa-challenge" }) .setProtectedHeader({ alg: "HS256", typ: "JWT" }) .setSubject(customerId) .setIssuer(JWT_ISSUER) .setAudience(JWT_AUDIENCE) .setIssuedAt() .setExpirationTime(`${TWO_FACTOR_CHALLENGE_TTL_SECONDS}s`) .sign(getSecretKey()); } export async function verifyTwoFactorChallenge(token: string): Promise { try { const { payload } = await jwtVerify(token, getSecretKey(), { issuer: JWT_ISSUER, audience: JWT_AUDIENCE, algorithms: ["HS256"], }); if (!payload.sub || payload.type !== "2fa-challenge") return null; return { customerId: payload.sub }; } catch { return null; } } // --- Kurzlebiger Setup-Token: haelt das frisch generierte TOTP-Secret, bis der // Nutzer den ersten Code bestaetigt hat. Erst danach landet es in der DB. --- interface TwoFactorSetupPayload { customerId: string; secret: string; } export async function signTwoFactorSetup(customerId: string, secret: string): Promise { return new SignJWT({ type: "2fa-setup", secret }) .setProtectedHeader({ alg: "HS256", typ: "JWT" }) .setSubject(customerId) .setIssuer(JWT_ISSUER) .setAudience(JWT_AUDIENCE) .setIssuedAt() .setExpirationTime(`${TWO_FACTOR_SETUP_TTL_SECONDS}s`) .sign(getSecretKey()); } export async function verifyTwoFactorSetup(token: string): Promise { try { const { payload } = await jwtVerify(token, getSecretKey(), { issuer: JWT_ISSUER, audience: JWT_AUDIENCE, algorithms: ["HS256"], }); if (!payload.sub || payload.type !== "2fa-setup" || typeof payload.secret !== "string") return null; return { customerId: payload.sub, secret: payload.secret }; } catch { return null; } } // --- Einmalige Backup-Codes-Anzeige: die Klartext-Codes existieren nur den // Moment lang zwischen Generierung und Anzeige, danach ist ausschliesslich // der Hash in der DB vorhanden. Das Cookie wird beim Lesen sofort geloescht, // ein Neuladen der Seite zeigt die Codes daher kein zweites Mal. export const TWO_FACTOR_BACKUP_CODES_COOKIE = "wabenhain_2fa_backup_display"; const TWO_FACTOR_BACKUP_CODES_TTL_SECONDS = 2 * 60; interface TwoFactorBackupCodesPayload { customerId: string; codes: string[]; } export async function signTwoFactorBackupCodesDisplay(customerId: string, codes: string[]): Promise { return new SignJWT({ type: "2fa-backup-display", codes }) .setProtectedHeader({ alg: "HS256", typ: "JWT" }) .setSubject(customerId) .setIssuer(JWT_ISSUER) .setAudience(JWT_AUDIENCE) .setIssuedAt() .setExpirationTime(`${TWO_FACTOR_BACKUP_CODES_TTL_SECONDS}s`) .sign(getSecretKey()); } export async function verifyTwoFactorBackupCodesDisplay(token: string): Promise { try { const { payload } = await jwtVerify(token, getSecretKey(), { issuer: JWT_ISSUER, audience: JWT_AUDIENCE, algorithms: ["HS256"], }); if (!payload.sub || payload.type !== "2fa-backup-display" || !Array.isArray(payload.codes)) return null; return { customerId: payload.sub, codes: payload.codes as string[] }; } catch { return null; } } // --- Entra-ID-SSO ("Mit Microsoft anmelden"): kurzlebiger Zwischenschritt // zwischen Redirect zu Microsoft und Rueckkehr am Callback. Haelt den // PKCE-code_verifier (darf nie an den Client/die URL, sonst waere PKCE // wirkungslos) und einen zufaelligen state-Wert zur CSRF-Absicherung — // genau wie beim 2FA-Challenge-Cookie oben ein eigener, separat benannter // Token-Typ, nicht wiederverwendbar fuer irgendeinen anderen Zweck. --- export const ENTRA_OAUTH_COOKIE = "wabenhain_entra_oauth"; const ENTRA_OAUTH_TTL_SECONDS = 10 * 60; // 10 Minuten, Microsoft-Login + Redirect zurueck muss darin passen interface EntraOAuthStatePayload { state: string; codeVerifier: string; } export async function signEntraOAuthState(state: string, codeVerifier: string): Promise { return new SignJWT({ type: "entra-oauth-state", state, codeVerifier }) .setProtectedHeader({ alg: "HS256", typ: "JWT" }) .setIssuer(JWT_ISSUER) .setAudience(JWT_AUDIENCE) .setIssuedAt() .setExpirationTime(`${ENTRA_OAUTH_TTL_SECONDS}s`) .sign(getSecretKey()); } export async function verifyEntraOAuthState(token: string): Promise { try { const { payload } = await jwtVerify(token, getSecretKey(), { issuer: JWT_ISSUER, audience: JWT_AUDIENCE, algorithms: ["HS256"], }); if (payload.type !== "entra-oauth-state" || typeof payload.state !== "string" || typeof payload.codeVerifier !== "string") { return null; } return { state: payload.state, codeVerifier: payload.codeVerifier }; } catch { return null; } }