# Спека v2: 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).

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

Что уже есть в контроллерах (через `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 мин.

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

## Контракт лимитеров {#D2}

Три именованных лимитера регистрируются в `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-login`
- `POST /api/auth/2fa/verify`       → `throttle:auth-2fa`
- `POST /api/auth/2fa/recovery-use` → `throttle:auth-2fa`
- `POST /api/auth/forgot`           → `throttle:auth-password`
- `POST /api/auth/reset-password`   → `throttle:auth-password`

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

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

- 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/мин — стартовое, подлежит снижению после
  наблюдения боевого трафика.

## Конвенция {#D4}

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

## Критерий приёмки {#D5}

- Новый 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` остаётся зелёным (нет регрессии).

## Переговоры

### Круг 1

Открытая точка для наставника: стартовое значение лимита `perMinute(20)`. Оно
выбрано как минимально безопасное над максимумом тестовых циклов (11), но для
anti-enumeration на `forgot` потенциально великовато (1200 запросов/час с IP).
Прошу наставника зафиксировать forward-рекомендацию: принять 20/мин стартовым с
последующим снижением по боевому трафику, либо предложить иное значение. Это
конкретный предмет рекомендации, а не пустой слот.

```verified-context-json
[
  {
    "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"
  }
]
```
