Files
Laufzettel/src/server/auth/benutzer.ts
T
florian.wildeandClaude Opus 5 ecc3c579df
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(mandant): Betriebsmodus, Kundenverwaltung und Einstellungen (#22)
Zwei Betriebsmodi, ein Codepfad. src/server/mandant.ts ist die eine Stelle,
an der die Mandantenregel steht.

Der Review fand drei Blocker: die Lesemodelle wandten die Mandantensicht nicht
an, der Protokolleintrag des Moduswechsels stand außerhalb der Transaktion, und
Prüfung und Vollzug lagen so weit auseinander, dass zwei gleichzeitige Wechsel
beide durchliefen. Alle drei geschlossen, der letzte mit einem Advisory Lock.

336 Tests.

Closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 05:03:11 +00:00

331 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 geändert",
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 geändert",
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 },
},
});
}