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>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user