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 { 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 | undefined; function platzhalterHash(): Promise { platzhalter ??= hash(randomUUID()); return platzhalter; } export async function passwortHashen(passwort: string): Promise { 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 { // 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 { 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 { 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 { 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 { 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 { 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 }, }, }); }