Files
proxdeploy/src/lib/sitzung.ts
T
florian.wildeandClaude Opus 5.5 871bb3a77c feat: Grundgerüst mit Anmeldung, Layout und Design-Tokens
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>
2026-10-12 09:23:41 +02:00

87 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}