Files
mailgoserver/internal/webui/ratelimit.go
T

75 lines
2.8 KiB
Go

package webui
import (
"net/http"
"sync"
"time"
)
// ipRateLimiter is a small in-memory fixed-window counter — same map+mutex shape as
// pgpKeyCache elsewhere in this package. Bounds how many login POSTs a single source
// IP can make per window, independent of the per-account lockout in login.go/
// webmail_login.go (that one tracks failures against one identifier from any IP;
// this one bounds request volume from one IP regardless of which account(s) it's
// trying — the two layers catch different attack shapes: a botnet spreading guesses
// across many accounts, versus one machine hammering a single account).
type ipRateLimiter struct {
mu sync.Mutex
limit int
window time.Duration
counts map[string]*ipWindow
}
type ipWindow struct {
count int
windowEnds time.Time
}
func newIPRateLimiter(limit int, window time.Duration) *ipRateLimiter {
return &ipRateLimiter{limit: limit, window: window, counts: map[string]*ipWindow{}}
}
// allow reports whether ip may make another request right now, incrementing its
// count as a side effect. Expired windows reset lazily on next access rather than
// via a background sweep — fine at this app's scale (a handful of login attempts
// per real user), and avoids a goroutine that outlives the App's own lifecycle.
func (l *ipRateLimiter) allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
w, ok := l.counts[ip]
if !ok || now.After(w.windowEnds) {
w = &ipWindow{count: 0, windowEnds: now.Add(l.window)}
l.counts[ip] = w
}
w.count++
return w.count <= l.limit
}
// rateLimitLogin replies 429 and returns false if the request's source IP has
// exceeded the per-IP login rate limit — callers should return immediately without
// touching the DB or checking a password when this returns false.
func (a *App) rateLimitLogin(w http.ResponseWriter, r *http.Request) bool {
if a.loginLimiter.allow(a.requestIP(r)) {
return true
}
http.Error(w, "Too many login attempts — try again in a minute.", http.StatusTooManyRequests)
return false
}
// accountLocked reports whether authType/identifier has accumulated enough recent
// failures (from any IP — see ratelimit.go's doc comment for why that's the point)
// to refuse another attempt right now, per the [Auth] login_attempt_limit/window_minutes
// config. Fails open (returns false) on a DB error rather than locking everyone out
// over a transient issue.
func (a *App) accountLocked(authType, identifier string) bool {
limit := a.Cfg.Section("Auth").Key("login_attempt_limit").MustInt(8)
windowMinutes := a.Cfg.Section("Auth").Key("login_attempt_window_minutes").MustInt(15)
since := time.Now().Add(-time.Duration(windowMinutes) * time.Minute)
n, err := a.DB.CountRecentFailedAttempts(authType, identifier, since)
if err != nil {
return false
}
return n >= limit
}