import type { Benutzer } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { entschluesseln, verschluesseln } from "@/lib/geheimnisse"; import { 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): OIDC-Konto uebernehmen, Notfall-Admin 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 NOTFALL_VERSUCHE = 5; const NOTFALL_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", }); } // ---------------------------------------------------------------- Notfall-Admin export type NotfallErgebnis = | { readonly art: "angemeldet"; readonly benutzer: Benutzer } | { readonly art: "totpEinrichten"; readonly benutzer: Benutzer }; /** * Prueft Anmeldename, Passwort und – sobald eingerichtet – den TOTP-Code. * * Ohne eingerichtetes TOTP fuehrt ein richtiges Passwort nur zur Einrichtung, nie direkt * in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht). */ export async function notfallAnmelden( anmeldename: string, passwort: string, code: string, jetzt = new Date(), ): Promise { const zaehler = `notfall:${anmeldename.toLowerCase()}`; const versuch = await versuchZaehlen(zaehler, NOTFALL_VERSUCHE, NOTFALL_FENSTER_SEKUNDEN); if (!versuch.erlaubt) { await protokollieren({ akteur: "System", aktion: "anmeldung.notfall.gesperrt", ergebnis: "VERWEIGERT", details: { anmeldename }, }); throw new AnmeldeFehler( `Zu viele Fehlversuche. Bitte in ${Math.ceil(versuch.wiederInSekunden / 60)} Minuten erneut versuchen.`, ); } const benutzer = await prisma.benutzer.findUnique({ where: { anmeldename } }); const passwortStimmt = benutzer?.istNotfall && benutzer.passwortHash ? await passwortPruefen(benutzer.passwortHash, passwort) : false; if (!benutzer || !passwortStimmt || !benutzer.aktiv) { await protokollieren({ akteur: "System", aktion: "anmeldung.notfall.fehlgeschlagen", ergebnis: "VERWEIGERT", details: { anmeldename }, }); throw new AnmeldeFehler(FALSCHE_ANGABEN); } 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: "anmeldung.notfall.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: "anmeldung.notfall", ergebnis: "ERFOLG", }); return { art: "angemeldet", benutzer: 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 * der Notfall-Admin 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?.istNotfall || benutzer.totpEingerichtetAm) { 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: "notfall.totp_eingerichtet", ergebnis: "ERFOLG", }); return aktualisiert; } /** Erneute Bestaetigung des Notfall-Admins: Passwort und TOTP noch einmal. */ export async function notfallBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise { if (!benutzer.istNotfall || !benutzer.anmeldename) { throw new AnmeldeFehler("Dieses Konto bestätigt sich über den Identitätsanbieter."); } const ergebnis = await notfallAnmelden(benutzer.anmeldename, passwort, code); if (ergebnis.art !== "angemeldet" || ergebnis.benutzer.id !== benutzer.id) { throw new AnmeldeFehler(FALSCHE_ANGABEN); } }