Fundament Benutzer-/Rollenverwaltung: Schema, Passwort-Policy, Force-Change

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:18:05 +02:00
co-authored by Claude Opus 4.8
parent e07dcd9f2a
commit db36c795bd
10 changed files with 262 additions and 2 deletions
+40
View File
@@ -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<ChangePwState> {
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");
}
+38
View File
@@ -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<string> {
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("");
}
+2 -1
View File
@@ -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",