Files
Wabenhain/src/lib/auth.ts
T
Nick Szaunig 4d3100e9ba
Docker Image bauen und veroeffentlichen / build-and-push (push) Failing after 1m33s
feat: Security CSRF Fix, Logout optimierung, Footer Anpassung, Rechtstexteanpassung
2026-09-03 13:51:50 +02:00

218 lines
8.4 KiB
TypeScript

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<string> = bcrypt.hash("wabenhain-timing-dummy", BCRYPT_ROUNDS);
export async function hashPassword(password: string): Promise<string> {
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<boolean> {
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<void> {
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<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
export async function getCurrentCustomer(): Promise<Customer | null> {
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<Customer | null> {
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<string | null> {
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<void> {
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<void> {
const cookieStore = await cookies();
cookieStore.delete(TWO_FACTOR_CHALLENGE_COOKIE);
}
export async function setTwoFactorSetupCookie(token: string): Promise<void> {
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<void> {
const cookieStore = await cookies();
cookieStore.delete(TWO_FACTOR_SETUP_COOKIE);
}
export async function setTwoFactorBackupCodesDisplayCookie(token: string): Promise<void> {
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<string[] | null> {
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<void> {
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<void> {
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<void> {
const cookieStore = await cookies();
cookieStore.delete(ENTRA_OAUTH_COOKIE);
}