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
+16 -31
View File
@@ -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')