140 lines
4.3 KiB
Go
140 lines
4.3 KiB
Go
package httpserver
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Login rate limiting.
|
|
//
|
|
// WHAT THIS IS: a fixed-window counter of *failed* sign-in attempts, held in
|
|
// this process's memory, keyed by client address and by email address. It turns
|
|
// online password guessing from "as fast as the server can hash" into a handful
|
|
// of tries per window, which is the whole job.
|
|
//
|
|
// WHAT THIS IS NOT, and the limitation to carry into production:
|
|
//
|
|
// - It is per-process. Two API instances behind a load balancer each allow
|
|
// the full budget, so the effective limit is the limit times the instance
|
|
// count, and a restart clears every counter. A deployment with more than
|
|
// one instance needs shared state — Redis, or the database — and this
|
|
// package is the seam where that goes: attemptLimiter is an implementation
|
|
// detail behind Allow/Fail/Reset.
|
|
// - The per-address budget is only as good as the address. That used to be
|
|
// net/http's RemoteAddr, which behind a reverse proxy is the proxy on
|
|
// every request and makes this budget global. It is now resolved by
|
|
// proxyTrust.clientAddr (clientip.go), which reads a forwarded address
|
|
// when — and only when — the immediate peer is a configured trusted
|
|
// proxy. An unconfigured deployment still gets RemoteAddr, so a proxied
|
|
// deployment must set HTTP_TRUSTED_PROXIES for this limit to be per-user.
|
|
// - It is memory-bounded by pruning, not by a hard cap, so a flood from many
|
|
// distinct addresses grows the map until the next prune.
|
|
//
|
|
// Only failures are counted. A correct password resets the email's counter, so
|
|
// a person who mistypes twice and then succeeds is not left carrying a penalty.
|
|
|
|
const (
|
|
// loginAttemptLimit is per email address per window. Five is comfortably
|
|
// above human error and far below useful for guessing.
|
|
loginAttemptLimit = 5
|
|
// loginAddressLimit is per client address per window. Higher than the
|
|
// per-email limit because one address legitimately covers a whole office
|
|
// behind NAT, where several people may each fumble a password.
|
|
loginAddressLimit = 20
|
|
// loginAttemptWindow is how long a counter lives.
|
|
loginAttemptWindow = 15 * time.Minute
|
|
)
|
|
|
|
// attemptLimiter counts failures per key within a fixed window.
|
|
type attemptLimiter struct {
|
|
mu sync.Mutex
|
|
limit int
|
|
window time.Duration
|
|
now func() time.Time
|
|
buckets map[string]*attemptBucket
|
|
}
|
|
|
|
type attemptBucket struct {
|
|
count int
|
|
resetAt time.Time
|
|
}
|
|
|
|
func newAttemptLimiter(limit int, window time.Duration, now func() time.Time) *attemptLimiter {
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
return &attemptLimiter{
|
|
limit: limit, window: window, now: now,
|
|
buckets: make(map[string]*attemptBucket),
|
|
}
|
|
}
|
|
|
|
// Allow reports whether another attempt may be made, and if not, how long the
|
|
// caller should wait. It records nothing: only Fail does.
|
|
//
|
|
// Checking and recording are separate so a *successful* login never consumes
|
|
// budget — the check happens before the password is verified, and the recording
|
|
// only if it turns out to be wrong.
|
|
func (l *attemptLimiter) Allow(key string) (bool, time.Duration) {
|
|
if key == "" {
|
|
return true, 0
|
|
}
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
b, ok := l.buckets[key]
|
|
now := l.now()
|
|
if !ok || !now.Before(b.resetAt) {
|
|
return true, 0
|
|
}
|
|
if b.count < l.limit {
|
|
return true, 0
|
|
}
|
|
return false, b.resetAt.Sub(now)
|
|
}
|
|
|
|
// Fail records one failed attempt.
|
|
func (l *attemptLimiter) Fail(key string) {
|
|
if key == "" {
|
|
return
|
|
}
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
now := l.now()
|
|
l.pruneLocked(now)
|
|
|
|
b, ok := l.buckets[key]
|
|
if !ok || !now.Before(b.resetAt) {
|
|
l.buckets[key] = &attemptBucket{count: 1, resetAt: now.Add(l.window)}
|
|
return
|
|
}
|
|
b.count++
|
|
}
|
|
|
|
// Reset clears a key's counter. Called on a successful sign-in.
|
|
func (l *attemptLimiter) Reset(key string) {
|
|
if key == "" {
|
|
return
|
|
}
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
delete(l.buckets, key)
|
|
}
|
|
|
|
// pruneMinimum is the size below which pruning is not worth the walk.
|
|
const pruneMinimum = 1024
|
|
|
|
// pruneLocked drops expired buckets once the map is large enough to be worth
|
|
// walking. Called from Fail, which is the only path that grows the map.
|
|
func (l *attemptLimiter) pruneLocked(now time.Time) {
|
|
if len(l.buckets) < pruneMinimum {
|
|
return
|
|
}
|
|
for key, b := range l.buckets {
|
|
if !now.Before(b.resetAt) {
|
|
delete(l.buckets, key)
|
|
}
|
|
}
|
|
}
|