Files
krow_backend/go-api/internal/httpserver/ratelimit.go
2026-08-24 13:06:29 +05:30

155 lines
4.6 KiB
Go

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
}