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,106 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user