first commit
This commit is contained in:
154
go-api/internal/httpserver/ratelimit.go
Normal file
154
go-api/internal/httpserver/ratelimit.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"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.
|
||||
// - It trusts net/http's RemoteAddr for the client address. Behind a reverse
|
||||
// proxy every request appears to come from the proxy, so the per-address
|
||||
// budget becomes global. Reading X-Forwarded-For instead would be worse,
|
||||
// not better, until there is a trusted-proxy list to validate it against —
|
||||
// a client can send that header itself and mint a fresh budget per request.
|
||||
// Deploying behind a proxy means adding that list first.
|
||||
// - 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clientAddr is the key for per-address limiting.
|
||||
//
|
||||
// The port is stripped: a browser uses a new source port for every connection,
|
||||
// so keying on host:port would give each attempt its own budget and limit
|
||||
// nothing at all.
|
||||
func clientAddr(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
||||
if err != nil {
|
||||
return strings.TrimSpace(r.RemoteAddr)
|
||||
}
|
||||
return host
|
||||
}
|
||||
Reference in New Issue
Block a user