Paket C — MFA optional für Superadmins und Mandanten-Nutzer

MFA ist nicht mehr erzwungen, sondern freiwillig; die Erzwingung bleibt über ein
Policy-Flag als Option erhalten (Anpassung von Phase-1-Paket 2).

Plattform-Admins:
- (platform)/layout erzwingt Enrollment nur noch, wenn PlatformSetting.mfaRequired
  aktiv ist (Default: aus) → Login ohne MFA möglich.
- Profil & Sicherheit (/platform/profile): MFA freiwillig aktivieren (Enroll-Flow)
  bzw. deaktivieren (nur solange keine Pflicht gilt); Recovery-Code-Anzeige.
- Policy-Flag „MFA-Pflicht" umschaltbar (setPlatformMfaRequired) — stellt die
  Erzwingung ohne Codeumbau wieder her. Rate-Limit/Lockout/Audit bleiben unverändert.

Mandanten-Nutzer (analog):
- auth.ts verlangt den TOTP-/Recovery-Code beim Login nur, wenn der Nutzer MFA
  eingerichtet hat; Login-Formular um optionales MFA-Feld ergänzt (i18n de/en).
- Persönliches Konto (/account): MFA selbst aktivieren (QR + Bestätigung, Recovery-
  Codes) bzw. deaktivieren; disableOwnMfa respektiert eine tenant-weite MFA-Pflicht
  (securityPolicy.mfaRequired) als fortbestehende Option.

