Files
craftvia/scripts/test-auth-selfservice.ts
T
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

171 lines
6.6 KiB
TypeScript

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/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);
});