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)) }