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