go-wiki/handler/ratelimit.go
2026-07-14 20:46:19 +02:00

98 lines
2.6 KiB
Go

// Dieses File implementiert ein einfaches In-Memory-Rate-Limiting für den Login —
// ohne das würde ein Angreifer beliebig oft Zugriffskeys durchprobieren können.
//
// Fixed-Window-Zähler pro Client-IP: nach loginRateLimitMax fehlgeschlagenen
// Versuchen innerhalb von loginRateLimitWindow muss die IP bis zum Fensterende warten.
// Ein erfolgreicher Login setzt den Zähler der IP sofort zurück.
package handler
import (
"sync"
"time"
)
const (
loginRateLimitMax = 5 // erlaubte Fehlversuche pro Fenster
loginRateLimitWindow = 1 * time.Minute // Fenstergröße
)
// attemptRecord zählt Fehlversuche einer IP innerhalb eines Zeitfensters.
type attemptRecord struct {
count int
windowEnds time.Time
}
// LoginRateLimiter begrenzt Login-Versuche pro Client-IP.
type LoginRateLimiter struct {
mu sync.Mutex
attempts map[string]*attemptRecord
}
// NewLoginRateLimiter erstellt einen Rate-Limiter und startet einen Hintergrund-Job
// der abgelaufene Einträge regelmäßig entfernt (verhindert unbegrenztes Wachstum
// der Map bei vielen verschiedenen IPs, z.B. durch Scanner/Bots).
func NewLoginRateLimiter() *LoginRateLimiter {
l := &LoginRateLimiter{attempts: make(map[string]*attemptRecord)}
go func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
l.cleanup()
}
}()
return l
}
// 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 *LoginRateLimiter) Allow(ip string) (ok bool, retryAfter time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
rec, exists := l.attempts[ip]
now := time.Now()
if !exists || now.After(rec.windowEnds) {
return true, 0
}
if rec.count >= loginRateLimitMax {
return false, rec.windowEnds.Sub(now)
}
return true, 0
}
// RecordFailure zählt einen fehlgeschlagenen Login-Versuch für die IP.
func (l *LoginRateLimiter) RecordFailure(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
rec, exists := l.attempts[ip]
if !exists || now.After(rec.windowEnds) {
rec = &attemptRecord{windowEnds: now.Add(loginRateLimitWindow)}
l.attempts[ip] = rec
}
rec.count++
}
// Reset löscht den Zähler einer IP nach einem erfolgreichen Login.
func (l *LoginRateLimiter) Reset(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.attempts, ip)
}
// cleanup entfernt abgelaufene Einträge aus der Map.
func (l *LoginRateLimiter) cleanup() {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
for ip, rec := range l.attempts {
if now.After(rec.windowEnds) {
delete(l.attempts, ip)
}
}
}