Anmeldesperre gegen Selbstverlaengerung und Schreiblast sichern

Die Sperre hielt sich selbst am Leben. Jeder Aufruf wurde festgehalten,
auch der, der wegen der laufenden Sperre gar nicht geprueft wurde; die
Frist lief zugleich ab dem juengsten Fehlversuch. Wer also alle zehn
Minuten ein falsches Passwort an eine fremde Adresse schickte, sperrte
deren Inhaber dauerhaft aus - ohne das Passwort zu kennen. Jeder dieser
Aufrufe hinterliess ausserdem eine Zeile, die erst der Wartungslauf nach
30 Tagen wieder los wurde.

Jetzt zwei getrennte Dinge: Ein geprueft fehlgeschlagener Versuch zaehlt
und wird einzeln festgehalten. Ein waehrend der Sperre abgewiesener
Aufruf zaehlt nicht und wird hoechstens einmal je Sperre vermerkt.

Die Auswertung geht die Versuche von alt nach neu durch und zieht die
Runden nach: Ist das Mass voll, beginnt an diesem Versuch eine Sperre
und der Zaehler faengt von vorn an. Damit laeuft die Frist auch dann ab,
wenn die Fehlversuche noch im Zeitfenster stehen - vorher blieb der
Zugang bis zum Fensterende dicht.

Die Tests sind gegen vier Mutationen geprueft; jede wird rot.

Refs #38
This commit is contained in:
Florian Wilde
2026-08-24 02:13:04 +02:00
parent 2bed78915f
commit 133b0314c5
3 changed files with 335 additions and 16 deletions
+3 -2
View File
@@ -9,6 +9,7 @@ import {
anmeldungErlaubt, anmeldungErlaubt,
aufMindestdauerWarten, aufMindestdauerWarten,
fehlversucheZuruecksetzen, fehlversucheZuruecksetzen,
sperreVermerken,
versuchFesthalten, versuchFesthalten,
} from '@/lib/auth/ratelimit' } from '@/lib/auth/ratelimit'
import { protokollieren } from '@/lib/audit' import { protokollieren } from '@/lib/audit'
@@ -110,7 +111,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
// 1. Sperre pruefen, bevor teuer gerechnet wird. // 1. Sperre pruefen, bevor teuer gerechnet wird.
const sperre = await anmeldungErlaubt(email, ip) const sperre = await anmeldungErlaubt(email, ip)
if (sperre.gesperrt) { if (sperre.gesperrt) {
await versuchFesthalten(email, ip, false, 'gesperrt') await sperreVermerken(email, ip)
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden) 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. // anmeldet - erraten laesst sich ein Passkey ohnehin nicht.
const sperre = await anmeldungErlaubt(benutzer.email, ip) const sperre = await anmeldungErlaubt(benutzer.email, ip)
if (sperre.gesperrt) { if (sperre.gesperrt) {
await versuchFesthalten(benutzer.email, ip, false, 'gesperrt') await sperreVermerken(benutzer.email, ip)
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden) throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
} }
+82 -14
View File
@@ -14,8 +14,32 @@ import { ipHashen } from '@/lib/crypto/hash'
* Gezaehlt wird nach E-Mail UND nach Anschluss (IP-Hash). Damit hilft es * Gezaehlt wird nach E-Mail UND nach Anschluss (IP-Hash). Damit hilft es
* weder, dieselbe Adresse von vielen Anschluessen zu probieren, noch viele * weder, dieselbe Adresse von vielen Anschluessen zu probieren, noch viele
* Adressen von einem Anschluss. * 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 = export type SperrErgebnis =
| { gesperrt: false; verbleibendeVersuche: number } | { gesperrt: false; verbleibendeVersuche: number }
| { gesperrt: true; nochSekunden: number } | { gesperrt: true; nochSekunden: number }
@@ -29,33 +53,49 @@ function fensterStart(): Date {
* Passwortvergleich aufgerufen - ein gesperrter Zugang soll gar nicht erst * Passwortvergleich aufgerufen - ein gesperrter Zugang soll gar nicht erst
* Rechenzeit fuer Argon2 kosten. * Rechenzeit fuer Argon2 kosten.
*/ */
export async function anmeldungErlaubt( export async function anmeldungErlaubt(email: string, ip: string | null): Promise<SperrErgebnis> {
email: string, const jetzt = Date.now()
ip: string | null,
): Promise<SperrErgebnis> {
const seit = fensterStart()
const kennung = email.trim().toLowerCase() const kennung = email.trim().toLowerCase()
const ipH = ipHashen(ip) const ipH = ipHashen(ip)
const fehlversuche = await db.loginVersuch.findMany({ const fehlversuche = await db.loginVersuch.findMany({
where: { where: {
erfolg: false, 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 }] : [])], OR: [{ email: kennung }, ...(ipH ? [{ ipHash: ipH }] : [])],
}, },
orderBy: { zeitpunkt: 'desc' }, orderBy: { zeitpunkt: 'asc' },
select: { zeitpunkt: true }, 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) { // Von alt nach neu durchgehen und die Runden nachvollziehen: Sobald das
const juengster = fehlversuche[0]!.zeitpunkt.getTime() // Mass voll ist, beginnt an genau diesem Versuch eine Sperre, und der
const frei = juengster + env.loginSperreSekunden * 1000 // Zaehler faengt von vorn an. Ist ihre Frist inzwischen abgelaufen, sind
const nochSekunden = Math.max(1, Math.ceil((frei - Date.now()) / 1000)) // die Versuche davor abgegolten - sonst bliebe der Zugang bis zum Ende
return { gesperrt: true, nochSekunden } // 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. */ /** 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<boolean> {
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 * Nach einer erfolgreichen Anmeldung werden die Fehlversuche dieser
* Adresse geloescht - sonst bliebe der Zaehler bis zum Fensterende stehen. * Adresse geloescht - sonst bliebe der Zaehler bis zum Fensterende stehen.
+250
View File
@@ -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<string, unknown>): 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<string, unknown>[]
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<string, unknown>
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<string, unknown> }) =>
tabelle.find((zeile) => passt(zeile, where)) ?? null,
create: async ({ data }: { data: Omit<Zeile, 'id' | 'zeitpunkt'> }) => {
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<string, unknown> }) => {
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<void> {
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)
})
})