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,23 @@
|
||||
/**
|
||||
* Adressen der Anwendung nach aussen (#007).
|
||||
*
|
||||
* Massgeblich ist APP_URL, nicht die eingehende Anfrage. Hinter einem Reverse
|
||||
* Proxy traegt die Anfrage den internen Host und oft auch das falsche Schema –
|
||||
* eine daraus gebaute Umleitung schickt den Benutzer dann an eine Adresse, die
|
||||
* er gar nicht erreicht, und eine daraus gebaute OIDC-Rueckruf-Adresse passt
|
||||
* nicht zu der beim Anbieter hinterlegten.
|
||||
*
|
||||
* Die Herkunft der Anfrage dient nur als Notnagel, damit die Anwendung auch ohne
|
||||
* gesetzte APP_URL nicht stehen bleibt.
|
||||
*/
|
||||
export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL {
|
||||
const basis = process.env["APP_URL"]?.trim();
|
||||
if (basis) {
|
||||
try {
|
||||
return new URL(pfad, basis);
|
||||
} catch {
|
||||
// Eine unbrauchbare APP_URL soll die Anmeldung nicht blockieren.
|
||||
}
|
||||
}
|
||||
return new URL(pfad, ersatzHerkunft);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
-3
@@ -5,6 +5,8 @@
|
||||
* die Huelle von Anfang an die echte Struktur, ohne auf Seiten zu verlinken, die
|
||||
* es noch nicht gibt. Die Beschriftungen haengen am Betriebsmodus (#008).
|
||||
*/
|
||||
import type { Recht } from "@/server/auth/rollen";
|
||||
|
||||
export type Betriebsmodus = "INTERN" | "DIENSTLEISTUNG";
|
||||
|
||||
export type NavEintrag = {
|
||||
@@ -15,6 +17,8 @@ export type NavEintrag = {
|
||||
/** Nur in diesem Modus zeigen. Fehlt der Wert, gilt der Eintrag in beiden. */
|
||||
readonly nurModus?: Betriebsmodus;
|
||||
readonly symbol: string;
|
||||
/** Wer dieses Recht nicht hat, sieht den Eintrag gar nicht erst. */
|
||||
readonly recht?: Recht;
|
||||
readonly verfuegbar: boolean;
|
||||
/** Issue, das die Seite liefert – nur fuer den Hinweis, solange sie fehlt. */
|
||||
readonly ausIssue?: number;
|
||||
@@ -41,11 +45,32 @@ export const NAVIGATION: readonly NavEintrag[] = [
|
||||
ausIssue: 8,
|
||||
},
|
||||
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: false, ausIssue: 15 },
|
||||
{ pfad: "/admin", beschriftung: "Admin", symbol: "Settings", verfuegbar: false, ausIssue: 8 },
|
||||
{
|
||||
// Zeigt vorerst direkt auf die Benutzerverwaltung; mit #008 kommen die
|
||||
// Einstellungen dazu und daraus wird ein eigener Bereich.
|
||||
pfad: "/admin/benutzer",
|
||||
beschriftung: "Admin",
|
||||
symbol: "Settings",
|
||||
recht: "verwaltung",
|
||||
verfuegbar: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function navigationFuer(modus: Betriebsmodus): readonly NavEintrag[] {
|
||||
return NAVIGATION.filter((e) => e.nurModus === undefined || e.nurModus === modus).map((e) =>
|
||||
/**
|
||||
* Die sichtbaren Eintraege fuer diesen Modus und diese Rolle.
|
||||
*
|
||||
* Das Ausblenden ist Bequemlichkeit, keine Absicherung – die Rechtepruefung
|
||||
* sitzt in der Service-Schicht. Aber ein Leser soll nicht auf Knoepfe klicken,
|
||||
* die ihm hinterher eine Fehlermeldung zeigen.
|
||||
*/
|
||||
export function navigationFuer(
|
||||
modus: Betriebsmodus,
|
||||
darfEintrag: (recht: Recht) => boolean = () => true,
|
||||
): readonly NavEintrag[] {
|
||||
return NAVIGATION.filter(
|
||||
(e) =>
|
||||
(e.nurModus === undefined || e.nurModus === modus) && (e.recht === undefined || darfEintrag(e.recht)),
|
||||
).map((e) =>
|
||||
modus === "DIENSTLEISTUNG" && e.beschriftungDienstleistung
|
||||
? { ...e, beschriftung: e.beschriftungDienstleistung }
|
||||
: e,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Rolle } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* Beschriftung und Rangfolge der Rollen.
|
||||
*
|
||||
* Liegt bewusst in src/lib und nicht in src/server/auth/rollen.ts: die
|
||||
* Oberflaeche braucht die Beschriftungen auch in Client-Komponenten, und ein
|
||||
* Import aus der Server-Schicht wuerde die Rechtetabelle mit ins Browser-Buendel
|
||||
* ziehen. Wer darf was, bleibt serverseitig.
|
||||
*/
|
||||
export const ROLLEN_TEXT: Record<Rolle, string> = {
|
||||
[Rolle.ADMIN]: "Administrator",
|
||||
[Rolle.REDAKTEUR]: "Redakteur",
|
||||
[Rolle.TECHNIKER]: "Techniker",
|
||||
[Rolle.LESER]: "Leser",
|
||||
};
|
||||
|
||||
/**
|
||||
* Hoeher ist staerker. Die Luecken von zehn lassen Platz fuer Zwischenrollen,
|
||||
* ohne die vorhandenen Werte anfassen zu muessen.
|
||||
*/
|
||||
export const ROLLEN_RANG: Record<Rolle, number> = {
|
||||
[Rolle.ADMIN]: 40,
|
||||
[Rolle.REDAKTEUR]: 30,
|
||||
[Rolle.TECHNIKER]: 20,
|
||||
[Rolle.LESER]: 10,
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user