OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig, Rolle und Aktiv-Status pro Anfrage aus der Datenbank. Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser //fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft verglichen. Dazu acht weitere Befunde, alle behoben. 274 Tests. Closes #7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
145 lines
5.2 KiB
TypeScript
145 lines
5.2 KiB
TypeScript
import { SignJWT, jwtVerify } from "jose";
|
|
|
|
/**
|
|
* Das signierte Sitzungscookie (#007).
|
|
*
|
|
* Absichtlich framework-frei: kein `next/headers`, kein Prisma, kein React. Das Setzen und
|
|
* Loeschen des Cookies gehoert in die Next-Schicht, das Nachladen von Rolle und Aktiv-Status
|
|
* in die Datenbankschicht. Hier steht nur, wie ein Token entsteht und wie es geprueft wird --
|
|
* so laesst sich beides ohne Next und ohne Datenbank testen.
|
|
*
|
|
* Im Token stehen deshalb nur Angaben, die sich nicht hinter dem Ruecken der Anwendung
|
|
* aendern: wer angemeldet ist und woher die Anmeldung kam. Rolle und Aktiv-Status stehen
|
|
* bewusst *nicht* darin, sonst wirkte ein Rechteentzug erst nach zwoelf Stunden.
|
|
*/
|
|
export const SITZUNG_COOKIE = "lz_sitzung";
|
|
|
|
/**
|
|
* Ein Arbeitstag. Lang genug, dass niemand sich mitten in einem Lauf neu anmelden muss,
|
|
* kurz genug, dass ein liegengelassenes Notebook am naechsten Morgen abgemeldet ist.
|
|
*/
|
|
export const SITZUNG_LAUFZEIT_STUNDEN = 12;
|
|
|
|
export type SitzungsHerkunft = "passwort" | "oidc";
|
|
|
|
export type Sitzung = {
|
|
benutzerId: string;
|
|
email: string;
|
|
name: string;
|
|
herkunft: SitzungsHerkunft;
|
|
/** Sekunden seit Epoche, aus dem iat-Anspruch. */
|
|
ausgestelltAm: number;
|
|
};
|
|
|
|
/**
|
|
* `issuer` und `audience` werden beim Lesen geprueft, damit ein mit demselben Geheimnis
|
|
* signiertes Token aus einem anderen Zweck (etwa ein Einladungslink) nicht als Sitzung
|
|
* durchrutscht.
|
|
*/
|
|
const AUSSTELLER = "laufzettel";
|
|
const ZIELGRUPPE = "laufzettel";
|
|
|
|
/** HS256 genuegt: derselbe Dienst signiert und prueft, ein Schluesselpaar braucht es nicht. */
|
|
const ALGORITHMUS = "HS256";
|
|
|
|
const MINDESTLAENGE = 32;
|
|
|
|
const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["passwort", "oidc"];
|
|
|
|
export class SitzungsFehler extends Error {
|
|
constructor(nachricht: string, optionen?: ErrorOptions) {
|
|
super(nachricht, optionen);
|
|
this.name = "SitzungsFehler";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Der Schluessel wird erst beim ersten Aufruf abgeleitet, nicht beim Import: `next build`
|
|
* laeuft ohne gesetztes `APP_SECRET` und wuerde sonst abbrechen. Gemerkt wird die Quelle mit,
|
|
* damit ein geaendertes `APP_SECRET` (in Tests, nach einer Rotation) nicht am Zwischenspeicher
|
|
* vorbeilaeuft -- genau wie in `src/lib/geheimnisse.ts`.
|
|
*/
|
|
let abgeleitet: { quelle: string; schluessel: Uint8Array } | undefined;
|
|
|
|
function schluesselHolen(): Uint8Array {
|
|
const quelle = process.env["APP_SECRET"] ?? "";
|
|
if (quelle.length < MINDESTLAENGE) {
|
|
// Die tatsaechliche Laenge steht bewusst nicht in der Meldung: sie koennte in einem Log
|
|
// landen und verriete etwas ueber das Geheimnis.
|
|
throw new SitzungsFehler(
|
|
`APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
|
|
"Erzeugen mit: openssl rand -base64 48",
|
|
);
|
|
}
|
|
if (abgeleitet?.quelle !== quelle) {
|
|
abgeleitet = { quelle, schluessel: new TextEncoder().encode(quelle) };
|
|
}
|
|
return abgeleitet.schluessel;
|
|
}
|
|
|
|
export async function sitzungErzeugen(daten: Omit<Sitzung, "ausgestelltAm">): Promise<string> {
|
|
const schluessel = schluesselHolen();
|
|
return await new SignJWT({
|
|
email: daten.email,
|
|
name: daten.name,
|
|
herkunft: daten.herkunft,
|
|
})
|
|
.setProtectedHeader({ alg: ALGORITHMUS })
|
|
// Die Benutzerkennung steht im `sub`-Anspruch, dem dafuer vorgesehenen Feld -- so ist das
|
|
// Token auch mit fremdem Werkzeug lesbar.
|
|
.setSubject(daten.benutzerId)
|
|
.setIssuer(AUSSTELLER)
|
|
.setAudience(ZIELGRUPPE)
|
|
.setIssuedAt()
|
|
.setExpirationTime(`${SITZUNG_LAUFZEIT_STUNDEN}h`)
|
|
.sign(schluessel);
|
|
}
|
|
|
|
/** Gibt undefined zurueck, wenn das Token fehlt, abgelaufen, verfaelscht oder fremd ist -- wirft nicht. */
|
|
export async function sitzungLesen(jwt: string | undefined): Promise<Sitzung | undefined> {
|
|
// Absichtlich vor dem `try`: ein zu kurzes `APP_SECRET` ist ein Konfigurationsfehler und
|
|
// darf nicht als "nicht angemeldet" durchgehen, sonst sucht niemand die Ursache.
|
|
const schluessel = schluesselHolen();
|
|
if (jwt === undefined || jwt.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const { payload } = await jwtVerify(jwt, schluessel, {
|
|
// Ohne diese Einschraenkung waere ein Token mit `alg: none` oder einem anderen
|
|
// Verfahren denkbar. `issuer` und `audience` haelt jose selbst gegen.
|
|
algorithms: [ALGORITHMUS],
|
|
issuer: AUSSTELLER,
|
|
audience: ZIELGRUPPE,
|
|
});
|
|
|
|
const { sub, iat, email, name, herkunft } = payload;
|
|
// Signatur und Ablauf sind geprueft, der Inhalt aber noch nicht: ein aelteres oder von
|
|
// Hand gebautes Token kann Felder vermissen. Ein halb gefuelltes Sitzungsobjekt waere
|
|
// schlimmer als keines.
|
|
if (
|
|
typeof sub !== "string" ||
|
|
sub.length === 0 ||
|
|
typeof iat !== "number" ||
|
|
typeof email !== "string" ||
|
|
typeof name !== "string" ||
|
|
typeof herkunft !== "string" ||
|
|
!ERLAUBTE_HERKUNFT.includes(herkunft as SitzungsHerkunft)
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
benutzerId: sub,
|
|
email,
|
|
name,
|
|
herkunft: herkunft as SitzungsHerkunft,
|
|
ausgestelltAm: iat,
|
|
};
|
|
} catch {
|
|
// Fehlende Signatur, falscher Schluessel, Ablauf, fremder Aussteller, kaputtes base64 --
|
|
// fuer den Aufrufer ist das alles dasselbe: nicht angemeldet.
|
|
return undefined;
|
|
}
|
|
}
|