Files
certvia/src/server/actions/platform.ts
T
msolarczekandClaude Opus 4.8 8e4040d1da Separater Superadmin-Store + eigener Login + MFA-Pflicht (Phase-1-Härtung Paket 2)
Plattform-Administratoren sind nicht länger isPlatformAdmin-Nutzer innerhalb eines
Mandanten, sondern ein getrennter Store mit eigener Auth-Domäne — Voraussetzung
für den sicheren Betrieb beim ersten echten Kunden.

Store & Migration
- Neues Modell PlatformAdmin (kein tenant_id): Argon2id-Hash, TOTP-Secret,
  Recovery-Codes (nur SHA-256-Hashes), Fehlversuchszähler + Sperre, lastLogin.
- AuditLog.tenant_id nullable → mandantenlose Plattform-Ereignisse (scope=platform).
- Datenmigration: bestehende isPlatformAdmin-Nutzer in den neuen Store übernommen
  (gleicher Hash → Login sofort möglich), Flag mandantenweit auf false gesetzt.

Getrennter Login + MFA
- Zweite NextAuth-Instanz (server/platform-auth.ts) mit eigenem Cookie und
  eigenem basePath /api/platform-auth; Session trägt bewusst KEINEN tenantId.
- TOTP-MFA (otplib): Enrollment beim ersten Login (/platform/enroll-mfa, QR +
  Klartext-Secret), danach bei jedem Login erzwungen; 10 einmalige Recovery-Codes.
- Härtung: Konto-Sperre nach 5 Fehlversuchen (15 min), Audit aller Anmeldungen,
  Fehlversuche und Sperren (scope=platform).

Autorisierung / Trennung
- Admin-Konsole nach (platform)/admin verschoben; (platform)/layout.tsx erzwingt
  Plattform-Session + aktivierte MFA. Mandanten-Session hat KEINEN Zugriff auf /admin.
- Mandanten-Shell zeigt keinen /admin-Link mehr; admin-Actions prüfen die
  Plattform-Session statt des abgelösten Flags.
- provision/seed setzen isPlatformAdmin nicht mehr; Seed legt den Demo-Plattform-
  Admin (admin@demo.example) im getrennten Store an.

Browser-verifiziert: Plattform-Login → erzwungenes MFA-Enrollment → Recovery-Codes →
/admin; Login ohne Code scheitert (?error=1); Mandanten-Session auf /admin wird auf
/platform/login umgeleitet. tsc + lint + build + Guard-Check grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:55:12 +02:00

49 lines
2.2 KiB
TypeScript

"use server";
import { prisma } from "@/server/db";
import { requirePlatformSession, platformSignOut } from "@/server/platform-auth";
import { verifyTotp, generateRecoveryCodes } from "@/server/mfa";
import { writePlatformAudit } from "@/server/audit";
/**
* Plattform-Actions (Phase-1-Härtung Paket 2): MFA-Enrollment und Logout.
* EXEMPT vom Modul-Gating (keine per TenantModule gegatete Fachfunktion) — eigene
* Autorisierung über requirePlatformSession.
*/
export type EnrollState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "done"; recoveryCodes: string[] };
/**
* Bestätigt die MFA-Einrichtung: prüft den TOTP-Code gegen das (beim Seitenaufruf
* gespeicherte) Einrichtungs-Secret, aktiviert MFA und liefert einmalig die
* Recovery-Codes im Klartext zurück (nur Hashes werden gespeichert).
*/
export async function confirmMfaEnrollment(_prev: EnrollState, formData: FormData): Promise<EnrollState> {
const session = await requirePlatformSession();
const admin = await prisma.platformAdmin.findUnique({ where: { id: session.user.id } });
if (!admin) return { status: "error", message: "Konto nicht gefunden." };
if (admin.mfaEnrolledAt) return { status: "error", message: "MFA ist bereits eingerichtet." };
if (!admin.mfaSecret) return { status: "error", message: "Kein Einrichtungs-Secret vorhanden. Bitte Seite neu laden." };
const code = String(formData.get("token") ?? "");
if (!verifyTotp(code, admin.mfaSecret)) {
return { status: "error", message: "Code ungültig. Bitte den aktuellen 6-stelligen Code aus der App eingeben." };
}
const { plain, hashed } = generateRecoveryCodes(10);
await prisma.platformAdmin.update({
where: { id: admin.id },
data: { mfaEnrolledAt: new Date(), recoveryCodes: hashed, failedLogins: 0, lockedUntil: null },
});
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "platform_admin", entityId: admin.id, after: { mfaEnrolled: true } });
return { status: "done", recoveryCodes: plain };
}
/** Plattform-Logout (eigene Session-Domäne). */
export async function platformSignOutAction() {
await platformSignOut({ redirectTo: "/platform/login" });
}