package main import ( "sync" "time" ) // Login attempts are limited per email address, which is the thing under attack — and the only key // available without parsing X-Forwarded-For and maintaining a trusted-proxy list. // // ponytail: in memory, dies with the process. A restart clearing the counters is not an attack an // attacker can mount. A shared store is the upgrade if this ever runs as more than one process. const ( loginMaxFailures = 10 loginWindow = 15 * time.Minute loginLockout = 15 * time.Minute ) type attempts struct { count int first time.Time until time.Time // zero unless locked } type limiter struct { mu sync.Mutex by map[string]*attempts } // locked reports whether the key is currently refused. It does not count as an attempt. func (l *limiter) locked(key string) bool { l.mu.Lock() defer l.mu.Unlock() a := l.by[key] return a != nil && time.Now().Before(a.until) } func (l *limiter) fail(key string) { now := time.Now() l.mu.Lock() defer l.mu.Unlock() if l.by == nil { l.by = map[string]*attempts{} } l.sweep(now) a := l.by[key] if a == nil || now.Sub(a.first) > loginWindow { l.by[key] = &attempts{count: 1, first: now} return } a.count++ if a.count >= loginMaxFailures { a.until = now.Add(loginLockout) } } // A correct password clears the record: the limit is on guessing, not on the account. Locking the // account itself would let anyone lock its owner out by trying. func (l *limiter) succeed(key string) { l.mu.Lock() defer l.mu.Unlock() delete(l.by, key) } // Called under the lock, on failures only — there is nothing to grow the map otherwise. func (l *limiter) sweep(now time.Time) { for k, a := range l.by { if now.Sub(a.first) > loginWindow && now.After(a.until) { delete(l.by, k) } } }