feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
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>
This commit was merged in pull request #21.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { scryptSync } from "node:crypto";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
|
||||
/**
|
||||
* Kurzlebige, signierte Nutzlast fuer Zustaende, die zwischen zwei Anfragen
|
||||
* ueberleben muessen (#007) – heute der OIDC-Anmeldefluss mit `state`, `nonce`,
|
||||
* PKCE-Pruefwert und dem urspruenglich angeforderten Ziel.
|
||||
*
|
||||
* Bewusst getrennt von der Sitzung in src/lib/sitzung.ts: das hier lebt Minuten
|
||||
* statt Stunden, ist einmalig und traegt keine Identitaet. Beide sind ueber
|
||||
* `audience` gegeneinander abgegrenzt, ein Token kann also nicht an der falschen
|
||||
* Stelle durchrutschen.
|
||||
*/
|
||||
const ALGORITHMUS = "HS256";
|
||||
|
||||
const HERAUSGEBER = "laufzettel";
|
||||
|
||||
const SALZ = "laufzettel.kurzzeit.v1";
|
||||
|
||||
const MINDESTLAENGE = 32;
|
||||
|
||||
export class KurzzeitFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "KurzzeitFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Schluessel wird erst beim ersten Aufruf abgeleitet, damit `next build` ohne
|
||||
* gesetztes APP_SECRET laeuft. Die Quelle wird mitgemerkt, damit ein geaendertes
|
||||
* Geheimnis nicht am Zwischenspeicher vorbeilaeuft.
|
||||
*/
|
||||
let abgeleitet: { quelle: string; schluessel: Uint8Array } | undefined;
|
||||
|
||||
function schluesselHolen(): Uint8Array {
|
||||
const quelle = process.env["APP_SECRET"] ?? "";
|
||||
if (quelle.length < MINDESTLAENGE) {
|
||||
throw new KurzzeitFehler(
|
||||
`APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
|
||||
"Erzeugen mit: openssl rand -base64 48",
|
||||
);
|
||||
}
|
||||
if (abgeleitet?.quelle !== quelle) {
|
||||
abgeleitet = { quelle, schluessel: new Uint8Array(scryptSync(quelle, SALZ, 32)) };
|
||||
}
|
||||
return abgeleitet.schluessel;
|
||||
}
|
||||
|
||||
/** Signiert eine Nutzlast fuer den angegebenen Zweck und die angegebene Dauer. */
|
||||
export async function kurzzeitTokenErzeugen(
|
||||
zweck: string,
|
||||
nutzlast: Record<string, string>,
|
||||
gueltigkeitSekunden: number,
|
||||
): Promise<string> {
|
||||
return new SignJWT(nutzlast)
|
||||
.setProtectedHeader({ alg: ALGORITHMUS })
|
||||
.setIssuedAt()
|
||||
.setIssuer(HERAUSGEBER)
|
||||
.setAudience(zweck)
|
||||
.setExpirationTime(`${gueltigkeitSekunden}s`)
|
||||
.sign(schluesselHolen());
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest eine Nutzlast zurueck. Gibt `undefined` zurueck, wenn das Token fehlt,
|
||||
* abgelaufen, verfaelscht oder fuer einen anderen Zweck ausgestellt ist – der
|
||||
* Aufrufer behandelt all das gleich.
|
||||
*/
|
||||
export async function kurzzeitTokenLesen(
|
||||
zweck: string,
|
||||
token: string | undefined,
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
if (!token) return undefined;
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, schluesselHolen(), {
|
||||
algorithms: [ALGORITHMUS],
|
||||
issuer: HERAUSGEBER,
|
||||
audience: zweck,
|
||||
});
|
||||
const werte: Record<string, string> = {};
|
||||
for (const [schluessel, wert] of Object.entries(payload)) {
|
||||
if (typeof wert === "string") werte[schluessel] = wert;
|
||||
}
|
||||
return werte;
|
||||
} catch (fehler) {
|
||||
// Ein Konfigurationsfehler ist etwas anderes als ein ungueltiges Token.
|
||||
if (fehler instanceof KurzzeitFehler) throw fehler;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user