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) }) })