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