feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

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:
2026-09-25 01:35:02 +00:00
co-authored by Claude Opus 5
parent 7a0f875f55
commit 40e95dbe5f
43 changed files with 3426 additions and 56 deletions
+330
View File
@@ -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 },
},
});
}