Plattform-Administratoren sind nicht länger isPlatformAdmin-Nutzer innerhalb eines Mandanten, sondern ein getrennter Store mit eigener Auth-Domäne — Voraussetzung für den sicheren Betrieb beim ersten echten Kunden. Store & Migration - Neues Modell PlatformAdmin (kein tenant_id): Argon2id-Hash, TOTP-Secret, Recovery-Codes (nur SHA-256-Hashes), Fehlversuchszähler + Sperre, lastLogin. - AuditLog.tenant_id nullable → mandantenlose Plattform-Ereignisse (scope=platform). - Datenmigration: bestehende isPlatformAdmin-Nutzer in den neuen Store übernommen (gleicher Hash → Login sofort möglich), Flag mandantenweit auf false gesetzt. Getrennter Login + MFA - Zweite NextAuth-Instanz (server/platform-auth.ts) mit eigenem Cookie und eigenem basePath /api/platform-auth; Session trägt bewusst KEINEN tenantId. - TOTP-MFA (otplib): Enrollment beim ersten Login (/platform/enroll-mfa, QR + Klartext-Secret), danach bei jedem Login erzwungen; 10 einmalige Recovery-Codes. - Härtung: Konto-Sperre nach 5 Fehlversuchen (15 min), Audit aller Anmeldungen, Fehlversuche und Sperren (scope=platform). Autorisierung / Trennung - Admin-Konsole nach (platform)/admin verschoben; (platform)/layout.tsx erzwingt Plattform-Session + aktivierte MFA. Mandanten-Session hat KEINEN Zugriff auf /admin. - Mandanten-Shell zeigt keinen /admin-Link mehr; admin-Actions prüfen die Plattform-Session statt des abgelösten Flags. - provision/seed setzen isPlatformAdmin nicht mehr; Seed legt den Demo-Plattform- Admin (admin@demo.example) im getrennten Store an. Browser-verifiziert: Plattform-Login → erzwungenes MFA-Enrollment → Recovery-Codes → /admin; Login ohne Code scheitert (?error=1); Mandanten-Session auf /admin wird auf /platform/login umgeleitet. tsc + lint + build + Guard-Check grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
5.6 KiB
TypeScript
158 lines
5.6 KiB
TypeScript
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;
|
|
}
|