MFA fix, added IP blacklist, update webmail client
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user