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,104 @@
|
||||
import { cookies } from "next/headers";
|
||||
import type { Benutzer } from "@/generated/prisma/client";
|
||||
import {
|
||||
SITZUNG_COOKIE,
|
||||
SITZUNG_LAUFZEIT_STUNDEN,
|
||||
sitzungErzeugen,
|
||||
type SitzungsHerkunft,
|
||||
} from "@/lib/sitzung";
|
||||
|
||||
/**
|
||||
* Setzen und Loeschen des Sitzungscookies (#007).
|
||||
*
|
||||
* Getrennt von src/lib/sitzung.ts, weil dort nichts aus Next stehen soll – so
|
||||
* bleibt das Signieren und Pruefen ohne Framework testbar.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ein Secure-Cookie ueber http kommt nie beim Server an, und die Anmeldung
|
||||
* laeuft dann endlos im Kreis. Massgeblich ist das Schema von APP_URL, nicht das
|
||||
* der eingehenden Anfrage – hinter einem Reverse Proxy ist die intern oft http,
|
||||
* obwohl der Benutzer https spricht.
|
||||
*/
|
||||
let fehlendeAppUrlGemeldet = false;
|
||||
|
||||
export function ueberHttps(): boolean {
|
||||
const basis = process.env["APP_URL"];
|
||||
if (!basis) {
|
||||
// Ohne APP_URL faellt das Secure-Flag weg. In der Entwicklung ueber http ist
|
||||
// das richtig; in Produktion waere es ein Sitzungscookie im Klartext, und das
|
||||
// soll nicht unbemerkt bleiben.
|
||||
if (process.env["NODE_ENV"] === "production" && !fehlendeAppUrlGemeldet) {
|
||||
fehlendeAppUrlGemeldet = true;
|
||||
console.warn(
|
||||
"APP_URL ist nicht gesetzt. Das Sitzungscookie wird ohne Secure-Flag gesetzt. " +
|
||||
"Hinter HTTPS muss APP_URL auf die oeffentliche Adresse zeigen.",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return new URL(basis).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sitzungSetzen(benutzer: Benutzer, herkunft: SitzungsHerkunft): Promise<void> {
|
||||
const token = await sitzungErzeugen({
|
||||
benutzerId: benutzer.id,
|
||||
email: benutzer.email,
|
||||
name: benutzer.name,
|
||||
herkunft,
|
||||
});
|
||||
const laden = await cookies();
|
||||
laden.set(SITZUNG_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: ueberHttps(),
|
||||
path: "/",
|
||||
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 60 * 60,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sitzungBeenden(): Promise<void> {
|
||||
const laden = await cookies();
|
||||
laden.delete(SITZUNG_COOKIE);
|
||||
}
|
||||
|
||||
/** Basis, die es nicht gibt – dient nur dazu, das Ziel aufzuloesen. */
|
||||
const WEGWERF_BASIS = "http://ziel.invalid";
|
||||
|
||||
/**
|
||||
* Nimmt nur Ziele innerhalb der eigenen Anwendung an.
|
||||
*
|
||||
* Ohne diese Pruefung waere `?ziel=https://fremd.example` eine offene
|
||||
* Weiterleitung: ein Angreifer koennte einen Anmeldelink verschicken, der nach
|
||||
* erfolgreicher Anmeldung auf seiner Seite landet.
|
||||
*
|
||||
* Geprueft wird ueber das Aufloesen gegen eine Wegwerf-Basis, nicht ueber
|
||||
* Praefixe. Eine Praefix-Pruefung laesst sich austricksen: `/<TAB>/fremd.example`
|
||||
* faengt mit einem Schraegstrich an und ist kein `//`, aber Browser entfernen
|
||||
* Tabulator, Zeilenumbruch und Wagenruecklauf beim Verarbeiten einer URL – uebrig
|
||||
* bleibt `//fremd.example`, also ein fremder Host. Dasselbe gilt fuer kodierte
|
||||
* Varianten, Backslashes und `..`. Das Aufloesen macht genau das, was der Browser
|
||||
* auch tut, und der Vergleich der Herkunft erschlaegt damit alle Formen auf
|
||||
* einmal.
|
||||
*/
|
||||
export function sicheresZiel(ziel: string | null | undefined): string {
|
||||
if (!ziel || !ziel.startsWith("/")) return "/";
|
||||
try {
|
||||
const aufgeloest = new URL(ziel, WEGWERF_BASIS);
|
||||
if (aufgeloest.origin !== WEGWERF_BASIS) return "/";
|
||||
|
||||
const fertig = aufgeloest.pathname + aufgeloest.search + aufgeloest.hash;
|
||||
// Zweite Runde: aus `/..//fremd.example` macht das Aufloesen `//fremd.example`,
|
||||
// und genau das waere als Location-Kopfzeile wieder ein fremder Host. Ein
|
||||
// Ergebnis, das selbst nicht mehr eindeutig zur eigenen Anwendung gehoert,
|
||||
// wird verworfen.
|
||||
if (!fertig.startsWith("/") || fertig.startsWith("//")) return "/";
|
||||
return fertig;
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hash, verify } from "@node-rs/argon2";
|
||||
import type { Benutzer } from "@/generated/prisma/client";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
kundenkuerzelAusGruppen,
|
||||
rolleAusGruppen,
|
||||
rollenZuordnungAktiv,
|
||||
type OidcAnspruecke,
|
||||
} from "@/server/auth/oidc";
|
||||
|
||||
/**
|
||||
* Anlegen und Pflegen der App-Benutzer (#007).
|
||||
*
|
||||
* Nicht zu verwechseln mit Mitarbeiter: das sind die Personen der Firma aus dem
|
||||
* Verzeichnis. Hier geht es um die Menschen, die Laufzettel bedienen.
|
||||
*/
|
||||
|
||||
export class AnmeldeFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "AnmeldeFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der lokale Login ist Entwicklungshilfe und Notfallzugang. Er ist aus, solange
|
||||
* er nicht ausdruecklich eingeschaltet wird – faellt der Anbieter aus, laesst er
|
||||
* sich einschalten, ohne dass er im Normalbetrieb offensteht.
|
||||
*/
|
||||
export function lokalerLoginAktiv(): boolean {
|
||||
return process.env["AUTH_LOCAL_ENABLED"]?.trim().toLowerCase() === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft E-Mail und Passwort.
|
||||
*
|
||||
* Gibt bei falscher Adresse und falschem Passwort dieselbe Meldung – sonst
|
||||
* verriete die Anwendung, welche Adressen es gibt. Der Hash wird auch bei
|
||||
* unbekannter Adresse geprueft, damit die Antwortzeit nichts verraet.
|
||||
*/
|
||||
export async function lokalAnmelden(email: string, passwort: string): Promise<Benutzer> {
|
||||
if (!lokalerLoginAktiv()) {
|
||||
throw new AnmeldeFehler("Die Anmeldung mit Passwort ist abgeschaltet.");
|
||||
}
|
||||
|
||||
const benutzer = await prisma.benutzer.findUnique({
|
||||
where: { email: email.trim().toLowerCase() },
|
||||
});
|
||||
|
||||
// Auch bei unbekannter Adresse wird geprueft, und zwar gegen einen echten
|
||||
// argon2-Hash: sonst waere die Antwort messbar schneller und verriete, welche
|
||||
// Adressen es gibt.
|
||||
const hashZumPruefen = benutzer?.passwortHash ?? (await platzhalterHash());
|
||||
let stimmt = false;
|
||||
try {
|
||||
stimmt = await verify(hashZumPruefen, passwort);
|
||||
} catch {
|
||||
stimmt = false;
|
||||
}
|
||||
|
||||
if (!benutzer || !benutzer.passwortHash || !stimmt) {
|
||||
throw new AnmeldeFehler("E-Mail-Adresse oder Passwort stimmt nicht.");
|
||||
}
|
||||
if (!benutzer.aktiv) {
|
||||
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
|
||||
}
|
||||
|
||||
await prisma.benutzer.update({
|
||||
where: { id: benutzer.id },
|
||||
data: { letzteAnmeldungAm: new Date() },
|
||||
});
|
||||
return benutzer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Echter argon2-Hash eines Zufallswerts, gegen den bei unbekannter Adresse
|
||||
* geprueft wird.
|
||||
*
|
||||
* Er wird einmal beim ersten Bedarf erzeugt, nicht beim Import – Hashen ist
|
||||
* absichtlich langsam und soll den Start nicht aufhalten. Ein von Hand
|
||||
* hingeschriebener Hash taugt hier nicht: waere er ungueltig, wuerde verify()
|
||||
* sofort werfen und die Pruefung waere wieder messbar schneller.
|
||||
*/
|
||||
let platzhalter: Promise<string> | undefined;
|
||||
|
||||
function platzhalterHash(): Promise<string> {
|
||||
platzhalter ??= hash(randomUUID());
|
||||
return platzhalter;
|
||||
}
|
||||
|
||||
export async function passwortHashen(passwort: string): Promise<string> {
|
||||
return hash(passwort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uebernimmt einen per OIDC angemeldeten Benutzer in die eigene Tabelle.
|
||||
*
|
||||
* Unbekannte Benutzer werden beim ersten Login angelegt (JIT-Provisioning), und
|
||||
* die Rolle wird bei **jeder** Anmeldung neu aus den Gruppen gesetzt. Eine
|
||||
* Aenderung im Verzeichnis greift damit ab der naechsten Anmeldung, ohne dass
|
||||
* jemand hier nachpflegen muss.
|
||||
*/
|
||||
export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Promise<Benutzer> {
|
||||
// Zuerst ueber sub suchen: die Adresse eines Kontos kann sich aendern, die
|
||||
// Kennung beim Anbieter nicht.
|
||||
const nachSub = await prisma.benutzer.findUnique({ where: { oidcSub: anspruecke.sub } });
|
||||
|
||||
// Ueber die Adresse verknuepfen nur, wenn der Anbieter sie bestaetigt hat.
|
||||
// Sonst waere ein Konto mit frei waehlbarer Adresse der Weg, den lokalen
|
||||
// Notfall-Administrator zu uebernehmen.
|
||||
const nachEmail = nachSub
|
||||
? null
|
||||
: anspruecke.emailBestaetigt
|
||||
? await prisma.benutzer.findUnique({ where: { email: anspruecke.email } })
|
||||
: null;
|
||||
|
||||
if (!nachSub && !nachEmail) {
|
||||
const belegt = await prisma.benutzer.findUnique({ where: { email: anspruecke.email } });
|
||||
if (belegt) {
|
||||
throw new AnmeldeFehler(
|
||||
`Es gibt bereits ein Konto mit der Adresse ${anspruecke.email}, und der Anbieter hat sie ` +
|
||||
"nicht bestaetigt. Ein Administrator muss die Konten von Hand zusammenfuehren.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const vorhanden = nachSub ?? nachEmail;
|
||||
|
||||
// Ein deaktiviertes Konto wird abgewiesen, bevor irgendetwas geschrieben wird.
|
||||
if (vorhanden && !vorhanden.aktiv) {
|
||||
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
|
||||
}
|
||||
|
||||
// Ohne eingerichtete Gruppen-Zuordnung bleibt die Rolle, wie sie ist – sonst
|
||||
// waere jede Anmeldung eine stille Herabstufung auf LESER.
|
||||
const rolle = rollenZuordnungAktiv() ? rolleAusGruppen(anspruecke.gruppen) : (vorhanden?.rolle ?? "LESER");
|
||||
|
||||
const benutzer = vorhanden
|
||||
? await prisma.benutzer.update({
|
||||
where: { id: vorhanden.id },
|
||||
data: {
|
||||
oidcSub: anspruecke.sub,
|
||||
email: anspruecke.email,
|
||||
name: anspruecke.name,
|
||||
rolle,
|
||||
letzteAnmeldungAm: new Date(),
|
||||
},
|
||||
})
|
||||
: await prisma.benutzer.create({
|
||||
data: {
|
||||
oidcSub: anspruecke.sub,
|
||||
email: anspruecke.email,
|
||||
name: anspruecke.name,
|
||||
rolle,
|
||||
aktiv: true,
|
||||
letzteAnmeldungAm: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!vorhanden) {
|
||||
await prisma.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Benutzer",
|
||||
entitaetId: benutzer.id,
|
||||
aktion: "beim ersten OIDC-Login angelegt",
|
||||
benutzerId: benutzer.id,
|
||||
nachher: { email: benutzer.email, rolle: benutzer.rolle },
|
||||
},
|
||||
});
|
||||
} else if (vorhanden.rolle !== rolle) {
|
||||
await prisma.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Benutzer",
|
||||
entitaetId: benutzer.id,
|
||||
aktion: "Rolle aus den OIDC-Gruppen geaendert",
|
||||
benutzerId: benutzer.id,
|
||||
vorher: { rolle: vorhanden.rolle },
|
||||
nachher: { rolle },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await kundenbindungSchreiben(benutzer.id, anspruecke.gruppen);
|
||||
return benutzer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die Kundenbindung aus den Gruppen neu.
|
||||
*
|
||||
* Ohne OIDC_CUSTOMER_GROUP_PREFIX passiert nichts – dann gilt keine
|
||||
* Einschraenkung, und eine leere Bindung duerfte nicht faelschlich als
|
||||
* "sieht gar nichts" gelesen werden.
|
||||
*/
|
||||
async function kundenbindungSchreiben(benutzerId: string, gruppen: readonly string[]): Promise<void> {
|
||||
const kuerzel = kundenkuerzelAusGruppen(gruppen);
|
||||
if (kuerzel.length === 0 && !process.env["OIDC_CUSTOMER_GROUP_PREFIX"]?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kunden = await prisma.kunde.findMany({
|
||||
where: { kuerzel: { in: [...kuerzel] } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.benutzerKunde.deleteMany({ where: { benutzerId } }),
|
||||
prisma.benutzerKunde.createMany({
|
||||
data: kunden.map((kunde) => ({ benutzerId, kundeId: kunde.id })),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export type NeuerLokalerBenutzer = {
|
||||
readonly email: string;
|
||||
readonly name: string;
|
||||
readonly rolle: Rolle;
|
||||
readonly passwort: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legt ein lokales Konto an. Nur sinnvoll, solange der lokale Login aktiv ist –
|
||||
* sonst entsteht ein Konto, mit dem sich niemand anmelden kann.
|
||||
*/
|
||||
export async function lokalenBenutzerAnlegen(
|
||||
eingabe: NeuerLokalerBenutzer,
|
||||
durchBenutzerId: string,
|
||||
): Promise<Benutzer> {
|
||||
const email = eingabe.email.trim().toLowerCase();
|
||||
const vorhanden = await prisma.benutzer.findUnique({ where: { email } });
|
||||
if (vorhanden) {
|
||||
throw new AnmeldeFehler(`Es gibt bereits ein Konto mit der Adresse ${email}.`);
|
||||
}
|
||||
|
||||
const benutzer = await prisma.benutzer.create({
|
||||
data: {
|
||||
email,
|
||||
name: eingabe.name.trim(),
|
||||
rolle: eingabe.rolle,
|
||||
passwortHash: await passwortHashen(eingabe.passwort),
|
||||
aktiv: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Benutzer",
|
||||
entitaetId: benutzer.id,
|
||||
aktion: "lokales Konto angelegt",
|
||||
benutzerId: durchBenutzerId,
|
||||
nachher: { email: benutzer.email, rolle: benutzer.rolle },
|
||||
},
|
||||
});
|
||||
return benutzer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Es muss immer mindestens ein aktiver Administrator uebrig bleiben.
|
||||
*
|
||||
* Ohne diese Schranke koennte sich der letzte Administrator selbst zum Leser
|
||||
* machen oder ein vorletzter den letzten deaktivieren – zurueck ginge es dann nur
|
||||
* noch ueber die Datenbank.
|
||||
*/
|
||||
async function letzterAdminSchutz(benutzerId: string, bleibtAdmin: boolean): Promise<void> {
|
||||
if (bleibtAdmin) return;
|
||||
const uebrige = await prisma.benutzer.count({
|
||||
where: { rolle: "ADMIN", aktiv: true, id: { not: benutzerId } },
|
||||
});
|
||||
if (uebrige === 0) {
|
||||
throw new AnmeldeFehler(
|
||||
"Das ist der letzte aktive Administrator. Erst einen weiteren einrichten, dann aendern.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rolleSetzen(benutzerId: string, rolle: Rolle, durchBenutzerId: string): Promise<void> {
|
||||
const vorher = await prisma.benutzer.findUniqueOrThrow({ where: { id: benutzerId } });
|
||||
if (vorher.rolle === rolle) return;
|
||||
if (vorher.rolle === "ADMIN" && vorher.aktiv) {
|
||||
await letzterAdminSchutz(benutzerId, rolle === "ADMIN");
|
||||
}
|
||||
|
||||
await prisma.benutzer.update({ where: { id: benutzerId }, data: { rolle } });
|
||||
await prisma.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Benutzer",
|
||||
entitaetId: benutzerId,
|
||||
aktion: "Rolle geaendert",
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: { rolle: vorher.rolle },
|
||||
nachher: { rolle },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schaltet ein Konto ab oder wieder frei.
|
||||
*
|
||||
* Beim Abschalten werden zugleich alle bestehenden Sitzungen ungueltig – sonst
|
||||
* koennte sich der Betroffene mit einem gueltigen Cookie noch bis zu zwoelf
|
||||
* Stunden weiterbewegen.
|
||||
*/
|
||||
export async function aktivSetzen(
|
||||
benutzerId: string,
|
||||
aktiv: boolean,
|
||||
durchBenutzerId: string,
|
||||
): Promise<void> {
|
||||
const vorher = await prisma.benutzer.findUniqueOrThrow({ where: { id: benutzerId } });
|
||||
if (vorher.aktiv === aktiv) return;
|
||||
if (vorher.rolle === "ADMIN" && vorher.aktiv) {
|
||||
await letzterAdminSchutz(benutzerId, aktiv);
|
||||
}
|
||||
|
||||
await prisma.benutzer.update({
|
||||
where: { id: benutzerId },
|
||||
data: { aktiv, sitzungenUngueltigAb: aktiv ? vorher.sitzungenUngueltigAb : new Date() },
|
||||
});
|
||||
await prisma.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Benutzer",
|
||||
entitaetId: benutzerId,
|
||||
aktion: aktiv ? "Konto freigeschaltet" : "Konto deaktiviert",
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: { aktiv: vorher.aktiv },
|
||||
nachher: { aktiv },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import * as oidc from "openid-client";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
|
||||
import { staerksteRolle } from "@/server/auth/rollen";
|
||||
|
||||
/**
|
||||
* Anmeldung ueber einen generischen OIDC-Anbieter (#007). Ziel ist authentik,
|
||||
* aber es wird nichts Anbieter-Eigenes benutzt: alles kommt aus der Discovery.
|
||||
*
|
||||
* Kein Auth.js – v5 steht dauerhaft auf Beta und das Projekt ist im
|
||||
* Wartungsmodus, siehe docs/architektur.md, Abschnitt 3.
|
||||
*/
|
||||
|
||||
/** Zweck des Kurzzeit-Tokens, das den Fluss ueber die Weiterleitung traegt. */
|
||||
const FLUSS_ZWECK = "oidc-fluss";
|
||||
|
||||
/** Der Fluss ist eine Weiterleitung und zurueck – zehn Minuten sind reichlich. */
|
||||
const FLUSS_GUELTIGKEIT_SEKUNDEN = 600;
|
||||
|
||||
export const OIDC_FLUSS_COOKIE = "lz_oidc";
|
||||
|
||||
export class OidcFehler extends Error {
|
||||
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||
super(nachricht, optionen);
|
||||
this.name = "OidcFehler";
|
||||
}
|
||||
}
|
||||
|
||||
function umgebung(name: string): string | undefined {
|
||||
const wert = process.env[name]?.trim();
|
||||
return wert ? wert : undefined;
|
||||
}
|
||||
|
||||
/** OIDC ist abgeschaltet, solange nicht alle drei Angaben vorliegen. */
|
||||
export function oidcAktiv(): boolean {
|
||||
return Boolean(umgebung("OIDC_ISSUER") && umgebung("OIDC_CLIENT_ID") && umgebung("OIDC_CLIENT_SECRET"));
|
||||
}
|
||||
|
||||
export function oidcName(): string {
|
||||
return umgebung("OIDC_NAME") ?? "Single Sign-on";
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Rueckruf-Adresse wird aus APP_URL abgeleitet und nicht aus der eingehenden
|
||||
* Anfrage. Hinter einem Reverse Proxy ist der Host der Anfrage der interne –
|
||||
* genau dort geht die Anmeldung sonst schief.
|
||||
*/
|
||||
export function rueckrufAdresse(): string {
|
||||
const basis = umgebung("APP_URL");
|
||||
if (!basis) {
|
||||
throw new OidcFehler("APP_URL ist nicht gesetzt. Sie steckt in der OIDC-Rueckruf-Adresse.");
|
||||
}
|
||||
return new URL("/api/auth/oidc/rueckruf", basis).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Discovery wird einmal je Aussteller geholt und behalten. Sie kostet einen
|
||||
* Netzaufruf, den nicht jede Anmeldung bezahlen soll.
|
||||
*/
|
||||
let entdeckt:
|
||||
{ aussteller: string; kundennummer: string; konfiguration: Promise<oidc.Configuration> } | undefined;
|
||||
|
||||
function konfigurationHolen(): Promise<oidc.Configuration> {
|
||||
const aussteller = umgebung("OIDC_ISSUER");
|
||||
const kundennummer = umgebung("OIDC_CLIENT_ID");
|
||||
const geheimnis = umgebung("OIDC_CLIENT_SECRET");
|
||||
if (!aussteller || !kundennummer || !geheimnis) {
|
||||
throw new OidcFehler(
|
||||
"OIDC ist nicht vollstaendig konfiguriert. Erwartet werden OIDC_ISSUER, " +
|
||||
"OIDC_CLIENT_ID und OIDC_CLIENT_SECRET.",
|
||||
);
|
||||
}
|
||||
if (entdeckt?.aussteller !== aussteller || entdeckt.kundennummer !== kundennummer) {
|
||||
entdeckt = {
|
||||
aussteller,
|
||||
kundennummer,
|
||||
konfiguration: oidc.discovery(new URL(aussteller), kundennummer, geheimnis).catch((fehler) => {
|
||||
// Beim naechsten Versuch erneut holen, statt den Fehler ewig zu behalten.
|
||||
entdeckt = undefined;
|
||||
throw new OidcFehler(`Discovery bei ${aussteller} fehlgeschlagen.`, { cause: fehler });
|
||||
}),
|
||||
};
|
||||
}
|
||||
return entdeckt.konfiguration;
|
||||
}
|
||||
|
||||
/** Nur fuer Tests: den gemerkten Discovery-Stand verwerfen. */
|
||||
export function konfigurationVergessen(): void {
|
||||
entdeckt = undefined;
|
||||
}
|
||||
|
||||
export type FlussStart = {
|
||||
/** Adresse, zu der der Browser weitergeleitet wird. */
|
||||
readonly adresse: string;
|
||||
/** Inhalt des Fluss-Cookies, das den Rueckruf wiedererkennbar macht. */
|
||||
readonly flussToken: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Beginnt die Anmeldung. `ziel` ist die Seite, die der Benutzer eigentlich
|
||||
* wollte – nach dem Rueckruf landet er dort und nicht stumpf auf der Uebersicht.
|
||||
* Das ist die Voraussetzung dafuer, dass ein gescannter QR-Code auch dann
|
||||
* funktioniert, wenn noch niemand angemeldet war.
|
||||
*/
|
||||
export async function anmeldungStarten(ziel: string): Promise<FlussStart> {
|
||||
const konfiguration = await konfigurationHolen();
|
||||
|
||||
const pruefwert = oidc.randomPKCECodeVerifier();
|
||||
const herausforderung = await oidc.calculatePKCECodeChallenge(pruefwert);
|
||||
const zustand = oidc.randomState();
|
||||
const einmalwert = oidc.randomNonce();
|
||||
|
||||
const adresse = oidc.buildAuthorizationUrl(konfiguration, {
|
||||
redirect_uri: rueckrufAdresse(),
|
||||
scope: umgebung("OIDC_SCOPE") ?? "openid profile email groups",
|
||||
code_challenge: herausforderung,
|
||||
code_challenge_method: "S256",
|
||||
state: zustand,
|
||||
nonce: einmalwert,
|
||||
});
|
||||
|
||||
const flussToken = await kurzzeitTokenErzeugen(
|
||||
FLUSS_ZWECK,
|
||||
{ pruefwert, zustand, einmalwert, ziel },
|
||||
FLUSS_GUELTIGKEIT_SEKUNDEN,
|
||||
);
|
||||
|
||||
return { adresse: adresse.toString(), flussToken };
|
||||
}
|
||||
|
||||
export type OidcAnspruecke = {
|
||||
readonly sub: string;
|
||||
readonly email: string;
|
||||
/**
|
||||
* Hat der Anbieter die Adresse bestaetigt? Entscheidend beim Verknuepfen mit
|
||||
* einem bestehenden Konto: laesst ein Anbieter selbst gewaehlte, unbestaetigte
|
||||
* Adressen zu, waere das sonst die Uebernahme eines fremden Kontos.
|
||||
*/
|
||||
readonly emailBestaetigt: boolean;
|
||||
readonly name: string;
|
||||
readonly gruppen: readonly string[];
|
||||
/** Ziel, das vor der Anmeldung angefordert wurde. */
|
||||
readonly ziel: string;
|
||||
};
|
||||
|
||||
function textAus(wert: unknown): string | undefined {
|
||||
return typeof wert === "string" && wert.trim() ? wert.trim() : undefined;
|
||||
}
|
||||
|
||||
function gruppenAus(wert: unknown): readonly string[] {
|
||||
if (!Array.isArray(wert)) return [];
|
||||
return wert.filter((eintrag): eintrag is string => typeof eintrag === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Schliesst die Anmeldung ab. Prueft `state` und `nonce` gegen das Fluss-Cookie –
|
||||
* ohne das waere der Rueckruf faelschbar.
|
||||
*/
|
||||
export async function anmeldungAbschliessen(
|
||||
aktuelleAdresse: URL,
|
||||
flussToken: string | undefined,
|
||||
): Promise<OidcAnspruecke> {
|
||||
const fluss = await kurzzeitTokenLesen(FLUSS_ZWECK, flussToken);
|
||||
if (!fluss?.["pruefwert"] || !fluss["zustand"] || !fluss["einmalwert"]) {
|
||||
throw new OidcFehler(
|
||||
"Der Anmeldevorgang ist abgelaufen oder wurde nicht von hier begonnen. Bitte erneut anmelden.",
|
||||
);
|
||||
}
|
||||
|
||||
const konfiguration = await konfigurationHolen();
|
||||
let antwort;
|
||||
try {
|
||||
antwort = await oidc.authorizationCodeGrant(konfiguration, aktuelleAdresse, {
|
||||
pkceCodeVerifier: fluss["pruefwert"],
|
||||
expectedState: fluss["zustand"],
|
||||
expectedNonce: fluss["einmalwert"],
|
||||
});
|
||||
} catch (fehler) {
|
||||
throw new OidcFehler("Der Anbieter hat die Anmeldung abgelehnt.", { cause: fehler });
|
||||
}
|
||||
|
||||
const anspruecke = antwort.claims();
|
||||
if (!anspruecke) {
|
||||
throw new OidcFehler("Der Anbieter hat kein ID-Token geliefert.");
|
||||
}
|
||||
|
||||
const sub = textAus(anspruecke.sub);
|
||||
const email = textAus(anspruecke["email"]);
|
||||
if (!sub) throw new OidcFehler("Das ID-Token enthaelt keine Kennung (sub).");
|
||||
if (!email) {
|
||||
throw new OidcFehler(
|
||||
"Das ID-Token enthaelt keine E-Mail-Adresse. Der Scope 'email' muss beim Anbieter freigegeben sein.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
sub,
|
||||
email: email.toLowerCase(),
|
||||
emailBestaetigt: anspruecke["email_verified"] === true,
|
||||
name: textAus(anspruecke["name"]) ?? textAus(anspruecke["preferred_username"]) ?? email,
|
||||
gruppen: gruppenAus(anspruecke["groups"]),
|
||||
ziel: fluss["ziel"] ?? "/",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet die Gruppen des Anbieters auf eine Rolle ab. Wird bei **jeder** Anmeldung
|
||||
* neu ausgewertet, damit eine Aenderung im Verzeichnis sofort greift.
|
||||
*
|
||||
* Ohne Treffer gibt es LESER – nie einen Fehler und nie ADMIN. Wer in mehreren
|
||||
* Gruppen steht, bekommt die staerkste Rolle.
|
||||
*/
|
||||
/**
|
||||
* Ist ueberhaupt eine Gruppen-Zuordnung eingerichtet?
|
||||
*
|
||||
* Ohne diese Unterscheidung waere jede OIDC-Anmeldung eine stille Herabstufung
|
||||
* auf LESER: `rolleAusGruppen` findet nichts und liefert den Standard. Bei einem
|
||||
* einzigen Administrator koennte danach niemand mehr etwas zuruecksetzen.
|
||||
*/
|
||||
export function rollenZuordnungAktiv(): boolean {
|
||||
return ["OIDC_GROUP_ADMIN", "OIDC_GROUP_EDITOR", "OIDC_GROUP_TECHNICIAN", "OIDC_GROUP_VIEWER"].some(
|
||||
(name) => umgebung(name) !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function rolleAusGruppen(gruppen: readonly string[]): Rolle {
|
||||
const zuordnung: readonly (readonly [string | undefined, Rolle])[] = [
|
||||
[umgebung("OIDC_GROUP_ADMIN"), "ADMIN"],
|
||||
[umgebung("OIDC_GROUP_EDITOR"), "REDAKTEUR"],
|
||||
[umgebung("OIDC_GROUP_TECHNICIAN"), "TECHNIKER"],
|
||||
[umgebung("OIDC_GROUP_VIEWER"), "LESER"],
|
||||
];
|
||||
const vorhanden = new Set(gruppen);
|
||||
const getroffen = zuordnung
|
||||
.filter(([gruppe]) => gruppe !== undefined && vorhanden.has(gruppe))
|
||||
.map(([, rolle]) => rolle);
|
||||
return staerksteRolle(getroffen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kunden, an die der Benutzer gebunden ist, aus Gruppen wie `lz-kunde-MUS`.
|
||||
* Ohne OIDC_CUSTOMER_GROUP_PREFIX gibt es keine Einschraenkung – dann ist die
|
||||
* Liste leer und der Aufrufer bindet niemanden.
|
||||
*/
|
||||
export function kundenkuerzelAusGruppen(gruppen: readonly string[]): readonly string[] {
|
||||
const praefix = umgebung("OIDC_CUSTOMER_GROUP_PREFIX");
|
||||
if (!praefix) return [];
|
||||
return gruppen
|
||||
.filter((gruppe) => gruppe.startsWith(praefix))
|
||||
.map((gruppe) => gruppe.slice(praefix.length).toUpperCase())
|
||||
.filter((kuerzel) => /^[A-Z0-9]{2,6}$/.test(kuerzel));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Rolle } from "@/generated/prisma/enums";
|
||||
import { ROLLEN_RANG, ROLLEN_TEXT } from "@/lib/rollen-text";
|
||||
|
||||
/**
|
||||
* Wer darf was (#007).
|
||||
*
|
||||
* Die Rechte stehen bewusst als eine Tabelle an einer Stelle -- nicht als
|
||||
* `if (rolle === "ADMIN")` verstreut ueber Seiten, Aktionen und Services. Drei Gruende:
|
||||
*
|
||||
* 1. Eine Rechteaenderung hat genau einen Ort. Kommt spaeter eine Rolle hinzu oder darf ein
|
||||
* `REDAKTEUR` etwas mehr, wird eine Zeile hier geaendert und nicht ein Dutzend Abfragen
|
||||
* gesucht, von denen eine uebersehen wird.
|
||||
* 2. Die Tabelle ist pruefbar. `tests/rollen.test.ts` schreibt jede Rolle gegen jedes Recht aus;
|
||||
* eine versehentlich erweiterte Liste faellt sofort auf, statt erst im Betrieb.
|
||||
* 3. Wer eine neue Aktion baut, sucht sich hier das passende Recht und prueft es mit `darf()`.
|
||||
* Ein Recht kann so nie doppelt und unterschiedlich ausgelegt werden.
|
||||
*
|
||||
* Geprueft wird ausschliesslich serverseitig. Die Rolle kommt dabei pro Anfrage aus der
|
||||
* Datenbank, nicht aus dem Sitzungstoken -- ein Rechteentzug wirkt sonst erst nach Ablauf
|
||||
* des Cookies.
|
||||
*/
|
||||
export const RECHTE = {
|
||||
/** Vorlagen, Laeufe, Geraete und Verzeichnis ansehen. Jede Rolle darf das. */
|
||||
lesen: ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"],
|
||||
/** Laeufe starten, abarbeiten, abschliessen und archivieren. */
|
||||
laeufeBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
|
||||
/** Geraete und deren Stammdaten pflegen, Tags registrieren. */
|
||||
geraeteBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
|
||||
/** Vorlagen anlegen, bearbeiten und veroeffentlichen. */
|
||||
vorlagenBearbeiten: ["ADMIN", "REDAKTEUR"],
|
||||
/**
|
||||
* Einen archivierten Lauf zurueckholen. Nur `ADMIN`: das Archiv ist die Zusage, dass ein
|
||||
* abgeschlossener Lauf sich nicht mehr aendert -- diese Zusage darf nur eine Stelle aufheben.
|
||||
*/
|
||||
archivVerwalten: ["ADMIN"],
|
||||
/** App-Benutzer, Verzeichnis, Abteilungen, Einstellungen, Kunden. */
|
||||
verwaltung: ["ADMIN"],
|
||||
} as const satisfies Record<string, readonly Rolle[]>;
|
||||
|
||||
export type Recht = keyof typeof RECHTE;
|
||||
|
||||
export function darf(rolle: Rolle, recht: Recht): boolean {
|
||||
return (RECHTE[recht] as readonly Rolle[]).includes(rolle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die staerkste der uebergebenen Rollen.
|
||||
*
|
||||
* Der OIDC-Login braucht das: ist jemand in mehreren abgebildeten Gruppen, gewinnt die
|
||||
* staerkere Rolle. Eine leere Liste ergibt `LESER` -- nie ein Fehler und nie `ADMIN`. Wer
|
||||
* sich anmelden darf, aber keiner abgebildeten Gruppe angehoert, soll hineinsehen koennen
|
||||
* und nichts aendern; ein Fehler an dieser Stelle wuerde stattdessen den Login blockieren.
|
||||
*/
|
||||
export function staerksteRolle(rollen: readonly Rolle[]): Rolle {
|
||||
let staerkste: Rolle = Rolle.LESER;
|
||||
for (const rolle of rollen) {
|
||||
if (ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) {
|
||||
staerkste = rolle;
|
||||
}
|
||||
}
|
||||
return staerkste;
|
||||
}
|
||||
|
||||
// Beschriftung und Rang liegen in src/lib, damit Client-Komponenten sie ohne
|
||||
// die Rechtetabelle bekommen. Hier weitergereicht fuer serverseitige Aufrufer.
|
||||
export { ROLLEN_RANG, ROLLEN_TEXT };
|
||||
@@ -0,0 +1,114 @@
|
||||
import { cache } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Benutzer } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung";
|
||||
import { darf, type Recht } from "@/server/auth/rollen";
|
||||
|
||||
/**
|
||||
* Wer ist angemeldet und was darf er (#007).
|
||||
*
|
||||
* Rolle und Aktiv-Status kommen bei **jeder** Anfrage aus der Datenbank, nicht
|
||||
* aus dem Cookie. Das kostet eine Abfrage und ist es wert: eine entzogene Rolle
|
||||
* greift sofort, und ein deaktiviertes Konto kommt nicht mit einem gueltigen
|
||||
* Cookie noch zwoelf Stunden weiter.
|
||||
*/
|
||||
|
||||
export class RechteFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "RechteFehler";
|
||||
}
|
||||
}
|
||||
|
||||
export type Urteil =
|
||||
| { readonly gueltig: true }
|
||||
| { readonly gueltig: false; readonly grund: "unbekannt" | "deaktiviert" | "widerrufen" };
|
||||
|
||||
/**
|
||||
* Reine Entscheidung, ob eine Sitzung noch traegt – ohne Datenbank und ohne Next,
|
||||
* damit sie sich einzeln testen laesst.
|
||||
*/
|
||||
export function sitzungBewerten(
|
||||
sitzung: Sitzung,
|
||||
benutzer: Pick<Benutzer, "id" | "aktiv" | "sitzungenUngueltigAb"> | null,
|
||||
): Urteil {
|
||||
if (!benutzer || benutzer.id !== sitzung.benutzerId) {
|
||||
return { gueltig: false, grund: "unbekannt" };
|
||||
}
|
||||
if (!benutzer.aktiv) {
|
||||
return { gueltig: false, grund: "deaktiviert" };
|
||||
}
|
||||
if (benutzer.sitzungenUngueltigAb) {
|
||||
// Der Anspruch iat steht in Sekunden, der Zeitstempel in Millisekunden.
|
||||
const ausgestellt = sitzung.ausgestelltAm * 1000;
|
||||
if (ausgestellt < benutzer.sitzungenUngueltigAb.getTime()) {
|
||||
return { gueltig: false, grund: "widerrufen" };
|
||||
}
|
||||
}
|
||||
return { gueltig: true };
|
||||
}
|
||||
|
||||
export type Angemeldet = {
|
||||
readonly benutzer: Benutzer;
|
||||
readonly sitzung: Sitzung;
|
||||
};
|
||||
|
||||
/**
|
||||
* Der angemeldete Benutzer oder `undefined`. Leitet nicht weiter – dafuer gibt es
|
||||
* `sitzungErforderlich`.
|
||||
*
|
||||
* `cache` sorgt dafuer, dass mehrere Komponenten derselben Anfrage sich eine
|
||||
* Abfrage teilen.
|
||||
*/
|
||||
export const angemeldet = cache(async (): Promise<Angemeldet | undefined> => {
|
||||
const laden = await cookies();
|
||||
const sitzung = await sitzungLesen(laden.get(SITZUNG_COOKIE)?.value);
|
||||
if (!sitzung) return undefined;
|
||||
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
||||
const urteil = sitzungBewerten(sitzung, benutzer);
|
||||
if (!urteil.gueltig || !benutzer) return undefined;
|
||||
|
||||
return { benutzer, sitzung };
|
||||
});
|
||||
|
||||
/**
|
||||
* Verlangt eine Anmeldung und – wenn angegeben – ein bestimmtes Recht.
|
||||
*
|
||||
* Ohne Anmeldung geht es zur Anmeldeseite, mit `ziel` auf die urspruenglich
|
||||
* angeforderte Adresse. Genau daran haengt, dass ein gescannter QR-Code auch
|
||||
* dann funktioniert, wenn noch niemand angemeldet war.
|
||||
*/
|
||||
export async function sitzungErforderlich(recht?: Recht, ziel?: string): Promise<Angemeldet> {
|
||||
const vorhanden = await angemeldet();
|
||||
if (!vorhanden) {
|
||||
redirect(ziel ? `/anmelden?ziel=${encodeURIComponent(ziel)}` : "/anmelden");
|
||||
}
|
||||
if (recht && !darf(vorhanden.benutzer.rolle, recht)) {
|
||||
// Bei einer Seite ist ein geworfener Fehler eine 500er-Seite – fuer jemanden,
|
||||
// der einfach ein altes Lesezeichen geoeffnet hat, die falsche Auskunft.
|
||||
// Deshalb hier eine Weiterleitung mit Erklaerung; in Server Actions bleibt
|
||||
// das Werfen richtig, siehe rechtErforderlich.
|
||||
redirect("/anmelden?fehler=keinrecht");
|
||||
}
|
||||
return vorhanden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dasselbe fuer Server Actions und Route Handler: wirft statt weiterzuleiten,
|
||||
* weil eine Weiterleitung mitten in einer Aktion nichts hilft.
|
||||
*/
|
||||
export async function rechtErforderlich(recht: Recht): Promise<Angemeldet> {
|
||||
const vorhanden = await angemeldet();
|
||||
if (!vorhanden) {
|
||||
throw new RechteFehler("Nicht angemeldet.");
|
||||
}
|
||||
if (!darf(vorhanden.benutzer.rolle, recht)) {
|
||||
throw new RechteFehler(
|
||||
`Diese Aktion verlangt das Recht "${recht}", die Rolle ${vorhanden.benutzer.rolle} hat es nicht.`,
|
||||
);
|
||||
}
|
||||
return vorhanden;
|
||||
}
|
||||
Reference in New Issue
Block a user