Files
ipam_control/docs/changes/012-login-rate-limit/PLAN.md
T

35 lines
4.2 KiB
Markdown
Raw Normal View History

# Ограничение попыток входа и защита журнала от вытеснения (изменение 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/<next>_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.