From db36c795bdca38cfe6197526e4ad5f849368533f Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 22 Jul 2026 09:18:05 +0200 Subject: [PATCH] Fundament Benutzer-/Rollenverwaltung: Schema, Passwort-Policy, Force-Change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorbereitung für die Benutzer- & Rollenverwaltung (Pakete A–C) ohne E-Mail-Flow. - Schema: User.mustChangePassword (erzwungener Wechsel), User.mfaEnrolledAt + recoveryCodes (optionale Nutzer-MFA, Paket C); neues Singleton PlatformSetting mit mfaRequired (Default aus → MFA optional). Migration additiv + Grant/Seed. - RBAC: neue Permission role:manage (Rollenverwaltung), dem Mandanten-Admin zugewiesen; für den bestehenden Demo-Mandanten nachgezogen (neue Tenants via provision/seed automatisch). - Passwort-Policy: lib/password-policy.ts (client-safe Validierung/Beschreibung, Defaults + Ableitung aus TenantSettings.securityPolicy) und server/password.ts (Argon2id-Hash + policy-konformer Einmal-Passwort-Generator). - Force-Change-Flow: /change-password (außerhalb der (app)-Shell) + Self-Service- Action changeOwnPassword (policy-geprüft, Audit). (app)-Layout erzwingt den Wechsel autoritativ aus der DB und sperrt deaktivierte Konten (Abmelden-Screen). tsc + lint + build + Guard-Check grün. Co-Authored-By: Claude Opus 4.8 --- .../migration.sql | 22 ++++++++ prisma/schema.prisma | 17 +++++- scripts/check-module-guards.ts | 1 + src/app/(app)/layout.tsx | 21 ++++++++ src/app/change-password/page.tsx | 38 +++++++++++++ src/components/force-password-change-form.tsx | 30 +++++++++++ src/lib/password-policy.ts | 54 +++++++++++++++++++ src/server/actions/account.ts | 40 ++++++++++++++ src/server/password.ts | 38 +++++++++++++ src/server/rbac.ts | 3 +- 10 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260722071432_user_mgmt_and_platform_settings/migration.sql create mode 100644 src/app/change-password/page.tsx create mode 100644 src/components/force-password-change-form.tsx create mode 100644 src/lib/password-policy.ts create mode 100644 src/server/actions/account.ts create mode 100644 src/server/password.ts diff --git a/prisma/migrations/20260722071432_user_mgmt_and_platform_settings/migration.sql b/prisma/migrations/20260722071432_user_mgmt_and_platform_settings/migration.sql new file mode 100644 index 0000000..cc20255 --- /dev/null +++ b/prisma/migrations/20260722071432_user_mgmt_and_platform_settings/migration.sql @@ -0,0 +1,22 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "mfa_enrolled_at" TIMESTAMP(3), +ADD COLUMN "must_change_password" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "recovery_codes" JSONB NOT NULL DEFAULT '[]'; + +-- CreateTable +CREATE TABLE "platform_settings" ( + "id" TEXT NOT NULL DEFAULT 'singleton', + "mfa_required" BOOLEAN NOT NULL DEFAULT false, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "platform_settings_pkey" PRIMARY KEY ("id") +); + + +-- platform_settings ist plattformweit (kein tenant_id) → RLS nicht aktiviert. +GRANT SELECT, INSERT, UPDATE, DELETE ON "platform_settings" TO isms_app; + +-- Singleton-Zeile anlegen (MFA-Pflicht standardmäßig aus). +INSERT INTO "platform_settings" ("id", "mfa_required", "updated_at") +VALUES ('singleton', false, now()) +ON CONFLICT ("id") DO NOTHING; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5715b65..4330f8e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -102,7 +102,12 @@ model User { passwordHash String @map("password_hash") name String status UserStatus @default(ACTIVE) - mfaSecret String? @map("mfa_secret") + // Erzwungener Passwortwechsel beim nächsten Login (Initial-/Reset-Passwort). + mustChangePassword Boolean @default(false) @map("must_change_password") + // Optionale TOTP-MFA je Nutzer (Paket C). recoveryCodes = SHA-256-Hashes. + mfaSecret String? @map("mfa_secret") + mfaEnrolledAt DateTime? @map("mfa_enrolled_at") + recoveryCodes Json @default("[]") @map("recovery_codes") // Plattform-Admin ist mandantenübergreifend (Betreiberrolle) isPlatformAdmin Boolean @default(false) @map("is_platform_admin") createdAt DateTime @default(now()) @map("created_at") @@ -187,6 +192,16 @@ model PlatformAdmin { @@map("platform_admins") } +// Plattformweite Betriebseinstellungen (Singleton). Steuert u. a., ob MFA für +// Plattform-Admins verpflichtend ist (Default: aus → MFA optional, Paket C). +model PlatformSetting { + id String @id @default("singleton") + mfaRequired Boolean @default(false) @map("mfa_required") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("platform_settings") +} + // ── Assets & BIA (ein Modul, SPEC §4.1) ───────────────────────────────────── enum AssetType { diff --git a/scripts/check-module-guards.ts b/scripts/check-module-guards.ts index 1f0e576..3f79670 100644 --- a/scripts/check-module-guards.ts +++ b/scripts/check-module-guards.ts @@ -33,6 +33,7 @@ const ACTION_MODULE: Record = { // keine per TenantModule gegateten Fachmodule — eigene Autorisierung, kein moduleGuard. "admin.ts": "EXEMPT", "platform.ts": "EXEMPT", + "account.ts": "EXEMPT", "tenant-settings.ts": "EXEMPT", }; diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 10a1cd3..13486fb 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -32,6 +32,27 @@ export default async function AppLayout({ const session = await auth(); if (!session?.user) redirect("/login"); + // Kontostatus (autoritativ aus DB, nicht aus dem JWT): deaktivierte Nutzer werden + // abgemeldet, ein offener Passwortwechsel wird erzwungen (Force-Change). + const account = await dbForTenant(session.user.tenantId).user.findUnique({ + where: { id: session.user.id }, + select: { status: true, mustChangePassword: true }, + }); + if (!account || account.status !== "ACTIVE") { + return ( +
+
+

Konto deaktiviert

+

Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.

+
{ "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5"> + +
+
+
+ ); + } + if (account.mustChangePassword) redirect("/change-password"); + const t = await getTranslations("nav"); const tc = await getTranslations("common"); diff --git a/src/app/change-password/page.tsx b/src/app/change-password/page.tsx new file mode 100644 index 0000000..1c0a27d --- /dev/null +++ b/src/app/change-password/page.tsx @@ -0,0 +1,38 @@ +import { redirect } from "next/navigation"; +import { auth } from "@/server/auth"; +import { dbForTenant } from "@/server/db"; +import { resolvePasswordPolicy, describePasswordPolicy } from "@/lib/password-policy"; +import { ForcePasswordChangeForm } from "@/components/force-password-change-form"; + +/** + * Erzwungener Passwortwechsel beim ersten Login (Force-Change). Liegt außerhalb der + * (app)-Shell, damit deren Guard nicht in eine Weiterleitungsschleife läuft. + */ +export default async function ChangePasswordPage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + + const db = dbForTenant(session.user.tenantId); + const [user, settings] = await Promise.all([ + db.user.findUnique({ where: { id: session.user.id }, select: { mustChangePassword: true } }), + db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }), + ]); + // Wer keinen Wechsel (mehr) schuldet, wird zur App durchgereicht. + if (!user?.mustChangePassword) redirect("/dashboard"); + + const hint = describePasswordPolicy(resolvePasswordPolicy(settings?.securityPolicy)); + + return ( +
+
+

