Separater Superadmin-Store + eigener Login + MFA-Pflicht (Phase-1-Härtung Paket 2)

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>
This commit is contained in:
2026-07-20 20:55:12 +02:00
co-authored by Claude Opus 4.8
parent 8348764c99
commit 8e4040d1da
22 changed files with 1009 additions and 54 deletions
+4 -13
View File
@@ -18,7 +18,6 @@ import {
LineChart,
Search,
Settings,
ShieldCheck,
} from "lucide-react";
import { auth, signOut } from "@/server/auth";
import { dbForTenant } from "@/server/db";
@@ -61,7 +60,6 @@ export default async function AppLayout({
};
const visibleNav = nav.filter((item) => !item.enabled || moduleEnabled(item.href));
const isPlatformAdmin = session.user.isPlatformAdmin;
const canManageTenant = hasPermission(session, "tenant:manage");
const initials = (session.user.name ?? "?")
@@ -111,18 +109,11 @@ export default async function AppLayout({
)
)}
{(canManageTenant || isPlatformAdmin) && (
{canManageTenant && (
<div className="mt-2 space-y-0.5 border-t border-sidebar-border pt-2">
{canManageTenant && (
<NavLink href="/settings">
<Settings className="size-[18px] opacity-85" /> {t("settings")}
</NavLink>
)}
{isPlatformAdmin && (
<NavLink href="/admin">
<ShieldCheck className="size-[18px] opacity-85" /> {t("admin")}
</NavLink>
)}
<NavLink href="/settings">
<Settings className="size-[18px] opacity-85" /> {t("settings")}
</NavLink>
</div>
)}
</nav>
@@ -1,7 +1,6 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { requireSession } from "@/server/auth";
import { prisma } from "@/server/db";
import { Button } from "@/components/ui/button";
import { PageHead, Pill } from "@/components/mockup-ui";
@@ -12,8 +11,7 @@ const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPE
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" };
export default async function AdminTenantPage({ params }: { params: Promise<{ id: string }> }) {
const session = await requireSession();
if (!session.user.isPlatformAdmin) redirect("/dashboard");
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const { id } = await params;
const tenant = await prisma.tenant.findUnique({
@@ -21,7 +19,7 @@ export default async function AdminTenantPage({ params }: { params: Promise<{ id
include: {
settings: true,
modules: true,
users: { select: { id: true, name: true, email: true, status: true, isPlatformAdmin: true }, orderBy: { createdAt: "asc" } },
users: { select: { id: true, name: true, email: true, status: true }, orderBy: { createdAt: "asc" } },
},
});
if (!tenant) notFound();
@@ -96,7 +94,7 @@ export default async function AdminTenantPage({ params }: { params: Promise<{ id
<span className="font-medium">{u.name}</span>
<span className="block text-[11px] text-muted-foreground">{u.email}</span>
</span>
{u.isPlatformAdmin && <Pill tone="violet">Superadmin</Pill>}
<Pill tone="mut">{u.status}</Pill>
</li>
))}
</ul>
@@ -1,7 +1,5 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { Plus } from "lucide-react";
import { requireSession } from "@/server/auth";
import { prisma } from "@/server/db";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -22,8 +20,7 @@ const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesp
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string }> }) {
const session = await requireSession();
if (!session.user.isPlatformAdmin) redirect("/dashboard");
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const params = await searchParams;
const tenants = await prisma.tenant.findMany({
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ShieldCheck } from "lucide-react";
import { platformAuth } from "@/server/platform-auth";
import { prisma } from "@/server/db";
import { platformSignOutAction } from "@/server/actions/platform";
import { Button } from "@/components/ui/button";
/**
* Shell des Plattform-Betriebsbereichs (Phase-1-Härtung Paket 2). Zugriff nur mit
* Plattform-Session (getrennte Auth-Domäne, kein Mandantenkontext) und aktivierter
* MFA. Enthält bewusst KEINE Mandanten-Navigation — Plattform-Admins haben keinen
* Zugriff auf Kundenfachdaten.
*/
export default async function PlatformLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const session = await platformAuth();
if (!session?.user?.id) redirect("/platform/login");
const admin = await prisma.platformAdmin.findUnique({ where: { id: session.user.id } });
if (!admin || admin.status !== "ACTIVE") redirect("/platform/login");
if (!admin.mfaEnrolledAt) redirect("/platform/enroll-mfa");
return (
<div className="flex min-h-screen flex-col">
<header className="flex items-center justify-between border-b bg-[var(--panel)] px-6 py-3">
<Link href="/admin" className="flex items-center gap-2 font-heading text-sm font-semibold">
<ShieldCheck className="size-5 text-[var(--info)]" /> ISMS · Plattform-Administration
</Link>
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">
{admin.name} · {admin.email}
</span>
<form action={platformSignOutAction}>
<Button type="submit" variant="ghost" size="sm">Abmelden</Button>
</form>
</div>
</header>
{children}
</div>
);
}
@@ -0,0 +1,3 @@
import { platformHandlers } from "@/server/platform-auth";
export const { GET, POST } = platformHandlers;
+52
View File
@@ -0,0 +1,52 @@
import { redirect } from "next/navigation";
import QRCode from "qrcode";
import { platformAuth } from "@/server/platform-auth";
import { prisma } from "@/server/db";
import { newTotpSecret, totpUri } from "@/server/mfa";
import { PlatformEnrollForm } from "@/components/platform-enroll-form";
/**
* MFA-Einrichtung für Plattform-Administratoren (Pflicht beim ersten Login).
* Erzeugt/persistiert ein Einrichtungs-Secret, zeigt QR + Klartext-Secret und
* bestätigt per TOTP-Code (Server-Action confirmMfaEnrollment).
*/
export default async function EnrollMfaPage() {
const session = await platformAuth();
if (!session?.user?.id) redirect("/platform/login");
const admin = await prisma.platformAdmin.findUnique({ where: { id: session.user.id } });
if (!admin) redirect("/platform/login");
if (admin.mfaEnrolledAt) redirect("/admin");
let secret = admin.mfaSecret;
if (!secret) {
secret = newTotpSecret();
await prisma.platformAdmin.update({ where: { id: admin.id }, data: { mfaSecret: secret } });
}
const qr = await QRCode.toDataURL(totpUri(admin.email, secret), { margin: 1, width: 208 });
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-md rounded-2xl border bg-card p-8">
<p className="font-heading text-lg font-semibold">Zwei-Faktor-Authentifizierung einrichten</p>
<p className="mt-1 text-sm text-muted-foreground">
Für Plattform-Administratoren verpflichtend. Scannen Sie den QR-Code mit einer
Authenticator-App (z. B. Google Authenticator, Aegis, 1Password) und bestätigen Sie mit dem angezeigten Code.
</p>
<div className="mt-5 flex flex-col items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qr} alt="QR-Code für die Authenticator-App" width={208} height={208} className="rounded-lg border bg-white p-2" />
<div className="text-center">
<p className="text-xs text-muted-foreground">Manuell eingeben:</p>
<code className="select-all break-all font-mono text-xs">{secret}</code>
</div>
</div>
<div className="mt-6">
<PlatformEnrollForm />
</div>
</div>
</main>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { redirect } from "next/navigation";
import { AuthError } from "next-auth";
import { platformAuth, platformSignIn } from "@/server/platform-auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
/**
* Getrennter Login für Plattform-Administratoren (Phase-1-Härtung Paket 2).
* Eigene Auth-Domäne, kein Mandantenkontext. TOTP-Code ist ab eingerichteter MFA
* erforderlich (Feld optional, damit der erste Login zur Einrichtung durchläuft).
*/
export default async function PlatformLoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
const session = await platformAuth();
if (session?.user?.id) redirect("/admin");
async function login(formData: FormData) {
"use server";
try {
await platformSignIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
token: formData.get("token"),
redirectTo: "/admin",
});
} catch (err) {
if (err instanceof AuthError) redirect("/platform/login?error=1");
throw err; // NEXT_REDIRECT eines erfolgreichen signIn muss durchlaufen
}
}
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
<p className="font-heading text-lg font-semibold">Plattform-Administration</p>
<h2 className="mt-1 text-sm font-normal text-muted-foreground">
Betreiberzugang getrennt vom Mandanten-Login
</h2>
{error && (
<p
role="alert"
className="mt-4 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]"
>
Anmeldung fehlgeschlagen. Bitte E-Mail, Passwort und falls MFA eingerichtet den 6-stelligen Code prüfen.
</p>
)}
<form action={login} className="mt-6 space-y-4">
<div>
<Label htmlFor="email">E-Mail</Label>
<Input id="email" name="email" type="email" required autoComplete="email" className="mt-1" />
</div>
<div>
<Label htmlFor="password">Passwort</Label>
<Input id="password" name="password" type="password" required autoComplete="current-password" className="mt-1" />
</div>
<div>
<Label htmlFor="token">MFA-Code (falls eingerichtet)</Label>
<Input
id="token"
name="token"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6-stelliger Code oder Recovery-Code"
className="mt-1"
/>
</div>
<Button type="submit" className="w-full">Anmelden</Button>
</form>
</div>
</main>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { confirmMfaEnrollment, type EnrollState } from "@/server/actions/platform";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const initial: EnrollState = { status: "idle" };
export function PlatformEnrollForm() {
const [state, action, pending] = useActionState(confirmMfaEnrollment, initial);
if (state.status === "done") {
return (
<div className="space-y-4">
<div className="rounded-lg bg-[rgba(46,204,113,0.14)] px-3 py-2 text-sm text-[var(--ok)]">
MFA ist aktiv. Bewahren Sie die folgenden Recovery-Codes sicher auf sie werden
<strong> nur jetzt</strong> angezeigt und ermöglichen den Zugang, falls der Authenticator verloren geht.
</div>
<ul className="grid grid-cols-2 gap-2 font-mono text-sm">
{state.recoveryCodes.map((c) => (
<li key={c} className="rounded border bg-card px-3 py-1.5 text-center tracking-wider">{c}</li>
))}
</ul>
<Button nativeButton={false} render={<Link href="/admin" />} className="w-full">
Codes gesichert zur Admin-Konsole
</Button>
</div>
);
}
return (
<form action={action} className="space-y-4">
<div>
<Label htmlFor="token">6-stelliger Code aus der Authenticator-App</Label>
<Input
id="token"
name="token"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
required
className="mt-1"
/>
</div>
{state.status === "error" && (
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">
{state.message}
</p>
)}
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Prüfe…" : "MFA aktivieren"}
</Button>
</form>
);
}
+4 -4
View File
@@ -4,14 +4,14 @@ import { join } from "node:path";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { platformAuth } from "@/server/platform-auth";
import { prisma } from "@/server/db";
import { provisionTenant } from "@/server/provision";
/** Nur Plattform-Admins (Superadmin) — mandantenübergreifend. */
/** Nur Plattform-Admins über die getrennte Plattform-Session (kein Mandantenkontext). */
async function requirePlatformAdmin() {
const session = await requireSession();
if (!session.user.isPlatformAdmin) throw new Error("Nur für Plattform-Admins.");
const session = await platformAuth();
if (!session?.user?.id) throw new Error("Nur für Plattform-Admins.");
return session;
}
+48
View File
@@ -0,0 +1,48 @@
"use server";
import { prisma } from "@/server/db";
import { requirePlatformSession, platformSignOut } from "@/server/platform-auth";
import { verifyTotp, generateRecoveryCodes } from "@/server/mfa";
import { writePlatformAudit } from "@/server/audit";
/**
* Plattform-Actions (Phase-1-Härtung Paket 2): MFA-Enrollment und Logout.
* EXEMPT vom Modul-Gating (keine per TenantModule gegatete Fachfunktion) — eigene
* Autorisierung über requirePlatformSession.
*/
export type EnrollState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "done"; recoveryCodes: string[] };
/**
* Bestätigt die MFA-Einrichtung: prüft den TOTP-Code gegen das (beim Seitenaufruf
* gespeicherte) Einrichtungs-Secret, aktiviert MFA und liefert einmalig die
* Recovery-Codes im Klartext zurück (nur Hashes werden gespeichert).
*/
export async function confirmMfaEnrollment(_prev: EnrollState, formData: FormData): Promise<EnrollState> {
const session = await requirePlatformSession();
const admin = await prisma.platformAdmin.findUnique({ where: { id: session.user.id } });
if (!admin) return { status: "error", message: "Konto nicht gefunden." };
if (admin.mfaEnrolledAt) return { status: "error", message: "MFA ist bereits eingerichtet." };
if (!admin.mfaSecret) return { status: "error", message: "Kein Einrichtungs-Secret vorhanden. Bitte Seite neu laden." };
const code = String(formData.get("token") ?? "");
if (!verifyTotp(code, admin.mfaSecret)) {
return { status: "error", message: "Code ungültig. Bitte den aktuellen 6-stelligen Code aus der App eingeben." };
}
const { plain, hashed } = generateRecoveryCodes(10);
await prisma.platformAdmin.update({
where: { id: admin.id },
data: { mfaEnrolledAt: new Date(), recoveryCodes: hashed, failedLogins: 0, lockedUntil: null },
});
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "platform_admin", entityId: admin.id, after: { mfaEnrolled: true } });
return { status: "done", recoveryCodes: plain };
}
/** Plattform-Logout (eigene Session-Domäne). */
export async function platformSignOutAction() {
await platformSignOut({ redirectTo: "/platform/login" });
}
+26
View File
@@ -28,3 +28,29 @@ export async function writeAuditLog(entry: {
},
});
}
/**
* Audit-Eintrag der Plattform-Ebene (kein Mandantenbezug, scope="platform").
* Für Superadmin-Anmeldungen und -Aktionen (Phase-1-Härtung Paket 2).
*/
export async function writePlatformAudit(entry: {
actorId?: string;
action: "create" | "update" | "delete" | "login" | "logout" | "denied";
entity: string;
entityId?: string;
before?: unknown;
after?: unknown;
}) {
await prisma.auditLog.create({
data: {
tenantId: null,
scope: "platform",
actorId: entry.actorId,
action: entry.action,
entity: entry.entity,
entityId: entry.entityId,
before: entry.before as object | undefined,
after: entry.after as object | undefined,
},
});
}
+57
View File
@@ -0,0 +1,57 @@
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
import { generateSecret, generateURI, verifySync } from "otplib";
/**
* TOTP-MFA für Plattform-Administratoren (Phase-1-Härtung Paket 2).
* Secret + Recovery-Codes gehören zum PlatformAdmin-Store; Recovery-Codes werden
* nur als SHA-256-Hash gespeichert und einmalig im Klartext angezeigt.
*/
const ISSUER = "ISMS-Tool · Plattform";
/** Neues TOTP-Secret (Base32) für die Enrollment-Phase. */
export function newTotpSecret(): string {
return generateSecret();
}
/** otpauth://-URI für den QR-Code der Authenticator-App. */
export function totpUri(email: string, secret: string): string {
return generateURI({ secret, label: email, issuer: ISSUER });
}
/** Prüft einen 6-stelligen TOTP-Code gegen das Secret (Zeitfenster-Toleranz aus otplib). */
export function verifyTotp(token: string, secret: string): boolean {
const t = token.replace(/\s+/g, "");
if (!/^\d{6}$/.test(t)) return false;
try {
return verifySync({ token: t, secret }).valid === true;
} catch {
return false;
}
}
const normalizeRecovery = (code: string) => code.replace(/[\s-]/g, "").toLowerCase();
export function hashRecovery(code: string): string {
return createHash("sha256").update(normalizeRecovery(code)).digest("hex");
}
/** Erzeugt `count` Recovery-Codes: Klartext (einmalig anzeigen) + Hashes (speichern). */
export function generateRecoveryCodes(count = 10): { plain: string[]; hashed: string[] } {
const plain: string[] = [];
for (let i = 0; i < count; i++) {
const raw = randomBytes(5).toString("hex"); // 10 Hex-Zeichen
plain.push(`${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 10)}`);
}
return { plain, hashed: plain.map(hashRecovery) };
}
/** Findet den Index eines passenden Recovery-Code-Hashes (konstante Zeit), sonst -1. */
export function matchRecovery(input: string, hashed: string[]): number {
const h = Buffer.from(hashRecovery(input));
for (let i = 0; i < hashed.length; i++) {
const candidate = Buffer.from(hashed[i]);
if (candidate.length === h.length && timingSafeEqual(candidate, h)) return i;
}
return -1;
}
+157
View File
@@ -0,0 +1,157 @@
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;
}
+3 -3
View File
@@ -38,7 +38,7 @@ export interface ProvisionOpts {
slug: string;
short?: string;
sector?: string;
admin: { email: string; name: string; password: string; isPlatformAdmin?: boolean };
admin: { email: string; name: string; password: string };
tisaxLevel?: "AL2" | "AL3";
seedPoliciesDir?: string;
actorId?: string | null;
@@ -85,8 +85,8 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
const passwordHash = await hash(opts.admin.password);
const admin = await prisma.user.upsert({
where: { tenantId_email: { tenantId: tenant.id, email: opts.admin.email } },
update: { name: opts.admin.name, isPlatformAdmin: opts.admin.isPlatformAdmin ?? false },
create: { tenantId: tenant.id, email: opts.admin.email, name: opts.admin.name, passwordHash, isPlatformAdmin: opts.admin.isPlatformAdmin ?? false },
update: { name: opts.admin.name },
create: { tenantId: tenant.id, email: opts.admin.email, name: opts.admin.name, passwordHash },
});
const adminRoles = await prisma.role.findMany({ where: { tenantId: tenant.id, key: { in: ["tenant-admin", "isb"] } } });
for (const r of adminRoles) {
+4
View File
@@ -9,6 +9,8 @@ declare module "next-auth" {
roles: string[];
permissions: string[];
isPlatformAdmin: boolean;
// Nur in der Plattform-Session (getrennte NextAuth-Instanz) gesetzt.
mfaEnrolled?: boolean;
} & DefaultSession["user"];
}
@@ -19,6 +21,7 @@ declare module "next-auth" {
roles: string[];
permissions: string[];
isPlatformAdmin: boolean;
mfaEnrolled?: boolean;
}
}
@@ -31,5 +34,6 @@ declare module "@auth/core/jwt" {
roles: string[];
permissions: string[];
isPlatformAdmin: boolean;
mfaEnrolled?: boolean;
}
}