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>
107 lines
4.0 KiB
TypeScript
107 lines
4.0 KiB
TypeScript
import { prisma } from "@/server/db";
|
|
|
|
/**
|
|
* SEC2 §5 — Session-Invalidierung als wiederverwendbarer Baustein.
|
|
*
|
|
* NextAuth v5 arbeitet hier mit **JWT (stateless)** — es gibt keine
|
|
* Session-Tabelle, die man leeren könnte. Die Invalidierung läuft deshalb über
|
|
* eine Versionsmarke am Konto:
|
|
*
|
|
* `sessionsValidAfter` (Zeitstempel) wird hochgesetzt. Jedes JWT trägt sein
|
|
* Ausstellungsdatum (`iat`); liegt es **vor** der Marke, gilt das Token als
|
|
* ungültig und der Nutzer wird abgemeldet.
|
|
*
|
|
* Geprüft wird an zwei Stellen:
|
|
* - im `jwt`-Callback beider Auth-Instanzen (greift bei jedem Token-Refresh),
|
|
* - in den Layout-Guards, die den Kontostatus ohnehin schon aus der DB lesen.
|
|
*
|
|
* **Sekundengenauigkeit:** `iat` ist ein Unix-Zeitstempel in Sekunden. Der
|
|
* Vergleich schneidet die Marke deshalb auf Sekunden ab und verlangt echtes
|
|
* „älter als" — sonst würde ein im selben Sekundenfenster neu ausgestelltes
|
|
* Token (Fall „aktuelle Session behalten") fälschlich mit-invalidiert.
|
|
*
|
|
* Auslöser: Passwort-Reset, Passwortwechsel, E-Mail-Änderung, Deaktivierung
|
|
* eines Kontos und — ab SEC3 — MFA-Änderungen.
|
|
*/
|
|
|
|
// Option C (WS4): der Mandanten-Kill-Switch lebt an der GLOBALEN Identity — eine
|
|
// Marke entwertet alle Sessions der Person über alle Mandanten hinweg. Plattform-
|
|
// Admins bleiben ein getrennter Store.
|
|
export type Principal = { type: "identity" | "platform_admin"; id: string };
|
|
|
|
/**
|
|
* Entwertet **alle** bestehenden Sessions eines Kontos.
|
|
* Gibt den gesetzten Zeitpunkt zurück, damit der Aufrufer bei Bedarf die
|
|
* aktuelle Session danach neu ausstellen kann.
|
|
*/
|
|
export async function invalidateSessions(principal: Principal): Promise<Date> {
|
|
const at = new Date();
|
|
if (principal.type === "identity") {
|
|
await prisma.identity.update({ where: { id: principal.id }, data: { sessionsValidAfter: at } });
|
|
} else {
|
|
await prisma.platformAdmin.update({
|
|
where: { id: principal.id },
|
|
data: { sessionsValidAfter: at },
|
|
});
|
|
}
|
|
return at;
|
|
}
|
|
|
|
/**
|
|
* Ist ein JWT nach der Invalidierungsmarke ausgestellt worden?
|
|
*
|
|
* @param issuedAtSeconds `iat` des Tokens (Sekunden seit Epoche)
|
|
* @param validAfter Marke am Konto, `null` = nie invalidiert
|
|
*/
|
|
export function isTokenStillValid(
|
|
issuedAtSeconds: number | undefined,
|
|
validAfter: Date | null | undefined,
|
|
): boolean {
|
|
if (!validAfter) return true;
|
|
// Ohne `iat` lässt sich das Alter nicht bestimmen → fail-closed.
|
|
if (typeof issuedAtSeconds !== "number") return false;
|
|
const markSeconds = Math.floor(validAfter.getTime() / 1000);
|
|
return issuedAtSeconds >= markSeconds;
|
|
}
|
|
|
|
/**
|
|
* Liest die Invalidierungsmarke eines Kontos. Getrennte Abfragen, weil die
|
|
* beiden Auth-Domänen unterschiedliche Stores haben.
|
|
*/
|
|
export async function getSessionsValidAfter(principal: Principal): Promise<Date | null> {
|
|
if (principal.type === "identity") {
|
|
const row = await prisma.identity.findUnique({
|
|
where: { id: principal.id },
|
|
select: { sessionsValidAfter: true },
|
|
});
|
|
return row?.sessionsValidAfter ?? null;
|
|
}
|
|
const row = await prisma.platformAdmin.findUnique({
|
|
where: { id: principal.id },
|
|
select: { sessionsValidAfter: true },
|
|
});
|
|
return row?.sessionsValidAfter ?? null;
|
|
}
|
|
|
|
/**
|
|
* „Andere Sitzungen abmelden, aktuelle behalten" (Selbständerung, SEC2 §3).
|
|
*
|
|
* Die Marke wird auf **eine Sekunde vor jetzt** gesetzt. Damit sind alle zuvor
|
|
* ausgestellten Tokens ungültig, während das unmittelbar danach erneuerte JWT
|
|
* der laufenden Sitzung (dessen `iat` in der aktuellen Sekunde liegt) die
|
|
* Prüfung besteht. Ohne diesen Versatz würde sich der Nutzer bei der eigenen
|
|
* Passwortänderung selbst aussperren.
|
|
*/
|
|
export async function invalidateOtherSessions(principal: Principal): Promise<Date> {
|
|
const at = new Date(Date.now() - 1000);
|
|
if (principal.type === "identity") {
|
|
await prisma.identity.update({ where: { id: principal.id }, data: { sessionsValidAfter: at } });
|
|
} else {
|
|
await prisma.platformAdmin.update({
|
|
where: { id: principal.id },
|
|
data: { sessionsValidAfter: at },
|
|
});
|
|
}
|
|
return at;
|
|
}
|