MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
- NewID uses 96 random bits; invite codes get their own 100 bits in Base32. - Registration and forgot-password are limited per address via loginGuard. - The invite is claimed atomically before the account is created and released again when the account cannot be created. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
188 lines
5.5 KiB
Go
188 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Other endpoints that can be abused in bulk are limited per address in the same buckets (FLAW-39).
|
|
// Every request counts here, not only failures: registration (also guessing invite codes) and
|
|
// "forgot password" (mail flood to many addresses).
|
|
const (
|
|
limitRegisterPerIP = 20
|
|
limitForgotPerIP = 10
|
|
)
|
|
|
|
// throttle counts one request of this kind from the visitor's address and returns how long further
|
|
// requests are refused (0 = this one may go ahead). Unknown addresses are not limited.
|
|
func (g *loginGuard) throttle(kind string, ip clientIPInfo, limit int) time.Duration {
|
|
if !g.enabled || ip.viaProxyWithoutHeader || ip.ip == "" {
|
|
return 0
|
|
}
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
now := g.now()
|
|
g.cleanupLocked(now)
|
|
key := kind + ":" + ip.ip
|
|
b := g.buckets[key]
|
|
if b != nil && b.lockedUntil.After(now) {
|
|
return b.lockedUntil.Sub(now)
|
|
}
|
|
if b == nil || now.Sub(b.windowStart) > loginLockWindow {
|
|
b = &loginBucket{windowStart: now}
|
|
g.buckets[key] = b
|
|
}
|
|
b.count++
|
|
if b.count >= limit {
|
|
b.lockedUntil = now.Add(loginLockWindow)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func writeThrottled(w http.ResponseWriter, wait time.Duration) {
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
|
|
writeError(w, http.StatusTooManyRequests,
|
|
fmt.Sprintf("Zu viele Anfragen von dieser Adresse. Bitte versuche es in %d Minuten erneut.", int(math.Ceil(wait.Minutes()))))
|
|
}
|
|
|
|
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))
|
|
}
|