import NextAuth, { CredentialsSignin } from 'next-auth' import Credentials from 'next-auth/providers/credentials' import type { Rolle } from '@prisma/client' import { db } from '@/lib/db' import { env } from '@/lib/env' import { hashVeraltet, passwortHashen, passwortPruefen } from '@/lib/crypto/hash' import { anmeldungErlaubt, aufMindestdauerWarten, fehlversucheZuruecksetzen, sperreVermerken, versuchFesthalten, } from '@/lib/auth/ratelimit' import { protokollieren } from '@/lib/audit' import { zweitenFaktorEinloesen } from '@/lib/auth/zweiter-faktor' import { anschlussAus } from '@/lib/auth/anschluss' import { PasskeyFehler, anmeldungPruefen } from '@/lib/auth/passkey' import type { AuthenticationResponseJSON } from '@simplewebauthn/server' /** * Anmeldung: E-Mail + Passwort + TOTP - alle drei zusammen, in einem Schritt. * * Warum in einem Schritt und nicht zweistufig? Ein zweistufiger Ablauf muesste * zwischen den Schritten einen Zwischenzustand halten ("Passwort war richtig, * warte auf Code"). Genau dieser Zustand ist die typische Schwachstelle. Ein * Formular mit drei Feldern ist am Handy ausserdem schneller ausgefuellt. * * Der zweite Faktor ist Pflicht. Ist er noch nicht eingerichtet, kommt der * Benutzer nur bis zur Einrichtungsseite - erzwungen im App-Layout. */ export type AnmeldeGrund = | 'daten-falsch' | 'gesperrt' | 'konto-inaktiv' | 'code-falsch' /** Passkey-Fehler; der genaue Grund haengt mit Doppelpunkt daran. */ | 'passkey' /** * Auth.js transportiert Fehler aus authorize() nur ueber das Feld "code". * Deshalb erbt der Fehler von CredentialsSignin und kodiert eine etwaige * Restsperrzeit in den Code hinein: "gesperrt:120". */ export class AnmeldeFehler extends CredentialsSignin { code: string constructor(grund: AnmeldeGrund, nochSekunden?: number) { super(grund) this.code = nochSekunden ? `${grund}:${nochSekunden}` : grund } } /** * Wie AnmeldeFehler, nur fuer Passkeys: der Grund wird als "passkey:" * kodiert, damit die Anmeldeseite die passende Erklaerung zeigen kann. */ export class PasskeyAnmeldeFehler extends CredentialsSignin { code: string constructor(grund: string) { super(grund) this.code = `passkey:${grund}` } } function ipAusAnfrage(anfrage: Request | undefined): string | null { if (!anfrage) return null return anschlussAus(anfrage.headers) } export const { handlers, signIn, signOut, auth } = NextAuth({ // Siehe env.vertraueHost: Ist AUTH_URL gesetzt, wird dem Host-Kopf nicht // geglaubt. Vorher stand hier fest "true". trustHost: env.vertraueHost, secret: process.env.AUTH_SECRET, session: { strategy: 'jwt', // Leerlauf bis zur automatischen Abmeldung. maxAge: env.sitzungMaxAlterSekunden, // Bei jeder Anfrage verlaengern, solange gearbeitet wird. updateAge: 60, }, pages: { signIn: '/anmelden', error: '/anmelden', }, providers: [ Credentials({ id: 'zugangsdaten', name: 'E-Mail, Passwort und Code', credentials: { email: { label: 'E-Mail', type: 'email' }, passwort: { label: 'Passwort', type: 'password' }, code: { label: 'Sechsstelliger Code', type: 'text' }, }, async authorize(daten, anfrage) { const begonnen = Date.now() const email = String(daten?.email ?? '').trim().toLowerCase() const passwort = String(daten?.passwort ?? '') const code = String(daten?.code ?? '') const ip = ipAusAnfrage(anfrage) if (!email || !passwort) { await aufMindestdauerWarten(begonnen) throw new AnmeldeFehler('daten-falsch') } // 1. Sperre pruefen, bevor teuer gerechnet wird. const sperre = await anmeldungErlaubt(email, ip) if (sperre.gesperrt) { await sperreVermerken(email, ip) throw new AnmeldeFehler('gesperrt', sperre.nochSekunden) } const benutzer = await db.user.findUnique({ where: { email } }) // 2. Passwort pruefen. Auch bei unbekanntem Benutzer wird gerechnet, // damit die Antwortzeit nichts verraet. const hashZumPruefen = benutzer?.passwortHash ?? '$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' const passwortStimmt = await passwortPruefen(hashZumPruefen, passwort) if (!benutzer || !passwortStimmt) { await versuchFesthalten(email, ip, false, benutzer ? 'passwort-falsch' : 'unbekannt') await protokollieren({ aktion: 'ANMELDUNG_FEHLGESCHLAGEN', entitaet: 'User', entitaetId: benutzer?.id ?? null, userEmail: email, }) await aufMindestdauerWarten(begonnen) throw new AnmeldeFehler('daten-falsch') } if (!benutzer.aktiv || benutzer.geloeschtAm) { await versuchFesthalten(email, ip, false, 'konto-inaktiv') await aufMindestdauerWarten(begonnen) throw new AnmeldeFehler('konto-inaktiv') } // 3. Zweiter Faktor. Ist er noch nicht eingerichtet, kommt der // Benutzer nur bis zur Einrichtungsseite - erzwungen in // erfordereBenutzer(), nicht erst im Layout. if (benutzer.totpSecretVerschluesselt && benutzer.totpAktiviertAm) { const eingeloest = await zweitenFaktorEinloesen(benutzer, code) if (!eingeloest.ok) { await versuchFesthalten(email, ip, false, 'code-falsch') await protokollieren({ aktion: 'ANMELDUNG_FEHLGESCHLAGEN', entitaet: 'User', entitaetId: benutzer.id, userEmail: email, userId: benutzer.id, bezeichnung: eingeloest.grund === 'wiederverwendet' ? 'Zweiter Faktor bereits verbraucht' : 'Zweiter Faktor falsch', }) await aufMindestdauerWarten(begonnen) throw new AnmeldeFehler('code-falsch') } } // 4. Erfolg. if (hashVeraltet(benutzer.passwortHash)) { // Parameter wurden inzwischen erhoeht - Gelegenheit zum Nachziehen. await db.user.update({ where: { id: benutzer.id }, data: { passwortHash: await passwortHashen(passwort) }, }) } await Promise.all([ versuchFesthalten(email, ip, true), fehlversucheZuruecksetzen(email), db.user.update({ where: { id: benutzer.id }, data: { letzteAnmeldung: new Date() }, }), protokollieren({ aktion: 'ANMELDUNG', entitaet: 'User', entitaetId: benutzer.id, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, }), ]) await aufMindestdauerWarten(begonnen) return { id: benutzer.id, email: benutzer.email, name: benutzer.name, rolle: benutzer.rolle, sitzungsGeneration: benutzer.sitzungsGeneration, zweiterFaktorAktiv: Boolean(benutzer.totpAktiviertAm), } }, }), /** * Anmeldung mit Passkey - ein Schritt, ohne E-Mail und ohne Passwort. * * Die vollstaendige Pruefung laeuft hier drin und nicht in der Server * Action davor. Das ist Absicht: Wuerde die Action pruefen und das * Ergebnis anschliessend an signIn() weiterreichen, gaebe es einen * Zwischenzustand, dem authorize() glauben muesste. Genau solche * "war schon geprueft"-Uebergaben sind die Stelle, an der sich * Anmeldungen faelschen lassen. Hier gibt es sie nicht. */ Credentials({ id: 'passkey', name: 'Passkey', credentials: { antwort: { label: 'Antwort des Authenticators', type: 'text' }, }, async authorize(daten, anfrage) { const ip = ipAusAnfrage(anfrage) let antwort: AuthenticationResponseJSON try { antwort = JSON.parse(String(daten?.antwort ?? '')) as AuthenticationResponseJSON } catch { throw new PasskeyAnmeldeFehler('ungueltig') } let geprueft try { geprueft = await anmeldungPruefen(antwort) } catch (fehler) { // Fehlversuche zaehlen mit, auch wenn der Zugang unbekannt ist - // sonst waere der Passkey-Weg ein Schlupfloch an der Sperre vorbei. await versuchFesthalten('(passkey)', ip, false, 'passkey-ungueltig') await protokollieren({ aktion: 'ANMELDUNG_FEHLGESCHLAGEN', entitaet: 'User', userEmail: '(passkey)', bezeichnung: fehler instanceof PasskeyFehler ? `Passkey: ${fehler.grund}` : 'Passkey abgewiesen', }) throw new PasskeyAnmeldeFehler( fehler instanceof PasskeyFehler ? fehler.grund : 'ungueltig', ) } const { benutzer, passkey } = geprueft // Die Sperre gilt fuer den Zugang, nicht fuer das Verfahren. Sie // wird erst hier geprueft, weil vorher niemand weiss, wer sich // anmeldet - erraten laesst sich ein Passkey ohnehin nicht. const sperre = await anmeldungErlaubt(benutzer.email, ip) if (sperre.gesperrt) { await sperreVermerken(benutzer.email, ip) throw new AnmeldeFehler('gesperrt', sperre.nochSekunden) } if (!benutzer.aktiv || benutzer.geloeschtAm) { await versuchFesthalten(benutzer.email, ip, false, 'konto-inaktiv') throw new AnmeldeFehler('konto-inaktiv') } await Promise.all([ versuchFesthalten(benutzer.email, ip, true, 'passkey'), fehlversucheZuruecksetzen(benutzer.email), db.user.update({ where: { id: benutzer.id }, data: { letzteAnmeldung: new Date() }, }), protokollieren({ aktion: 'ANMELDUNG', entitaet: 'User', entitaetId: benutzer.id, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, bezeichnung: `Mit Passkey „${passkey.name}“`, }), ]) return { id: benutzer.id, email: benutzer.email, name: benutzer.name, rolle: benutzer.rolle, sitzungsGeneration: benutzer.sitzungsGeneration, // Ein Passkey mit Nutzerverifikation IST der zweite Faktor. Ohne // eingerichtetes TOTP laesst er sich gar nicht erst anlegen, das // Flag ist hier also immer wahr - es wird trotzdem aus dem // Benutzer gelesen und nicht fest gesetzt. zweiterFaktorAktiv: Boolean(benutzer.totpAktiviertAm), } }, }), ], callbacks: { async jwt({ token, user, trigger, session }) { if (user) { token.rolle = user.rolle token.sitzungsGeneration = user.sitzungsGeneration token.zweiterFaktorAktiv = user.zweiterFaktorAktiv // Zeitpunkt der vollstaendigen Anmeldung - Grundlage fuer die // erneute Bestaetigung vor sensiblen Aktionen. token.angemeldetSeit = Math.floor(Date.now() / 1000) } // Nach dem Einrichten des zweiten Faktors aktualisiert die Seite // die Sitzung, ohne dass man sich neu anmelden muss. if (trigger === 'update' && session) { const aktualisierung = session as Partial<{ zweiterFaktorAktiv: boolean sitzungsGeneration: number }> if (typeof aktualisierung.zweiterFaktorAktiv === 'boolean') { token.zweiterFaktorAktiv = aktualisierung.zweiterFaktorAktiv } if (typeof aktualisierung.sitzungsGeneration === 'number') { token.sitzungsGeneration = aktualisierung.sitzungsGeneration } } return token }, async session({ session, token }) { session.user.id = token.sub ?? '' session.user.rolle = (token.rolle as Rolle) ?? 'BETREUER' session.user.sitzungsGeneration = (token.sitzungsGeneration as number) ?? 0 session.user.zweiterFaktorAktiv = Boolean(token.zweiterFaktorAktiv) session.user.angemeldetSeit = (token.angemeldetSeit as number) ?? 0 return session }, }, events: { async signOut(nachricht) { const userId = 'token' in nachricht ? nachricht.token?.sub : undefined if (userId) { await protokollieren({ aktion: 'ABMELDUNG', entitaet: 'User', entitaetId: userId, userId, }) } }, }, })