diff --git a/.env.example b/.env.example index 6b80b28..18c19f7 100644 --- a/.env.example +++ b/.env.example @@ -15,9 +15,17 @@ DATABASE_URL="postgresql://cockpit:bitte-aendern@localhost:5432/betreuungs_cockp # 32 zufaellige Bytes, base64. Erzeugen: npm run schluessel AUTH_SECRET= # Oeffentliche Basis-URL der Anwendung (ohne abschliessenden Slash). +# Hinter einem Proxy die Adresse, die im Browser in der Adresszeile steht - +# nicht die interne. Passkeys pruefen dagegen. AUTH_URL=http://localhost:3000 -# Hinter einem Reverse Proxy auf true setzen. -AUTH_TRUST_HOST=true + +# Darf der Host-Kopf der Anfrage die Adresse bestimmen? +# +# Solange AUTH_URL gesetzt ist: nein, und die Zeile bleibt weg. Die Adresse +# ist dann bekannt und muss nicht aus einer Kopfzeile abgeleitet werden, die +# von aussen kommt. Ein "X-Forwarded-Host: fremde.example" schlaegt sonst in +# erzeugte Adressen durch. +# AUTH_TRUST_HOST=true # --- Erreichbarkeit: HIER MUSST DU DICH ENTSCHEIDEN ------------------------- # @@ -73,6 +81,25 @@ LOGIN_MAX_VERSUCHE=5 LOGIN_FENSTER_SEKUNDEN=900 LOGIN_SPERRE_SEKUNDEN=900 +# Geheimnis fuer die Ersteinrichtung. OHNE DIESEN WERT LAESST SICH DER ERSTE +# ZUGANG NICHT ANLEGEN - das ist Absicht. +# +# Zwischen dem ersten Start und dem Anlegen des Zugangs stand die +# Einrichtungsseite sonst offen: Wer sie in diesem Fenster findet, wird +# Administrator. Erzeugen: npm run schluessel +ERSTEINRICHTUNG_GEHEIMNIS= + +# Steht ein Reverse Proxy davor? +# +# Nur mit "true" wird "X-Forwarded-For" geglaubt - und dann von HINTEN +# gelesen, weil der vordere Teil der Liste vom Aufrufer selbst kommt. +# Ohne diese Zeile zaehlt die Anmeldesperre nur nach E-Mail-Adresse. +# +# Der dokumentierte Betrieb (Reverse Proxy davor) braucht sie also: +VERTRAUE_PROXY=true +# Bei mehreren gestaffelten Proxys deren Anzahl. Sonst weglassen. +# VERTRAUE_PROXY_STUFEN=1 + # --- Objektspeicher (S3-kompatibel, EU!) ------------------------------------ # Wird ab Etappe 3 verwendet. Beispiel: Hetzner Object Storage, Falkenstein. # ACHTUNG: Mit dem Anbieter muss ein AV-Vertrag nach Art. 28 DSGVO bestehen. diff --git a/src/auth.ts b/src/auth.ts index b3b5a0b..8d72fa5 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,8 +3,7 @@ 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, wiederherstellungscodeFinden } from '@/lib/crypto/hash' -import { totpPruefen, totpSecretEntschluesseln } from '@/lib/crypto/totp' +import { hashVeraltet, passwortHashen, passwortPruefen } from '@/lib/crypto/hash' import { anmeldungErlaubt, aufMindestdauerWarten, @@ -13,6 +12,8 @@ import { 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' @@ -65,12 +66,13 @@ export class PasskeyAnmeldeFehler extends CredentialsSignin { function ipAusAnfrage(anfrage: Request | undefined): string | null { if (!anfrage) return null - const weitergeleitet = anfrage.headers.get('x-forwarded-for')?.split(',')[0]?.trim() - return weitergeleitet || anfrage.headers.get('x-real-ip') || null + return anschlussAus(anfrage.headers) } export const { handlers, signIn, signOut, auth } = NextAuth({ - trustHost: true, + // 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: { @@ -142,33 +144,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ throw new AnmeldeFehler('konto-inaktiv') } - // 3. Zweiter Faktor. Ohne eingerichtetes TOTP kommt der Benutzer - // zwar herein, das App-Layout schickt ihn aber sofort zur - // Einrichtung - er kann nichts anderes tun. + // 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 codeOhneTrenner = code.replace(/[\s-]/g, '') - let zweiterFaktorOk = false + const eingeloest = await zweitenFaktorEinloesen(benutzer, code) - if (/^\d{6}$/.test(codeOhneTrenner)) { - const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt) - zweiterFaktorOk = totpPruefen(secret, benutzer.email, codeOhneTrenner).gueltig - } else if (codeOhneTrenner.length >= 12) { - // Wiederherstellungscode - wird nach Gebrauch entfernt. - const treffer = await wiederherstellungscodeFinden( - benutzer.wiederherstellungscodes, - code, - ) - if (treffer >= 0) { - zweiterFaktorOk = true - const rest = benutzer.wiederherstellungscodes.filter((_, i) => i !== treffer) - await db.user.update({ - where: { id: benutzer.id }, - data: { wiederherstellungscodes: rest }, - }) - } - } - - if (!zweiterFaktorOk) { + if (!eingeloest.ok) { await versuchFesthalten(email, ip, false, 'code-falsch') await protokollieren({ aktion: 'ANMELDUNG_FEHLGESCHLAGEN', @@ -176,7 +158,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ entitaetId: benutzer.id, userEmail: email, userId: benutzer.id, - bezeichnung: 'Zweiter Faktor falsch', + bezeichnung: + eingeloest.grund === 'wiederverwendet' + ? 'Zweiter Faktor bereits verbraucht' + : 'Zweiter Faktor falsch', }) await aufMindestdauerWarten(begonnen) throw new AnmeldeFehler('code-falsch') diff --git a/src/lib/auth/anschluss.ts b/src/lib/auth/anschluss.ts new file mode 100644 index 0000000..3e64a73 --- /dev/null +++ b/src/lib/auth/anschluss.ts @@ -0,0 +1,92 @@ +import 'server-only' +import { env } from '@/lib/env' + +/** + * Von welchem Anschluss kommt diese Anfrage? + * + * Gebraucht wird das nur fuer eines: die zweite Dimension der Anmeldesperre. + * Gezaehlt wird nach E-Mail UND nach Anschluss, damit es weder hilft, + * dieselbe Adresse von vielen Anschluessen zu probieren, noch viele + * Adressen von einem. + * + * ## Warum das vorher nichts geschuetzt hat + * + * Der Wert kam aus "x-forwarded-for", und zwar aus dem ERSTEN Eintrag: + * + * kopf.get('x-forwarded-for')?.split(',')[0]?.trim() + * + * Dieser Kopf ist eine Liste, an die jeder Zwischenknoten hinten anhaengt. + * Der erste Eintrag ist deshalb der, den der Aufrufer selbst geschickt hat - + * frei erfunden. Wer bei jeder Anfrage eine andere Zahl hineinschrieb, sah + * fuer die Sperre wie tausend verschiedene Anschluesse aus. Die Zaehlung + * nach Anschluss war damit nicht nur wirkungslos, sondern irrefuehrend: Im + * Protokoll standen tausend Anschluesse, wo einer war. + * + * Richtig ist das andere Ende: Was der vertraute Proxy anhaengt, hat er + * selbst gesehen und nicht der Aufrufer geschrieben. + * + * ## Und wenn kein Proxy davor steht + * + * Dann ist der Kopf vollstaendig fremdbestimmt und es gibt hier nichts zu + * holen - also null. Keine Zaehlung nach Anschluss ist besser als eine, die + * sich mit einer Kopfzeile aushebeln laesst. Deshalb muss der Betrieb + * ausdruecklich sagen, dass ein Proxy davorsteht (VERTRAUE_PROXY=true). + */ + +let gewarnt = false + +/** + * Warnt einmal, wenn weitergereichte Koepfe ankommen, ihnen aber nicht + * geglaubt wird. Genau dann fehlt die Einstellung im Betrieb. + */ +function einmalWarnen(): void { + if (gewarnt) return + gewarnt = true + console.warn( + '[anschluss] Es kommen "x-forwarded-for"-Koepfe an, aber VERTRAUE_PROXY ist nicht gesetzt. ' + + 'Die Anmeldesperre zaehlt deshalb nur nach E-Mail-Adresse, nicht nach Anschluss. ' + + 'Steht ein Reverse Proxy davor, setze VERTRAUE_PROXY=true (und, bei mehreren, ' + + 'VERTRAUE_PROXY_STUFEN=). Steht keiner davor, ist das hier richtig so.', + ) +} + +/** + * Liest die Adresse des Aufrufers aus den Koepfen. + * + * @param kopf Die Kopfzeilen der Anfrage. + * @returns Die Adresse, oder null, wenn sie sich nicht vertrauenswuerdig + * bestimmen laesst. + */ +export function anschlussAus(kopf: Headers): string | null { + const weitergereicht = kopf.get('x-forwarded-for') + const echterIp = kopf.get('x-real-ip') + + if (!env.vertraueProxy) { + if (weitergereicht || echterIp) einmalWarnen() + return null + } + + if (weitergereicht) { + const kette = weitergereicht + .split(',') + .map((teil) => teil.trim()) + .filter((teil) => teil.length > 0) + + // Von hinten zaehlen: Der letzte Eintrag stammt vom naechsten + // Zwischenknoten, der zweitletzte von dem davor. Bei einer Stufe ist + // der letzte Eintrag der Aufrufer, wie der Proxy ihn gesehen hat. + if (kette.length > 0) { + const stelle = kette.length - env.vertraueProxyStufen + return kette[Math.max(0, stelle)] ?? null + } + } + + // Manche Proxys setzen nur diesen Kopf. Er enthaelt genau eine Adresse, + // also gibt es hier nichts abzuzaehlen. + return echterIp?.trim() || null +} + +/** Nur fuer Tests: setzt die einmalige Warnung zurueck. */ +export function warnungZuruecksetzen(): void { + gewarnt = false +} diff --git a/src/lib/auth/zweiter-faktor.ts b/src/lib/auth/zweiter-faktor.ts new file mode 100644 index 0000000..cd8ed18 --- /dev/null +++ b/src/lib/auth/zweiter-faktor.ts @@ -0,0 +1,93 @@ +import 'server-only' +import type { User } from '@prisma/client' +import { db } from '@/lib/db' +import { wiederherstellungscodeFinden } from '@/lib/crypto/hash' +import { totpPruefen, totpSchritt, totpSecretEntschluesseln } from '@/lib/crypto/totp' + +/** + * Einloesen des zweiten Faktors - einmal und nur einmal. + * + * Ein TOTP-Code gilt 30 Sekunden, mit der Toleranz von einem Fenster bis zu + * 90. Wer ihn in dieser Zeit noch einmal einreicht, kommt ohne die + * Toleranz ein zweites Mal herein: abgelesen ueber die Schulter, + * mitgeschrieben von einem Zwischenglied, oder aus einem Formular, das im + * Browserverlauf steht. Der Kommentar in totp.ts versprach seit dem Anfang, + * der Aufrufer speichere das getroffene Fenster - getan hat es niemand. + * + * Deshalb hier: Der Schritt wird festgehalten, und angenommen wird nur ein + * groesserer. Damit ist jeder Code genau einmal gut. + * + * ## Warum updateMany und nicht update + * + * Die Bedingung steht im WHERE, nicht in einem if davor. Zwei Anfragen mit + * demselben Code, gleichzeitig abgeschickt, wuerden sonst beide den + * Vergleich bestehen, bevor eine von ihnen schreibt. So entscheidet die + * Datenbank: Genau eine trifft eine Zeile, die andere keine. + */ + +export type ZweiterFaktorErgebnis = + | { ok: true; art: 'totp' | 'wiederherstellungscode' } + | { ok: false; grund: 'falsch' | 'wiederverwendet' } + +/** Ab dieser Laenge wird die Eingabe als Wiederherstellungscode gelesen. */ +const CODE_MINDESTLAENGE = 12 + +/** + * Prueft die Eingabe und verbraucht sie. + * + * Nimmt sowohl einen sechsstelligen TOTP-Code als auch einen + * Wiederherstellungscode. Letzterer wird nach Gebrauch aus der Liste + * entfernt - auch das eine Einmalverwendung, nur mit anderem Mechanismus. + * + * @param benutzer Der Datensatz aus der Datenbank, nicht aus der Sitzung. + * @param eingabe Was im Formular stand, ungereinigt. + */ +export async function zweitenFaktorEinloesen( + benutzer: User, + eingabe: string, +): Promise { + const ohneTrenner = eingabe.replace(/[\s-]/g, '') + + if (/^\d{6}$/.test(ohneTrenner)) { + if (!benutzer.totpSecretVerschluesselt) return { ok: false, grund: 'falsch' } + + const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt) + const { gueltig, delta } = totpPruefen(secret, benutzer.email, ohneTrenner) + if (!gueltig || delta === null) return { ok: false, grund: 'falsch' } + + const schritt = totpSchritt(delta) + + const { count } = await db.user.updateMany({ + where: { + id: benutzer.id, + OR: [{ totpLetzterSchritt: null }, { totpLetzterSchritt: { lt: schritt } }], + }, + data: { totpLetzterSchritt: schritt }, + }) + + // Keine Zeile getroffen: Dieser Schritt war schon einmal dran. Der Code + // ist rechnerisch richtig und trotzdem verbraucht. + if (count === 0) return { ok: false, grund: 'wiederverwendet' } + + return { ok: true, art: 'totp' } + } + + if (ohneTrenner.length >= CODE_MINDESTLAENGE) { + const treffer = await wiederherstellungscodeFinden(benutzer.wiederherstellungscodes, eingabe) + if (treffer < 0) return { ok: false, grund: 'falsch' } + + // Auch hier die Bedingung in die Abfrage: Sonst koennten zwei + // gleichzeitige Anmeldungen denselben Code einloesen, weil beide noch + // die alte Liste sehen. + const rest = benutzer.wiederherstellungscodes.filter((_, i) => i !== treffer) + const { count } = await db.user.updateMany({ + where: { id: benutzer.id, wiederherstellungscodes: { equals: benutzer.wiederherstellungscodes } }, + data: { wiederherstellungscodes: rest }, + }) + if (count === 0) return { ok: false, grund: 'wiederverwendet' } + + return { ok: true, art: 'wiederherstellungscode' } + } + + return { ok: false, grund: 'falsch' } +} diff --git a/src/lib/crypto/totp.ts b/src/lib/crypto/totp.ts index 8871769..37e95d2 100644 --- a/src/lib/crypto/totp.ts +++ b/src/lib/crypto/totp.ts @@ -20,6 +20,9 @@ const ZIFFERN = 6 const PERIODE = 30 const TOLERANZ_FENSTER = 1 +/** Laenge eines TOTP-Fensters in Sekunden - fuer den Wiederverwendungsschutz. */ +export const TOTP_PERIODE = PERIODE + function totpBauen(secret: string, kennung: string): TOTP { return new TOTP({ issuer: AUSSTELLER, @@ -54,10 +57,24 @@ export async function totpQrCode(secret: string, kennung: string): Promise { try { - const kopf = await headers() - return kopf.get('x-forwarded-for')?.split(',')[0]?.trim() || kopf.get('x-real-ip') || null + return anschlussAus(await headers()) } catch { return null } diff --git a/src/server/actions/bestaetigen.ts b/src/server/actions/bestaetigen.ts index f3d0f99..6283303 100644 --- a/src/server/actions/bestaetigen.ts +++ b/src/server/actions/bestaetigen.ts @@ -5,7 +5,7 @@ import { z } from 'zod' import { db } from '@/lib/db' import { erfordereBenutzer, reauthBestaetigen } from '@/lib/auth/sitzung' import { passwortPruefen } from '@/lib/crypto/hash' -import { totpPruefen, totpSecretEntschluesseln } from '@/lib/crypto/totp' +import { zweitenFaktorEinloesen } from '@/lib/auth/zweiter-faktor' import { aufMindestdauerWarten } from '@/lib/auth/ratelimit' /** @@ -56,10 +56,14 @@ export async function anmeldungBestaetigen( const passwortStimmt = await passwortPruefen(frisch.passwortHash, gelesen.data.passwort) + // Der Code wird nur eingeloest, wenn das Passwort stimmt. Sonst + // verbrauchte ein falsches Passwort mit richtigem Code das Zeitfenster, + // und der Benutzer muesste dreissig Sekunden auf den naechsten warten - + // ohne zu verstehen, warum. let codeStimmt = true - if (frisch.totpSecretVerschluesselt && frisch.totpAktiviertAm) { - const secret = totpSecretEntschluesseln(frisch.totpSecretVerschluesselt) - codeStimmt = totpPruefen(secret, frisch.email, gelesen.data.code).gueltig + if (passwortStimmt && frisch.totpSecretVerschluesselt && frisch.totpAktiviertAm) { + const eingeloest = await zweitenFaktorEinloesen(frisch, gelesen.data.code) + codeStimmt = eingeloest.ok } if (!passwortStimmt || !codeStimmt) { diff --git a/src/server/actions/passkey.ts b/src/server/actions/passkey.ts index 41f3919..014a84f 100644 --- a/src/server/actions/passkey.ts +++ b/src/server/actions/passkey.ts @@ -20,7 +20,11 @@ import { registrierungPruefen, registrierungVorbereiten, } from '@/lib/auth/passkey' -import { erfordereBenutzer, reauthBestaetigen } from '@/lib/auth/sitzung' +import { + erfordereBenutzer, + erfordereFrischeAnmeldung, + reauthBestaetigen, +} from '@/lib/auth/sitzung' /** * Server Actions rund um Passkeys. @@ -42,6 +46,21 @@ export type PasskeyStart = { ok: true; optionen: T } | { ok: false; fehler: s /** Ergebnis eines abschliessenden Schritts. */ export type PasskeyErgebnis = { ok: true; meldung?: string } | { ok: false; fehler: string } +/** + * Erkennt die Weiterleitung, die Next.js intern als Ausnahme wirft. + * Wie in anmelden.ts ueber das digest-Feld statt ueber einen Import aus + * next/dist - interne Pfade aendern sich zwischen Nebenversionen. + */ +function istWeiterleitung(fehler: unknown): boolean { + return ( + typeof fehler === 'object' && + fehler !== null && + 'digest' in fehler && + typeof (fehler as { digest?: unknown }).digest === 'string' && + (fehler as { digest: string }).digest.startsWith('NEXT_REDIRECT') + ) +} + function alsFehler(fehler: unknown): { ok: false; fehler: string } { if (fehler instanceof PasskeyFehler) { return { ok: false, fehler: PASSKEY_MELDUNG[fehler.grund] } @@ -50,6 +69,18 @@ function alsFehler(fehler: unknown): { ok: false; fehler: string } { return { ok: false, fehler: PASSKEY_MELDUNG.ungueltig } } +/** + * Wie alsFehler, wirft aber die Weiterleitung von Next durch. + * + * Ohne das verschwindet der Ruf nach einer erneuten Bestaetigung in der + * allgemeinen Meldung "Der Passkey konnte nicht geprueft werden" - der + * Benutzer sieht einen Fehler, wo eine Zwischenfrage gemeint war. + */ +function alsFehlerOderWeiter(fehler: unknown): { ok: false; fehler: string } { + if (istWeiterleitung(fehler)) throw fehler + return alsFehler(fehler) +} + /** * Parst die Antwort des Browsers. * @@ -74,14 +105,26 @@ function antwortLesen(roh: string): T { // Einrichten (angemeldet, unter "Mein Konto") // --------------------------------------------------------------------------- +/** + * Ein Passkey ist ein vollwertiger Zugang - wer einen anlegt, kommt danach + * ohne Passwort und ohne Code herein. Deshalb dasselbe Vorzimmer wie beim + * Vollexport: Die Anmeldung muss frisch sein. + * + * Das ist genau der Fall, gegen den die erneute Bestaetigung da ist: Eine + * offene Sitzung an einem unbeaufsichtigten Rechner ist in diesem Umfeld + * der wahrscheinlichste Angriffsweg. Wer daran vorbeikaeme, richtete sich + * in zwei Klicks einen dauerhaften eigenen Zugang ein - und der ueberlebte + * auch einen Passwortwechsel. + */ export async function passkeyEinrichtungStarten(): Promise< PasskeyStart > { try { const benutzer = await erfordereBenutzer() + await erfordereFrischeAnmeldung(benutzer, '/konto') return { ok: true, optionen: await registrierungVorbereiten(benutzer) } } catch (fehler) { - return alsFehler(fehler) + return alsFehlerOderWeiter(fehler) } } @@ -97,6 +140,11 @@ export async function passkeyEinrichtungAbschliessen( ): Promise { try { const benutzer = await erfordereBenutzer() + // Auch im zweiten Schritt: Zwischen Optionen holen und Antwort + // einreichen liegt der Griff zum Sensor, und die Frist kann in dieser + // Zeit ablaufen. Ohne die Pruefung waere der erste Schritt nur eine + // Empfehlung. + await erfordereFrischeAnmeldung(benutzer, '/konto') const geprueft = NameSchema.safeParse(name) if (!geprueft.success) { @@ -122,7 +170,7 @@ export async function passkeyEinrichtungAbschliessen( revalidatePath('/konto') return { ok: true, meldung: `„${passkey.name}“ ist eingerichtet.` } } catch (fehler) { - return alsFehler(fehler) + return alsFehlerOderWeiter(fehler) } } @@ -130,9 +178,15 @@ export async function passkeyEinrichtungAbschliessen( // Verwalten // --------------------------------------------------------------------------- +/** + * Entfernen braucht die frische Anmeldung genauso wie das Anlegen: Wer + * einem Betreuer alle Passkeys wegnimmt, sperrt ihn von dem Weg aus, auf + * dem er hereinkommt. + */ export async function passkeyEntfernen(id: string): Promise { try { const benutzer = await erfordereBenutzer() + await erfordereFrischeAnmeldung(benutzer, '/konto') // Zeilenweise Pruefung: Es genuegt nicht, dass die ID existiert - sie // muss zu DIESEM Benutzer gehoeren. deleteMany mit userId im WHERE @@ -163,7 +217,7 @@ export async function passkeyEntfernen(id: string): Promise { revalidatePath('/konto') return { ok: true, meldung: `„${passkey.name}“ wurde entfernt.` } } catch (fehler) { - return alsFehler(fehler) + return alsFehlerOderWeiter(fehler) } } @@ -187,21 +241,6 @@ function sicheresZiel(ziel: string): string { return ziel } -/** - * Erkennt die Weiterleitung, die Next.js intern als Ausnahme wirft. - * Wie in anmelden.ts ueber das digest-Feld statt ueber einen Import aus - * next/dist - interne Pfade aendern sich zwischen Nebenversionen. - */ -function istWeiterleitung(fehler: unknown): boolean { - return ( - typeof fehler === 'object' && - fehler !== null && - 'digest' in fehler && - typeof (fehler as { digest?: unknown }).digest === 'string' && - (fehler as { digest: string }).digest.startsWith('NEXT_REDIRECT') - ) -} - /** * Uebersetzt den Fehlercode aus authorize() in einen lesbaren Satz. * "passkey:" kommt aus PasskeyAnmeldeFehler, alles andere aus dem diff --git a/src/server/actions/zwei-faktor.ts b/src/server/actions/zwei-faktor.ts index 1b4854b..7cf0856 100644 --- a/src/server/actions/zwei-faktor.ts +++ b/src/server/actions/zwei-faktor.ts @@ -11,6 +11,7 @@ import { import { protokollieren } from '@/lib/audit' import { totpPruefen, + totpSchritt, totpQrCode, totpSecretEntschluesseln, totpSecretErzeugen, @@ -100,9 +101,9 @@ export async function zweitenFaktorBestaetigen( } const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt) - const { gueltig } = totpPruefen(secret, benutzer.email, gelesen.data) + const { gueltig, delta } = totpPruefen(secret, benutzer.email, gelesen.data) - if (!gueltig) { + if (!gueltig || delta === null) { return { fehler: 'Der Code stimmt nicht. Prüfe, ob die Uhrzeit deines Geräts automatisch gestellt wird — ' + @@ -115,7 +116,14 @@ export async function zweitenFaktorBestaetigen( await db.user.update({ where: { id: benutzer.id }, - data: { totpAktiviertAm: new Date(), wiederherstellungscodes: hashes }, + data: { + totpAktiviertAm: new Date(), + wiederherstellungscodes: hashes, + // Der Code, mit dem gerade bestaetigt wurde, ist verbraucht. Ohne + // diese Zeile liesse er sich unmittelbar danach fuer die erste + // Anmeldung noch einmal verwenden. + totpLetzterSchritt: totpSchritt(delta), + }, }) await protokollieren({ diff --git a/tests/anschluss.test.ts b/tests/anschluss.test.ts new file mode 100644 index 0000000..41822cb --- /dev/null +++ b/tests/anschluss.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { anschlussAus, warnungZuruecksetzen } from '@/lib/auth/anschluss' + +/** + * Aus welchem Kopf die Adresse des Aufrufers gelesen wird. + * + * Vorher stand dort der ERSTE Eintrag von "x-forwarded-for". Das ist die + * klassische Verwechslung: Der Kopf ist eine Liste, an die jeder + * Zwischenknoten hinten anhaengt - der erste Eintrag ist also der, den der + * Aufrufer selbst mitgeschickt hat. Wer bei jeder Anfrage eine andere Zahl + * hineinschrieb, sah fuer die Anmeldesperre wie tausend Anschluesse aus. + */ + +function koepfe(werte: Record): Headers { + return new Headers(werte) +} + +describe('Anschluss des Aufrufers', () => { + beforeEach(() => { + warnungZuruecksetzen() + delete process.env.VERTRAUE_PROXY + delete process.env.VERTRAUE_PROXY_STUFEN + }) + + afterEach(() => { + delete process.env.VERTRAUE_PROXY + delete process.env.VERTRAUE_PROXY_STUFEN + vi.restoreAllMocks() + }) + + describe('ohne vertrauten Proxy', () => { + it('glaubt dem weitergereichten Kopf nicht', () => { + const warnung = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + expect(anschlussAus(koepfe({ 'x-forwarded-for': '203.0.113.9' }))).toBeNull() + expect(warnung).toHaveBeenCalledOnce() + }) + + it('warnt nur einmal, nicht bei jeder Anfrage', () => { + const warnung = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + for (let i = 0; i < 20; i += 1) { + anschlussAus(koepfe({ 'x-forwarded-for': `203.0.113.${i}` })) + } + + expect(warnung).toHaveBeenCalledOnce() + }) + + it('schweigt, wenn gar kein solcher Kopf ankommt', () => { + const warnung = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + expect(anschlussAus(koepfe({}))).toBeNull() + expect(warnung).not.toHaveBeenCalled() + }) + }) + + describe('mit einem vertrauten Proxy', () => { + beforeEach(() => { + process.env.VERTRAUE_PROXY = 'true' + }) + + it('nimmt den letzten Eintrag, nicht den ersten', () => { + // Links das, was der Aufrufer selbst behauptet hat; rechts das, was + // der Proxy gesehen hat. Nur letzteres ist etwas wert. + const kopf = koepfe({ 'x-forwarded-for': '10.0.0.1, 198.51.100.7' }) + + expect(anschlussAus(kopf)).toBe('198.51.100.7') + }) + + it('laesst sich durch eine erfundene Kette nicht taeuschen', () => { + const kopf = koepfe({ + 'x-forwarded-for': '1.1.1.1, 2.2.2.2, 3.3.3.3, 198.51.100.7', + }) + + expect(anschlussAus(kopf)).toBe('198.51.100.7') + }) + + it('nimmt den einzigen Eintrag, wenn der Aufrufer keinen mitschickte', () => { + expect(anschlussAus(koepfe({ 'x-forwarded-for': '198.51.100.7' }))).toBe('198.51.100.7') + }) + + it('faellt auf x-real-ip zurueck', () => { + expect(anschlussAus(koepfe({ 'x-real-ip': '198.51.100.7' }))).toBe('198.51.100.7') + }) + + it('gibt null, wenn nichts da ist', () => { + expect(anschlussAus(koepfe({}))).toBeNull() + }) + + it('kommt mit Leerraum und leeren Gliedern zurecht', () => { + const kopf = koepfe({ 'x-forwarded-for': ' 10.0.0.1 , , 198.51.100.7 ' }) + + expect(anschlussAus(kopf)).toBe('198.51.100.7') + }) + }) + + describe('mit mehreren Stufen', () => { + it('zaehlt die angegebene Anzahl von hinten ab', () => { + process.env.VERTRAUE_PROXY = 'true' + process.env.VERTRAUE_PROXY_STUFEN = '2' + + // Zwei eigene Zwischenknoten: der aeussere haengt 198.51.100.7 an, + // der innere 10.0.0.5. Der Aufrufer steht eine Stelle weiter links. + const kopf = koepfe({ 'x-forwarded-for': 'erfunden, 198.51.100.7, 10.0.0.5' }) + + expect(anschlussAus(kopf)).toBe('198.51.100.7') + }) + + it('greift nicht ueber den Anfang der Kette hinaus', () => { + process.env.VERTRAUE_PROXY = 'true' + process.env.VERTRAUE_PROXY_STUFEN = '9' + + expect(anschlussAus(koepfe({ 'x-forwarded-for': '198.51.100.7' }))).toBe('198.51.100.7') + }) + }) + + describe('der Schalter selbst', () => { + it('gilt nur bei ausdruecklichem Ja', () => { + for (const wert of ['false', 'nein', '0', 'vielleicht', '']) { + warnungZuruecksetzen() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + process.env.VERTRAUE_PROXY = wert + + expect(anschlussAus(koepfe({ 'x-forwarded-for': '198.51.100.7' }))).toBeNull() + } + }) + + it('nimmt true, 1 und ja', () => { + for (const wert of ['true', '1', 'ja', 'TRUE', ' Ja ']) { + process.env.VERTRAUE_PROXY = wert + + expect(anschlussAus(koepfe({ 'x-forwarded-for': '198.51.100.7' }))).toBe('198.51.100.7') + } + }) + }) +}) diff --git a/tests/totp-wiederverwendung.test.ts b/tests/totp-wiederverwendung.test.ts new file mode 100644 index 0000000..4603069 --- /dev/null +++ b/tests/totp-wiederverwendung.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { User } from '@prisma/client' + +/** + * Ein TOTP-Code gilt genau einmal. + * + * Ein Code ist 30 Sekunden gueltig, mit der Toleranz von einem Fenster bis + * zu 90. In dieser Zeit liess er sich beliebig oft einloesen: abgelesen + * ueber die Schulter, mitgeschrieben von einem Zwischenglied, oder aus einem + * Formular im Browserverlauf. Der Kommentar in totp.ts versprach von Anfang + * an, der Aufrufer speichere das getroffene Fenster - getan hat es niemand. + * + * Geprueft wird gegen eine Attrappe der Datenbank, die das WHERE von + * updateMany nachbildet. Genau darauf beruht die Einmalverwendung: Die + * Bedingung steht in der Abfrage, nicht in einem if davor. + */ + +let benutzer: User +let updateAufrufe = 0 + +function passtSchritt(bedingung: Record, wert: number | null): boolean { + if ('OR' in bedingung) { + const zweige = bedingung.OR as Record[] + return zweige.some((zweig) => passtSchritt(zweig, wert)) + } + if ('totpLetzterSchritt' in bedingung) { + const erwartet = bedingung.totpLetzterSchritt + if (erwartet === null) return wert === null + if (typeof erwartet === 'object' && erwartet !== null && 'lt' in erwartet) { + return wert !== null && wert < (erwartet as { lt: number }).lt + } + } + return true +} + +vi.mock('@/lib/db', () => ({ + db: { + user: { + updateMany: async ({ + where, + data, + }: { + where: Record + data: Partial + }) => { + updateAufrufe += 1 + + if (where.id !== benutzer.id) return { count: 0 } + + // Die Liste der Wiederherstellungscodes muss noch die sein, die der + // Aufrufer gesehen hat - sonst war jemand schneller. + if ('wiederherstellungscodes' in where) { + const erwartet = (where.wiederherstellungscodes as { equals: string[] }).equals + const gleich = + erwartet.length === benutzer.wiederherstellungscodes.length && + erwartet.every((wert, i) => wert === benutzer.wiederherstellungscodes[i]) + if (!gleich) return { count: 0 } + } + + if (('OR' in where || 'totpLetzterSchritt' in where) && !passtSchritt(where, benutzer.totpLetzterSchritt)) { + return { count: 0 } + } + + benutzer = { ...benutzer, ...data } + return { count: 1 } + }, + }, + }, +})) + +const { zweitenFaktorEinloesen } = await import('@/lib/auth/zweiter-faktor') +const { totpAktuellerCode, totpSchritt, totpSecretErzeugen, totpSecretVerschluesseln } = + await import('@/lib/crypto/totp') +const { wiederherstellungscodeHashen, wiederherstellungscodesErzeugen } = await import( + '@/lib/crypto/hash' +) + +const EMAIL = 'betreuer@example.de' +const SECRET = totpSecretErzeugen() + +function grundstand(): User { + return { + id: 'benutzer-1', + email: EMAIL, + totpSecretVerschluesselt: totpSecretVerschluesseln(SECRET), + totpAktiviertAm: new Date('2026-01-01T00:00:00Z'), + totpLetzterSchritt: null, + wiederherstellungscodes: [], + } as unknown as User +} + +describe('Zweiten Faktor einloesen', () => { + beforeEach(() => { + benutzer = grundstand() + updateAufrufe = 0 + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-24T12:00:15Z')) + }) + + it('nimmt einen gueltigen Code an und haelt das Fenster fest', async () => { + const code = totpAktuellerCode(SECRET, EMAIL) + + const ergebnis = await zweitenFaktorEinloesen(benutzer, code) + + expect(ergebnis).toEqual({ ok: true, art: 'totp' }) + expect(benutzer.totpLetzterSchritt).toBe(totpSchritt(0)) + }) + + it('lehnt denselben Code beim zweiten Mal ab', async () => { + const code = totpAktuellerCode(SECRET, EMAIL) + + expect(await zweitenFaktorEinloesen(benutzer, code)).toEqual({ ok: true, art: 'totp' }) + + // Noch im selben Fenster - der Code waere rechnerisch weiter richtig. + vi.setSystemTime(new Date('2026-08-24T12:00:25Z')) + expect(await zweitenFaktorEinloesen(benutzer, code)).toEqual({ + ok: false, + grund: 'wiederverwendet', + }) + }) + + it('lehnt ihn auch im Toleranzfenster danach ab', async () => { + const code = totpAktuellerCode(SECRET, EMAIL) + await zweitenFaktorEinloesen(benutzer, code) + + // Ein Fenster weiter: Die Toleranz liesse den alten Code noch gelten. + vi.setSystemTime(new Date('2026-08-24T12:00:45Z')) + expect(await zweitenFaktorEinloesen(benutzer, code)).toEqual({ + ok: false, + grund: 'wiederverwendet', + }) + }) + + it('nimmt den naechsten Code an', async () => { + await zweitenFaktorEinloesen(benutzer, totpAktuellerCode(SECRET, EMAIL)) + const vorher = benutzer.totpLetzterSchritt + + vi.setSystemTime(new Date('2026-08-24T12:00:45Z')) + const naechster = totpAktuellerCode(SECRET, EMAIL) + expect(await zweitenFaktorEinloesen(benutzer, naechster)).toEqual({ ok: true, art: 'totp' }) + expect(benutzer.totpLetzterSchritt!).toBeGreaterThan(vorher!) + }) + + it('lehnt einen falschen Code ab, ohne zu schreiben', async () => { + expect(await zweitenFaktorEinloesen(benutzer, '000000')).toEqual({ + ok: false, + grund: 'falsch', + }) + expect(updateAufrufe).toBe(0) + expect(benutzer.totpLetzterSchritt).toBeNull() + }) + + it('toleriert Leerzeichen und Bindestriche in der Eingabe', async () => { + const code = totpAktuellerCode(SECRET, EMAIL) + const zerlegt = `${code.slice(0, 3)} ${code.slice(3)}` + + expect(await zweitenFaktorEinloesen(benutzer, zerlegt)).toEqual({ ok: true, art: 'totp' }) + }) + + it('loest einen Wiederherstellungscode genau einmal ein', async () => { + const codes = wiederherstellungscodesErzeugen(3) + benutzer = { + ...grundstand(), + wiederherstellungscodes: await Promise.all(codes.map(wiederherstellungscodeHashen)), + } as User + + const erster = codes[0]! + expect(await zweitenFaktorEinloesen(benutzer, erster)).toEqual({ + ok: true, + art: 'wiederherstellungscode', + }) + expect(benutzer.wiederherstellungscodes).toHaveLength(2) + + // Derselbe Code noch einmal: Er steht nicht mehr in der Liste. + expect(await zweitenFaktorEinloesen(benutzer, erster)).toEqual({ + ok: false, + grund: 'falsch', + }) + expect(benutzer.wiederherstellungscodes).toHaveLength(2) + }) + + it('haelt die Reihenfolge der Zeitschritte ein', () => { + const frueh = totpSchritt(0, Date.parse('2026-08-24T12:00:00Z')) + const spaet = totpSchritt(0, Date.parse('2026-08-24T12:00:30Z')) + + expect(spaet).toBe(frueh + 1) + // Das Delta verschiebt um ganze Fenster, nicht um Sekunden. + expect(totpSchritt(1, Date.parse('2026-08-24T12:00:00Z'))).toBe(spaet) + }) +})