249 lines
8.6 KiB
Go
249 lines
8.6 KiB
Go
// Dieses File enthält zwei Begrenzungen, die sich dieselbe Mechanik teilen:
|
|
//
|
|
// - RateLimiter sperrt eine IP nach zu vielen fehlgeschlagenen Anmeldungen.
|
|
// - WriteBudget deckelt, wie oft ein Projekt je Stunde schreiben darf.
|
|
//
|
|
// Beide sind Fixed-Window-Zähler: Ein Zeitfenster beginnt beim ersten Ereignis
|
|
// und endet nach einer festen Dauer; danach fängt die Zählung bei null an.
|
|
package auth
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ── Der gemeinsame Unterbau ──────────────────────────────────────────────────
|
|
|
|
// fensterStand ist der Zählerstand eines Schlüssels im laufenden Fenster.
|
|
type fensterStand struct {
|
|
anzahl int
|
|
fensterBis time.Time
|
|
}
|
|
|
|
// fensterZaehler zählt Ereignisse je Schlüssel innerhalb eines Zeitfensters.
|
|
//
|
|
// Was der Schlüssel bedeutet, entscheidet der Benutzer des Zählers: beim
|
|
// RateLimiter ist es eine IP-Adresse, beim WriteBudget eine Projekt-ID. Für den
|
|
// Zähler ist beides nur eine Zeichenkette — deshalb lässt sich dieselbe
|
|
// Mechanik für beides verwenden.
|
|
type fensterZaehler struct {
|
|
mu sync.Mutex
|
|
fenster time.Duration
|
|
staende map[string]*fensterStand
|
|
}
|
|
|
|
// neuerZaehler erstellt einen Zähler und startet einen Hintergrund-Job, der
|
|
// abgelaufene Einträge regelmäßig entfernt.
|
|
//
|
|
// Ohne das Aufräumen würde die Map unbegrenzt wachsen: Jede IP, die einmal
|
|
// vorbeigeschaut hat, bliebe für immer darin stehen — bei Scannern und Bots sind
|
|
// das schnell sehr viele.
|
|
func neuerZaehler(fenster time.Duration) *fensterZaehler {
|
|
z := &fensterZaehler{
|
|
fenster: fenster,
|
|
staende: make(map[string]*fensterStand),
|
|
}
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(10 * time.Minute)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
z.aufraeumen()
|
|
}
|
|
}()
|
|
|
|
return z
|
|
}
|
|
|
|
// lies gibt den aktuellen Stand zurück, ohne zu zählen.
|
|
// restlaufzeit ist die verbleibende Dauer des Fensters.
|
|
func (z *fensterZaehler) lies(schluessel string) (anzahl int, restlaufzeit time.Duration) {
|
|
z.mu.Lock()
|
|
defer z.mu.Unlock()
|
|
|
|
stand, vorhanden := z.staende[schluessel]
|
|
now := time.Now()
|
|
|
|
// Kein Eintrag oder Fenster abgelaufen: der Schlüssel steht bei null.
|
|
if !vorhanden || now.After(stand.fensterBis) {
|
|
return 0, 0
|
|
}
|
|
return stand.anzahl, stand.fensterBis.Sub(now)
|
|
}
|
|
|
|
// zaehle erhöht den Zähler um eins und gibt den neuen Stand zurück.
|
|
func (z *fensterZaehler) zaehle(schluessel string) (anzahl int, restlaufzeit time.Duration) {
|
|
z.mu.Lock()
|
|
defer z.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
stand, vorhanden := z.staende[schluessel]
|
|
|
|
// Ein abgelaufenes Fenster wird nicht fortgeschrieben, sondern durch ein
|
|
// frisches ersetzt — sonst würde der alte Stand weitergezählt.
|
|
if !vorhanden || now.After(stand.fensterBis) {
|
|
stand = &fensterStand{fensterBis: now.Add(z.fenster)}
|
|
z.staende[schluessel] = stand
|
|
}
|
|
|
|
stand.anzahl++
|
|
return stand.anzahl, stand.fensterBis.Sub(now)
|
|
}
|
|
|
|
// vergiss löscht den Zähler eines Schlüssels.
|
|
func (z *fensterZaehler) vergiss(schluessel string) {
|
|
z.mu.Lock()
|
|
defer z.mu.Unlock()
|
|
delete(z.staende, schluessel)
|
|
}
|
|
|
|
// aufraeumen entfernt abgelaufene Einträge aus der Map.
|
|
func (z *fensterZaehler) aufraeumen() {
|
|
z.mu.Lock()
|
|
defer z.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
for schluessel, stand := range z.staende {
|
|
if now.After(stand.fensterBis) {
|
|
delete(z.staende, schluessel)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Anmeldeversuche ──────────────────────────────────────────────────────────
|
|
|
|
const (
|
|
rateLimitMax = 5 // erlaubte Fehlversuche pro Fenster
|
|
rateLimitWindow = 1 * time.Minute // Fenstergröße
|
|
)
|
|
|
|
// RateLimiter begrenzt fehlgeschlagene Anmeldungen pro Client-IP —
|
|
// ohne das könnte ein Angreifer beliebig oft Passwörter und API-Keys
|
|
// durchprobieren.
|
|
type RateLimiter struct {
|
|
zaehler *fensterZaehler
|
|
}
|
|
|
|
// NewRateLimiter erstellt einen Rate-Limiter für Anmeldeversuche.
|
|
func NewRateLimiter() *RateLimiter {
|
|
return &RateLimiter{zaehler: neuerZaehler(rateLimitWindow)}
|
|
}
|
|
|
|
// Allow prüft ob die IP noch einen Versuch frei hat, ohne ihn zu verbrauchen.
|
|
// retryAfter gibt an wie lange bei einer Sperre noch gewartet werden muss.
|
|
func (l *RateLimiter) Allow(ip string) (ok bool, retryAfter time.Duration) {
|
|
anzahl, restlaufzeit := l.zaehler.lies(ip)
|
|
if anzahl >= rateLimitMax {
|
|
return false, restlaufzeit
|
|
}
|
|
return true, 0
|
|
}
|
|
|
|
// RecordFailure zählt einen fehlgeschlagenen Anmeldeversuch für die IP.
|
|
func (l *RateLimiter) RecordFailure(ip string) {
|
|
l.zaehler.zaehle(ip)
|
|
}
|
|
|
|
// Reset löscht den Zähler einer IP nach einem erfolgreichen Zugriff.
|
|
func (l *RateLimiter) Reset(ip string) {
|
|
l.zaehler.vergiss(ip)
|
|
}
|
|
|
|
// ── Schreibbudget je Projekt ─────────────────────────────────────────────────
|
|
|
|
// WriteBudget deckelt, wie oft ein Projekt innerhalb eines Zeitfensters
|
|
// schreiben darf.
|
|
//
|
|
// # Warum je Projekt und nicht je Key?
|
|
//
|
|
// Ein Limit je Key ließe sich umgehen, indem man einen zweiten Key anlegt — das
|
|
// darf jeder Projekt-Admin selbst. Damit schützte es nur vor dem Versehen (eine
|
|
// Schleife im Agenten), nicht vor dem Verbrauch. Das Projekt ist die Einheit der
|
|
// Abschottung, also gehört auch das Budget dorthin.
|
|
//
|
|
// # Warum überhaupt?
|
|
//
|
|
// Ein gültiger API-Key umgeht die IP-Sperre bewusst (siehe APIAuth in
|
|
// internal/web/middleware.go): Deploy-Skript, MCP-Client und Browser eines Büros
|
|
// teilen sich nach außen oft eine Adresse. Damit hätte aber eine durchdrehende
|
|
// Schleife im Agenten überhaupt keine Grenze mehr.
|
|
//
|
|
// # Was das Budget NICHT ist
|
|
//
|
|
// Der Zählerstand liegt im Arbeitsspeicher und ist nach einem Neustart weg. Bei
|
|
// Fehlversuchen ist das gleichgültig, bei einem Stundenbudget lädt es zum
|
|
// Umgehen ein. Für eine verbindliche Zusage müsste der Zähler in die Datenbank —
|
|
// siehe AUSBAUSTUFEN.md. Solange genau ein Prozess läuft, reicht das hier.
|
|
type WriteBudget struct {
|
|
zaehler *fensterZaehler
|
|
standard int
|
|
}
|
|
|
|
// NewWriteBudget erstellt ein Schreibbudget.
|
|
//
|
|
// standard gilt für alle Projekte, die keine eigene Obergrenze gesetzt haben.
|
|
// Ein Wert von 0 oder kleiner schaltet die Begrenzung vollständig ab.
|
|
func NewWriteBudget(standard int, fenster time.Duration) *WriteBudget {
|
|
return &WriteBudget{
|
|
zaehler: neuerZaehler(fenster),
|
|
standard: standard,
|
|
}
|
|
}
|
|
|
|
// Consume verbucht einen Schreibvorgang für ein Projekt und sagt, ob er noch ins
|
|
// Budget passte.
|
|
//
|
|
// projektLimit ist die eigene Obergrenze des Projekts; bei 0 gilt der Standard
|
|
// aus NewWriteBudget. Die Obergrenze wird bei jedem Aufruf mitgegeben statt im
|
|
// Budget gespeichert — so wirkt eine Änderung in der Verwaltung sofort, ohne
|
|
// dass hier irgendein zwischengespeicherter Wert veralten könnte.
|
|
//
|
|
// Gezählt wird auch der abgelehnte Aufruf. Sonst könnte ein Agent, der stur
|
|
// weiterversucht, das Fenster nie volllaufen lassen — und der Zählerstand wäre
|
|
// keine ehrliche Auskunft darüber, wie viel gerade los ist.
|
|
//
|
|
// ueberschreitung sagt, der wievielte Aufruf über der Grenze das war: 0 solange
|
|
// alles im Rahmen ist, 1 beim allerersten abgelehnten Aufruf, dann aufsteigend.
|
|
//
|
|
// Dieser dritte Rückgabewert sieht nach Ballast aus, hat aber einen konkreten
|
|
// Zweck: Der Aufrufer soll die Überschreitung protokollieren können, ohne bei
|
|
// JEDEM abgelehnten Aufruf eine Zeile zu schreiben. Ein Agent in einer Schleife
|
|
// erzeugt sonst genau die Datenflut, die das Budget verhindern soll — nur eben
|
|
// im Protokoll statt in den Einträgen. Mit ueberschreitung == 1 als Bedingung
|
|
// entsteht je Fenster genau ein Vermerk.
|
|
func (b *WriteBudget) Consume(projektID string, projektLimit int) (ok bool, retryAfter time.Duration, ueberschreitung int) {
|
|
grenze := b.grenzeFuer(projektLimit)
|
|
if grenze <= 0 {
|
|
return true, 0, 0 // Begrenzung abgeschaltet
|
|
}
|
|
|
|
anzahl, restlaufzeit := b.zaehler.zaehle(projektID)
|
|
if anzahl > grenze {
|
|
return false, restlaufzeit, anzahl - grenze
|
|
}
|
|
return true, 0, 0
|
|
}
|
|
|
|
// Remaining sagt, wie viele Schreibvorgänge im laufenden Fenster noch frei sind,
|
|
// ohne etwas zu verbrauchen. Für die Anzeige in der Verwaltung.
|
|
func (b *WriteBudget) Remaining(projektID string, projektLimit int) (frei int, grenze int) {
|
|
grenze = b.grenzeFuer(projektLimit)
|
|
if grenze <= 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
anzahl, _ := b.zaehler.lies(projektID)
|
|
frei = grenze - anzahl
|
|
if frei < 0 {
|
|
frei = 0
|
|
}
|
|
return frei, grenze
|
|
}
|
|
|
|
// grenzeFuer löst die geltende Obergrenze auf: erst das Projekt, dann der Standard.
|
|
func (b *WriteBudget) grenzeFuer(projektLimit int) int {
|
|
if projektLimit > 0 {
|
|
return projektLimit
|
|
}
|
|
return b.standard
|
|
}
|