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,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;
|
||||||
@@ -102,7 +102,12 @@ model User {
|
|||||||
passwordHash String @map("password_hash")
|
passwordHash String @map("password_hash")
|
||||||
name String
|
name String
|
||||||
status UserStatus @default(ACTIVE)
|
status UserStatus @default(ACTIVE)
|
||||||
|
// 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")
|
mfaSecret String? @map("mfa_secret")
|
||||||
|
mfaEnrolledAt DateTime? @map("mfa_enrolled_at")
|
||||||
|
recoveryCodes Json @default("[]") @map("recovery_codes")
|
||||||
// Plattform-Admin ist mandantenübergreifend (Betreiberrolle)
|
// Plattform-Admin ist mandantenübergreifend (Betreiberrolle)
|
||||||
isPlatformAdmin Boolean @default(false) @map("is_platform_admin")
|
isPlatformAdmin Boolean @default(false) @map("is_platform_admin")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@@ -187,6 +192,16 @@ model PlatformAdmin {
|
|||||||
@@map("platform_admins")
|
@@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) ─────────────────────────────────────
|
// ── Assets & BIA (ein Modul, SPEC §4.1) ─────────────────────────────────────
|
||||||
|
|
||||||
enum AssetType {
|
enum AssetType {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const ACTION_MODULE: Record<string, string> = {
|
|||||||
// keine per TenantModule gegateten Fachmodule — eigene Autorisierung, kein moduleGuard.
|
// keine per TenantModule gegateten Fachmodule — eigene Autorisierung, kein moduleGuard.
|
||||||
"admin.ts": "EXEMPT",
|
"admin.ts": "EXEMPT",
|
||||||
"platform.ts": "EXEMPT",
|
"platform.ts": "EXEMPT",
|
||||||
|
"account.ts": "EXEMPT",
|
||||||
"tenant-settings.ts": "EXEMPT",
|
"tenant-settings.ts": "EXEMPT",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,27 @@ export default async function AppLayout({
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) redirect("/login");
|
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 (
|
||||||
|
<main className="flex min-h-screen flex-1 items-center justify-center p-6">
|
||||||
|
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8 text-center">
|
||||||
|
<p className="font-heading text-lg font-semibold">Konto deaktiviert</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.</p>
|
||||||
|
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5">
|
||||||
|
<Button type="submit" variant="outline" className="w-full">Abmelden</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (account.mustChangePassword) redirect("/change-password");
|
||||||
|
|
||||||
const t = await getTranslations("nav");
|
const t = await getTranslations("nav");
|
||||||
const tc = await getTranslations("common");
|
const tc = await getTranslations("common");
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<main className="flex flex-1 items-center justify-center p-6">
|
||||||
|
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
|
||||||
|
<p className="font-heading text-lg font-semibold">Passwort ändern</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Ihr Konto wurde mit einem Initialpasswort angelegt. Bitte vergeben Sie jetzt ein eigenes Passwort, um fortzufahren.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<ForcePasswordChangeForm policyHint={hint} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<form action={action} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="password">Neues Passwort</Label>
|
||||||
|
<Input id="password" name="password" type="password" required autoComplete="new-password" className="mt-1" />
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground">Anforderungen: {policyHint}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="confirm">Neues Passwort wiederholen</Label>
|
||||||
|
<Input id="confirm" name="confirm" type="password" required autoComplete="new-password" className="mt-1" />
|
||||||
|
</div>
|
||||||
|
{state.status === "error" && (
|
||||||
|
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{state.message}</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" disabled={pending} className="w-full">{pending ? "Speichere…" : "Passwort setzen & fortfahren"}</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, unknown>) : {};
|
||||||
|
const p = (sp.password && typeof sp.password === "object" ? (sp.password as Partial<PasswordPolicy>) : {}) ?? {};
|
||||||
|
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(", ");
|
||||||
|
}
|
||||||
@@ -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",
|
"tenant:manage",
|
||||||
"user:read",
|
"user:read",
|
||||||
"user:manage",
|
"user:manage",
|
||||||
|
"role:manage",
|
||||||
// Assets & BIA (one module)
|
// Assets & BIA (one module)
|
||||||
"asset:read",
|
"asset:read",
|
||||||
"asset:write",
|
"asset:write",
|
||||||
@@ -56,7 +57,7 @@ export const ROLE_DEFS: Record<
|
|||||||
> = {
|
> = {
|
||||||
"tenant-admin": {
|
"tenant-admin": {
|
||||||
name: "Mandanten-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: {
|
isb: {
|
||||||
name: "ISB / CISO",
|
name: "ISB / CISO",
|
||||||
|
|||||||
Reference in New Issue
Block a user