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,99 @@
|
||||
// WS5-Abnahmetest (Option C) — Two-Step-Login (MFA-pending) Bausteine.
|
||||
//
|
||||
// Geprüft (die Zustandslogik; die Cookie-/signIn-Verdrahtung ist UI/Runtime):
|
||||
// 1. verifyIdentityPassword: richtig → {mfaRequired}, falsch → null (kein Orakel),
|
||||
// MFA-Konto meldet mfaRequired=true.
|
||||
// 2. verifyIdentityMfa: falscher Code → false, gültiger (Recovery-)Code → true.
|
||||
// 3. finalizeIdentityLogin: baut die Session NACH Passwort(+MFA)-Prüfung.
|
||||
// 4. login_ticket: signieren→verifizieren ok; manipuliert → null; abgelaufen → null;
|
||||
// Zweckbindung (mfa_pending ≠ login_ticket).
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-two-step-login.ts (setzt den Demo-Seed voraus)
|
||||
|
||||
import "dotenv/config";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { hashPassword } from "../src/server/password";
|
||||
import { generateRecoveryCodes } from "../src/server/mfa";
|
||||
import { verifyIdentityPassword, verifyIdentityMfa, finalizeIdentityLogin } from "../src/server/auth";
|
||||
import { signLoginTicket, verifyLoginTicket, signMfaPending, verifyMfaPending } from "../src/server/login-ticket";
|
||||
|
||||
const PW = "Two-Step-Passwort-1!";
|
||||
const A = "ws5-nomfa@demo.example";
|
||||
const B = "ws5-mfa@demo.example";
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
async function cleanup() {
|
||||
const ids = (await prisma.identity.findMany({ where: { email: { in: [A, B] } }, select: { id: true } })).map((i) => i.id);
|
||||
if (ids.length) await prisma.auditLog.deleteMany({ where: { actorId: { in: ids } } });
|
||||
await prisma.user.deleteMany({ where: { email: { in: [A, B] } } });
|
||||
await prisma.identity.deleteMany({ where: { email: { in: [A, B] } } });
|
||||
}
|
||||
|
||||
async function mkMember(identityId: string, tenantId: string, roleKey: string, email: string) {
|
||||
const role = await prisma.role.findFirst({ where: { tenantId, key: roleKey } });
|
||||
await prisma.user.create({
|
||||
data: { tenantId, identityId, email, name: "WS5", status: "ACTIVE", ...(role ? { userRoles: { create: [{ roleId: role.id }] } } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await cleanup();
|
||||
const demo = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" } });
|
||||
const hash = await hashPassword(PW);
|
||||
|
||||
const idA = await prisma.identity.create({ data: { email: A, passwordHash: hash } });
|
||||
await mkMember(idA.id, demo.id, "user", A);
|
||||
|
||||
const { plain, hashed } = await generateRecoveryCodes(5);
|
||||
const idB = await prisma.identity.create({
|
||||
data: { email: B, passwordHash: hash, mfaSecret: "JBSWY3DPEHPK3PXP", mfaEnrolledAt: new Date(), recoveryCodes: hashed },
|
||||
});
|
||||
await mkMember(idB.id, demo.id, "user", B);
|
||||
|
||||
console.log("\n— 1) Passwort-Schritt (kein Orakel) —");
|
||||
const pwA = await verifyIdentityPassword(A, PW);
|
||||
ok(pwA?.mfaRequired === false, "Konto ohne MFA: mfaRequired=false");
|
||||
ok((await verifyIdentityPassword(A, "falsch!!")) === null, "falsches Passwort ⇒ null");
|
||||
const pwB = await verifyIdentityPassword(B, PW);
|
||||
ok(pwB?.mfaRequired === true, "Konto mit MFA: mfaRequired=true (kein Login ohne 2. Schritt)");
|
||||
|
||||
console.log("\n— 2) MFA-Schritt —");
|
||||
ok((await verifyIdentityMfa(idB.id, "000000")) === false, "falscher Code ⇒ false");
|
||||
ok((await verifyIdentityMfa(idB.id, plain[0]!)) === true, "gültiger Recovery-Code ⇒ true");
|
||||
|
||||
console.log("\n— 3) Session erst nach Verifikation —");
|
||||
const sessA = await finalizeIdentityLogin(idA.id);
|
||||
ok(sessA?.tenantSlug === "demo" && sessA?.identityId === idA.id, "finalizeIdentityLogin baut Session (Single-Membership → demo)");
|
||||
|
||||
console.log("\n— 4) login_ticket: Signatur/Manipulation/Ablauf/Zweck —");
|
||||
const ticket = signLoginTicket(idA.id, "demo");
|
||||
ok(verifyLoginTicket(ticket)?.identityId === idA.id, "gültiges Ticket verifiziert");
|
||||
ok(verifyLoginTicket(ticket + "x") === null, "manipuliertes Ticket ⇒ null");
|
||||
ok(verifyLoginTicket(signLoginTicket(idA.id, "demo", -1000)) === null, "abgelaufenes Ticket ⇒ null");
|
||||
ok(verifyMfaPending(ticket) === null, "Zweckbindung: login_ticket wird NICHT als mfa_pending akzeptiert");
|
||||
const pending = signMfaPending(idB.id, "demo");
|
||||
ok(verifyMfaPending(pending)?.identityId === idB.id, "mfa_pending verifiziert (identityId + tenant)");
|
||||
ok(verifyLoginTicket(pending) === null, "Zweckbindung: mfa_pending wird NICHT als login_ticket akzeptiert");
|
||||
|
||||
await cleanup();
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
if (failures > 0) {
|
||||
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nOK");
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
await cleanup().catch(() => {});
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user