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
+185
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user