75 lines
2.8 KiB
Go
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
|
|
}
|