Alle Token in lib/session.ts sind mit DEMSELBEN Geheimnis signiert und
tragen denselben Aussteller und dieselbe Zielgruppe. Unterschieden werden
sie allein ueber ein "type"-Feld — und genau das prueft
verifySessionToken nicht. Es verlangte nur eine vorhandene "sub".
Damit bestand ein 2FA-Challenge-Token die Session-Pruefung und galt als
vollwertige Anmeldung. Dieses Cookie wird ausgestellt, NACHDEM das
Passwort stimmt, aber BEVOR der zweite Faktor geprueft ist: wer es im
Browser in das Session-Cookie kopiert, umgeht die Zwei-Faktor-Pruefung.
Praktisch nachgestellt:
signTwoFactorChallenge("customer_123")
-> verifySessionToken(...) ergab
{ customerId: "customer_123", role: "KUNDE", sessionVersion: 0 }
Dasselbe galt fuer den 2FA-Einrichtungstoken und den Token der
Backup-Code-Anzeige. Der Entra-OAuth-State trug keine "sub" und fiel
schon vorher durch.
Aufgehalten wurde das bisher nur zufaellig: diese Token fuehren keine
sessionVersion (also 0), und getCurrentCustomer vergleicht sie gegen die
Datenbank — wer 2FA aktiviert, steht dort bereits bei mindestens 1
(konto/actions/two-factor.ts zaehlt hoch). Das ist eine Sperre, die aus
einem voellig anderen Grund existiert und lautlos wegfaellt, sobald sich
an Zaehler oder Token etwas aendert.
verifySessionToken lehnt jetzt jeden Token mit "type"-Feld ab. Geprueft
wird auf ABWESENHEIT, nicht auf einen bestimmten Wert: das Session-Token
setzt selbst kein "type", bestehende Cookies bleiben also gueltig und
niemand wird durch das Ausrollen abgemeldet.
Dazu die erste Testabdeckung fuer die Token-Schicht ueberhaupt
(lib/session.test.ts, 12 Faelle): Rundlauf, manipulierter Token,
unbekannte Rolle wird auf KUNDE heruntergestuft, und jede Kombination von
Token-Verwechslung in beide Richtungen. Die Datei laeuft bewusst in der
node-Umgebung — jose prueft mit "instanceof Uint8Array", und jsdoms
TextEncoder liefert ein Uint8Array aus einem anderen Realm.
Nebenbei in honig/actions.ts: toggleWishlistAction nahm die productId
ungeprueft aus dem Formular. Ein erfundener Wert lief in eine
Fremdschluesselverletzung und damit in die allgemeine Fehlerseite — ein
500er als Antwort auf eine schlicht ungueltige Eingabe. Jetzt wird die
Existenz geprueft, und ein deaktiviertes Produkt kommt nicht neu auf eine
Wunschliste.
tsc: 0 Fehler. vitest: 328/328 gruen (zuvor 316). eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
241 lines
9.3 KiB
TypeScript
241 lines
9.3 KiB
TypeScript
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;
|
|
|
|
// Alle Sondertoken in dieser Datei — 2FA-Challenge, 2FA-Einrichtung,
|
|
// Backup-Code-Anzeige, Entra-OAuth-State — sind mit DEMSELBEN Geheimnis
|
|
// signiert und tragen denselben Aussteller und dieselbe Zielgruppe. Ihr
|
|
// einziger Unterschied ist das "type"-Feld. Ohne diese Zeile besteht ein
|
|
// solcher Token die Pruefung hier und gilt als vollwertige Anmeldung:
|
|
// das 2FA-Challenge-Cookie wird ausgestellt, NACHDEM das Passwort stimmt,
|
|
// aber BEVOR der zweite Faktor geprueft ist — wer es in das
|
|
// Session-Cookie kopiert, umgeht damit die Zwei-Faktor-Pruefung.
|
|
//
|
|
// Praktisch scheiterte das bisher nur zufaellig daran, dass diese Token
|
|
// keine sessionVersion tragen (also 0) und getCurrentCustomer die Version
|
|
// gegen die Datenbank prueft — wer 2FA aktiviert, steht dort bereits bei
|
|
// mindestens 1 (siehe konto/actions/two-factor.ts). Das ist eine Sperre,
|
|
// die aus einem voellig anderen Grund existiert; sie faellt lautlos weg,
|
|
// sobald sich an Zaehler oder Token etwas aendert.
|
|
//
|
|
// Das Session-Token selbst setzt kein "type" (siehe signSessionToken).
|
|
// Deshalb wird hier auf ABWESENHEIT geprueft statt auf einen bestimmten
|
|
// Wert: so bleiben alle bereits ausgestellten Cookies gueltig, niemand
|
|
// wird durch das Ausrollen abgemeldet.
|
|
if (payload.type !== undefined) 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;
|
|
}
|
|
}
|
|
|
|
// --- Entra-ID-SSO ("Mit Microsoft anmelden"): kurzlebiger Zwischenschritt
|
|
// zwischen Redirect zu Microsoft und Rueckkehr am Callback. Haelt den
|
|
// PKCE-code_verifier (darf nie an den Client/die URL, sonst waere PKCE
|
|
// wirkungslos) und einen zufaelligen state-Wert zur CSRF-Absicherung —
|
|
// genau wie beim 2FA-Challenge-Cookie oben ein eigener, separat benannter
|
|
// Token-Typ, nicht wiederverwendbar fuer irgendeinen anderen Zweck. ---
|
|
|
|
export const ENTRA_OAUTH_COOKIE = "wabenhain_entra_oauth";
|
|
const ENTRA_OAUTH_TTL_SECONDS = 10 * 60; // 10 Minuten, Microsoft-Login + Redirect zurueck muss darin passen
|
|
|
|
interface EntraOAuthStatePayload {
|
|
state: string;
|
|
codeVerifier: string;
|
|
}
|
|
|
|
export async function signEntraOAuthState(state: string, codeVerifier: string): Promise<string> {
|
|
return new SignJWT({ type: "entra-oauth-state", state, codeVerifier })
|
|
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
|
.setIssuer(JWT_ISSUER)
|
|
.setAudience(JWT_AUDIENCE)
|
|
.setIssuedAt()
|
|
.setExpirationTime(`${ENTRA_OAUTH_TTL_SECONDS}s`)
|
|
.sign(getSecretKey());
|
|
}
|
|
|
|
export async function verifyEntraOAuthState(token: string): Promise<EntraOAuthStatePayload | null> {
|
|
try {
|
|
const { payload } = await jwtVerify(token, getSecretKey(), {
|
|
issuer: JWT_ISSUER,
|
|
audience: JWT_AUDIENCE,
|
|
algorithms: ["HS256"],
|
|
});
|
|
if (payload.type !== "entra-oauth-state" || typeof payload.state !== "string" || typeof payload.codeVerifier !== "string") {
|
|
return null;
|
|
}
|
|
return { state: payload.state, codeVerifier: payload.codeVerifier };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|