feat(api): лимиты частоты запросов (AGENT.md 8.6)
- httpx.RateLimiter: token bucket в памяти с уборкой неактивных ключей и подменяемым источником времени; - login/register — 5 запросов в минуту на IP, весь API — 120 в минуту на пользователя (по хэшу токена сессии) или на IP для анонимных запросов; - превышение отдаёт 429 с Retry-After и retry_after_ms в конверте ошибки; - тесты: наполнение и пополнение ведра, независимость ключей, 429 на ручке входа.
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWriteErrorUsesEnvelope(t *testing.T) {
|
func TestWriteErrorUsesEnvelope(t *testing.T) {
|
||||||
@@ -177,3 +178,68 @@ func TestClientIPIgnoresForwardedForFromUntrustedPeer(t *testing.T) {
|
|||||||
t.Errorf("ClientIP() = %q, want the peer address", got)
|
t.Errorf("ClientIP() = %q, want the peer address", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRateLimiterAllowsBurstThenBlocks(t *testing.T) {
|
||||||
|
limiter := NewRateLimiter(5, 5)
|
||||||
|
current := time.Unix(0, 0)
|
||||||
|
limiter.SetClock(func() time.Time { return current })
|
||||||
|
|
||||||
|
for i := range 5 {
|
||||||
|
if allowed, _ := limiter.Allow("1.2.3.4"); !allowed {
|
||||||
|
t.Fatalf("request %d must be allowed", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allowed, retryAfter := limiter.Allow("1.2.3.4")
|
||||||
|
if allowed {
|
||||||
|
t.Fatal("sixth request must be rate limited")
|
||||||
|
}
|
||||||
|
if retryAfter <= 0 {
|
||||||
|
t.Fatal("retry_after must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Другой IP лимитом не затронут.
|
||||||
|
if allowed, _ := limiter.Allow("5.6.7.8"); !allowed {
|
||||||
|
t.Fatal("separate key must have its own bucket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Через 12 секунд на скорости 5/мин накапливается один токен.
|
||||||
|
current = current.Add(12 * time.Second)
|
||||||
|
if allowed, _ := limiter.Allow("1.2.3.4"); !allowed {
|
||||||
|
t.Fatal("bucket must refill over time")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitMiddlewareReturns429(t *testing.T) {
|
||||||
|
limiter := NewRateLimiter(1, 1)
|
||||||
|
handler := limiter.Middleware(func(*http.Request) string { return "client" })(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
first := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(first, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))
|
||||||
|
if first.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first request = %d, want 200", first.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(second, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))
|
||||||
|
if second.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("second request = %d, want 429", second.Code)
|
||||||
|
}
|
||||||
|
if second.Header().Get("Retry-After") == "" {
|
||||||
|
t.Error("Retry-After header is missing")
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
RetryAfterMS int64 `json:"retry_after_ms"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(second.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode body: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Error.Code != "rate_limited" || payload.Error.RetryAfterMS <= 0 {
|
||||||
|
t.Fatalf("unexpected payload: %s", second.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateLimiter — ограничитель частоты запросов по ключу (IP или пользователь).
|
||||||
|
// Реализация — token bucket без внешних зависимостей: 1 vCPU инстансу
|
||||||
|
// достаточно простого счётчика в памяти (AGENT.md 8.6).
|
||||||
|
type RateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buckets map[string]*rateBucket
|
||||||
|
// rate — токенов в секунду, burst — максимальный запас.
|
||||||
|
rate float64
|
||||||
|
burst float64
|
||||||
|
// now подменяется в тестах, чтобы не ждать реального времени.
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type rateBucket struct {
|
||||||
|
tokens float64
|
||||||
|
seen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimiter создаёт лимитер: perMinute запросов в минуту с запасом burst.
|
||||||
|
func NewRateLimiter(perMinute, burst int) *RateLimiter {
|
||||||
|
if perMinute <= 0 {
|
||||||
|
perMinute = 60
|
||||||
|
}
|
||||||
|
if burst <= 0 {
|
||||||
|
burst = perMinute
|
||||||
|
}
|
||||||
|
return &RateLimiter{
|
||||||
|
buckets: map[string]*rateBucket{},
|
||||||
|
rate: float64(perMinute) / 60,
|
||||||
|
burst: float64(burst),
|
||||||
|
now: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetClock подменяет источник времени (тесты).
|
||||||
|
func (l *RateLimiter) SetClock(now func() time.Time) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.now = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow проверяет запрос и возвращает паузу до следующей попытки.
|
||||||
|
func (l *RateLimiter) Allow(key string) (bool, time.Duration) {
|
||||||
|
if key == "" {
|
||||||
|
key = "unknown"
|
||||||
|
}
|
||||||
|
now := l.now()
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
bucket, ok := l.buckets[key]
|
||||||
|
if !ok {
|
||||||
|
// Попутная уборка: словарь не должен расти бесконечно (AGENT.md 9.1).
|
||||||
|
if len(l.buckets) > 4096 {
|
||||||
|
l.sweepLocked(now)
|
||||||
|
}
|
||||||
|
bucket = &rateBucket{tokens: l.burst, seen: now}
|
||||||
|
l.buckets[key] = bucket
|
||||||
|
}
|
||||||
|
elapsed := now.Sub(bucket.seen).Seconds()
|
||||||
|
if elapsed > 0 {
|
||||||
|
bucket.tokens = minFloat(l.burst, bucket.tokens+elapsed*l.rate)
|
||||||
|
}
|
||||||
|
bucket.seen = now
|
||||||
|
|
||||||
|
if bucket.tokens < 1 {
|
||||||
|
missing := 1 - bucket.tokens
|
||||||
|
retryAfter := time.Duration(missing / l.rate * float64(time.Second))
|
||||||
|
if retryAfter < time.Second {
|
||||||
|
retryAfter = time.Second
|
||||||
|
}
|
||||||
|
return false, retryAfter
|
||||||
|
}
|
||||||
|
bucket.tokens--
|
||||||
|
return true, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepLocked удаляет записи, неактивные больше десяти минут.
|
||||||
|
func (l *RateLimiter) sweepLocked(now time.Time) {
|
||||||
|
cutoff := now.Add(-10 * time.Minute)
|
||||||
|
for key, bucket := range l.buckets {
|
||||||
|
if bucket.seen.Before(cutoff) {
|
||||||
|
delete(l.buckets, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware ограничивает частоту запросов по ключу keyFn.
|
||||||
|
func (l *RateLimiter) Middleware(keyFn func(*http.Request) string) Middleware {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
allowed, retryAfter := l.Allow(keyFn(r))
|
||||||
|
if !allowed {
|
||||||
|
WriteRateLimited(w, retryAfter)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteRateLimited отдаёт 429 в конверте API с подсказкой по паузе (§8.5, §8.6).
|
||||||
|
func WriteRateLimited(w http.ResponseWriter, retryAfter time.Duration) {
|
||||||
|
milliseconds := retryAfter.Milliseconds()
|
||||||
|
if milliseconds <= 0 {
|
||||||
|
milliseconds = 1000
|
||||||
|
}
|
||||||
|
w.Header().Set("Retry-After", strconv.FormatInt((milliseconds+999)/1000, 10))
|
||||||
|
WriteJSON(w, http.StatusTooManyRequests, map[string]any{
|
||||||
|
"error": map[string]any{
|
||||||
|
"code": "rate_limited",
|
||||||
|
"message": "too many requests",
|
||||||
|
"retry_after_ms": milliseconds,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func minFloat(a, b float64) float64 {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -629,3 +629,22 @@ func hasAction[T any](entries []T, action string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthRateLimitReturns429(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
var last *httptest.ResponseRecorder
|
||||||
|
// Лимит входа — 5 запросов в минуту на IP (AGENT.md 8.6).
|
||||||
|
for range 6 {
|
||||||
|
last = doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
|
||||||
|
`{"email":"nobody@example.com","password":"wrong-password"}`)
|
||||||
|
}
|
||||||
|
if last.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("sixth login attempt = %d, want 429", last.Code)
|
||||||
|
}
|
||||||
|
if code := errorCodeOf(t, last); code != "rate_limited" {
|
||||||
|
t.Fatalf("error code = %q, want rate_limited", code)
|
||||||
|
}
|
||||||
|
if last.Header().Get("Retry-After") == "" {
|
||||||
|
t.Error("Retry-After header is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,8 +48,12 @@ func (s *Server) clearSessionCookie() *http.Cookie {
|
|||||||
// registerAuthRoutes вешает ручки аутентификации на chi: cookie и заголовки
|
// registerAuthRoutes вешает ручки аутентификации на chi: cookie и заголовки
|
||||||
// выставляются напрямую, а контракт описан в OpenAPI (docs.go).
|
// выставляются напрямую, а контракт описан в OpenAPI (docs.go).
|
||||||
func (s *Server) registerAuthRoutes(router chi.Router) {
|
func (s *Server) registerAuthRoutes(router chi.Router) {
|
||||||
router.Post("/auth/register", s.handleRegister)
|
// Вход и регистрация дополнительно ограничены по IP (AGENT.md 8.6).
|
||||||
router.Post("/auth/login", s.handleLogin)
|
authLimit := s.authLimiter.Middleware(func(r *http.Request) string {
|
||||||
|
return httpx.ClientIP(r, nil)
|
||||||
|
})
|
||||||
|
router.With(authLimit).Post("/auth/register", s.handleRegister)
|
||||||
|
router.With(authLimit).Post("/auth/login", s.handleLogin)
|
||||||
router.Post("/auth/logout", s.handleLogout)
|
router.Post("/auth/logout", s.handleLogout)
|
||||||
router.Post("/auth/logout-all", s.handleLogoutAll)
|
router.Post("/auth/logout-all", s.handleLogoutAll)
|
||||||
router.Get("/auth/sessions", s.handleListSessions)
|
router.Get("/auth/sessions", s.handleListSessions)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"glchat/internal/auth"
|
"glchat/internal/auth"
|
||||||
"glchat/internal/config"
|
"glchat/internal/config"
|
||||||
|
"glchat/internal/crypto"
|
||||||
"glchat/internal/database"
|
"glchat/internal/database"
|
||||||
"glchat/internal/gateway"
|
"glchat/internal/gateway"
|
||||||
"glchat/internal/httpx"
|
"glchat/internal/httpx"
|
||||||
@@ -40,11 +41,15 @@ type Server struct {
|
|||||||
auth *auth.Service
|
auth *auth.Service
|
||||||
gateway *gateway.Service
|
gateway *gateway.Service
|
||||||
// perms — движок прав: ручки проверяют права теми же правилами, что Gateway.
|
// perms — движок прав: ручки проверяют права теми же правилами, что Gateway.
|
||||||
perms *permissions.Calculator
|
perms *permissions.Calculator
|
||||||
logger *slog.Logger
|
// authLimiter ограничивает login/register по IP, apiLimiter — API целиком
|
||||||
http *http.Server
|
// (AGENT.md 8.6).
|
||||||
static *staticHandler
|
authLimiter *httpx.RateLimiter
|
||||||
api huma.API
|
apiLimiter *httpx.RateLimiter
|
||||||
|
logger *slog.Logger
|
||||||
|
http *http.Server
|
||||||
|
static *staticHandler
|
||||||
|
api huma.API
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server {
|
func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server {
|
||||||
@@ -56,6 +61,10 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
gateway: deps.Gateway,
|
gateway: deps.Gateway,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
static: newStaticHandler(cfg.WebRoot),
|
static: newStaticHandler(cfg.WebRoot),
|
||||||
|
// 5 попыток входа в минуту на IP и 120 запросов API в минуту на
|
||||||
|
// пользователя/IP — дефолты AGENT.md 8.6.
|
||||||
|
authLimiter: httpx.NewRateLimiter(5, 5),
|
||||||
|
apiLimiter: httpx.NewRateLimiter(120, 60),
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case deps.Permissions != nil:
|
case deps.Permissions != nil:
|
||||||
@@ -68,6 +77,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
router.Route("/api/v1", func(apiRouter chi.Router) {
|
router.Route("/api/v1", func(apiRouter chi.Router) {
|
||||||
// Сессия резолвится один раз на запрос: huma-ручки читают её из контекста.
|
// Сессия резолвится один раз на запрос: huma-ручки читают её из контекста.
|
||||||
apiRouter.Use(s.sessionContext)
|
apiRouter.Use(s.sessionContext)
|
||||||
|
apiRouter.Use(s.apiLimiter.Middleware(apiRateLimitKey))
|
||||||
s.api = s.registerAPI(apiRouter)
|
s.api = s.registerAPI(apiRouter)
|
||||||
s.registerMetaRoutes(s.api)
|
s.registerMetaRoutes(s.api)
|
||||||
s.registerAuthRoutes(apiRouter)
|
s.registerAuthRoutes(apiRouter)
|
||||||
@@ -187,6 +197,14 @@ func (s *Server) registerMetaRoutes(api huma.API) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// apiRateLimitKey ключует лимит по пользователю, а для анонимных запросов — по IP.
|
||||||
|
func apiRateLimitKey(r *http.Request) string {
|
||||||
|
if token := sessionToken(r); token != "" {
|
||||||
|
return "session:" + crypto.HashToken(token)
|
||||||
|
}
|
||||||
|
return "ip:" + httpx.ClientIP(r, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeBearer(value string) string {
|
func normalizeBearer(value string) string {
|
||||||
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
|
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
|
||||||
return strings.TrimSpace(value[7:])
|
return strings.TrimSpace(value[7:])
|
||||||
|
|||||||
Reference in New Issue
Block a user