Browser-verifiziert: Plattform-Login ohne MFA → /admin; Tenant-Login zeigt das
MFA-Feld; /account bietet QR-Enrollment. tsc + lint + build + Guard-Check grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:37:35 +02:00
co-authored by Claude Opus 4.8
parent 908b098400
commit 3db820ea90
12 changed files with 307 additions and 10 deletions
+42
View File
@@ -1,12 +1,18 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hashPassword } from "@/server/password";
import { resolvePasswordPolicy, validatePassword } from "@/lib/password-policy";
import { verifyTotp, generateRecoveryCodes } from "@/server/mfa";
import { writeAuditLog } from "@/server/audit";
function tenantMfaRequired(securityPolicy: unknown): boolean {
return !!(securityPolicy && typeof securityPolicy === "object" && (securityPolicy as Record<string, unknown>).mfaRequired);
}
/**
* Self-Service des angemeldeten Mandanten-Nutzers (EXEMPT vom Modul-Gating —
* keine Fachfunktion, eigene Auth über requireSession).
@@ -38,3 +44,39 @@ export async function changeOwnPassword(_prev: ChangePwState, formData: FormData
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_password", entityId: session.user.id });
redirect("/dashboard");
}
/* ── Optionale MFA je Mandanten-Nutzer (Paket C) ── */
export type MfaEnrollState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "done"; recoveryCodes: string[] };
/** Bestätigt die eigene MFA-Einrichtung (TOTP-Code gegen das gespeicherte Secret). */
export async function confirmOwnMfaEnrollment(_prev: MfaEnrollState, formData: FormData): Promise<MfaEnrollState> {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const user = await db.user.findUnique({ where: { id: session.user.id } });
if (!user) return { status: "error", message: "Konto nicht gefunden." };
if (user.mfaEnrolledAt) return { status: "error", message: "MFA ist bereits aktiv." };
if (!user.mfaSecret) return { status: "error", message: "Kein Einrichtungs-Secret vorhanden. Bitte Seite neu laden." };
const code = String(formData.get("token") ?? "");
if (!verifyTotp(code, user.mfaSecret)) return { status: "error", message: "Code ungültig. Bitte den aktuellen 6-stelligen Code eingeben." };
const { plain, hashed } = generateRecoveryCodes(10);
await db.user.update({ where: { id: user.id }, data: { mfaEnrolledAt: new Date(), recoveryCodes: hashed } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: user.id, action: "update", entity: "user_mfa", entityId: user.id, after: { mfaEnrolled: true } });
return { status: "done", recoveryCodes: plain };
}
/** Eigene MFA deaktivieren (nicht möglich, solange der Mandant MFA verlangt). */
export async function disableOwnMfa() {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const settings = await db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } });
if (tenantMfaRequired(settings?.securityPolicy)) throw new Error("MFA ist in diesem Mandanten verpflichtend und kann nicht deaktiviert werden.");
await db.user.update({ where: { id: session.user.id }, data: { mfaSecret: null, mfaEnrolledAt: null, recoveryCodes: [] } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_mfa", entityId: session.user.id, after: { mfaDisabled: true } });
revalidatePath("/account");
}
+27
View File
@@ -1,7 +1,9 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/server/db";
import { requirePlatformSession, platformSignOut } from "@/server/platform-auth";
import { getPlatformSettings } from "@/server/platform-settings";
import { verifyTotp, generateRecoveryCodes } from "@/server/mfa";
import { writePlatformAudit } from "@/server/audit";
@@ -42,6 +44,31 @@ export async function confirmMfaEnrollment(_prev: EnrollState, formData: FormDat
return { status: "done", recoveryCodes: plain };
}
/** MFA freiwillig deaktivieren (nur zulässig, solange keine MFA-Pflicht gilt). */
export async function disablePlatformMfa() {
const session = await requirePlatformSession();
const settings = await getPlatformSettings();
if (settings.mfaRequired) throw new Error("MFA ist per Plattform-Policy verpflichtend und kann nicht deaktiviert werden.");
await prisma.platformAdmin.update({
where: { id: session.user.id },
data: { mfaSecret: null, mfaEnrolledAt: null, recoveryCodes: [] },
});
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "platform_admin", entityId: session.user.id, after: { mfaDisabled: true } });
revalidatePath("/platform/profile");
}
/** Plattformweite MFA-Pflicht schalten (stellt die Erzwingung wieder her — nur Flag). */
export async function setPlatformMfaRequired(required: boolean) {
const session = await requirePlatformSession();
await prisma.platformSetting.upsert({
where: { id: "singleton" },
update: { mfaRequired: required },
create: { id: "singleton", mfaRequired: required },
});
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "platform_setting", entityId: "singleton", after: { mfaRequired: required } });
revalidatePath("/platform/profile");
}
/** Plattform-Logout (eigene Session-Domäne). */
export async function platformSignOutAction() {
await platformSignOut({ redirectTo: "/platform/login" });
+21 -1
View File
@@ -3,6 +3,7 @@ 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";
/**
* Auth.js (NextAuth v5) with local credentials (MVP, see docs/SPEC.md §0).
@@ -16,6 +17,8 @@ const credentialsSchema = z.object({
password: z.string().min(1),
// Optional: disambiguates when the same e-mail exists in several tenants.
tenant: z.string().optional(),
// Optional TOTP-/Recovery-Code (nur nötig, wenn der Nutzer MFA eingerichtet hat).
token: z.string().optional(),
});
export class LoginError extends Error {}
@@ -29,11 +32,12 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: {},
password: {},
tenant: {},
token: {},
},
authorize: async (raw) => {
const parsed = credentialsSchema.safeParse(raw);
if (!parsed.success) return null;
const { email, password, tenant } = parsed.data;
const { email, password, tenant, token } = parsed.data;
const users = await prisma.user.findMany({
where: {
@@ -65,6 +69,22 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const valid = await verify(user.passwordHash, password);
if (!valid) return null;
// Optionale MFA je Nutzer (Paket C): nur erzwungen, wenn eingerichtet.
if (user.mfaEnrolledAt && user.mfaSecret) {
const code = (token ?? "").trim();
if (!code) return null;
let mfaOk = verifyTotp(code, user.mfaSecret);
if (!mfaOk) {
const codes = Array.isArray(user.recoveryCodes) ? (user.recoveryCodes as string[]) : [];
const idx = matchRecovery(code, codes);
if (idx >= 0) {
mfaOk = true;
await prisma.user.update({ where: { id: user.id }, data: { recoveryCodes: codes.filter((_, i) => i !== idx) } });
}
}
if (!mfaOk) return null;
}
const roles = user.userRoles.map((ur) => ur.role.key);
const permissions = [
...new Set(
+10
View File
@@ -0,0 +1,10 @@
import { prisma } from "./db";
/**
* Plattformweite Betriebseinstellungen (Singleton). Steuert die MFA-Pflicht für
* Plattform-Admins (Default: aus → MFA optional, Paket C). Ohne Zeile gilt der Default.
*/
export async function getPlatformSettings() {
const row = await prisma.platformSetting.findUnique({ where: { id: "singleton" } });
return row ?? { id: "singleton", mfaRequired: false };
}