Files
craftvia/src/server/actions/account.ts
T
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

161 lines
9.1 KiB
TypeScript

"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { hashPassword, verifyPassword } from "@/server/password";
import { resolvePasswordPolicy, validatePassword } from "@/lib/password-policy";
import { verifyTotp, generateRecoveryCodes } from "@/server/mfa";
import { writeAuditLog } from "@/server/audit";
import { resolveMfaRequired } from "@/lib/mfa-policy";
/**
* Self-Service des angemeldeten Mandanten-Nutzers (EXEMPT vom Modul-Gating —
* keine Fachfunktion, eigene Auth über requireSession).
*/
/**
* Persönliche UI-Sprache der angemeldeten Person setzen (Option C: `Identity.uiLocale`).
* Folgt der Identity über alle Mandanten; getrennt von der Vorlagen-Import-Sprache
* (`TenantSettings.locale`). EXEMPT vom Modul-Gating (reine Selbstpräferenz).
*/
export async function setUiLocale(locale: "de" | "en") {
const session = await requireSession();
const identityId = session.user.identityId;
if (!identityId) return;
const value = locale === "en" ? "en" : "de";
await prisma.identity.update({ where: { id: identityId }, data: { uiLocale: value } });
// Die UI-Sprache wirkt auf jede Seite → das komplette Layout neu rendern.
revalidatePath("/", "layout");
}
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 identityId = session.user.identityId;
if (!identityId) return { status: "error", message: "Kein Identity-Kontext in der Session." };
// Option C (WS4): Passwort/MFA/mustChangePassword gehören der GLOBALEN Identity.
// Autoritativ aus der DB laden (F-08): mustChangePassword steuert die Re-Auth-
// Ausnahme, der aktuelle Hash wird serverseitig geprüft.
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
if (!identity) return { status: "error", message: "Konto nicht gefunden." };
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." };
// Re-Authentifizierung bei kritischer Kontoänderung (F-08): außerhalb des
// erzwungenen Erstwechsels ist das aktuelle Passwort Pflicht; bei aktiver MFA
// zusätzlich ein gültiger TOTP-Code. Ausnahme NUR beim erzwungenen Erstwechsel.
let totpStep: number | null = null;
if (!identity.mustChangePassword) {
const currentPw = String(formData.get("currentPassword") ?? "");
if (!currentPw || !(await verifyPassword(identity.passwordHash, currentPw))) {
return { status: "error", message: "Das aktuelle Passwort ist nicht korrekt." };
}
if (identity.mfaEnrolledAt && identity.mfaSecret) {
const code = String(formData.get("token") ?? "");
// Replay-Schutz (F-17): bereits verwendeter/älterer Zeitschritt wird abgelehnt.
const totp = verifyTotp(code, identity.mfaSecret, identity.lastTotpStep);
if (!totp.ok) {
return { status: "error", message: "Der MFA-Code ist ungültig. Bitte den aktuellen 6-stelligen Code eingeben." };
}
totpStep = totp.step;
}
}
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 prisma.identity.update({
where: { id: identityId },
data: { passwordHash: await hashPassword(pw), mustChangePassword: false, ...(totpStep != null ? { lastTotpStep: BigInt(totpStep) } : {}) },
});
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<MfaEnrollState> {
const session = await requireSession();
const identityId = session.user.identityId;
if (!identityId) return { status: "error", message: "Kein Identity-Kontext in der Session." };
// Option C (WS4): MFA gehört der GLOBALEN Identity.
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
if (!identity) return { status: "error", message: "Konto nicht gefunden." };
if (identity.mfaEnrolledAt) return { status: "error", message: "MFA ist bereits aktiv." };
if (!identity.mfaSecret) return { status: "error", message: "Kein Einrichtungs-Secret vorhanden. Bitte Seite neu laden." };
const code = String(formData.get("token") ?? "");
const totp = verifyTotp(code, identity.mfaSecret);
if (!totp.ok) return { status: "error", message: "Code ungültig. Bitte den aktuellen 6-stelligen Code eingeben." };
// F-17: den bei der Einrichtung akzeptierten Zeitschritt gleich als lastTotpStep
// festhalten, damit der Enrollment-Code nicht anschließend beim Login erneut gilt.
const { plain, hashed } = await generateRecoveryCodes(10);
await prisma.identity.update({ where: { id: identityId }, data: { mfaEnrolledAt: new Date(), recoveryCodes: hashed, lastTotpStep: BigInt(totp.step) } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_mfa", entityId: session.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(formData: FormData) {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const settings = await db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } });
if (resolveMfaRequired(settings?.securityPolicy)) throw new Error("MFA ist in diesem Mandanten verpflichtend und kann nicht deaktiviert werden.");
// Re-Authentifizierung (F-08): das Abschalten des zweiten Faktors darf nur mit
// gültigem TOTP-Code möglich sein. Prüfung gegen das Secret der GLOBALEN Identity.
const identityId = session.user.identityId;
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
if (!identity?.mfaEnrolledAt || !identity.mfaSecret) throw new Error("MFA ist nicht aktiv.");
const code = String(formData.get("token") ?? "");
// Replay-Schutz (F-17): ein bereits verwendeter Code kann MFA nicht deaktivieren.
if (!verifyTotp(code, identity.mfaSecret, identity.lastTotpStep).ok) throw new Error("Der MFA-Code ist ungültig. MFA wurde nicht deaktiviert.");
await prisma.identity.update({ where: { id: identityId }, data: { mfaSecret: null, mfaEnrolledAt: null, recoveryCodes: [], lastTotpStep: null } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_mfa", entityId: session.user.id, after: { mfaDisabled: true } });
revalidatePath("/account");
}
/**
* SEC3-c: Recovery-Codes neu erzeugen (Step-up per aktuellem TOTP-Code, F-08). Die alten
* Codes werden dabei entwertet; die neuen werden — wie bei der Ersteinrichtung — nur
* einmal im Klartext zurückgegeben und gehasht gespeichert.
*/
export async function regenerateOwnRecoveryCodes(_prev: MfaEnrollState, formData: FormData): Promise<MfaEnrollState> {
const session = await requireSession();
const identityId = session.user.identityId;
if (!identityId) return { status: "error", message: "Kein Identity-Kontext in der Session." };
// Option C (WS4): Recovery-Codes gehören der GLOBALEN Identity.
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
if (!identity?.mfaEnrolledAt || !identity.mfaSecret) return { status: "error", message: "MFA ist nicht aktiv." };
const code = String(formData.get("token") ?? "");
if (!verifyTotp(code, identity.mfaSecret, identity.lastTotpStep).ok) return { status: "error", message: "Der MFA-Code ist ungültig. Recovery-Codes wurden nicht erneuert." };
const { plain, hashed } = await generateRecoveryCodes();
await prisma.identity.update({ where: { id: identityId }, data: { recoveryCodes: hashed } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_mfa", entityId: session.user.id, after: { recoveryCodesRegenerated: true } });
revalidatePath("/account");
return { status: "done", recoveryCodes: plain };
}