import type { Benutzer } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { entschluesseln, verschluesseln } from "@/lib/geheimnisse"; import { lokaleKontenAktiv, passwortFehler } from "@/lib/lokale-konten"; import { passwortHashen, passwortPruefen } from "@/lib/passwort-hash"; import { totpPruefen } from "@/lib/totp"; import type { OidcAnspruecke } from "@/server/auth/oidc"; import { rolleAusGruppen } from "@/server/auth/oidc"; import { protokollieren } from "@/server/services/protokoll"; import { versuchZaehlen, zaehlerLoeschen } from "@/server/services/ratenbegrenzung"; /** * Anmeldelogik ohne Next (#004, #035): OIDC-Konto uebernehmen, Passwort-Konten pruefen, TOTP * einrichten, erneute Bestaetigung. Die Next-Schicht setzt danach nur noch das Cookie. */ /** `grund` landet als Code in der Adresse der Anmeldeseite – die Meldung selbst nie. */ export type AnmeldeGrund = "allgemein" | "keine-gruppe" | "konto-konflikt" | "deaktiviert" | "fremdes-konto"; export class AnmeldeFehler extends Error { constructor( nachricht: string, readonly grund: AnmeldeGrund = "allgemein", ) { super(nachricht); this.name = "AnmeldeFehler"; } } /** Fuenf Fehlversuche je Anmeldename in 15 Minuten. */ const KONTO_VERSUCHE = 5; const KONTO_FENSTER_SEKUNDEN = 15 * 60; /** Allgemeine Meldung – verraet nicht, ob der Name, das Passwort oder der Code falsch war. */ const FALSCHE_ANGABEN = "Anmeldung fehlgeschlagen. Bitte Angaben prüfen."; // ---------------------------------------------------------------- OIDC /** * Legt das Konto beim ersten Login an oder aktualisiert es. Die Rolle kommt bei jeder * Anmeldung neu aus den Gruppen – wer in authentik herausfaellt, verliert sofort den Zugang. */ export async function oidcBenutzerUebernehmen(anspruecke: OidcAnspruecke): Promise { const rolle = rolleAusGruppen(anspruecke.gruppen); if (!rolle) { await protokollieren({ akteur: "System", aktion: "anmeldung.verweigert", ergebnis: "VERWEIGERT", details: { email: anspruecke.email, grund: "keine sshkv-Gruppe" }, }); throw new AnmeldeFehler( "Ihr Konto ist keiner SSHKeyVault-Gruppe zugeordnet. Bitte beim Administrator Zugriff beantragen.", "keine-gruppe", ); } let benutzer = await prisma.benutzer.findUnique({ where: { oidcSub: anspruecke.sub } }); if (!benutzer) { const perEmail = await prisma.benutzer.findUnique({ where: { email: anspruecke.email } }); if (perEmail) { // Ein bestehendes Konto nur ueber eine vom Anbieter bestaetigte Adresse verknuepfen – // sonst koennte jemand mit selbst gewaehlter Adresse ein fremdes Konto uebernehmen. if (!anspruecke.emailBestaetigt || perEmail.oidcSub || perEmail.istNotfall) { throw new AnmeldeFehler( "Für diese E-Mail-Adresse besteht bereits ein anderes Konto.", "konto-konflikt", ); } benutzer = perEmail; } } const daten = { oidcSub: anspruecke.sub, email: anspruecke.email, // Fuer die Verknuepfung im Self-Service (#013). emailBestaetigt: anspruecke.emailBestaetigt, name: anspruecke.name, rolle, letzteAnmeldungAm: new Date(), }; const ergebnis = benutzer ? await prisma.benutzer.update({ where: { id: benutzer.id }, data: daten }) : await prisma.benutzer.create({ data: daten }); if (!ergebnis.aktiv) { throw new AnmeldeFehler("Dieses Konto ist deaktiviert.", "deaktiviert"); } await protokollieren({ akteur: { id: ergebnis.id, name: ergebnis.name }, aktion: benutzer ? "anmeldung.oidc" : "anmeldung.oidc.erstmals", ergebnis: "ERFOLG", details: { rolle }, }); return ergebnis; } /** Erneute Bestaetigung per OIDC: nur fuer das Konto, das schon angemeldet ist. */ export async function oidcBestaetigungVermerken( benutzerId: string, anspruecke: OidcAnspruecke, ): Promise { const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } }); if (!benutzer || benutzer.oidcSub !== anspruecke.sub) { throw new AnmeldeFehler( "Die Bestätigung kam von einem anderen Konto als dem angemeldeten.", "fremdes-konto", ); } await prisma.benutzer.update({ where: { id: benutzerId }, data: { bestaetigtAm: new Date() } }); await protokollieren({ akteur: { id: benutzer.id, name: benutzer.name }, aktion: "bestaetigung.oidc", ergebnis: "ERFOLG", }); } // ---------------------------------------------------------------- Lokale Konten /** * Passwort-Konten: der Notfall-Admin (#004) und von Admins angelegte lokale Konten (#035). * Beide melden sich mit Anmeldename, Passwort und Pflicht-TOTP an. */ export type KontoErgebnis = | { readonly art: "angemeldet"; readonly benutzer: Benutzer } | { readonly art: "passwortSetzen"; readonly benutzer: Benutzer } | { readonly art: "totpEinrichten"; readonly benutzer: Benutzer }; /** Darf sich dieses Konto mit Passwort anmelden? OIDC-Konten nie, lokale nur wenn eingeschaltet. */ export function passwortKonto(benutzer: Pick): boolean { return Boolean(benutzer.passwortHash) && !benutzer.oidcSub && (benutzer.istNotfall || lokaleKontenAktiv()); } /** Protokoll-Praefix: der Notfall-Admin bleibt im Audit-Log als solcher erkennbar. */ const praefix = (benutzer: Pick | null) => benutzer?.istNotfall ? "anmeldung.notfall" : "anmeldung.lokal"; /** * Prueft Anmeldename, Passwort und – sobald eingerichtet – den TOTP-Code. * * Ein Einmal-Passwort fuehrt nur zum Setzen eines eigenen, ein Konto ohne TOTP nur zur * Einrichtung – nie direkt in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht). */ export async function kontoAnmelden( anmeldename: string, passwort: string, code: string, jetzt = new Date(), ): Promise { const name = anmeldename.trim().toLowerCase(); const zaehler = `konto:${name}`; const versuch = await versuchZaehlen(zaehler, KONTO_VERSUCHE, KONTO_FENSTER_SEKUNDEN); const benutzer = await prisma.benutzer.findUnique({ where: { anmeldename: name } }); if (!versuch.erlaubt) { await protokollieren({ akteur: "System", aktion: `${praefix(benutzer)}.gesperrt`, ergebnis: "VERWEIGERT", details: { anmeldename: name }, }); throw new AnmeldeFehler( `Zu viele Fehlversuche. Bitte in ${Math.ceil(versuch.wiederInSekunden / 60)} Minuten erneut versuchen.`, ); } const passwortStimmt = benutzer && passwortKonto(benutzer) ? await passwortPruefen(benutzer.passwortHash!, passwort) : false; if (!benutzer || !passwortStimmt || !benutzer.aktiv) { await protokollieren({ akteur: "System", aktion: `${praefix(benutzer)}.fehlgeschlagen`, ergebnis: "VERWEIGERT", details: { anmeldename: name }, }); throw new AnmeldeFehler(FALSCHE_ANGABEN); } if (benutzer.passwortWechselNoetig) return { art: "passwortSetzen", benutzer }; if (!benutzer.totpGeheimnis || !benutzer.totpEingerichtetAm) { return { art: "totpEinrichten", benutzer }; } const schritt = totpPruefen( entschluesseln(Buffer.from(benutzer.totpGeheimnis).toString("utf8")), code, jetzt.getTime(), benutzer.totpLetzterSchritt === null ? null : Number(benutzer.totpLetzterSchritt), ); if (schritt === undefined) { await protokollieren({ akteur: { id: benutzer.id, name: benutzer.name }, aktion: `${praefix(benutzer)}.totp_falsch`, ergebnis: "VERWEIGERT", }); throw new AnmeldeFehler(FALSCHE_ANGABEN); } await zaehlerLoeschen(zaehler); const aktualisiert = await prisma.benutzer.update({ where: { id: benutzer.id }, data: { totpLetzterSchritt: BigInt(schritt), letzteAnmeldungAm: jetzt, bestaetigtAm: jetzt }, }); await protokollieren({ akteur: { id: benutzer.id, name: benutzer.name }, aktion: praefix(benutzer), ergebnis: "ERFOLG", }); return { art: "angemeldet", benutzer: aktualisiert }; } /** * Ersetzt das Einmal-Passwort durch ein eigenes (#035). Nur moeglich, solange der Wechsel * aussteht – das Kurzzeit-Token dafuer stellt allein ein richtiges Einmal-Passwort aus. */ export async function eigenesPasswortSetzen(benutzerId: string, neu: string): Promise { const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } }); if (!benutzer || !passwortKonto(benutzer) || !benutzer.passwortWechselNoetig || !benutzer.aktiv) { throw new AnmeldeFehler("Für dieses Konto steht kein Passwortwechsel aus."); } const fehler = passwortFehler(neu, benutzer.anmeldename); if (fehler) throw new AnmeldeFehler(fehler); if (await passwortPruefen(benutzer.passwortHash!, neu)) { throw new AnmeldeFehler("Bitte ein anderes Passwort als das Einmal-Passwort wählen."); } const aktualisiert = await prisma.benutzer.update({ where: { id: benutzerId }, data: { passwortHash: await passwortHashen(neu), passwortWechselNoetig: false }, }); await protokollieren({ akteur: { id: benutzer.id, name: benutzer.name }, aktion: "konto.passwort_gesetzt", ergebnis: "ERFOLG", }); return aktualisiert; } /** * Schliesst die TOTP-Einrichtung ab. Das Geheimnis kommt aus dem kurzlebigen * Einrichtungs-Token und wird erst gespeichert, wenn ein Code dazu passt – sonst saesse * das Konto mit einem Geheimnis fest, das in keiner App steht. */ export async function totpEinrichtungAbschliessen( benutzerId: string, geheimnisBase32: string, code: string, jetzt = new Date(), ): Promise { const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } }); if ( !benutzer || !passwortKonto(benutzer) || benutzer.totpEingerichtetAm || benutzer.passwortWechselNoetig ) { throw new AnmeldeFehler("TOTP ist für dieses Konto bereits eingerichtet."); } const schritt = totpPruefen(geheimnisBase32, code, jetzt.getTime()); if (schritt === undefined) { throw new AnmeldeFehler( "Der Code passt nicht. Bitte die Uhrzeit des Geräts prüfen und erneut versuchen.", ); } const aktualisiert = await prisma.benutzer.update({ where: { id: benutzerId }, data: { totpGeheimnis: Buffer.from(verschluesseln(geheimnisBase32), "utf8"), totpEingerichtetAm: jetzt, totpLetzterSchritt: BigInt(schritt), letzteAnmeldungAm: jetzt, bestaetigtAm: jetzt, }, }); await protokollieren({ akteur: { id: benutzer.id, name: benutzer.name }, aktion: benutzer.istNotfall ? "notfall.totp_eingerichtet" : "konto.totp_eingerichtet", ergebnis: "ERFOLG", }); return aktualisiert; } /** Erneute Bestaetigung eines Passwort-Kontos: Passwort und TOTP noch einmal. */ export async function kontoBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise { if (!passwortKonto(benutzer) || !benutzer.anmeldename) { throw new AnmeldeFehler("Dieses Konto bestätigt sich über den Identitätsanbieter."); } const ergebnis = await kontoAnmelden(benutzer.anmeldename, passwort, code); if (ergebnis.art !== "angemeldet" || ergebnis.benutzer.id !== benutzer.id) { throw new AnmeldeFehler(FALSCHE_ANGABEN); } }