Именованные лимитеры 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>
5.4 KiB
Спека: 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|iplockout 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 мин.
Пробел: forgot (и 2fa, 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-login—Limit::perMinute(20)->by($request->ip())auth-2fa—Limit::perMinute(20)->by($request->ip())auth-password—Limit::perMinute(20)->by($request->ip())
Применение middleware в routes/web.php к индивидуальным маршрутам:
POST /api/auth/login→throttle:auth-loginPOST /api/auth/2fa/verify→throttle:auth-2faPOST /api/auth/2fa/recovery-use→throttle:auth-2faPOST /api/auth/forgot→throttle:auth-passwordPOST /api/auth/reset-password→throttle: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"
}
]