diff --git a/src/auth.ts b/src/auth.ts index d70aecd..b3b5a0b 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -9,6 +9,7 @@ import { anmeldungErlaubt, aufMindestdauerWarten, fehlversucheZuruecksetzen, + sperreVermerken, versuchFesthalten, } from '@/lib/auth/ratelimit' import { protokollieren } from '@/lib/audit' @@ -110,7 +111,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ // 1. Sperre pruefen, bevor teuer gerechnet wird. const sperre = await anmeldungErlaubt(email, ip) if (sperre.gesperrt) { - await versuchFesthalten(email, ip, false, 'gesperrt') + await sperreVermerken(email, ip) throw new AnmeldeFehler('gesperrt', sperre.nochSekunden) } @@ -274,7 +275,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ // anmeldet - erraten laesst sich ein Passkey ohnehin nicht. const sperre = await anmeldungErlaubt(benutzer.email, ip) if (sperre.gesperrt) { - await versuchFesthalten(benutzer.email, ip, false, 'gesperrt') + await sperreVermerken(benutzer.email, ip) throw new AnmeldeFehler('gesperrt', sperre.nochSekunden) } diff --git a/src/lib/auth/ratelimit.ts b/src/lib/auth/ratelimit.ts index a3723ce..ed34b08 100644 --- a/src/lib/auth/ratelimit.ts +++ b/src/lib/auth/ratelimit.ts @@ -14,8 +14,32 @@ import { ipHashen } from '@/lib/crypto/hash' * Gezaehlt wird nach E-Mail UND nach Anschluss (IP-Hash). Damit hilft es * weder, dieselbe Adresse von vielen Anschluessen zu probieren, noch viele * Adressen von einem Anschluss. + * + * ## Warum abgewiesene Versuche nicht mitzaehlen + * + * Frueher wurde jeder Aufruf festgehalten, auch der, der wegen einer + * laufenden Sperre gar nicht geprueft wurde. Zusammen mit einer Frist, die + * vom juengsten Fehlversuch aus gerechnet wurde, ergab das eine + * Rueckkopplung: Jeder abgewiesene Aufruf schob die Frist um die volle + * Sperrdauer nach hinten. Wer alle zehn Minuten ein falsches Passwort an + * eine fremde Adresse schickte, sperrte deren Inhaber dauerhaft aus - ohne + * das Passwort zu kennen. Und jeder dieser Aufrufe hinterliess eine Zeile, + * die erst der Wartungslauf nach 30 Tagen wieder los wurde. + * + * Deshalb jetzt zwei getrennte Dinge: + * + * - Ein *geprueft* fehlgeschlagener Versuch (falsches Passwort, falscher + * Code, unbekannte Adresse) zaehlt und wird einzeln festgehalten. + * - Ein *abgewiesener* Aufruf waehrend der Sperre zaehlt nicht und wird + * hoechstens einmal je Sperre vermerkt. + * + * Damit steht die Frist fest, sobald sie einmal gesetzt ist, und die + * Tabelle waechst nicht mit der Zahl der Angriffsversuche. */ +/** Grund fuer eine Zeile, die nur den abgewiesenen Aufruf dokumentiert. */ +const GRUND_ABGEWIESEN = 'gesperrt' + export type SperrErgebnis = | { gesperrt: false; verbleibendeVersuche: number } | { gesperrt: true; nochSekunden: number } @@ -29,33 +53,49 @@ function fensterStart(): Date { * Passwortvergleich aufgerufen - ein gesperrter Zugang soll gar nicht erst * Rechenzeit fuer Argon2 kosten. */ -export async function anmeldungErlaubt( - email: string, - ip: string | null, -): Promise { - const seit = fensterStart() +export async function anmeldungErlaubt(email: string, ip: string | null): Promise { + const jetzt = Date.now() const kennung = email.trim().toLowerCase() const ipH = ipHashen(ip) const fehlversuche = await db.loginVersuch.findMany({ where: { erfolg: false, - zeitpunkt: { gte: seit }, + // Abgewiesene Aufrufe zaehlen nicht mit - sonst haelt sich die Sperre + // selbst am Leben, siehe Kopf dieser Datei. + grund: { not: GRUND_ABGEWIESEN }, + zeitpunkt: { gte: new Date(jetzt - env.loginFensterSekunden * 1000) }, OR: [{ email: kennung }, ...(ipH ? [{ ipHash: ipH }] : [])], }, - orderBy: { zeitpunkt: 'desc' }, + orderBy: { zeitpunkt: 'asc' }, select: { zeitpunkt: true }, - take: env.loginMaxVersuche + 1, + // Deckel gegen eine ausufernde Abfrage. Mehr als eine Handvoll Runden + // passt ohnehin nicht in ein Zeitfenster, in dem jede Runde gesperrt + // wird. + take: 200, }) - if (fehlversuche.length >= env.loginMaxVersuche) { - const juengster = fehlversuche[0]!.zeitpunkt.getTime() - const frei = juengster + env.loginSperreSekunden * 1000 - const nochSekunden = Math.max(1, Math.ceil((frei - Date.now()) / 1000)) - return { gesperrt: true, nochSekunden } + // Von alt nach neu durchgehen und die Runden nachvollziehen: Sobald das + // Mass voll ist, beginnt an genau diesem Versuch eine Sperre, und der + // Zaehler faengt von vorn an. Ist ihre Frist inzwischen abgelaufen, sind + // die Versuche davor abgegolten - sonst bliebe der Zugang bis zum Ende + // des Zeitfensters dicht, obwohl die Sperre laengst um ist. + let zaehler = 0 + let sperreBis = 0 + + for (const versuch of fehlversuche) { + zaehler += 1 + if (zaehler >= env.loginMaxVersuche) { + sperreBis = versuch.zeitpunkt.getTime() + env.loginSperreSekunden * 1000 + zaehler = 0 + } } - return { gesperrt: false, verbleibendeVersuche: env.loginMaxVersuche - fehlversuche.length } + if (sperreBis > jetzt) { + return { gesperrt: true, nochSekunden: Math.max(1, Math.ceil((sperreBis - jetzt) / 1000)) } + } + + return { gesperrt: false, verbleibendeVersuche: env.loginMaxVersuche - zaehler } } /** Haelt einen Versuch fest. Der Grund hilft spaeter beim Nachvollziehen. */ @@ -75,6 +115,34 @@ export async function versuchFesthalten( }) } +/** + * Vermerkt einen abgewiesenen Aufruf - aber nur den ersten je Sperre. + * + * Dass eine Sperre gegriffen hat, will man im Protokoll sehen. Wie oft + * dagegen geklopft wurde, ist die Information nicht wert: Sie kostet eine + * Zeile pro Anfrage, und genau daraus laesst sich die Tabelle vollschreiben. + * Eine Zeile je Sperre reicht, um den Vorgang zu erkennen. + * + * @returns true, wenn tatsaechlich vermerkt wurde. + */ +export async function sperreVermerken(email: string, ip: string | null): Promise { + const kennung = email.trim().toLowerCase() + const vorhanden = await db.loginVersuch.findFirst({ + where: { + email: kennung, + erfolg: false, + grund: GRUND_ABGEWIESEN, + zeitpunkt: { gte: fensterStart() }, + }, + select: { id: true }, + }) + + if (vorhanden) return false + + await versuchFesthalten(kennung, ip, false, GRUND_ABGEWIESEN) + return true +} + /** * Nach einer erfolgreichen Anmeldung werden die Fehlversuche dieser * Adresse geloescht - sonst bliebe der Zaehler bis zum Fensterende stehen. diff --git a/tests/anmeldesperre.test.ts b/tests/anmeldesperre.test.ts new file mode 100644 index 0000000..f8dbbf6 --- /dev/null +++ b/tests/anmeldesperre.test.ts @@ -0,0 +1,250 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Die Anmeldesperre - und warum sie sich frueher selbst am Leben hielt. + * + * Bis v1.10.2 wurde jeder Aufruf festgehalten, auch der, der wegen einer + * laufenden Sperre gar nicht geprueft wurde. Gleichzeitig lief die Frist ab + * dem juengsten Fehlversuch. Beides zusammen ergab eine Rueckkopplung: Wer + * regelmaessig gegen eine gesperrte Adresse klopfte, verlaengerte die Sperre + * bei jedem Klopfen um die volle Dauer - und sperrte den Inhaber damit + * dauerhaft aus, ohne dessen Passwort zu kennen. Nebenbei wuchs die Tabelle + * mit jedem Versuch. + * + * Diese Tests laufen gegen eine Attrappe der Datenbank. Das reicht: Was hier + * geprueft wird, ist die Rechnung, nicht die Abfrage. + */ + +// Kuerzere Sperre als Zeitfenster - nur so wird sichtbar, ob die Frist +// wirklich ablaeuft oder bis zum Fensterende stehen bleibt. +process.env.LOGIN_MAX_VERSUCHE = '5' +process.env.LOGIN_FENSTER_SEKUNDEN = '3600' +process.env.LOGIN_SPERRE_SEKUNDEN = '900' + +type Zeile = { + id: string + email: string + ipHash: string | null + erfolg: boolean + grund: string | null + zeitpunkt: Date +} + +let tabelle: Zeile[] = [] +let laufendeNummer = 0 + +/** Nur die Abfrageformen, die in ratelimit.ts wirklich vorkommen. */ +function passt(zeile: Zeile, wo: Record): boolean { + if ('erfolg' in wo && zeile.erfolg !== wo.erfolg) return false + if ('email' in wo && zeile.email !== wo.email) return false + + if ('grund' in wo) { + const bedingung = wo.grund + if (typeof bedingung === 'object' && bedingung !== null && 'not' in bedingung) { + if (zeile.grund === (bedingung as { not: string }).not) return false + } else if (zeile.grund !== bedingung) { + return false + } + } + + if ('zeitpunkt' in wo) { + const { gte, lt } = wo.zeitpunkt as { gte?: Date; lt?: Date } + if (gte && zeile.zeitpunkt < gte) return false + if (lt && zeile.zeitpunkt >= lt) return false + } + + if ('OR' in wo) { + const zweige = wo.OR as Record[] + if (!zweige.some((zweig) => passt(zeile, zweig))) return false + } + + if ('ipHash' in wo && zeile.ipHash !== wo.ipHash) return false + + return true +} + +vi.mock('@/lib/db', () => ({ + db: { + loginVersuch: { + findMany: async ({ + where, + orderBy, + take, + }: { + where: Record + orderBy?: { zeitpunkt?: 'asc' | 'desc' } + take?: number + }) => { + const treffer = tabelle.filter((zeile) => passt(zeile, where)) + treffer.sort((a, b) => + orderBy?.zeitpunkt === 'desc' + ? b.zeitpunkt.getTime() - a.zeitpunkt.getTime() + : a.zeitpunkt.getTime() - b.zeitpunkt.getTime(), + ) + return take ? treffer.slice(0, take) : treffer + }, + + findFirst: async ({ where }: { where: Record }) => + tabelle.find((zeile) => passt(zeile, where)) ?? null, + + create: async ({ data }: { data: Omit }) => { + laufendeNummer += 1 + const zeile: Zeile = { + id: `z${laufendeNummer}`, + email: data.email, + ipHash: data.ipHash ?? null, + erfolg: data.erfolg, + grund: data.grund ?? null, + zeitpunkt: new Date(), + } + tabelle.push(zeile) + return zeile + }, + + deleteMany: async ({ where }: { where: Record }) => { + const vorher = tabelle.length + tabelle = tabelle.filter((zeile) => !passt(zeile, where)) + return { count: vorher - tabelle.length } + }, + }, + }, +})) + +const { anmeldungErlaubt, sperreVermerken, versuchFesthalten, fehlversucheZuruecksetzen } = + await import('@/lib/auth/ratelimit') + +const OPFER = 'opfer@example.de' +const ANSCHLUSS = '198.51.100.7' +const FREMD = '203.0.113.9' +const START = new Date('2026-08-24T12:00:00Z').getTime() + +/** Setzt die Uhr auf START plus so viele Sekunden. */ +function beiSekunde(sekunden: number): void { + vi.setSystemTime(new Date(START + sekunden * 1000)) +} + +async function fehlversuch(sekunde: number): Promise { + beiSekunde(sekunde) + await versuchFesthalten(OPFER, ANSCHLUSS, false, 'passwort-falsch') +} + +describe('Anmeldesperre', () => { + beforeEach(() => { + tabelle = [] + laufendeNummer = 0 + vi.useFakeTimers() + beiSekunde(0) + }) + + it('sperrt erst, wenn das Mass voll ist', async () => { + for (let i = 0; i < 4; i += 1) await fehlversuch(i) + + beiSekunde(10) + expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(false) + + await fehlversuch(11) + beiSekunde(12) + expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(true) + }) + + it('laesst die Frist ablaufen, obwohl die Fehlversuche noch im Zeitfenster stehen', async () => { + for (let i = 0; i < 5; i += 1) await fehlversuch(i) + + beiSekunde(890) + expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(true) + + // Kurz danach offen - und mit vollem Kontingent, nicht mit null + // Restversuchen. Das Zeitfenster laeuft noch (3600 s), die fuenf + // Fehlversuche stehen also weiterhin in der Tabelle. + beiSekunde(905) + const frei = await anmeldungErlaubt(OPFER, ANSCHLUSS) + expect(frei.gesperrt).toBe(false) + if (!frei.gesperrt) expect(frei.verbleibendeVersuche).toBe(5) + }) + + it('laesst sich durch Klopfen waehrend der Sperre nicht verlaengern', async () => { + for (let i = 0; i < 5; i += 1) await fehlversuch(i) + + // Der Angreifer klopft mitten in der Sperre weiter. Genau das hat die + // Frist frueher jedes Mal um volle 900 s nach hinten geschoben. + for (const sekunde of [100, 300, 600, 880]) { + beiSekunde(sekunde) + expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(true) + await sperreVermerken(OPFER, FREMD) + } + + // Die Frist haengt am fuenften echten Fehlversuch (Sekunde 4), nicht am + // letzten Klopfen (Sekunde 880). + beiSekunde(905) + expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(false) + }) + + it('rechnet einen abgewiesenen Aufruf nicht als Fehlversuch', async () => { + // Vier echte Fehlversuche - das Mass ist noch nicht voll. + for (let i = 0; i < 4; i += 1) await fehlversuch(i) + + // Dazu ein Vermerk ueber einen abgewiesenen Aufruf. Er steht als + // fuenfte Zeile in der Tabelle, darf aber nicht mitzaehlen: Geprueft + // wurde bei ihm nichts, also sagt er ueber die Zugangsdaten nichts aus. + beiSekunde(5) + await sperreVermerken(OPFER, FREMD) + expect(tabelle.length).toBe(5) + + beiSekunde(6) + const stand = await anmeldungErlaubt(OPFER, ANSCHLUSS) + expect(stand.gesperrt).toBe(false) + if (!stand.gesperrt) expect(stand.verbleibendeVersuche).toBe(1) + }) + + it('rechnet die Frist ab dem Versuch, der das Mass vollgemacht hat', async () => { + // Vier Fehlversuche ganz frueh, der fuenfte deutlich spaeter. Die + // Sperre beginnt beim fuenften - nicht beim ersten und nicht beim + // naechsten, der noch kommt. + for (let i = 0; i < 4; i += 1) await fehlversuch(i) + await fehlversuch(600) + + beiSekunde(1400) + expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(true) + + beiSekunde(1510) + expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(false) + }) + + it('schreibt je Sperre hoechstens eine Zeile, egal wie oft geklopft wird', async () => { + for (let i = 0; i < 5; i += 1) await fehlversuch(i) + const nachSperre = tabelle.length + + for (let i = 0; i < 200; i += 1) { + beiSekunde(10 + i) + await sperreVermerken(OPFER, FREMD) + } + + expect(tabelle.length).toBe(nachSperre + 1) + }) + + it('zaehlt die naechste Runde erst nach Ablauf der Sperre wieder hoch', async () => { + for (let i = 0; i < 5; i += 1) await fehlversuch(i) + + await fehlversuch(906) + const stand = await anmeldungErlaubt(OPFER, ANSCHLUSS) + expect(stand.gesperrt).toBe(false) + if (!stand.gesperrt) expect(stand.verbleibendeVersuche).toBe(4) + + // Vier weitere - dann greift die zweite Sperre. + for (let i = 1; i < 5; i += 1) await fehlversuch(906 + i) + beiSekunde(915) + expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(true) + }) + + it('raeumt die Fehlversuche nach erfolgreicher Anmeldung ab', async () => { + for (let i = 0; i < 5; i += 1) await fehlversuch(i) + beiSekunde(10) + expect((await anmeldungErlaubt(OPFER, ANSCHLUSS)).gesperrt).toBe(true) + + await fehlversucheZuruecksetzen(OPFER) + + const frei = await anmeldungErlaubt(OPFER, ANSCHLUSS) + expect(frei.gesperrt).toBe(false) + if (!frei.gesperrt) expect(frei.verbleibendeVersuche).toBe(5) + }) +})