feat(auth): lock logins after repeated failures, trust proxies explicitly (FLAW-09)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
Failed logins are counted per account+address (3), per account (20) and per address (30) within 15 minutes; a full bucket answers 429 with Retry-After for 15 minutes. Locking the account+address bucket first means an attacker cannot lock the owner out from elsewhere. Unknown names count like accounts. Success clears the account buckets, a password reset (mail or admin) lifts the lock. LOGIN_PROTECTION=false switches it off. chi's RealIP believed X-Forwarded-For from anyone. The client address now comes from the headers only when the connection is from a trusted proxy: TRUSTED_PROXIES=private (default: loopback, Docker, LAN), all (old behaviour), none, or CIDRs. A proxy that sends no client address is logged once and per-address limits are skipped for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b9eb62caf
commit
11c14fa9ff
12 files changed
+503
-30
No files matched your search
@@ -0,0 +1,147 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Brute-force protection for the login (FLAW-09). Failed attempts are counted in memory in three
|
||||
// buckets; when one is full, logins for it are refused with 429 for the lock time:
|
||||
// account + IP 3 failures the attacker's address is locked out of this account, the real owner
|
||||
// logging in from elsewhere is not
|
||||
// account 20 failures stops attacks spread over many addresses
|
||||
// IP 30 failures stops one address trying many accounts
|
||||
// A bucket counts failures within the lock window and starts over afterwards. A successful login
|
||||
// clears the account buckets; a password reset clears the account completely. LOGIN_PROTECTION=false
|
||||
// switches all of it off. Counters are lost on restart, which is fine for this purpose.
|
||||
|
||||
const (
|
||||
loginLockWindow = 15 * time.Minute
|
||||
loginLimitAccountIP = 3
|
||||
loginLimitAccount = 20
|
||||
loginLimitIP = 30
|
||||
loginGuardCleanupEach = 5 * time.Minute
|
||||
)
|
||||
|
||||
type loginBucket struct {
|
||||
count int
|
||||
windowStart time.Time
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
type loginGuard struct {
|
||||
enabled bool
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
buckets map[string]*loginBucket
|
||||
lastCleanup time.Time
|
||||
}
|
||||
|
||||
func newLoginGuard(enabled bool) *loginGuard {
|
||||
return &loginGuard{enabled: enabled, now: time.Now, buckets: map[string]*loginBucket{}}
|
||||
}
|
||||
|
||||
type loginKey struct {
|
||||
key string
|
||||
limit int
|
||||
}
|
||||
|
||||
// loginKeys: account is the user ID (or the typed login for unknown names, so both look the same).
|
||||
// The IP buckets are left out when the IP is not known (proxy without client header).
|
||||
func loginKeys(account string, ip clientIPInfo) []loginKey {
|
||||
keys := []loginKey{{"acct:" + account, loginLimitAccount}}
|
||||
if !ip.viaProxyWithoutHeader && ip.ip != "" {
|
||||
keys = append(keys, loginKey{"acctip:" + account + "|" + ip.ip, loginLimitAccountIP}, loginKey{"ip:" + ip.ip, loginLimitIP})
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// lockedFor returns how long the login is still refused (0 = allowed).
|
||||
func (g *loginGuard) lockedFor(account string, ip clientIPInfo) time.Duration {
|
||||
if !g.enabled {
|
||||
return 0
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
now := g.now()
|
||||
var wait time.Duration
|
||||
for _, k := range loginKeys(account, ip) {
|
||||
if b := g.buckets[k.key]; b != nil && b.lockedUntil.After(now) && b.lockedUntil.Sub(now) > wait {
|
||||
wait = b.lockedUntil.Sub(now)
|
||||
}
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
func (g *loginGuard) recordFailure(account string, ip clientIPInfo) {
|
||||
if !g.enabled {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
now := g.now()
|
||||
g.cleanupLocked(now)
|
||||
for _, k := range loginKeys(account, ip) {
|
||||
b := g.buckets[k.key]
|
||||
if b == nil || now.Sub(b.windowStart) > loginLockWindow {
|
||||
b = &loginBucket{windowStart: now}
|
||||
g.buckets[k.key] = b
|
||||
}
|
||||
b.count++
|
||||
if b.count >= k.limit {
|
||||
b.lockedUntil = now.Add(loginLockWindow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordSuccess clears the counters of this account (the IP bucket stays, so one valid login cannot
|
||||
// be used to keep trying other accounts).
|
||||
func (g *loginGuard) recordSuccess(account string, ip clientIPInfo) {
|
||||
if !g.enabled {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.buckets, "acct:"+account)
|
||||
delete(g.buckets, "acctip:"+account+"|"+ip.ip)
|
||||
}
|
||||
|
||||
// unlockAccount lifts every lock of the account, e.g. after a password reset by mail or by an admin.
|
||||
func (g *loginGuard) unlockAccount(account string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
for k := range g.buckets {
|
||||
if k == "acct:"+account || strings.HasPrefix(k, "acctip:"+account+"|") {
|
||||
delete(g.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *loginGuard) cleanupLocked(now time.Time) {
|
||||
if now.Sub(g.lastCleanup) < loginGuardCleanupEach {
|
||||
return
|
||||
}
|
||||
g.lastCleanup = now
|
||||
for k, b := range g.buckets {
|
||||
if now.Sub(b.windowStart) > loginLockWindow && !b.lockedUntil.After(now) {
|
||||
delete(g.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeLoginLocked(w http.ResponseWriter, wait time.Duration) {
|
||||
minutes := int(math.Ceil(wait.Minutes()))
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
|
||||
unit := "Minuten"
|
||||
if minutes == 1 {
|
||||
unit = "Minute"
|
||||
}
|
||||
writeError(w, http.StatusTooManyRequests,
|
||||
fmt.Sprintf("Zu viele fehlgeschlagene Anmeldeversuche. Die Anmeldung ist für noch %d %s gesperrt.", minutes, unit))
|
||||
}
|
||||
Reference in new issue
Block a user