feat: Wabenhain Honig-Shop Anwendung

Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei:
Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich
(Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare
Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung,
PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma-
Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
This commit is contained in:
Nick Szaunig committed 2026-08-17 14:30:28 +02:00
1 parent 5c76fcc266
commit c7927f2883
240 files changed
+26096 -137

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
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,
signSessionToken,
verifySessionToken,
verifyTwoFactorChallenge,
verifyTwoFactorSetup,
verifyTwoFactorBackupCodesDisplay,
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);
}