"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 { 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 { 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 { 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 }; }