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>
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { verifyPassword } from "./password";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "./db";
|
||||
import { verifyTotp, matchRecovery } from "./mfa";
|
||||
import { writePlatformAudit } from "./audit";
|
||||
import { assertSecureEnv } from "./env";
|
||||
import { isTokenStillValid } from "./sessions";
|
||||
|
||||
/**
|
||||
* Getrennte Auth-Domäne für Plattform-Administratoren (Phase-1-Härtung Paket 2).
|
||||
*
|
||||
* Eigene NextAuth-Instanz mit eigenem Cookie und eigenem basePath, damit die
|
||||
* Plattform-Session strikt von der Mandanten-Session getrennt ist und **keinen**
|
||||
* tenantId trägt. Login gegen den PlatformAdmin-Store (kein User/Tenant), TOTP-MFA
|
||||
* sobald eingerichtet, Konto-Sperre nach zu vielen Fehlversuchen, Audit scope=platform.
|
||||
*/
|
||||
|
||||
const LOCK_THRESHOLD = 5;
|
||||
const LOCK_MINUTES = 15;
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
token: z.string().optional(), // TOTP-Code oder Recovery-Code
|
||||
});
|
||||
|
||||
/** Fehlversuch zählen und ggf. sperren; gibt immer null zurück (Login fehlgeschlagen). */
|
||||
async function registerFailure(id: string) {
|
||||
const updated = await prisma.platformAdmin.update({
|
||||
where: { id },
|
||||
data: { failedLogins: { increment: 1 } },
|
||||
});
|
||||
if (updated.failedLogins >= LOCK_THRESHOLD) {
|
||||
await prisma.platformAdmin.update({
|
||||
where: { id },
|
||||
data: { lockedUntil: new Date(Date.now() + LOCK_MINUTES * 60_000), failedLogins: 0 },
|
||||
});
|
||||
await writePlatformAudit({ actorId: id, action: "denied", entity: "platform_login", entityId: id, after: { locked: true } });
|
||||
} else {
|
||||
await writePlatformAudit({ actorId: id, action: "denied", entity: "platform_login", entityId: id });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Secure-Cookies/Prefix MÜSSEN dem tatsächlichen Protokoll folgen — exakt wie Auth.js
|
||||
// es für die Mandanten-Instanz tut: `config.useSecureCookies ?? url.protocol === "https:"`
|
||||
// (@auth/core lib/init.ts). NICHT an NODE_ENV koppeln: im Container ist NODE_ENV=production,
|
||||
// der interne Testserver wird aber ggf. über http bedient (AUTH_URL=http bzw. fehlendes
|
||||
// X-Forwarded-Proto). Dann würde ein hart auf `__Secure-`/secure gesetztes Cookie vom Browser
|
||||
// über http verworfen → die Plattform-Session bliebe leer (jede Aktion → Login-Maske, Logout
|
||||
// wirft), während die Mandanten-Session (secure folgt dem Protokoll) weiter funktioniert.
|
||||
const PLATFORM_SECURE_COOKIES = (process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? "").startsWith("https:");
|
||||
const securePrefix = PLATFORM_SECURE_COOKIES ? "__Secure-" : "";
|
||||
const hostPrefix = PLATFORM_SECURE_COOKIES ? "__Host-" : "";
|
||||
|
||||
export const {
|
||||
handlers: platformHandlers,
|
||||
auth: platformAuth,
|
||||
signIn: platformSignIn,
|
||||
signOut: platformSignOut,
|
||||
} = NextAuth({
|
||||
// F-09: Plattform-Session (Vollzugriff auf den Betrieb) auf 2 Stunden begrenzen,
|
||||
// mit rollierender Erneuerung bei Aktivität (updateAge 30 min).
|
||||
session: { strategy: "jwt", maxAge: 2 * 60 * 60, updateAge: 30 * 60 },
|
||||
basePath: "/api/platform-auth",
|
||||
pages: { signIn: "/platform/login" },
|
||||
// Eigene Cookie-Namen → keine Kollision mit der Mandanten-Session.
|
||||
//
|
||||
// WICHTIG: ALLE von Auth.js genutzten Cookies müssen umbenannt werden, nicht nur
|
||||
// `sessionToken`. Zwei Auth.js-Instanzen auf derselben Domain teilen sich sonst
|
||||
// `authjs.csrf-token` und `authjs.callback-url` (Defaults). Da „Anna Admin" zugleich
|
||||
// Mandanten-User und Plattform-Admin ist, überschreibt die zuletzt aktive Instanz das
|
||||
// gemeinsame CSRF-Cookie → die CSRF-Validierung der Plattform-Instanz schlägt fehl:
|
||||
// `platformSignOut` wirft (globale Error-Boundary „bitte erneut versuchen") und
|
||||
// CSRF-behaftete POSTs/Session-Erneuerungen landen auf der Login-Maske. Getrennte
|
||||
// Cookie-Namen isolieren beide Auth-Domänen vollständig.
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: `${securePrefix}platform-authjs.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: PLATFORM_SECURE_COOKIES,
|
||||
},
|
||||
},
|
||||
csrfToken: {
|
||||
// `__Host-`-Prefix wie im Auth.js-Default (verlangt path="/", secure, keine Domain).
|
||||
name: `${hostPrefix}platform-authjs.csrf-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: PLATFORM_SECURE_COOKIES,
|
||||
},
|
||||
},
|
||||
callbackUrl: {
|
||||
name: `${securePrefix}platform-authjs.callback-url`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: PLATFORM_SECURE_COOKIES,
|
||||
},
|
||||
},
|
||||
},
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: { email: {}, password: {}, token: {} },
|
||||
authorize: async (raw) => {
|
||||
// Fail-Secure: bei fehlendem/zu kurzem AUTH_SECRET hart abbrechen (F-01).
|
||||
assertSecureEnv();
|
||||
|
||||
const parsed = credentialsSchema.safeParse(raw);
|
||||
if (!parsed.success) return null;
|
||||
const { email, password, token } = parsed.data;
|
||||
|
||||
const admin = await prisma.platformAdmin.findUnique({ where: { email: email.toLowerCase() } });
|
||||
if (!admin || admin.status !== "ACTIVE") return null;
|
||||
|
||||
// Konto-Sperre aktiv?
|
||||
if (admin.lockedUntil && admin.lockedUntil > new Date()) {
|
||||
await writePlatformAudit({ actorId: admin.id, action: "denied", entity: "platform_login", entityId: admin.id, after: { reason: "locked" } });
|
||||
return null;
|
||||
}
|
||||
|
||||
const passwordOk = await verifyPassword(admin.passwordHash, password);
|
||||
if (!passwordOk) return registerFailure(admin.id);
|
||||
|
||||
// MFA erforderlich, sobald eingerichtet.
|
||||
// F-17: bei TOTP-Erfolg den akzeptierten Zeitschritt merken (acceptedStep),
|
||||
// um ihn als lastTotpStep zu persistieren (Replay-Schutz).
|
||||
let acceptedStep: number | null = null;
|
||||
if (admin.mfaEnrolledAt && admin.mfaSecret) {
|
||||
const code = (token ?? "").trim();
|
||||
if (!code) return registerFailure(admin.id);
|
||||
|
||||
let mfaOk = false;
|
||||
// Replay-Schutz: bereits verwendeter/älterer Zeitschritt wird abgelehnt (F-17).
|
||||
const totp = verifyTotp(code, admin.mfaSecret, admin.lastTotpStep);
|
||||
if (totp.ok) {
|
||||
mfaOk = true;
|
||||
acceptedStep = totp.step;
|
||||
} else {
|
||||
// Recovery-Code als Fallback (Einmalverbrauch).
|
||||
const codes = Array.isArray(admin.recoveryCodes) ? (admin.recoveryCodes as string[]) : [];
|
||||
const idx = await matchRecovery(code, codes);
|
||||
if (idx >= 0) {
|
||||
mfaOk = true;
|
||||
await prisma.platformAdmin.update({
|
||||
where: { id: admin.id },
|
||||
data: { recoveryCodes: codes.filter((_, i) => i !== idx) },
|
||||
});
|
||||
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "platform_admin", entityId: admin.id, after: { recoveryCodeUsed: true } });
|
||||
}
|
||||
}
|
||||
if (!mfaOk) return registerFailure(admin.id);
|
||||
}
|
||||
|
||||
await prisma.platformAdmin.update({
|
||||
where: { id: admin.id },
|
||||
data: { failedLogins: 0, lockedUntil: null, lastLoginAt: new Date(), ...(acceptedStep != null ? { lastTotpStep: BigInt(acceptedStep) } : {}) },
|
||||
});
|
||||
await writePlatformAudit({ actorId: admin.id, action: "login", entity: "platform_admin", entityId: admin.id });
|
||||
|
||||
return {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
name: admin.name,
|
||||
// Dummy-Mandantenfelder: Plattform-Session trägt bewusst keinen echten Tenant.
|
||||
tenantId: "",
|
||||
tenantSlug: "",
|
||||
roles: [],
|
||||
permissions: [],
|
||||
isPlatformAdmin: true,
|
||||
mfaEnrolled: !!admin.mfaEnrolledAt,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.userId = user.id!;
|
||||
token.mfaEnrolled = user.mfaEnrolled;
|
||||
token.isPlatformAdmin = true;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
session.user.id = token.userId;
|
||||
session.user.isPlatformAdmin = true;
|
||||
session.user.mfaEnrolled = token.mfaEnrolled;
|
||||
// Plattform-Session trägt keinen Mandantenkontext.
|
||||
session.user.tenantId = "";
|
||||
session.user.tenantSlug = "";
|
||||
session.user.roles = [];
|
||||
session.user.permissions = [];
|
||||
// SEC2: `iat` durchreichen fuer die Pruefung gegen sessionsValidAfter.
|
||||
session.user.tokenIssuedAt = typeof token.iat === "number" ? token.iat : undefined;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Guard für den Plattform-Bereich (Server-Komponenten/Actions). */
|
||||
export async function requirePlatformSession() {
|
||||
// Fail-Secure: bei fehlendem/zu kurzem AUTH_SECRET hart abbrechen (F-01).
|
||||
assertSecureEnv();
|
||||
const session = await platformAuth();
|
||||
// F-01 (Defense in Depth): positiv statt existenzbasiert prüfen — ein Auth.js-
|
||||
// Session-Objekt mit Fehlerzustand ("fail open") darf nicht durchgehen. Für die
|
||||
// Plattform-Session ist user.id das einzige Pflichtfeld (kein Mandantenkontext).
|
||||
if (!session || "error" in session || !session.user || !session.user.id) {
|
||||
throw new Error("Nicht als Plattform-Admin angemeldet");
|
||||
}
|
||||
// F-06: Status autoritativ aus der DB — ein gesperrter/deaktivierter Plattform-
|
||||
// Admin darf nicht bis zum Token-Ablauf weiter schreibend zugreifen. Das
|
||||
// Plattform-Layout prüft den Status bereits für Seitenaufrufe; hier für die
|
||||
// Server-Actions (die nicht durch das Layout laufen).
|
||||
const admin = await prisma.platformAdmin.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { status: true, sessionsValidAfter: true },
|
||||
});
|
||||
if (!admin || admin.status !== "ACTIVE") {
|
||||
throw new Error("Plattform-Konto ist nicht aktiv.");
|
||||
}
|
||||
// SEC2: serverseitig entwertete Sessions (Passwort-Reset/-Wechsel, E-Mail-
|
||||
// Aenderung) duerfen nicht bis zum Token-Ablauf weiterlaufen. Die Abfrage oben
|
||||
// laeuft ohnehin — die Pruefung kostet keine zusaetzliche Runde.
|
||||
if (!isTokenStillValid(session.user.tokenIssuedAt, admin.sessionsValidAfter)) {
|
||||
throw new Error("Sitzung ist nicht mehr gueltig. Bitte neu anmelden.");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* SEC4: erfordert einen aktiven Plattform-**Voll**-Admin (role="full"). Read-only-Admins
|
||||
* dürfen nichts verändern. Liefert Session + den handelnden Admin (inkl. MFA-Feldern für Step-up).
|
||||
*/
|
||||
export async function requirePlatformFullAdmin() {
|
||||
const session = await requirePlatformSession();
|
||||
const admin = await prisma.platformAdmin.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { id: true, role: true, mfaEnrolledAt: true, mfaSecret: true, lastTotpStep: true },
|
||||
});
|
||||
if (!admin) throw new Error("Plattform-Konto nicht gefunden.");
|
||||
if (admin.role !== "full") throw new Error("Nur Voll-Administratoren dürfen diese Aktion ausführen.");
|
||||
return { session, admin };
|
||||
}
|
||||
|
||||
/**
|
||||
* SEC4: Step-up-Re-Authentifizierung für kritische Aktionen. Hat der handelnde Admin MFA
|
||||
* eingerichtet, ist ein gültiger aktueller TOTP-Code Pflicht (verifyTotp entschlüsselt das
|
||||
* Secret intern, SEC3-d). Ohne MFA ist kein TOTP-Step-up möglich → wird übersprungen.
|
||||
*/
|
||||
export function assertPlatformStepUp(
|
||||
admin: { mfaEnrolledAt: Date | null; mfaSecret: string | null; lastTotpStep: bigint | null },
|
||||
code: string,
|
||||
): void {
|
||||
if (!admin.mfaEnrolledAt || !admin.mfaSecret) return;
|
||||
if (!verifyTotp(code, admin.mfaSecret, admin.lastTotpStep).ok) {
|
||||
throw new Error("Bitte die Aktion mit einem aktuellen MFA-Code bestätigen (Step-up).");
|
||||
}
|
||||
}
|
||||
|
||||
/** SEC4: Anzahl aktiver Voll-Admins (Selbst-Aussperr-Schutz). */
|
||||
export function countActiveFullAdmins() {
|
||||
return prisma.platformAdmin.count({ where: { status: "ACTIVE", role: "full" } });
|
||||
}
|
||||
Reference in New Issue
Block a user