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. +
+ +
+ ); + } + + 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 }; +}