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
+58 -19
View File
@@ -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<T> = { 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<T>(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<PublicKeyCredentialCreationOptionsJSON>
> {
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<PasskeyErgebnis> {
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<PasskeyErgebnis> {
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<PasskeyErgebnis> {
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:<grund>" kommt aus PasskeyAnmeldeFehler, alles andere aus dem