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

+176
View File
@@ -0,0 +1,176 @@
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<string> {
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<SessionPayload | null> {
try {
const { payload } = await jwtVerify(token, getSecretKey(), {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
algorithms: ["HS256"],
});
if (!payload.sub) 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<string> {
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<TwoFactorChallengePayload | null> {
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<string> {
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<TwoFactorSetupPayload | null> {
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<string> {
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<TwoFactorBackupCodesPayload | null> {
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;
}
}