import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { verifyPassword } from "./password"; import { z } from "zod"; import { prisma } from "./db"; import { verifyTotp, matchRecovery } from "./mfa"; import { writePlatformAudit } from "./audit"; import { assertSecureEnv } from "./env"; import { isTokenStillValid } from "./sessions"; /** * 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; } // Secure-Cookies/Prefix MÜSSEN dem tatsächlichen Protokoll folgen — exakt wie Auth.js // es für die Mandanten-Instanz tut: `config.useSecureCookies ?? url.protocol === "https:"` // (@auth/core lib/init.ts). NICHT an NODE_ENV koppeln: im Container ist NODE_ENV=production, // der interne Testserver wird aber ggf. über http bedient (AUTH_URL=http bzw. fehlendes // X-Forwarded-Proto). Dann würde ein hart auf `__Secure-`/secure gesetztes Cookie vom Browser // über http verworfen → die Plattform-Session bliebe leer (jede Aktion → Login-Maske, Logout // wirft), während die Mandanten-Session (secure folgt dem Protokoll) weiter funktioniert. const PLATFORM_SECURE_COOKIES = (process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? "").startsWith("https:"); const securePrefix = PLATFORM_SECURE_COOKIES ? "__Secure-" : ""; const hostPrefix = PLATFORM_SECURE_COOKIES ? "__Host-" : ""; export const { handlers: platformHandlers, auth: platformAuth, signIn: platformSignIn, signOut: platformSignOut, } = NextAuth({ // F-09: Plattform-Session (Vollzugriff auf den Betrieb) auf 2 Stunden begrenzen, // mit rollierender Erneuerung bei Aktivität (updateAge 30 min). session: { strategy: "jwt", maxAge: 2 * 60 * 60, updateAge: 30 * 60 }, basePath: "/api/platform-auth", pages: { signIn: "/platform/login" }, // Eigene Cookie-Namen → keine Kollision mit der Mandanten-Session. // // WICHTIG: ALLE von Auth.js genutzten Cookies müssen umbenannt werden, nicht nur // `sessionToken`. Zwei Auth.js-Instanzen auf derselben Domain teilen sich sonst // `authjs.csrf-token` und `authjs.callback-url` (Defaults). Da „Anna Admin" zugleich // Mandanten-User und Plattform-Admin ist, überschreibt die zuletzt aktive Instanz das // gemeinsame CSRF-Cookie → die CSRF-Validierung der Plattform-Instanz schlägt fehl: // `platformSignOut` wirft (globale Error-Boundary „bitte erneut versuchen") und // CSRF-behaftete POSTs/Session-Erneuerungen landen auf der Login-Maske. Getrennte // Cookie-Namen isolieren beide Auth-Domänen vollständig. cookies: { sessionToken: { name: `${securePrefix}platform-authjs.session-token`, options: { httpOnly: true, sameSite: "lax", path: "/", secure: PLATFORM_SECURE_COOKIES, }, }, csrfToken: { // `__Host-`-Prefix wie im Auth.js-Default (verlangt path="/", secure, keine Domain). name: `${hostPrefix}platform-authjs.csrf-token`, options: { httpOnly: true, sameSite: "lax", path: "/", secure: PLATFORM_SECURE_COOKIES, }, }, callbackUrl: { name: `${securePrefix}platform-authjs.callback-url`, options: { httpOnly: true, sameSite: "lax", path: "/", secure: PLATFORM_SECURE_COOKIES, }, }, }, providers: [ Credentials({ credentials: { email: {}, password: {}, token: {} }, authorize: async (raw) => { // Fail-Secure: bei fehlendem/zu kurzem AUTH_SECRET hart abbrechen (F-01). assertSecureEnv(); 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 verifyPassword(admin.passwordHash, password); if (!passwordOk) return registerFailure(admin.id); // MFA erforderlich, sobald eingerichtet. // F-17: bei TOTP-Erfolg den akzeptierten Zeitschritt merken (acceptedStep), // um ihn als lastTotpStep zu persistieren (Replay-Schutz). let acceptedStep: number | null = null; if (admin.mfaEnrolledAt && admin.mfaSecret) { const code = (token ?? "").trim(); if (!code) return registerFailure(admin.id); let mfaOk = false; // Replay-Schutz: bereits verwendeter/älterer Zeitschritt wird abgelehnt (F-17). const totp = verifyTotp(code, admin.mfaSecret, admin.lastTotpStep); if (totp.ok) { mfaOk = true; acceptedStep = totp.step; } else { // Recovery-Code als Fallback (Einmalverbrauch). const codes = Array.isArray(admin.recoveryCodes) ? (admin.recoveryCodes as string[]) : []; const idx = await 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(), ...(acceptedStep != null ? { lastTotpStep: BigInt(acceptedStep) } : {}) }, }); 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 = []; // SEC2: `iat` durchreichen fuer die Pruefung gegen sessionsValidAfter. session.user.tokenIssuedAt = typeof token.iat === "number" ? token.iat : undefined; return session; }, }, }); /** Guard für den Plattform-Bereich (Server-Komponenten/Actions). */ export async function requirePlatformSession() { // Fail-Secure: bei fehlendem/zu kurzem AUTH_SECRET hart abbrechen (F-01). assertSecureEnv(); const session = await platformAuth(); // F-01 (Defense in Depth): positiv statt existenzbasiert prüfen — ein Auth.js- // Session-Objekt mit Fehlerzustand ("fail open") darf nicht durchgehen. Für die // Plattform-Session ist user.id das einzige Pflichtfeld (kein Mandantenkontext). if (!session || "error" in session || !session.user || !session.user.id) { throw new Error("Nicht als Plattform-Admin angemeldet"); } // F-06: Status autoritativ aus der DB — ein gesperrter/deaktivierter Plattform- // Admin darf nicht bis zum Token-Ablauf weiter schreibend zugreifen. Das // Plattform-Layout prüft den Status bereits für Seitenaufrufe; hier für die // Server-Actions (die nicht durch das Layout laufen). const admin = await prisma.platformAdmin.findUnique({ where: { id: session.user.id }, select: { status: true, sessionsValidAfter: true }, }); if (!admin || admin.status !== "ACTIVE") { throw new Error("Plattform-Konto ist nicht aktiv."); } // SEC2: serverseitig entwertete Sessions (Passwort-Reset/-Wechsel, E-Mail- // Aenderung) duerfen nicht bis zum Token-Ablauf weiterlaufen. Die Abfrage oben // laeuft ohnehin — die Pruefung kostet keine zusaetzliche Runde. if (!isTokenStillValid(session.user.tokenIssuedAt, admin.sessionsValidAfter)) { throw new Error("Sitzung ist nicht mehr gueltig. Bitte neu anmelden."); } return session; } /** * SEC4: erfordert einen aktiven Plattform-**Voll**-Admin (role="full"). Read-only-Admins * dürfen nichts verändern. Liefert Session + den handelnden Admin (inkl. MFA-Feldern für Step-up). */ export async function requirePlatformFullAdmin() { const session = await requirePlatformSession(); const admin = await prisma.platformAdmin.findUnique({ where: { id: session.user.id }, select: { id: true, role: true, mfaEnrolledAt: true, mfaSecret: true, lastTotpStep: true }, }); if (!admin) throw new Error("Plattform-Konto nicht gefunden."); if (admin.role !== "full") throw new Error("Nur Voll-Administratoren dürfen diese Aktion ausführen."); return { session, admin }; } /** * SEC4: Step-up-Re-Authentifizierung für kritische Aktionen. Hat der handelnde Admin MFA * eingerichtet, ist ein gültiger aktueller TOTP-Code Pflicht (verifyTotp entschlüsselt das * Secret intern, SEC3-d). Ohne MFA ist kein TOTP-Step-up möglich → wird übersprungen. */ export function assertPlatformStepUp( admin: { mfaEnrolledAt: Date | null; mfaSecret: string | null; lastTotpStep: bigint | null }, code: string, ): void { if (!admin.mfaEnrolledAt || !admin.mfaSecret) return; if (!verifyTotp(code, admin.mfaSecret, admin.lastTotpStep).ok) { throw new Error("Bitte die Aktion mit einem aktuellen MFA-Code bestätigen (Step-up)."); } } /** SEC4: Anzahl aktiver Voll-Admins (Selbst-Aussperr-Schutz). */ export function countActiveFullAdmins() { return prisma.platformAdmin.count({ where: { status: "ACTIVE", role: "full" } }); }