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>
204 lines
8.3 KiB
TypeScript
204 lines
8.3 KiB
TypeScript
import "dotenv/config";
|
|
import { prisma } from "../src/server/db";
|
|
import { hashPassword, verifyPassword } from "../src/server/password";
|
|
import { redeemPasswordReset, requestPasswordReset } from "../src/server/actions/auth-recovery";
|
|
import { issueToken, peekToken } from "../src/server/auth-token";
|
|
import { resetRateLimits } from "../src/server/rate-limit";
|
|
import { closeQueues } from "../src/server/mail/queue";
|
|
import { closeMailProvider } from "../src/server/mail/provider-smtp";
|
|
|
|
/**
|
|
* SEC2 — End-to-End des Reset-Ablaufs gegen ein Wegwerf-Konto
|
|
* (`npx tsx scripts/test-reset-flow.ts`).
|
|
*
|
|
* Ergänzt `test-auth-selfservice.ts` (dort: die Token-/Limit-Eigenschaften
|
|
* isoliert) um das Zusammenspiel: Anfrage → Token → Einlösung → Passwort
|
|
* geschrieben → Sessions entwertet → Bestätigungsmail → Audit.
|
|
*
|
|
* Zusätzlich die beiden Fälle, die man leicht falsch baut:
|
|
* - ein **Policy-Verstoß darf den Link nicht entwerten** (sonst ist der Nutzer
|
|
* nach einem Tippfehler ausgesperrt),
|
|
* - ein **deaktiviertes Konto** bekommt keinen Reset, aber dieselbe Antwort.
|
|
*
|
|
* Das Testkonto wird angelegt und am Ende restlos entfernt.
|
|
*/
|
|
|
|
const EMAIL = "sec2-e2e@example.test";
|
|
const OLD_PW = "Sec2-E2E-Alt-2026!";
|
|
const NEW_PW = "Sec2-E2E-Neu-2026!";
|
|
|
|
let failures = 0;
|
|
function check(name: string, ok: boolean, detail?: string) {
|
|
if (ok) console.log(` ✓ ${name}`);
|
|
else {
|
|
failures++;
|
|
console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
resetRateLimits();
|
|
|
|
const tenant = await prisma.tenant.findFirst({ where: { status: "ACTIVE" } });
|
|
if (!tenant) throw new Error("Kein aktiver Mandant vorhanden — bitte zuerst seeden.");
|
|
|
|
await prisma.user.deleteMany({ where: { email: EMAIL } });
|
|
await prisma.identity.deleteMany({ where: { email: EMAIL, memberships: { none: {} } } });
|
|
// Option C: Mitgliedschaft braucht eine globale Identity (Anmeldung).
|
|
const identity = await prisma.identity.create({ data: { email: EMAIL, passwordHash: await hashPassword(OLD_PW) } });
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
tenantId: tenant.id,
|
|
identityId: identity.id,
|
|
email: EMAIL,
|
|
name: "SEC2 E2E",
|
|
status: "ACTIVE",
|
|
},
|
|
});
|
|
|
|
try {
|
|
console.log("1) Anfrage");
|
|
const fd = new FormData();
|
|
fd.set("email", EMAIL);
|
|
fd.set("domain", "tenant");
|
|
const req = await requestPasswordReset({ status: "idle" }, fd);
|
|
check("neutrale Antwort", req.status === "done");
|
|
|
|
const unknownEmail = `unbekannt-${Date.now()}@example.test`;
|
|
const fdUnknown = new FormData();
|
|
fdUnknown.set("email", unknownEmail);
|
|
fdUnknown.set("domain", "tenant");
|
|
const reqUnknown = await requestPasswordReset({ status: "idle" }, fdUnknown);
|
|
check("unbekannte Adresse: identische Antwort", JSON.stringify(req) === JSON.stringify(reqUnknown));
|
|
check(
|
|
"keine Mail an die unbekannte Adresse",
|
|
(await prisma.mailLog.count({ where: { to: unknownEmail } })) === 0,
|
|
);
|
|
check(
|
|
"Token nur für das existierende Konto (Prinzipal = Identity)",
|
|
(await prisma.authToken.count({ where: { principalId: identity.id, usedAt: null } })) === 1,
|
|
);
|
|
const resetMail = await prisma.mailLog.findFirst({
|
|
where: { to: EMAIL, template: "password_reset" },
|
|
});
|
|
check("Reset-Mail eingestellt", resetMail != null, resetMail?.status);
|
|
|
|
// Das Rohtoken existiert nur im Link. Für den weiteren Ablauf einen frischen
|
|
// Link ausstellen — die Neuanforderung entwertet den vorherigen (geprüft in
|
|
// test-auth-selfservice.ts).
|
|
const issued = await issueToken({
|
|
principalType: "identity",
|
|
principalId: identity.id,
|
|
tenantId: tenant.id,
|
|
type: "password_reset",
|
|
});
|
|
|
|
console.log("2) Policy-Verstoß entwertet den Link nicht");
|
|
const weak = new FormData();
|
|
weak.set("token", issued.raw);
|
|
weak.set("domain", "tenant");
|
|
weak.set("password", "kurz");
|
|
weak.set("confirm", "kurz");
|
|
check("schwaches Passwort abgelehnt", (await redeemPasswordReset({ status: "idle" }, weak)).status === "error");
|
|
check("Link danach noch gültig", (await peekToken(issued.raw, "password_reset")) != null);
|
|
|
|
const mismatch = new FormData();
|
|
mismatch.set("token", issued.raw);
|
|
mismatch.set("domain", "tenant");
|
|
mismatch.set("password", NEW_PW);
|
|
mismatch.set("confirm", `${NEW_PW}x`);
|
|
check(
|
|
"abweichende Wiederholung abgelehnt",
|
|
(await redeemPasswordReset({ status: "idle" }, mismatch)).status === "error",
|
|
);
|
|
check("Link weiterhin gültig", (await peekToken(issued.raw, "password_reset")) != null);
|
|
|
|
console.log("3) Einlösung (Passwort/Session an der Identity)");
|
|
const before = await prisma.identity.findUniqueOrThrow({ where: { id: identity.id } });
|
|
const fd2 = new FormData();
|
|
fd2.set("token", issued.raw);
|
|
fd2.set("domain", "tenant");
|
|
fd2.set("password", NEW_PW);
|
|
fd2.set("confirm", NEW_PW);
|
|
const done = await redeemPasswordReset({ status: "idle" }, fd2);
|
|
check("Einlösung erfolgreich", done.status === "done", JSON.stringify(done));
|
|
|
|
const after = await prisma.identity.findUniqueOrThrow({ where: { id: identity.id } });
|
|
check("Passwort-Hash (Identity) geändert", before.passwordHash !== after.passwordHash);
|
|
check("neues Passwort gültig", await verifyPassword(after.passwordHash, NEW_PW));
|
|
check("altes Passwort ungültig", !(await verifyPassword(after.passwordHash, OLD_PW)));
|
|
check("Sessions invalidiert (Identity)", after.sessionsValidAfter != null);
|
|
check("Force-Change zurückgesetzt (Identity)", after.mustChangePassword === false);
|
|
check("Link verbraucht", (await peekToken(issued.raw, "password_reset")) === null);
|
|
|
|
const secondUse = new FormData();
|
|
secondUse.set("token", issued.raw);
|
|
secondUse.set("domain", "tenant");
|
|
secondUse.set("password", NEW_PW);
|
|
secondUse.set("confirm", NEW_PW);
|
|
check(
|
|
"zweite Einlösung abgewiesen",
|
|
(await redeemPasswordReset({ status: "idle" }, secondUse)).status === "error",
|
|
);
|
|
|
|
const confirmMail = await prisma.mailLog.findFirst({
|
|
where: { to: EMAIL, template: "password_changed" },
|
|
});
|
|
check("Bestätigungsmail versendet", confirmMail != null, confirmMail?.status);
|
|
|
|
const audit = await prisma.auditLog.findFirst({
|
|
where: { entity: "password_reset", entityId: identity.id },
|
|
});
|
|
check("Audit-Eintrag vorhanden", audit != null);
|
|
check(
|
|
"kein Token im Audit",
|
|
audit == null || !JSON.stringify(audit).includes(issued.raw.slice(0, 12)),
|
|
);
|
|
check(
|
|
"kein Token im MailLog",
|
|
(await prisma.mailLog.count({ where: { dedupeKey: { contains: issued.raw.slice(0, 12) } } })) === 0,
|
|
);
|
|
|
|
console.log("4) Deaktivierte Identity");
|
|
// Option C: das Konto ist die Identity — Deaktivierung wirkt an ihr, nicht an
|
|
// der einzelnen Mitgliedschaft.
|
|
await prisma.identity.update({ where: { id: identity.id }, data: { status: "DISABLED" } });
|
|
resetRateLimits();
|
|
const openBefore = await prisma.authToken.count({
|
|
where: { principalId: identity.id, type: "password_reset", usedAt: null },
|
|
});
|
|
const fd3 = new FormData();
|
|
fd3.set("email", EMAIL);
|
|
fd3.set("domain", "tenant");
|
|
const req3 = await requestPasswordReset({ status: "idle" }, fd3);
|
|
const openAfter = await prisma.authToken.count({
|
|
where: { principalId: identity.id, type: "password_reset", usedAt: null },
|
|
});
|
|
check("Antwort trotzdem neutral", req3.status === "done");
|
|
check("kein neuer Token", openAfter === openBefore);
|
|
} finally {
|
|
await prisma.authToken.deleteMany({ where: { principalId: { in: [identity.id, user.id] } } });
|
|
await prisma.mailLog.deleteMany({ where: { to: EMAIL } });
|
|
await prisma.auditLog.deleteMany({ where: { actorId: { in: [identity.id, user.id] } } });
|
|
await prisma.user.delete({ where: { id: user.id } }).catch(() => {});
|
|
await prisma.identity.deleteMany({ where: { email: EMAIL } });
|
|
}
|
|
|
|
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 Reset-Ablauf: alle Prüfungen bestanden.");
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch(async (err) => {
|
|
console.error(err);
|
|
await prisma.$disconnect().catch(() => {});
|
|
process.exit(1);
|
|
});
|