import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { verify } from "@node-rs/argon2"; import { z } from "zod"; import { prisma } from "./db"; import { verifyTotp, matchRecovery } from "./mfa"; import { writePlatformAudit } from "./audit"; /** * Getrennte Auth-Domäne für Plattform-Administratoren (Phase-1-Härtung Paket 2). * * Eigene NextAuth-Instanz mit eigenem Cookie und eigenem basePath, damit die * Plattform-Session strikt von der Mandanten-Session getrennt ist und **keinen** * tenantId trägt. Login gegen den PlatformAdmin-Store (kein User/Tenant), TOTP-MFA * sobald eingerichtet, Konto-Sperre nach zu vielen Fehlversuchen, Audit scope=platform. */ const LOCK_THRESHOLD = 5; const LOCK_MINUTES = 15; const credentialsSchema = z.object({ email: z.string().email(), password: z.string().min(1), token: z.string().optional(), // TOTP-Code oder Recovery-Code }); /** Fehlversuch zählen und ggf. sperren; gibt immer null zurück (Login fehlgeschlagen). */ async function registerFailure(id: string) { const updated = await prisma.platformAdmin.update({ where: { id }, data: { failedLogins: { increment: 1 } }, }); if (updated.failedLogins >= LOCK_THRESHOLD) { await prisma.platformAdmin.update({ where: { id }, data: { lockedUntil: new Date(Date.now() + LOCK_MINUTES * 60_000), failedLogins: 0 }, }); await writePlatformAudit({ actorId: id, action: "denied", entity: "platform_login", entityId: id, after: { locked: true } }); } else { await writePlatformAudit({ actorId: id, action: "denied", entity: "platform_login", entityId: id }); } return null; } export const { handlers: platformHandlers, auth: platformAuth, signIn: platformSignIn, signOut: platformSignOut, } = NextAuth({ session: { strategy: "jwt" }, basePath: "/api/platform-auth", pages: { signIn: "/platform/login" }, // Eigener Cookie-Name → keine Kollision mit der Mandanten-Session. cookies: { sessionToken: { name: `${process.env.NODE_ENV === "production" ? "__Secure-" : ""}platform-authjs.session-token`, options: { httpOnly: true, sameSite: "lax", path: "/", secure: process.env.NODE_ENV === "production", }, }, }, providers: [ Credentials({ credentials: { email: {}, password: {}, token: {} }, authorize: async (raw) => { const parsed = credentialsSchema.safeParse(raw); if (!parsed.success) return null; const { email, password, token } = parsed.data; const admin = await prisma.platformAdmin.findUnique({ where: { email: email.toLowerCase() } }); if (!admin || admin.status !== "ACTIVE") return null; // Konto-Sperre aktiv? if (admin.lockedUntil && admin.lockedUntil > new Date()) { await writePlatformAudit({ actorId: admin.id, action: "denied", entity: "platform_login", entityId: admin.id, after: { reason: "locked" } }); return null; } const passwordOk = await verify(admin.passwordHash, password); if (!passwordOk) return registerFailure(admin.id); // MFA erforderlich, sobald eingerichtet. if (admin.mfaEnrolledAt && admin.mfaSecret) { const code = (token ?? "").trim(); if (!code) return registerFailure(admin.id); let mfaOk = verifyTotp(code, admin.mfaSecret); if (!mfaOk) { // Recovery-Code als Fallback (Einmalverbrauch). const codes = Array.isArray(admin.recoveryCodes) ? (admin.recoveryCodes as string[]) : []; const idx = matchRecovery(code, codes); if (idx >= 0) { mfaOk = true; await prisma.platformAdmin.update({ where: { id: admin.id }, data: { recoveryCodes: codes.filter((_, i) => i !== idx) }, }); await writePlatformAudit({ actorId: admin.id, action: "update", entity: "platform_admin", entityId: admin.id, after: { recoveryCodeUsed: true } }); } } if (!mfaOk) return registerFailure(admin.id); } await prisma.platformAdmin.update({ where: { id: admin.id }, data: { failedLogins: 0, lockedUntil: null, lastLoginAt: new Date() }, }); await writePlatformAudit({ actorId: admin.id, action: "login", entity: "platform_admin", entityId: admin.id }); return { id: admin.id, email: admin.email, name: admin.name, // Dummy-Mandantenfelder: Plattform-Session trägt bewusst keinen echten Tenant. tenantId: "", tenantSlug: "", roles: [], permissions: [], isPlatformAdmin: true, mfaEnrolled: !!admin.mfaEnrolledAt, }; }, }), ], callbacks: { jwt({ token, user }) { if (user) { token.userId = user.id!; token.mfaEnrolled = user.mfaEnrolled; token.isPlatformAdmin = true; } return token; }, session({ session, token }) { session.user.id = token.userId; session.user.isPlatformAdmin = true; session.user.mfaEnrolled = token.mfaEnrolled; // Plattform-Session trägt keinen Mandantenkontext. session.user.tenantId = ""; session.user.tenantSlug = ""; session.user.roles = []; session.user.permissions = []; return session; }, }, }); /** Guard für den Plattform-Bereich (Server-Komponenten/Actions). */ export async function requirePlatformSession() { const session = await platformAuth(); if (!session?.user?.id) throw new Error("Nicht als Plattform-Admin angemeldet"); return session; }