diff --git a/internal/httpx/httpx_test.go b/internal/httpx/httpx_test.go index 8a79850..f854d8b 100644 --- a/internal/httpx/httpx_test.go +++ b/internal/httpx/httpx_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" ) func TestWriteErrorUsesEnvelope(t *testing.T) { @@ -177,3 +178,68 @@ func TestClientIPIgnoresForwardedForFromUntrustedPeer(t *testing.T) { 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()) + } +} diff --git a/internal/httpx/ratelimit.go b/internal/httpx/ratelimit.go new file mode 100644 index 0000000..292151a --- /dev/null +++ b/internal/httpx/ratelimit.go @@ -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 +} diff --git a/internal/server/api_test.go b/internal/server/api_test.go index c0d0471..c2a2ce8 100644 --- a/internal/server/api_test.go +++ b/internal/server/api_test.go @@ -629,3 +629,22 @@ func hasAction[T any](entries []T, action string) bool { } 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") + } +} diff --git a/internal/server/auth.go b/internal/server/auth.go index 32612d3..5e13dd6 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -48,8 +48,12 @@ func (s *Server) clearSessionCookie() *http.Cookie { // registerAuthRoutes вешает ручки аутентификации на chi: cookie и заголовки // выставляются напрямую, а контракт описан в OpenAPI (docs.go). func (s *Server) registerAuthRoutes(router chi.Router) { - router.Post("/auth/register", s.handleRegister) - router.Post("/auth/login", s.handleLogin) + // Вход и регистрация дополнительно ограничены по IP (AGENT.md 8.6). + 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-all", s.handleLogoutAll) router.Get("/auth/sessions", s.handleListSessions) diff --git a/internal/server/server.go b/internal/server/server.go index 989abd7..a030b3a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -13,6 +13,7 @@ import ( "glchat/internal/auth" "glchat/internal/config" + "glchat/internal/crypto" "glchat/internal/database" "glchat/internal/gateway" "glchat/internal/httpx" @@ -40,11 +41,15 @@ type Server struct { auth *auth.Service gateway *gateway.Service // perms — движок прав: ручки проверяют права теми же правилами, что Gateway. - perms *permissions.Calculator - logger *slog.Logger - http *http.Server - static *staticHandler - api huma.API + perms *permissions.Calculator + // authLimiter ограничивает login/register по IP, apiLimiter — API целиком + // (AGENT.md 8.6). + authLimiter *httpx.RateLimiter + 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 { @@ -56,6 +61,10 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se gateway: deps.Gateway, logger: logger, static: newStaticHandler(cfg.WebRoot), + // 5 попыток входа в минуту на IP и 120 запросов API в минуту на + // пользователя/IP — дефолты AGENT.md 8.6. + authLimiter: httpx.NewRateLimiter(5, 5), + apiLimiter: httpx.NewRateLimiter(120, 60), } switch { 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) { // Сессия резолвится один раз на запрос: huma-ручки читают её из контекста. apiRouter.Use(s.sessionContext) + apiRouter.Use(s.apiLimiter.Middleware(apiRateLimitKey)) s.api = s.registerAPI(apiRouter) s.registerMetaRoutes(s.api) 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 { if strings.HasPrefix(strings.ToLower(value), "bearer ") { return strings.TrimSpace(value[7:])