Passwort ändern

+

+ Ihr Konto wurde mit einem Initialpasswort angelegt. Bitte vergeben Sie jetzt ein eigenes Passwort, um fortzufahren. +

+
+ +
+
+
+ ); +} diff --git a/src/components/force-password-change-form.tsx b/src/components/force-password-change-form.tsx new file mode 100644 index 0000000..712c065 --- /dev/null +++ b/src/components/force-password-change-form.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useActionState } from "react"; +import { changeOwnPassword, type ChangePwState } from "@/server/actions/account"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const initial: ChangePwState = { status: "idle" }; + +export function ForcePasswordChangeForm({ policyHint }: { policyHint: string }) { + const [state, action, pending] = useActionState(changeOwnPassword, initial); + return ( +
+
+ + +

Anforderungen: {policyHint}

+
+
+ + +
+ {state.status === "error" && ( +

{state.message}

+ )} + +
+ ); +} diff --git a/src/lib/password-policy.ts b/src/lib/password-policy.ts new file mode 100644 index 0000000..3b506b4 --- /dev/null +++ b/src/lib/password-policy.ts @@ -0,0 +1,54 @@ +/** + * Passwort-Policy (Benutzer- & Rollenverwaltung, Querschnitt). Reine Validierung + * ohne Server-Abhängigkeiten — auch in Client-Komponenten nutzbar (Hinweistexte). + * Werte stammen aus TenantSettings.securityPolicy.password bzw. dem Plattform-Default. + */ +export interface PasswordPolicy { + minLength: number; + requireUpper: boolean; + requireLower: boolean; + requireDigit: boolean; + requireSymbol: boolean; +} + +export const DEFAULT_PASSWORD_POLICY: PasswordPolicy = { + minLength: 12, + requireUpper: true, + requireLower: true, + requireDigit: true, + requireSymbol: false, +}; + +/** Liest die Policy aus einem TenantSettings.securityPolicy-JSON (mit Defaults). */ +export function resolvePasswordPolicy(securityPolicy: unknown): PasswordPolicy { + const sp = securityPolicy && typeof securityPolicy === "object" ? (securityPolicy as Record) : {}; + const p = (sp.password && typeof sp.password === "object" ? (sp.password as Partial) : {}) ?? {}; + return { + minLength: typeof p.minLength === "number" ? p.minLength : DEFAULT_PASSWORD_POLICY.minLength, + requireUpper: p.requireUpper ?? DEFAULT_PASSWORD_POLICY.requireUpper, + requireLower: p.requireLower ?? DEFAULT_PASSWORD_POLICY.requireLower, + requireDigit: p.requireDigit ?? DEFAULT_PASSWORD_POLICY.requireDigit, + requireSymbol: p.requireSymbol ?? DEFAULT_PASSWORD_POLICY.requireSymbol, + }; +} + +/** Gibt die verletzten Anforderungen zurück (leeres Array = gültig). */ +export function validatePassword(pw: string, policy: PasswordPolicy = DEFAULT_PASSWORD_POLICY): string[] { + const missing: string[] = []; + if (pw.length < policy.minLength) missing.push(`mindestens ${policy.minLength} Zeichen`); + if (policy.requireUpper && !/[A-Z]/.test(pw)) missing.push("einen Großbuchstaben"); + if (policy.requireLower && !/[a-z]/.test(pw)) missing.push("einen Kleinbuchstaben"); + if (policy.requireDigit && !/\d/.test(pw)) missing.push("eine Ziffer"); + if (policy.requireSymbol && !/[^A-Za-z0-9]/.test(pw)) missing.push("ein Sonderzeichen"); + return missing; +} + +/** Menschlich lesbare Beschreibung der Anforderungen (für Hinweistexte). */ +export function describePasswordPolicy(policy: PasswordPolicy = DEFAULT_PASSWORD_POLICY): string { + const parts = [`mind. ${policy.minLength} Zeichen`]; + if (policy.requireUpper) parts.push("Großbuchstabe"); + if (policy.requireLower) parts.push("Kleinbuchstabe"); + if (policy.requireDigit) parts.push("Ziffer"); + if (policy.requireSymbol) parts.push("Sonderzeichen"); + return parts.join(", "); +} diff --git a/src/server/actions/account.ts b/src/server/actions/account.ts new file mode 100644 index 0000000..40db77a --- /dev/null +++ b/src/server/actions/account.ts @@ -0,0 +1,40 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { requireSession } from "@/server/auth"; +import { dbForTenant } from "@/server/db"; +import { hashPassword } from "@/server/password"; +import { resolvePasswordPolicy, validatePassword } from "@/lib/password-policy"; +import { writeAuditLog } from "@/server/audit"; + +/** + * Self-Service des angemeldeten Mandanten-Nutzers (EXEMPT vom Modul-Gating — + * keine Fachfunktion, eigene Auth über requireSession). + */ + +export type ChangePwState = { status: "idle" } | { status: "error"; message: string }; + +/** + * Erzwungener Passwortwechsel beim ersten Login (mustChangePassword). Prüft die + * Passwort-Policy des Mandanten, setzt neuen Argon2id-Hash und löscht das Flag. + */ +export async function changeOwnPassword(_prev: ChangePwState, formData: FormData): Promise { + const session = await requireSession(); + const db = dbForTenant(session.user.tenantId); + + const pw = String(formData.get("password") ?? ""); + const confirm = String(formData.get("confirm") ?? ""); + if (pw !== confirm) return { status: "error", message: "Die beiden Passwörter stimmen nicht überein." }; + + const settings = await db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }); + const policy = resolvePasswordPolicy(settings?.securityPolicy); + const missing = validatePassword(pw, policy); + if (missing.length) return { status: "error", message: `Das Passwort benötigt ${missing.join(", ")}.` }; + + await db.user.update({ + where: { id: session.user.id }, + data: { passwordHash: await hashPassword(pw), mustChangePassword: false }, + }); + await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_password", entityId: session.user.id }); + redirect("/dashboard"); +} diff --git a/src/server/password.ts b/src/server/password.ts new file mode 100644 index 0000000..f8c21c4 --- /dev/null +++ b/src/server/password.ts @@ -0,0 +1,38 @@ +import { randomInt } from "node:crypto"; +import { hash } from "@node-rs/argon2"; +import { type PasswordPolicy, DEFAULT_PASSWORD_POLICY } from "@/lib/password-policy"; + +/** Argon2id-Hash (server-only). */ +export function hashPassword(pw: string): Promise { + return hash(pw); +} + +const UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ"; +const LOWER = "abcdefghijkmnpqrstuvwxyz"; +const DIGIT = "23456789"; +const SYMBOL = "!@#$%*?-_"; + +const pick = (chars: string) => chars[randomInt(chars.length)]; +const shuffle = (arr: string[]) => { + for (let i = arr.length - 1; i > 0; i--) { + const j = randomInt(i + 1); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +}; + +/** + * Erzeugt ein zufälliges, policy-konformes Einmal-Passwort (für vom Admin + * generierte Initial-/Reset-Passwörter, einmalig angezeigt). + */ +export function generateCompliantPassword(policy: PasswordPolicy = DEFAULT_PASSWORD_POLICY): string { + const pool = UPPER + LOWER + DIGIT + (policy.requireSymbol ? SYMBOL : ""); + const chars: string[] = []; + if (policy.requireUpper) chars.push(pick(UPPER)); + if (policy.requireLower) chars.push(pick(LOWER)); + if (policy.requireDigit) chars.push(pick(DIGIT)); + if (policy.requireSymbol) chars.push(pick(SYMBOL)); + const target = Math.max(policy.minLength, 14); + while (chars.length < target) chars.push(pick(pool)); + return shuffle(chars).join(""); +} diff --git a/src/server/rbac.ts b/src/server/rbac.ts index b9e0a89..381105f 100644 --- a/src/server/rbac.ts +++ b/src/server/rbac.ts @@ -9,6 +9,7 @@ export const PERMISSIONS = [ "tenant:manage", "user:read", "user:manage", + "role:manage", // Assets & BIA (one module) "asset:read", "asset:write", @@ -56,7 +57,7 @@ export const ROLE_DEFS: Record< > = { "tenant-admin": { name: "Mandanten-Admin", - permissions: ["tenant:manage", "user:read", "user:manage", "report:read", "chat:use"], + permissions: ["tenant:manage", "user:read", "user:manage", "role:manage", "report:read", "chat:use"], }, isb: { name: "ISB / CISO",