Files
portal/docs/superpowers/specs/2026-06-17-auth-route-throttle-spec.md
T
Дмитрий fdb1c1cca2 feat(security): per-IP route-throttle на auth-эндпоинтах — P1 go-live
Именованные лимитеры auth-login/auth-2fa/auth-password (perMinute 20 by IP) в AppServiceProvider; throttle-middleware на login/forgot/reset/2fa-verify/recovery в web.php. Закрывает per-IP объёмный перебор. Pest tests/Feature/Auth 97/97 GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:56:23 +03:00

5.4 KiB
Raw Blame History

Спека: route-level rate-limit (Laravel Throttle) на auth-эндпоинтах

Цель

Добавить HTTP-уровневый rate-limit (Laravel Throttle middleware) на публичные auth-эндпоинты login / forgot / reset-password / 2fa/verify / 2fa/recovery-use, чтобы закрыть P1-находку go-live аудита и, главное, пробел объёмного перебора с одного IP. Лимит — per-IP, поверх уже существующего прикладного per-credential rate-limit в контроллерах (defense-in-depth).

Существующая защита и пробел

Что уже есть в контроллерах (через Illuminate\Support\Facades\RateLimiter):

  • login — per-email|ip lockout 5 / 15 мин + per-IP lockout 10 неудач/час (через auth_log). Это уже строгий per-IP cap по НЕУДАЧАМ.
  • forgot / reset-password — per-email|ip (forgot) и per-token|ip (reset), 5 / 15 мин.
  • 2fa/verify / 2fa/recovery-use — per-pendingUser|ip, 5 / 15 мин.

Пробел: forgot2fa, reset) НЕ имеют per-IP объёмного предела. Ключ throttle привязан к email/токену/пользователю, поэтому один IP может слать неограниченно запросов forgot с РАЗНЫМИ email (каждый email — свежий счётчик) → перебор/enumeration без преграды. Route-throttle по IP закрывает это. Для login route-throttle — defense-in-depth (per-IP cap по неудачам уже есть).

Контракт лимитеров

Три именованных лимитера регистрируются в App\Providers\AppServiceProvider::boot через RateLimiter::for(...):

  • auth-loginLimit::perMinute(20)->by($request->ip())
  • auth-2faLimit::perMinute(20)->by($request->ip())
  • auth-passwordLimit::perMinute(20)->by($request->ip())

Применение middleware в routes/web.php к индивидуальным маршрутам:

  • POST /api/auth/loginthrottle:auth-login
  • POST /api/auth/2fa/verifythrottle:auth-2fa
  • POST /api/auth/2fa/recovery-usethrottle:auth-2fa
  • POST /api/auth/forgotthrottle:auth-password
  • POST /api/auth/reset-passwordthrottle:auth-password

Именованные (а не throttle:20,1) — чтобы счётчики login/2fa/password были изолированы друг от друга (общий throttle:N,1 делит per-IP счётчик между всеми маршрутами домена). При превышении Laravel отдаёт 429 + Retry-After (стандартно).

Граничные случаи и совместимость с тестами

  • 20/мин выбран ВЫШЕ максимума запросов любого существующего теста к одному маршруту в одном методе (наблюдаемо: login — 11, forgot — 7, 2fa — 6), поэтому route-throttle не пре-эмптит и не ломает существующие auth-тесты.
  • Каждый Pest-метод получает свежий app-инстанс → array-кэш (и счётчики RateLimiter) сбрасываются per-method → route-throttle не накапливается между тестами.
  • Route-лимит (20) всегда выше прикладного per-credential лимита (5), поэтому существующее поведение контроллеров (их 429 на 6-й попытке) сохраняется — middleware к этому моменту ещё не сработал.
  • register намеренно не throttle-ится на этом шаге (вне P1-перечня; регистрация обычно открыта). Значение 20/мин — стартовое, подлежит снижению после наблюдения боевого трафика.

Конвенция

  • Лимитеры — в AppServiceProvider::boot (пустой boot() сейчас).
  • Ключ — $request->ip() (per-IP).
  • Ответ на превышение — стандартный Laravel 429 (не кастомизируется).

Критерий приёмки

  • Новый Pest-тест tests/Feature/Auth/AuthRouteThrottleTest.php: 20 запросов POST /api/auth/forgot с РАЗНЫМИ unknown-email отдают 200 (контроллерный per-email лимит не срабатывает), 21-й отбивается middleware'ом — 429. Это доказывает, что 429 пришёл от route-throttle (по IP), а не от контроллера.
  • Весь каталог tests/Feature/Auth остаётся зелёным (нет регрессии).
[
  {
    "id": "ctx-route-forgot",
    "kind": "EXTRACTED",
    "ref": "app/routes/web.php",
    "anchor": "Route::post('/forgot', 'App\\Http\\Controllers\\Api\\PasswordResetController@forgotPassword');"
  },
  {
    "id": "ctx-provider-boot",
    "kind": "EXTRACTED",
    "ref": "app/app/Providers/AppServiceProvider.php",
    "anchor": "public function boot(): void"
  }
]