package web import ( "math" "strings" "sync" "time" ) const ( // freeAttempts sind die Fehlversuche ohne Wartezeit — genug für Tippfehler. freeAttempts = 3 // maxBackoff deckelt die Wartezeit. Ein harter Lockout wäre selbst eine // Denial-of-Service-Möglichkeit gegen Kollegen. maxBackoff = 5 * time.Minute // entryTTL räumt ruhende Zähler ab. entryTTL = 1 * time.Hour ) type limitEntry struct { failures int lastFail time.Time } // Limiter bremst Anmeldeversuche pro Benutzername und pro Quell-IP. // Beide Schlüssel werden geführt: die IP fängt Passwort-Spraying ab, der // Benutzername verteilte Angriffe und den NAT-Fall. type Limiter struct { clock func() time.Time mu sync.Mutex entries map[string]*limitEntry } func NewLimiter(clock func() time.Time) *Limiter { if clock == nil { clock = time.Now } return &Limiter{clock: clock, entries: make(map[string]*limitEntry)} } // backoff berechnet die Wartezeit nach n Fehlversuchen. // Die ersten freeAttempts Versuche sind frei: bei freeAttempts=3 sind die // Versuche 1 bis 3 ungebremst, ab dem vierten wird gewartet. func backoff(failures int) time.Duration { if failures < freeAttempts { return 0 } exp := failures - freeAttempts + 1 if exp > 20 { // schützt vor Overflow in math.Pow return maxBackoff } d := time.Duration(math.Pow(2, float64(exp))) * time.Second if d > maxBackoff { return maxBackoff } return d } // keys liefert die beiden Zählerschlüssel. Benutzernamen werden // normalisiert, damit "MMueller" und "mmueller" derselbe Zähler sind. func keys(username, ip string) []string { var out []string if u := strings.ToLower(strings.TrimSpace(username)); u != "" { out = append(out, "u:"+u) } if ip != "" { out = append(out, "i:"+ip) } return out } // Allow meldet, ob ein Versuch erlaubt ist, und andernfalls die Restwartezeit. // Allow zählt nicht mit — dafür ist RecordFailure zuständig. func (l *Limiter) Allow(username, ip string) (time.Duration, bool) { now := l.clock() l.mu.Lock() defer l.mu.Unlock() var worst time.Duration for _, k := range keys(username, ip) { e, ok := l.entries[k] if !ok { continue } wait := backoff(e.failures) - now.Sub(e.lastFail) if wait > worst { worst = wait } } if worst > 0 { return worst, false } return 0, true } // RecordFailure erhöht beide Zähler. func (l *Limiter) RecordFailure(username, ip string) { now := l.clock() l.mu.Lock() defer l.mu.Unlock() for _, k := range keys(username, ip) { e, ok := l.entries[k] if !ok { e = &limitEntry{} l.entries[k] = e } e.failures++ e.lastFail = now } } // RecordSuccess setzt beide Zähler zurück. func (l *Limiter) RecordSuccess(username, ip string) { l.mu.Lock() defer l.mu.Unlock() for _, k := range keys(username, ip) { delete(l.entries, k) } } // GC entfernt Zähler, die lange nicht mehr angefasst wurden. func (l *Limiter) GC() { now := l.clock() l.mu.Lock() defer l.mu.Unlock() for k, e := range l.entries { if now.Sub(e.lastFail) > entryTTL { delete(l.entries, k) } } } // size ist ein Testhelfer. func (l *Limiter) size() int { l.mu.Lock() defer l.mu.Unlock() return len(l.entries) }