From 3db820ea90908024edf8321db4edc8795af266ae Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 22 Jul 2026 09:37:35 +0200 Subject: [PATCH] =?UTF-8?q?Paket=20C=20=E2=80=94=20MFA=20optional=20f?= =?UTF-8?q?=C3=BCr=20Superadmins=20und=20Mandanten-Nutzer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- messages/de.json | 1 + messages/en.json | 1 + src/app/(app)/account/page.tsx | 67 ++++++++++++++++++++ src/app/(app)/layout.tsx | 16 ++--- src/app/(platform)/layout.tsx | 10 ++- src/app/(platform)/profile/page.tsx | 74 +++++++++++++++++++++++ src/app/login/page.tsx | 5 ++ src/components/tenant-mfa-enroll-form.tsx | 42 +++++++++++++ src/server/actions/account.ts | 42 +++++++++++++ src/server/actions/platform.ts | 27 +++++++++ src/server/auth.ts | 22 ++++++- src/server/platform-settings.ts | 10 +++ 12 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 src/app/(app)/account/page.tsx create mode 100644 src/app/(platform)/profile/page.tsx create mode 100644 src/components/tenant-mfa-enroll-form.tsx create mode 100644 src/server/platform-settings.ts diff --git a/messages/de.json b/messages/de.json index d05bc8b..bbd6239 100644 --- a/messages/de.json +++ b/messages/de.json @@ -41,6 +41,7 @@ "password": "Passwort", "tenant": "Organisation (optional)", "tenantHint": "Nur nötig, wenn deine E-Mail in mehreren Organisationen existiert.", + "mfaOptional": "MFA-Code (falls eingerichtet)", "submit": "Anmelden", "error": "Anmeldung fehlgeschlagen. Bitte prüfe E-Mail, Passwort und ggf. die Organisation." }, diff --git a/messages/en.json b/messages/en.json index ef6834f..f4f0963 100644 --- a/messages/en.json +++ b/messages/en.json @@ -41,6 +41,7 @@ "password": "Password", "tenant": "Organization (optional)", "tenantHint": "Only needed if your e-mail exists in several organizations.", + "mfaOptional": "MFA code (if enabled)", "submit": "Sign in", "error": "Sign-in failed. Please check e-mail, password and organization." }, diff --git a/src/app/(app)/account/page.tsx b/src/app/(app)/account/page.tsx new file mode 100644 index 0000000..024a14d --- /dev/null +++ b/src/app/(app)/account/page.tsx @@ -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 ( +
+ + +
+
+
+

Zwei-Faktor-Authentifizierung (optional)

+ {enrolled ? "Aktiv" : "Inaktiv"} +
+ + {enrolled ? ( +
+

MFA ist aktiv. Verbleibende Recovery-Codes: {recoveryLeft}.

+
+ +
+
+ ) : ( +
+

+ Scannen Sie den QR-Code mit einer Authenticator-App und bestätigen Sie mit dem angezeigten Code. +

+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR-Code für die Authenticator-App + {secret} +
+ +
+ )} +
+
+
+ ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 13486fb..dfdaa63 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -155,13 +155,15 @@ export default async function AppLayout({ />
-
-

{session.user.name}

-

{session.user.tenantSlug}

-
-
- {initials} -
+ +
+

{session.user.name}

+

{session.user.tenantSlug}

+
+
+ {initials} +
+
diff --git a/src/app/(platform)/profile/page.tsx b/src/app/(platform)/profile/page.tsx new file mode 100644 index 0000000..819d4de --- /dev/null +++ b/src/app/(platform)/profile/page.tsx @@ -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 ( +
+ + +
+ {/* MFA (persönlich) */} +
+
+

Zwei-Faktor-Authentifizierung

+ {enrolled ? "Aktiv" : "Inaktiv"} +
+

+ MFA per TOTP ist {settings.mfaRequired ? "durch die Plattform-Policy verpflichtend" : "optional"}. Empfohlen für Betreiberzugänge. +

+ {enrolled ? ( +
+

Verbleibende Recovery-Codes: {recoveryLeft}

+
+ +
+ {settings.mfaRequired &&

Deaktivieren nicht möglich, solange die MFA-Pflicht aktiv ist.

} +
+ ) : ( +
+ +
+ )} +
+ + {/* Plattform-Policy: MFA-Pflicht */} +
+
+

Plattform-Policy: MFA-Pflicht

+ {settings.mfaRequired ? "Erzwungen" : "Aus"} +
+

+ Ist die Pflicht aktiv, müssen alle Plattform-Admins MFA einrichten (Enrollment wird beim Zugriff erzwungen). Default: aus. +

+
+ +
+
+
+
+ ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index c27fbfd..0fdb502 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -27,6 +27,7 @@ export default async function LoginPage({ email: formData.get("email"), password: formData.get("password"), tenant: formData.get("tenant"), + token: formData.get("token"), redirectTo: target, }); } catch (err) { @@ -81,6 +82,10 @@ export default async function LoginPage({

{t("tenantHint")}

+
+ + +
diff --git a/src/components/tenant-mfa-enroll-form.tsx b/src/components/tenant-mfa-enroll-form.tsx new file mode 100644 index 0000000..8c10128 --- /dev/null +++ b/src/components/tenant-mfa-enroll-form.tsx @@ -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 ( +
+
+ MFA ist aktiv. Bewahren Sie diese Recovery-Codes sicher auf — sie werden nur jetzt angezeigt. +
+
    + {state.recoveryCodes.map((c) => ( +
  • {c}
  • + ))} +
+
+ ); + } + + return ( +
+
+ + +
+ {state.status === "error" && ( +

{state.message}

+ )} + +
+ ); +} diff --git a/src/server/actions/account.ts b/src/server/actions/account.ts index 40db77a..edeaacb 100644 --- a/src/server/actions/account.ts +++ b/src/server/actions/account.ts @@ -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).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 { + 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"); +} diff --git a/src/server/actions/platform.ts b/src/server/actions/platform.ts index 65c3d72..bdeb6ca 100644 --- a/src/server/actions/platform.ts +++ b/src/server/actions/platform.ts @@ -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" }); diff --git a/src/server/auth.ts b/src/server/auth.ts index fd9d2eb..b2a7d69 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -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( diff --git a/src/server/platform-settings.ts b/src/server/platform-settings.ts new file mode 100644 index 0000000..a18d2dd --- /dev/null +++ b/src/server/platform-settings.ts @@ -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 }; +}