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
+1
View File
@@ -41,6 +41,7 @@
"password": "Passwort", "password": "Passwort",
"tenant": "Organisation (optional)", "tenant": "Organisation (optional)",
"tenantHint": "Nur nötig, wenn deine E-Mail in mehreren Organisationen existiert.", "tenantHint": "Nur nötig, wenn deine E-Mail in mehreren Organisationen existiert.",
"mfaOptional": "MFA-Code (falls eingerichtet)",
"submit": "Anmelden", "submit": "Anmelden",
"error": "Anmeldung fehlgeschlagen. Bitte prüfe E-Mail, Passwort und ggf. die Organisation." "error": "Anmeldung fehlgeschlagen. Bitte prüfe E-Mail, Passwort und ggf. die Organisation."
}, },
+1
View File
@@ -41,6 +41,7 @@
"password": "Password", "password": "Password",
"tenant": "Organization (optional)", "tenant": "Organization (optional)",
"tenantHint": "Only needed if your e-mail exists in several organizations.", "tenantHint": "Only needed if your e-mail exists in several organizations.",
"mfaOptional": "MFA code (if enabled)",
"submit": "Sign in", "submit": "Sign in",
"error": "Sign-in failed. Please check e-mail, password and organization." "error": "Sign-in failed. Please check e-mail, password and organization."
}, },
+67
View File
@@ -0,0 +1,67 @@
import { redirect } from "next/navigation";
import QRCode from "qrcode";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { newTotpSecret, totpUri } from "@/server/mfa";
import { disableOwnMfa } from "@/server/actions/account";
import { Button } from "@/components/ui/button";
import { PageHead, Pill } from "@/components/mockup-ui";
import { TenantMfaEnrollForm } from "@/components/tenant-mfa-enroll-form";
/**
* Persönliches Konto des Mandanten-Nutzers (Paket C): optionale MFA selbst
* aktivieren/deaktivieren. Login verlangt den Code erst, sobald MFA aktiv ist.
*/
export default async function AccountPage() {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const user = await db.user.findUnique({ where: { id: session.user.id } });
if (!user) redirect("/login");
const enrolled = !!user.mfaEnrolledAt;
const recoveryLeft = Array.isArray(user.recoveryCodes) ? user.recoveryCodes.length : 0;
let qr: string | null = null;
let secret: string | null = null;
if (!enrolled) {
secret = user.mfaSecret ?? newTotpSecret();
if (!user.mfaSecret) await db.user.update({ where: { id: user.id }, data: { mfaSecret: secret } });
qr = await QRCode.toDataURL(totpUri(user.email, secret), { margin: 1, width: 192 });
}
return (
<main className="flex-1 p-6">
<PageHead crumb="Konto" title="Mein Konto" sub={`${user.name} · ${user.email}`} />
<div className="mt-4 max-w-xl">
<div className="shadow-card rounded-xl border bg-card p-5">
<div className="flex items-center justify-between">
<p className="font-heading text-sm font-semibold">Zwei-Faktor-Authentifizierung (optional)</p>
<Pill tone={enrolled ? "ok" : "mut"}>{enrolled ? "Aktiv" : "Inaktiv"}</Pill>
</div>
{enrolled ? (
<div className="mt-3 space-y-2">
<p className="text-[12.5px] text-muted-foreground">MFA ist aktiv. Verbleibende Recovery-Codes: {recoveryLeft}.</p>
<form action={disableOwnMfa}>
<Button type="submit" variant="outline" size="sm">MFA deaktivieren</Button>
</form>
</div>
) : (
<div className="mt-3 space-y-4">
<p className="text-[12.5px] text-muted-foreground">
Scannen Sie den QR-Code mit einer Authenticator-App und bestätigen Sie mit dem angezeigten Code.
</p>
<div className="flex flex-col items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qr!} alt="QR-Code für die Authenticator-App" width={192} height={192} className="rounded-lg border bg-white p-2" />
<code className="select-all break-all font-mono text-xs text-muted-foreground">{secret}</code>
</div>
<TenantMfaEnrollForm />
</div>
)}
</div>
</div>
</main>
);
}
+9 -7
View File
@@ -155,13 +155,15 @@ export default async function AppLayout({
/> />
</form> </form>
<div className="flex-1" /> <div className="flex-1" />
<div className="text-right leading-tight"> <Link href="/account" className="flex items-center gap-3" title="Mein Konto (MFA)">
<p className="text-[13px] font-semibold">{session.user.name}</p> <div className="text-right leading-tight">
<p className="text-xs text-muted-foreground">{session.user.tenantSlug}</p> <p className="text-[13px] font-semibold">{session.user.name}</p>
</div> <p className="text-xs text-muted-foreground">{session.user.tenantSlug}</p>
<div className="bg-grad-soft grid size-9.5 shrink-0 place-items-center rounded-full font-heading text-sm font-bold text-white"> </div>
{initials} <div className="bg-grad-soft grid size-9.5 shrink-0 place-items-center rounded-full font-heading text-sm font-bold text-white">
</div> {initials}
</div>
</Link>
<form action={logout}> <form action={logout}>
<Button type="submit" variant="ghost" size="sm"> <Button type="submit" variant="ghost" size="sm">
{tc("logout")} {tc("logout")}
+8 -2
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { ShieldCheck } from "lucide-react"; import { ShieldCheck } from "lucide-react";
import { platformAuth } from "@/server/platform-auth"; import { platformAuth } from "@/server/platform-auth";
import { prisma } from "@/server/db"; import { prisma } from "@/server/db";
import { getPlatformSettings } from "@/server/platform-settings";
import { platformSignOutAction } from "@/server/actions/platform"; import { platformSignOutAction } from "@/server/actions/platform";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -16,9 +17,13 @@ export default async function PlatformLayout({ children }: Readonly<{ children:
const session = await platformAuth(); const session = await platformAuth();
if (!session?.user?.id) redirect("/platform/login"); if (!session?.user?.id) redirect("/platform/login");
const admin = await prisma.platformAdmin.findUnique({ where: { id: session.user.id } }); const [admin, settings] = await Promise.all([
prisma.platformAdmin.findUnique({ where: { id: session.user.id } }),
getPlatformSettings(),
]);
if (!admin || admin.status !== "ACTIVE") redirect("/platform/login"); if (!admin || admin.status !== "ACTIVE") redirect("/platform/login");
if (!admin.mfaEnrolledAt) redirect("/platform/enroll-mfa"); // MFA ist optional (Paket C) — Enrollment nur erzwingen, wenn die Plattform-Policy es verlangt.
if (settings.mfaRequired && !admin.mfaEnrolledAt) redirect("/platform/enroll-mfa");
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
@@ -30,6 +35,7 @@ export default async function PlatformLayout({ children }: Readonly<{ children:
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{admin.name} · {admin.email} {admin.name} · {admin.email}
</span> </span>
<Link href="/platform/profile" className="text-muted-foreground hover:text-foreground">Profil &amp; Sicherheit</Link>
<form action={platformSignOutAction}> <form action={platformSignOutAction}>
<Button type="submit" variant="ghost" size="sm">Abmelden</Button> <Button type="submit" variant="ghost" size="sm">Abmelden</Button>
</form> </form>
+74
View File
@@ -0,0 +1,74 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { platformAuth } from "@/server/platform-auth";
import { prisma } from "@/server/db";
import { getPlatformSettings } from "@/server/platform-settings";
import { disablePlatformMfa, setPlatformMfaRequired } from "@/server/actions/platform";
import { Button } from "@/components/ui/button";
import { PageHead, Pill } from "@/components/mockup-ui";
/**
* Profil & Sicherheit des Plattform-Admins (Paket C): MFA ist optional und kann hier
* freiwillig aktiviert/deaktiviert werden. Zusätzlich lässt sich die plattformweite
* MFA-Pflicht (Policy-Flag) schalten, um die Erzwingung wiederherzustellen.
*/
export default async function PlatformProfilePage() {
const session = await platformAuth();
if (!session?.user?.id) redirect("/platform/login");
const [admin, settings] = await Promise.all([
prisma.platformAdmin.findUnique({ where: { id: session.user.id } }),
getPlatformSettings(),
]);
if (!admin) redirect("/platform/login");
const enrolled = !!admin.mfaEnrolledAt;
const recoveryLeft = Array.isArray(admin.recoveryCodes) ? admin.recoveryCodes.length : 0;
return (
<main className="flex-1 p-6">
<PageHead crumb="Plattform-Betrieb" title="Profil & Sicherheit" sub={`${admin.name} · ${admin.email}`} />
<div className="mt-4 grid gap-5 lg:grid-cols-2">
{/* MFA (persönlich) */}
<div className="shadow-card rounded-xl border bg-card p-5">
<div className="flex items-center justify-between">
<p className="font-heading text-sm font-semibold">Zwei-Faktor-Authentifizierung</p>
<Pill tone={enrolled ? "ok" : "mut"}>{enrolled ? "Aktiv" : "Inaktiv"}</Pill>
</div>
<p className="mt-2 text-[12.5px] text-muted-foreground">
MFA per TOTP ist {settings.mfaRequired ? "durch die Plattform-Policy verpflichtend" : "optional"}. Empfohlen für Betreiberzugänge.
</p>
{enrolled ? (
<div className="mt-3 space-y-2">
<p className="text-[12px] text-muted-foreground">Verbleibende Recovery-Codes: {recoveryLeft}</p>
<form action={disablePlatformMfa}>
<Button type="submit" variant="outline" size="sm" disabled={settings.mfaRequired}>MFA deaktivieren</Button>
</form>
{settings.mfaRequired && <p className="text-[11px] text-muted-foreground">Deaktivieren nicht möglich, solange die MFA-Pflicht aktiv ist.</p>}
</div>
) : (
<div className="mt-3">
<Button nativeButton={false} render={<Link href="/platform/enroll-mfa" />} size="sm">MFA aktivieren</Button>
</div>
)}
</div>
{/* Plattform-Policy: MFA-Pflicht */}
<div className="shadow-card rounded-xl border bg-card p-5">
<div className="flex items-center justify-between">
<p className="font-heading text-sm font-semibold">Plattform-Policy: MFA-Pflicht</p>
<Pill tone={settings.mfaRequired ? "ok" : "mut"}>{settings.mfaRequired ? "Erzwungen" : "Aus"}</Pill>
</div>
<p className="mt-2 text-[12.5px] text-muted-foreground">
Ist die Pflicht aktiv, müssen alle Plattform-Admins MFA einrichten (Enrollment wird beim Zugriff erzwungen). Default: aus.
</p>
<form action={setPlatformMfaRequired.bind(null, !settings.mfaRequired)} className="mt-3">
<Button type="submit" variant="outline" size="sm">
{settings.mfaRequired ? "MFA-Pflicht deaktivieren" : "MFA-Pflicht aktivieren"}
</Button>
</form>
</div>
</div>
</main>
);
}
+5
View File
@@ -27,6 +27,7 @@ export default async function LoginPage({
email: formData.get("email"), email: formData.get("email"),
password: formData.get("password"), password: formData.get("password"),
tenant: formData.get("tenant"), tenant: formData.get("tenant"),
token: formData.get("token"),
redirectTo: target, redirectTo: target,
}); });
} catch (err) { } catch (err) {
@@ -81,6 +82,10 @@ export default async function LoginPage({
<Input id="tenant" name="tenant" autoComplete="organization" className="mt-1" /> <Input id="tenant" name="tenant" autoComplete="organization" className="mt-1" />
<p className="mt-1 text-xs text-muted-foreground">{t("tenantHint")}</p> <p className="mt-1 text-xs text-muted-foreground">{t("tenantHint")}</p>
</div> </div>
<div>
<Label htmlFor="token">{t("mfaOptional")}</Label>
<Input id="token" name="token" inputMode="numeric" autoComplete="one-time-code" className="mt-1" placeholder="123456" />
</div>
<Button type="submit" className="w-full"> <Button type="submit" className="w-full">
{t("submit")} {t("submit")}
</Button> </Button>
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useActionState } from "react";
import { confirmOwnMfaEnrollment, type MfaEnrollState } from "@/server/actions/account";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const initial: MfaEnrollState = { status: "idle" };
/** MFA-Einrichtung für den angemeldeten Mandanten-Nutzer (QR wird serverseitig erzeugt). */
export function TenantMfaEnrollForm() {
const [state, action, pending] = useActionState(confirmOwnMfaEnrollment, initial);
if (state.status === "done") {
return (
<div className="space-y-3">
<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 diese Recovery-Codes sicher auf sie werden <strong>nur jetzt</strong> angezeigt.
</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>
</div>
);
}
return (
<form action={action} className="space-y-3">
<div>
<Label htmlFor="mfa-token">6-stelliger Code aus der Authenticator-App</Label>
<Input id="mfa-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" size="sm" disabled={pending}>{pending ? "Prüfe…" : "MFA aktivieren"}</Button>
</form>
);
}
+42
View File
@@ -1,12 +1,18 @@
"use server"; "use server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/server/auth"; import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db"; import { dbForTenant } from "@/server/db";
import { hashPassword } from "@/server/password"; import { hashPassword } from "@/server/password";
import { resolvePasswordPolicy, validatePassword } from "@/lib/password-policy"; import { resolvePasswordPolicy, validatePassword } from "@/lib/password-policy";
import { verifyTotp, generateRecoveryCodes } from "@/server/mfa";
import { writeAuditLog } from "@/server/audit"; 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 — * Self-Service des angemeldeten Mandanten-Nutzers (EXEMPT vom Modul-Gating —
* keine Fachfunktion, eigene Auth über requireSession). * 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 }); await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_password", entityId: session.user.id });
redirect("/dashboard"); 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"; "use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/server/db"; import { prisma } from "@/server/db";
import { requirePlatformSession, platformSignOut } from "@/server/platform-auth"; import { requirePlatformSession, platformSignOut } from "@/server/platform-auth";
import { getPlatformSettings } from "@/server/platform-settings";
import { verifyTotp, generateRecoveryCodes } from "@/server/mfa"; import { verifyTotp, generateRecoveryCodes } from "@/server/mfa";
import { writePlatformAudit } from "@/server/audit"; import { writePlatformAudit } from "@/server/audit";
@@ -42,6 +44,31 @@ export async function confirmMfaEnrollment(_prev: EnrollState, formData: FormDat
return { status: "done", recoveryCodes: plain }; 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). */ /** Plattform-Logout (eigene Session-Domäne). */
export async function platformSignOutAction() { export async function platformSignOutAction() {
await platformSignOut({ redirectTo: "/platform/login" }); 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 { verify } from "@node-rs/argon2";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "./db"; import { prisma } from "./db";
import { verifyTotp, matchRecovery } from "./mfa";
/** /**
* Auth.js (NextAuth v5) with local credentials (MVP, see docs/SPEC.md §0). * 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), password: z.string().min(1),
// Optional: disambiguates when the same e-mail exists in several tenants. // Optional: disambiguates when the same e-mail exists in several tenants.
tenant: z.string().optional(), 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 {} export class LoginError extends Error {}
@@ -29,11 +32,12 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: {}, email: {},
password: {}, password: {},
tenant: {}, tenant: {},
token: {},
}, },
authorize: async (raw) => { authorize: async (raw) => {
const parsed = credentialsSchema.safeParse(raw); const parsed = credentialsSchema.safeParse(raw);
if (!parsed.success) return null; if (!parsed.success) return null;
const { email, password, tenant } = parsed.data; const { email, password, tenant, token } = parsed.data;
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { where: {
@@ -65,6 +69,22 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const valid = await verify(user.passwordHash, password); const valid = await verify(user.passwordHash, password);
if (!valid) return null; 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 roles = user.userRoles.map((ur) => ur.role.key);
const permissions = [ const permissions = [
...new Set( ...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 };
}