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,203 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user