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:
@@ -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:])
|
||||
|
||||
Reference in New Issue
Block a user