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:
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -155,6 +155,7 @@ export default async function AppLayout({
|
|||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
<Link href="/account" className="flex items-center gap-3" title="Mein Konto (MFA)">
|
||||||
<div className="text-right leading-tight">
|
<div className="text-right leading-tight">
|
||||||
<p className="text-[13px] font-semibold">{session.user.name}</p>
|
<p className="text-[13px] font-semibold">{session.user.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{session.user.tenantSlug}</p>
|
<p className="text-xs text-muted-foreground">{session.user.tenantSlug}</p>
|
||||||
@@ -162,6 +163,7 @@ export default async function AppLayout({
|
|||||||
<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 className="bg-grad-soft grid size-9.5 shrink-0 place-items-center rounded-full font-heading text-sm font-bold text-white">
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</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")}
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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