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>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user