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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user