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
176 lines
6.1 KiB
TypeScript
176 lines
6.1 KiB
TypeScript
import 'server-only'
|
|
import { db } from '@/lib/db'
|
|
import { env } from '@/lib/env'
|
|
import { ipHashen } from '@/lib/crypto/hash'
|
|
|
|
/**
|
|
* Rate-Limiting am Anmeldeformular.
|
|
*
|
|
* Bewusst in der Datenbank statt in Redis: die Anwendung laeuft auf einem
|
|
* einzelnen kleinen Server, eine zusaetzliche Komponente waere Aufwand ohne
|
|
* Gegenwert. Bei ein paar Benutzern und wenigen Anmeldungen pro Tag ist die
|
|
* Tabelle winzig; ein Aufraeumen nach 30 Tagen haelt sie klein.
|
|
*
|
|
* 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 }
|
|
|
|
function fensterStart(): Date {
|
|
return new Date(Date.now() - env.loginFensterSekunden * 1000)
|
|
}
|
|
|
|
/**
|
|
* Prueft, ob eine Anmeldung ueberhaupt versucht werden darf. Wird VOR dem
|
|
* Passwortvergleich aufgerufen - ein gesperrter Zugang soll gar nicht erst
|
|
* Rechenzeit fuer Argon2 kosten.
|
|
*/
|
|
export async function anmeldungErlaubt(email: string, ip: string | null): Promise<SperrErgebnis> {
|
|
const jetzt = Date.now()
|
|
const kennung = email.trim().toLowerCase()
|
|
const ipH = ipHashen(ip)
|
|
|
|
const fehlversuche = await db.loginVersuch.findMany({
|
|
where: {
|
|
erfolg: false,
|
|
// 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: 'asc' },
|
|
select: { zeitpunkt: true },
|
|
// Deckel gegen eine ausufernde Abfrage. Mehr als eine Handvoll Runden
|
|
// passt ohnehin nicht in ein Zeitfenster, in dem jede Runde gesperrt
|
|
// wird.
|
|
take: 200,
|
|
})
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
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. */
|
|
export async function versuchFesthalten(
|
|
email: string,
|
|
ip: string | null,
|
|
erfolg: boolean,
|
|
grund?: string,
|
|
): Promise<void> {
|
|
await db.loginVersuch.create({
|
|
data: {
|
|
email: email.trim().toLowerCase(),
|
|
ipHash: ipHashen(ip),
|
|
erfolg,
|
|
grund: grund ?? null,
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* Adresse geloescht - sonst bliebe der Zaehler bis zum Fensterende stehen.
|
|
*/
|
|
export async function fehlversucheZuruecksetzen(email: string): Promise<void> {
|
|
await db.loginVersuch.deleteMany({
|
|
where: { email: email.trim().toLowerCase(), erfolg: false },
|
|
})
|
|
}
|
|
|
|
/** Aufraeumen; wird vom Wartungslauf aufgerufen. */
|
|
export async function alteVersucheEntfernen(): Promise<number> {
|
|
const grenze = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
|
const { count } = await db.loginVersuch.deleteMany({
|
|
where: { zeitpunkt: { lt: grenze } },
|
|
})
|
|
return count
|
|
}
|
|
|
|
/**
|
|
* Gleicht die Antwortzeit an. Ohne das verraet ein schneller "Benutzer
|
|
* unbekannt" gegenueber einem langsamen "Passwort falsch", welche
|
|
* E-Mail-Adressen es gibt.
|
|
*/
|
|
export async function aufMindestdauerWarten(begonnenAm: number, mindestMs = 400): Promise<void> {
|
|
const vergangen = Date.now() - begonnenAm
|
|
if (vergangen < mindestMs) {
|
|
await new Promise((fertig) => setTimeout(fertig, mindestMs - vergangen))
|
|
}
|
|
}
|