Files
betreuungs-cockpit/tests/totp-wiederverwendung.test.ts
T
Florian Wilde 609aa5d27d 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
2026-08-24 08:03:33 +02:00

191 lines
6.5 KiB
TypeScript

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<string, unknown>, wert: number | null): boolean {
if ('OR' in bedingung) {
const zweige = bedingung.OR as Record<string, unknown>[]
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<string, unknown>
data: Partial<User>
}) => {
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)
})
})