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:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user