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
This commit is contained in:
Florian Wilde
2026-08-24 08:03:33 +02:00
parent 6bd8f7e8a4
commit 609aa5d27d
12 changed files with 726 additions and 64 deletions
+92
View File
@@ -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=<Anzahl>). 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
}
+93
View File
@@ -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<ZweiterFaktorErgebnis> {
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' }
}
+19 -2
View File
@@ -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<strin
})
}
/**
* Der absolute Zeitschritt, auf den ein Code passt.
*
* validate() gibt nur die Abweichung zum aktuellen Fenster zurueck (delta).
* Zum Festhalten braucht es eine Zahl, die nicht davon abhaengt, wann
* geprueft wurde - sonst waere ein spaeter geprueftes Fenster kleiner als
* ein frueher geprueftes.
*/
export function totpSchritt(delta: number, jetztMs = Date.now()): number {
return Math.floor(jetztMs / 1000 / PERIODE) + delta
}
/**
* Prueft einen eingegebenen Sechsstelligen gegen das Geheimnis.
* Gibt zusaetzlich das getroffene Zeitfenster zurueck - der Aufrufer
* speichert es, um eine Wiederverwendung desselben Codes zu verhindern.
*
* Gibt zusaetzlich das getroffene Zeitfenster zurueck. Wer damit anmeldet,
* muss es ueber zweitenFaktorEinloesen() festhalten - sonst gilt derselbe
* Code bis zu 90 Sekunden lang ein zweites Mal.
*/
export function totpPruefen(
secret: string,
+71
View File
@@ -31,6 +31,18 @@ function zahl(name: string, standard: number): number {
return n
}
/**
* Ein Ja/Nein-Schalter. Bewusst nur "true"/"1"/"ja" als Ja - alles andere,
* auch ein Tippfehler, gilt als Nein. Bei einem Sicherheitsschalter ist das
* die richtige Richtung: Wer sich verschreibt, bekommt die strengere
* Einstellung, nicht die laxere.
*/
function schalter(name: string, standard = false): boolean {
const wert = process.env[name]?.trim().toLowerCase()
if (wert === undefined || wert === '') return standard
return wert === 'true' || wert === '1' || wert === 'ja'
}
function schluessel32(name: string): Buffer {
const roh = pflicht(name)
const buf = Buffer.from(roh, 'base64')
@@ -214,6 +226,65 @@ export const env = {
}
},
/**
* Darf der Host-Kopf der Anfrage die oeffentliche Adresse bestimmen?
*
* Standard: nur dann, wenn AUTH_URL nicht gesetzt ist. Ist die Adresse
* konfiguriert - der dokumentierte Betrieb setzt sie, Passkeys brauchen
* sie ohnehin -, gibt es keinen Grund, sie aus einer Kopfzeile von aussen
* abzuleiten. Vorher stand in auth.ts fest "true", und ein
* "X-Forwarded-Host: fremde.example" haette in erzeugte Adressen
* durchgeschlagen.
*
* AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung
* nicht stillschweigend uebergangen wird.
*/
get vertraueHost() {
return schalter('AUTH_TRUST_HOST', !process.env.AUTH_URL)
},
/**
* Geheimnis fuer die Ersteinrichtung.
*
* Zwischen dem ersten Start und dem Anlegen des Zugangs steht die Seite
* offen: Wer sie in diesem Fenster findet, legt sich selbst einen
* Administratorzugang an. Das Fenster ist kurz, aber es ist da - und bei
* einer Anwendung mit Gesundheits- und Finanzdaten ist "kurz" kein
* Argument.
*
* Ohne diesen Wert ist die Ersteinrichtung gesperrt. Das ist Absicht:
* Eine Installation, bei der das Feld vergessen wurde, soll nicht
* einrichtbar sein, sondern eine Meldung zeigen.
*/
get ersteinrichtungGeheimnis() {
return process.env.ERSTEINRICHTUNG_GEHEIMNIS?.trim() ?? ''
},
/**
* Steht ein Reverse Proxy davor, dem die weitergereichten Koepfe zu
* glauben sind?
*
* Standard ist nein. Das ist die unbequeme, aber richtige Voreinstellung:
* Ohne Proxy ist "x-forwarded-for" frei erfunden, und eine Sperre, die
* darauf zaehlt, laesst sich mit einer Kopfzeile aushebeln. Wer einen
* Proxy davorstellt - der dokumentierte Betrieb tut das -, sagt es hier
* ausdruecklich. Siehe src/lib/auth/anschluss.ts.
*/
get vertraueProxy() {
return schalter('VERTRAUE_PROXY', false)
},
/**
* Wie viele Zwischenknoten vertraut werden. Bei einem Proxy: 1.
*
* Die Zahl sagt, wie weit von hinten in der Kette der Aufrufer steht.
* Zu hoch gesetzt liest man wieder fremdbestimmte Eintraege, deshalb
* bleibt es bei eins, solange nichts anderes dasteht.
*/
get vertraueProxyStufen() {
return Math.max(1, zahl('VERTRAUE_PROXY_STUFEN', 1))
},
get istProduktion() {
return process.env.NODE_ENV === 'production'
},