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:
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user