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:
2026-09-19 21:51:47 +03:00
parent 6d7062dc2f
commit c059cd9f51
5 changed files with 247 additions and 7 deletions
+19
View File
@@ -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")
}
}
+6 -2
View File
@@ -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)
+23 -5
View File
@@ -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:])