mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-16 08:32:37 +00:00
Add login rate limiting
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user