# Ограничение попыток входа и защита журнала от вытеснения (изменение 012) Находка ревью № 2, серьёзность — высокая. ## Context `POST /auth/login` не ограничен: возможен перебор паролей без авторизации. Каждая неудача пишет `session.failed` в журнал, а ротация по количеству (`max_entries`, 100 000) удаляет самые старые записи — анонимный клиент может вытеснить историю изменений. Дополнительно: при несуществующем логине argon2 не вызывается (разница во времени ответа раскрывает существование логина), у пароля нет ограничения длины. ## Решение 1. **Учёт попыток** — таблица `login_attempts (id, ts, client_ip INET, username)` (новая миграция), по образцу `ClearAttempt`; индексы по `(client_ip, ts)` и `(lower(username), ts)`. 2. **Лимиты** (константы в `app/api/v1/auth.py`, при необходимости — в `Settings`): - по логину: 5 неудач за 10 минут → блокировка входа для этого логина на 10 минут; - по IP: 20 неудач за 10 минут → блокировка IP на 10 минут. Ответ при блокировке — 429 `{"message", "retry_after_seconds"}` + заголовок `Retry-After`; верный пароль во время блокировки тоже отклоняется. Успешный вход очищает счётчик логина. 3. **Время ответа:** для несуществующего логина — проверка по фиктивному argon2-хэшу (вычисляется один раз при старте). `LoginIn`: `username` ≤ 100, `password` ≤ 128 символов. 4. **Журнал без вытеснения:** - `session.failed` пишется только для первой неудачи логина/IP в окне; при срабатывании блокировки — одна запись `session.locked` (`diff`: логин, IP, число попыток, до какого времени); - `rotate()` при превышении `max_entries` сначала удаляет самые старые `session.failed`, затем остальное — события изменений данных уходят последними. 5. **Очистка** `login_attempts` старше суток — в том же часовом цикле ротации. 6. **UI:** на экране входа — текст 429 с временем до разблокировки (уже выводится `err.message`). ## Файлы `alembic/versions/_login_attempts.py`, `app/models.py`, `app/api/v1/auth.py`, `app/schemas.py`, `app/rotation.py`, `web/app.js` (при необходимости), `tests/test_journal.py` или новый `tests/test_auth.py`, `README.md`. ## Тест (один сценарий) 6 неверных паролей для временного пользователя → на 6-й 429 с `retry_after_seconds`; верный пароль тоже 429; в журнале одна `session.failed` и одна `session.locked`. Очистка блокировки в тесте — прямым SQL (фикстура `db`). ## Проверка `pytest -q`; вручную: серия неверных входов в UI → сообщение о блокировке; через 10 минут вход работает. ## Риски Блокировка по логину позволяет «запереть» чужую учётную запись перебором — поэтому окно короткое (10 минут) и событие видно в журнале. Если стенд за reverse-proxy, без `TRUSTED_PROXIES` все клиенты делят один IP — лимит по IP заденет всех; отметить в README.