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,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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user