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 { 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 { 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 { 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 { await db.loginVersuch.deleteMany({ where: { email: email.trim().toLowerCase(), erfolg: false }, }) } /** Aufraeumen; wird vom Wartungslauf aufgerufen. */ export async function alteVersucheEntfernen(): Promise { 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 { const vergangen = Date.now() - begonnenAm if (vergangen < mindestMs) { await new Promise((fertig) => setTimeout(fertig, mindestMs - vergangen)) } }