From 609aa5d27d039cd4a91398946ca49b8e2639bdf5 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 24 Aug 2026 08:03:33 +0200 Subject: [PATCH] Anmeldung haerten: Einmalcodes, Anschluss, Host, Passkey-Vorzimmer Vier Stellen, an denen eine Zusage nicht gehalten wurde. 1. Ein TOTP-Code galt mehrfach. Der Kommentar in totp.ts versprach seit dem Anfang, der Aufrufer speichere das getroffene Zeitfenster - getan hat es niemand. Damit liess sich ein abgelesener Code innerhalb von 90 Sekunden noch einmal einloesen. Jetzt geht jede Pruefung durch zweitenFaktorEinloesen(), und die Bedingung steht im WHERE von updateMany: Zwei gleichzeitige Anmeldungen mit demselben Code treffen nicht beide eine Zeile. 2. Der Anschluss kam aus dem ERSTEN Eintrag von "x-forwarded-for". Das ist der Teil, den der Aufrufer selbst schreibt. Wer bei jeder Anfrage eine andere Zahl hineinschrieb, sah fuer die Anmeldesperre wie tausend Anschluesse aus - die Zaehlung war nicht nur wirkungslos, sondern irrefuehrend. Jetzt von hinten gelesen, und nur wenn VERTRAUE_PROXY gesetzt ist. Ohne Proxy ist der Kopf fremdbestimmt; dann lieber keine Zaehlung nach Anschluss als eine, die eine Kopfzeile aushebelt. Beim ersten Mal steht eine Zeile im Log, damit das Fehlen auffaellt. 3. trustHost stand fest auf true. Ist AUTH_URL gesetzt - der dokumentierte Betrieb setzt es, Passkeys brauchen es -, gibt es keinen Grund, die Adresse aus einer Kopfzeile von aussen abzuleiten. AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung nicht stillschweigend uebergangen wird. 4. Passkeys liessen sich ohne erneute Bestaetigung anlegen und entfernen. Ein Passkey ist ein vollwertiger Zugang, der einen Passwortwechsel ueberlebt - an einem unbeaufsichtigten Rechner waren das zwei Klicks. Jetzt dasselbe Vorzimmer wie beim Vollexport. Dabei musste alsFehler() lernen, die Weiterleitung von Next durchzulassen: Sonst verschwand die Zwischenfrage in "Der Passkey konnte nicht geprueft werden". Refs #38 --- .env.example | 31 ++++- src/auth.ts | 47 +++---- src/lib/auth/anschluss.ts | 92 ++++++++++++++ src/lib/auth/zweiter-faktor.ts | 93 ++++++++++++++ src/lib/crypto/totp.ts | 21 ++- src/lib/env.ts | 71 +++++++++++ src/server/actions/anmelden.ts | 6 +- src/server/actions/bestaetigen.ts | 12 +- src/server/actions/passkey.ts | 77 ++++++++--- src/server/actions/zwei-faktor.ts | 14 +- tests/anschluss.test.ts | 136 ++++++++++++++++++++ tests/totp-wiederverwendung.test.ts | 190 ++++++++++++++++++++++++++++ 12 files changed, 726 insertions(+), 64 deletions(-) create mode 100644 src/lib/auth/anschluss.ts create mode 100644 src/lib/auth/zweiter-faktor.ts create mode 100644 tests/anschluss.test.ts create mode 100644 tests/totp-wiederverwendung.test.ts 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) + }) +})