Add rate limiting to login endpoint to prevent brute force

Apply per-IP rate limiting (5 attempts/minute) to POST /login using
golang.org/x/time/rate. Returns 429 Too Many Requests when exceeded.

Closes #12
This commit is contained in:
clawbot
2026-02-15 14:04:52 -08:00
committed by clawbot
parent 3a2bd0e51d
commit 66661d1b1d
3 changed files with 175 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ import (
"log/slog"
"net"
"net/http"
"sync"
"time"
"github.com/99designs/basicauth-go"
@@ -12,6 +13,7 @@ import (
"github.com/go-chi/cors"
"github.com/gorilla/csrf"
"go.uber.org/fx"
"golang.org/x/time/rate"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/globals"
@@ -162,6 +164,71 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
)
}
// loginRateLimit configures the login rate limiter.
const (
loginRateLimit = rate.Limit(5.0 / 60.0) // 5 requests per 60 seconds
loginBurst = 5 // allow burst of 5
)
// ipLimiter tracks per-IP rate limiters for login attempts.
type ipLimiter struct {
mu sync.Mutex
limiters map[string]*rate.Limiter
}
func newIPLimiter() *ipLimiter {
return &ipLimiter{
limiters: make(map[string]*rate.Limiter),
}
}
func (i *ipLimiter) getLimiter(ip string) *rate.Limiter {
i.mu.Lock()
defer i.mu.Unlock()
limiter, exists := i.limiters[ip]
if !exists {
limiter = rate.NewLimiter(loginRateLimit, loginBurst)
i.limiters[ip] = limiter
}
return limiter
}
// loginLimiter is the singleton IP rate limiter for login attempts.
//
//nolint:gochecknoglobals // intentional singleton for rate limiting state
var loginLimiter = newIPLimiter()
// LoginRateLimit returns middleware that rate-limits login attempts per IP.
// It allows 5 attempts per minute and returns 429 Too Many Requests when exceeded.
func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
ip := ipFromHostPort(request.RemoteAddr)
limiter := loginLimiter.getLimiter(ip)
if !limiter.Allow() {
m.log.WarnContext(request.Context(), "login rate limit exceeded",
"remoteIP", ip,
)
http.Error(
writer,
"Too Many Requests",
http.StatusTooManyRequests,
)
return
}
next.ServeHTTP(writer, request)
})
}
}
// SetupRequired returns middleware that redirects to setup if no user exists.
func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {