Next.js 16, Prisma 7, Tailwind 4 und Vitest/Playwright, Konfiguration aus Laufzettel übernommen, weil dort derselbe Stack erprobt ist. Die Farben der Branding-Vorgabe stehen als Tokens in globals.css, das helle Theme setzt dieselben Namen neu. Amber und Rot sind dort abgedunkelt, sonst reicht der Kontrast nicht – ein Test prüft beide Themes gegen 4,5:1. Anmeldung mit lokalem Benutzer, Rolle je Anfrage aus der Datenbank, Audit-Log per Trigger nur anhängbar. Geprüft im gebauten Stand auf 1440 px und 390 px. Closes #001 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
87 lines
2.7 KiB
TypeScript
87 lines
2.7 KiB
TypeScript
import { SignJWT, jwtVerify } from "jose";
|
||
|
||
/**
|
||
* Das signierte Sitzungscookie der Web-GUI.
|
||
*
|
||
* Framework-frei, damit es ohne Next und ohne Datenbank testbar ist. Im Token steht nur, wer
|
||
* angemeldet ist und wie. Rolle und Aktiv-Status stehen bewusst **nicht** darin – sie werden je
|
||
* Anfrage aus der Datenbank gelesen, sonst wirkte ein Rechteentzug erst nach Ablauf der Sitzung.
|
||
*/
|
||
export const SITZUNG_COOKIE = "pd_sitzung";
|
||
|
||
/** Ein Arbeitstag. */
|
||
export const SITZUNG_LAUFZEIT_STUNDEN = 12;
|
||
|
||
export type SitzungsHerkunft = "passwort" | "oidc";
|
||
|
||
export type Sitzung = {
|
||
benutzerId: string;
|
||
herkunft: SitzungsHerkunft;
|
||
ausgestelltAm: number;
|
||
};
|
||
|
||
const AUSSTELLER = "proxdeploy";
|
||
const ZIELGRUPPE = "proxdeploy-web";
|
||
const ALGORITHMUS = "HS256";
|
||
const MINDESTLAENGE = 32;
|
||
|
||
export class SitzungsFehler extends Error {
|
||
constructor(nachricht: string) {
|
||
super(nachricht);
|
||
this.name = "SitzungsFehler";
|
||
}
|
||
}
|
||
|
||
let zwischenspeicher: { quelle: string; schluessel: Uint8Array } | undefined;
|
||
|
||
function schluesselHolen(): Uint8Array {
|
||
const quelle = process.env["SITZUNG_GEHEIMNIS"] ?? "";
|
||
if (quelle.length < MINDESTLAENGE) {
|
||
throw new SitzungsFehler(
|
||
`SITZUNG_GEHEIMNIS fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
|
||
"Erzeugen mit: openssl rand -base64 48",
|
||
);
|
||
}
|
||
if (zwischenspeicher?.quelle !== quelle) {
|
||
zwischenspeicher = { quelle, schluessel: new TextEncoder().encode(quelle) };
|
||
}
|
||
return zwischenspeicher.schluessel;
|
||
}
|
||
|
||
export async function sitzungErzeugen(daten: Omit<Sitzung, "ausgestelltAm">): Promise<string> {
|
||
return await new SignJWT({ herkunft: daten.herkunft })
|
||
.setProtectedHeader({ alg: ALGORITHMUS })
|
||
.setSubject(daten.benutzerId)
|
||
.setIssuer(AUSSTELLER)
|
||
.setAudience(ZIELGRUPPE)
|
||
.setIssuedAt()
|
||
.setExpirationTime(`${SITZUNG_LAUFZEIT_STUNDEN}h`)
|
||
.sign(schluesselHolen());
|
||
}
|
||
|
||
/** Gibt undefined zurück, wenn das Token fehlt, abgelaufen, verfälscht oder fremd ist. */
|
||
export async function sitzungLesen(jwt: string | undefined): Promise<Sitzung | undefined> {
|
||
// Vor dem `try`: ein fehlendes Geheimnis ist ein Konfigurationsfehler, kein „nicht angemeldet“.
|
||
const schluessel = schluesselHolen();
|
||
if (!jwt) return undefined;
|
||
try {
|
||
const { payload } = await jwtVerify(jwt, schluessel, {
|
||
algorithms: [ALGORITHMUS],
|
||
issuer: AUSSTELLER,
|
||
audience: ZIELGRUPPE,
|
||
});
|
||
const { sub, iat, herkunft } = payload;
|
||
if (
|
||
typeof sub !== "string" ||
|
||
sub.length === 0 ||
|
||
typeof iat !== "number" ||
|
||
(herkunft !== "passwort" && herkunft !== "oidc")
|
||
) {
|
||
return undefined;
|
||
}
|
||
return { benutzerId: sub, herkunft, ausgestelltAm: iat };
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|