Add login rate limiting

This commit is contained in:
Jonathon Leight
2026-06-20 18:12:12 -04:00
parent 855c915074
commit 5319c35f48
3 changed files with 156 additions and 2 deletions
+96
View File
@@ -0,0 +1,96 @@
package web
import (
"net"
"net/http"
"sync"
"time"
)
// rateLimiter is a per-key token-bucket limiter, safe for concurrent use. It
// throttles abusive bursts (e.g. password guessing) using only in-process
// state. Each key (a client IP) gets a bucket that starts full and refills at a
// steady rate; a request costs one token.
type rateLimiter struct {
mu sync.Mutex
buckets map[string]*tokenBucket
ratePerSec float64 // tokens replenished per second
burst float64 // bucket capacity (max immediate requests)
now func() time.Time
lastSweep time.Time
}
type tokenBucket struct {
tokens float64
last time.Time
}
// newRateLimiter builds a limiter allowing bursts up to burst requests, then
// one further request every refill interval.
func newRateLimiter(burst float64, refill time.Duration) *rateLimiter {
return &rateLimiter{
buckets: map[string]*tokenBucket{},
ratePerSec: 1 / refill.Seconds(),
burst: burst,
now: time.Now,
}
}
// allow reports whether a request for key may proceed, consuming a token.
func (l *rateLimiter) allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
l.sweep(now)
b, ok := l.buckets[key]
if !ok {
// First request from this key: start full, spend one token.
l.buckets[key] = &tokenBucket{tokens: l.burst - 1, last: now}
return true
}
b.tokens = min(l.burst, b.tokens+now.Sub(b.last).Seconds()*l.ratePerSec)
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// sweep drops fully-recovered buckets so memory stays bounded by the number of
// recently-active keys. A bucket back at capacity is indistinguishable from a
// fresh one, so removing it is safe. Runs at most once per minute.
func (l *rateLimiter) sweep(now time.Time) {
if now.Sub(l.lastSweep) < time.Minute {
return
}
l.lastSweep = now
for key, b := range l.buckets {
if b.tokens+now.Sub(b.last).Seconds()*l.ratePerSec >= l.burst {
delete(l.buckets, key)
}
}
}
// middleware rejects requests from a client that has exceeded its rate, keyed by
// client IP.
func (l *rateLimiter) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !l.allow(clientIP(r)) {
http.Error(w, "Too many attempts. Please wait a moment and try again.", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// clientIP returns the request's client IP without the port. The RealIP
// middleware has already resolved X-Forwarded-For / X-Real-IP into RemoteAddr.
func clientIP(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
+53
View File
@@ -0,0 +1,53 @@
package web
import (
"testing"
"time"
)
func TestRateLimiterBurstThenThrottle(t *testing.T) {
now := time.Unix(0, 0)
l := newRateLimiter(3, time.Second)
l.now = func() time.Time { return now }
// Burst of 3 is allowed from a cold start.
for i := 0; i < 3; i++ {
if !l.allow("1.2.3.4") {
t.Fatalf("request %d should be allowed within burst", i)
}
}
// 4th in the same instant is denied.
if l.allow("1.2.3.4") {
t.Fatal("4th request should be throttled")
}
// A different key has its own bucket.
if !l.allow("5.6.7.8") {
t.Fatal("distinct key should not be throttled")
}
// After the refill interval, one more token is available.
now = now.Add(time.Second)
if !l.allow("1.2.3.4") {
t.Fatal("request should be allowed after refill")
}
if l.allow("1.2.3.4") {
t.Fatal("only one token should have refilled")
}
}
func TestRateLimiterSweepReclaims(t *testing.T) {
now := time.Unix(0, 0)
l := newRateLimiter(2, time.Second)
l.now = func() time.Time { return now }
l.allow("1.2.3.4")
if len(l.buckets) != 1 {
t.Fatalf("expected 1 bucket, got %d", len(l.buckets))
}
// Advance well past full recovery + the sweep interval; the next call sweeps
// the now-recovered bucket before creating a fresh one.
now = now.Add(2 * time.Minute)
l.allow("9.9.9.9")
if _, ok := l.buckets["1.2.3.4"]; ok {
t.Fatal("recovered bucket should have been swept")
}
}
+7 -2
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -119,8 +120,12 @@ func (s *Server) routes() {
// Public auth pages + JSON ceremony endpoints.
r.Get("/login", s.pageLogin)
r.Get("/signup", s.pageSignup)
r.Post("/login/password", s.auth.LoginPassword)
r.Post("/signup/password", s.auth.SignupPassword)
// Throttle credential submission per client IP to blunt password guessing
// and signup spam. Allows a burst (e.g. fat-fingered retries), then ~1 try
// every 6s; bcrypt's cost is the second line of defense.
authLimit := newRateLimiter(10, 6*time.Second)
r.With(authLimit.middleware).Post("/login/password", s.auth.LoginPassword)
r.With(authLimit.middleware).Post("/signup/password", s.auth.SignupPassword)
r.Post("/api/register/begin", s.auth.RegisterBegin)
r.Post("/api/register/finish", s.auth.RegisterFinish)
r.Post("/api/login/begin", s.auth.LoginBegin)