import "dotenv/config"; import { prisma } from "../src/server/db"; import { consumeToken, issueToken, peekToken, purgeExpiredTokens } from "../src/server/auth-token"; import { isTokenStillValid } from "../src/server/sessions"; import { checkRateLimit, RATE_LIMITS, resetRateLimits } from "../src/server/rate-limit"; import { findAccountByEmail } from "../src/server/auth-selfservice"; import { closeQueues } from "../src/server/mail/queue"; import { closeMailProvider } from "../src/server/mail/provider-smtp"; /** * SEC2 — Abnahmetest der Sicherheitseigenschaften * (`npx tsx scripts/test-auth-selfservice.ts`). * * Geprüft wird, was sich ohne Browser prüfen lässt — die eigentlichen * Sicherheitsgarantien: * 1. Token: nur der Hash liegt in der DB, nie das Rohtoken. * 2. Single-use: zweite Einlösung schlägt fehl. * 3. Ablauf: abgelaufene Tokens werden abgewiesen. * 4. Neuanforderung entwertet den vorherigen Link. * 5. Manipuliertes/unbekanntes Token wird abgewiesen. * 6. Typ-Verwechslung (email_change als password_reset) wird abgewiesen. * 7. Rate-Limit greift je IP und je Konto. * 8. Session-Invalidierung: älteres JWT ungültig, neueres gültig. * 9. Enumeration: unbekannte Adresse liefert kein Konto. * * Die Browser-Abläufe (Reset-Mail → Link → neues Passwort) stehen im Testplan * von docs/_certvia-archiv/SEC2-AUTH-SELFSERVICE.md. */ let failures = 0; function check(name: string, ok: boolean, detail?: string) { if (ok) console.log(` ✓ ${name}`); else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); } } const PRINCIPAL = "sec2-test-principal"; async function cleanup() { await prisma.authToken.deleteMany({ where: { principalId: { startsWith: PRINCIPAL } } }); } async function main() { await cleanup(); resetRateLimits(); console.log("1) Token wird nur gehasht gespeichert"); const t1 = await issueToken({ principalType: "tenant_user", principalId: `${PRINCIPAL}-1`, tenantId: null, type: "password_reset", }); const rows = await prisma.authToken.findMany({ where: { principalId: `${PRINCIPAL}-1` } }); check("genau ein Token angelegt", rows.length === 1); check("Rohtoken steht NICHT in der DB", !rows.some((r) => r.tokenHash === t1.raw)); check("Hash ist 64 Hex-Zeichen (SHA-256)", /^[0-9a-f]{64}$/.test(rows[0]?.tokenHash ?? "")); check("Rohtoken ist ausreichend lang", t1.raw.length >= 40, `${t1.raw.length} Zeichen`); console.log("2) Single-use"); const first = await consumeToken(t1.raw, "password_reset"); check("erste Einlösung erfolgreich", first != null); const second = await consumeToken(t1.raw, "password_reset"); check("zweite Einlösung abgewiesen", second === null); check("peek nach Verbrauch liefert null", (await peekToken(t1.raw, "password_reset")) === null); console.log("3) Ablauf"); const t2 = await issueToken({ principalType: "tenant_user", principalId: `${PRINCIPAL}-2`, type: "password_reset", }); await prisma.authToken.updateMany({ where: { principalId: `${PRINCIPAL}-2` }, data: { expiresAt: new Date(Date.now() - 1000) }, }); check("abgelaufenes Token abgewiesen", (await peekToken(t2.raw, "password_reset")) === null); console.log("4) Neuanforderung entwertet den vorherigen Link"); const t3a = await issueToken({ principalType: "tenant_user", principalId: `${PRINCIPAL}-3`, type: "password_reset", }); const t3b = await issueToken({ principalType: "tenant_user", principalId: `${PRINCIPAL}-3`, type: "password_reset", }); check("alter Link ungültig", (await peekToken(t3a.raw, "password_reset")) === null); check("neuer Link gültig", (await peekToken(t3b.raw, "password_reset")) != null); console.log("5) Unbekanntes/manipuliertes Token"); check("Zufallswert abgewiesen", (await peekToken("nicht-existent-xyz", "password_reset")) === null); check("leeres Token abgewiesen", (await peekToken("", "password_reset")) === null); check( "manipuliertes Token abgewiesen", (await peekToken(t3b.raw.slice(0, -2) + "AA", "password_reset")) === null, ); console.log("6) Typ-Verwechslung"); const t4 = await issueToken({ principalType: "tenant_user", principalId: `${PRINCIPAL}-4`, type: "email_change", newEmail: "neu@example.test", }); check("email_change nicht als password_reset einlösbar", (await peekToken(t4.raw, "password_reset")) === null); check("email_change als email_change gültig", (await peekToken(t4.raw, "email_change")) != null); console.log("7) Rate-Limit"); resetRateLimits(); const rule = RATE_LIMITS.passwordResetRequest; let blockedAt = -1; for (let i = 1; i <= rule.limit + 2; i++) { const r = checkRateLimit("passwordResetRequest", { ip: "203.0.113.9", account: "a@example.test" }); if (!r.allowed && blockedAt < 0) blockedAt = i; } check(`greift nach ${rule.limit} Versuchen`, blockedAt === rule.limit + 1, `blockiert ab ${blockedAt}`); const otherIp = checkRateLimit("passwordResetRequest", { ip: "198.51.100.4", account: "b@example.test" }); check("anderes Konto/IP unberührt", otherIp.allowed); const sameAccountOtherIp = checkRateLimit("passwordResetRequest", { ip: "198.51.100.5", account: "a@example.test", }); check("Kontozähler greift auch von anderer IP", !sameAccountOtherIp.allowed); console.log("8) Session-Invalidierung"); const mark = new Date("2026-07-30T12:00:00Z"); const before = Math.floor(mark.getTime() / 1000) - 10; const after = Math.floor(mark.getTime() / 1000) + 10; check("älteres JWT ungültig", !isTokenStillValid(before, mark)); check("neueres JWT gültig", isTokenStillValid(after, mark)); check("ohne Marke immer gültig", isTokenStillValid(before, null)); check("ohne iat fail-closed", !isTokenStillValid(undefined, mark)); console.log("9) Enumeration"); check( "unbekannte Adresse liefert kein Konto", (await findAccountByEmail("tenant", `nichtda-${Date.now()}@example.test`)) === null, ); check( "unbekannter Plattform-Admin liefert kein Konto", (await findAccountByEmail("platform", `nichtda-${Date.now()}@example.test`)) === null, ); console.log("10) Aufräumen abgelaufener Tokens"); const purged = await purgeExpiredTokens(); check("purge läuft durch", purged >= 0, `${purged} entfernt`); await cleanup(); await prisma.$disconnect(); await closeMailProvider(); await closeQueues(); if (failures > 0) { console.error(`\n✗ ${failures} Prüfung(en) fehlgeschlagen.`); process.exit(1); } console.log("\n✓ SEC2-Self-Service: alle Prüfungen bestanden."); process.exit(0); } main().catch(async (err) => { console.error(err); await prisma.$disconnect().catch(() => {}); process.exit(1); });