Ревью кодовой базы (docs/reviews/2026-09-26-codebase-review.md) и планы по каждой находке:
011 IP уникален в VRF и хранится в самом узком префиксе (addresses.vrf_id, составной FK
с каскадом при переносе VRF, миграция 0007 с остановкой на дублях).
012 Ограничение попыток входа (login_attempts, 429 + Retry-After), выравнивание времени
ответа, журнал без вытеснения анонимными событиями (миграция 0006).
013 Границы пагинации: отрицательные/чрезмерные limit/offset дают 422 вместо 500.
014 Экран адресов: страница свободных адресов арифметикой, пагинация в SQL.
015 Запрет адреса сети/broadcast, загрузка не выше 100 %.
016 Роль по умолчанию — viewer.
017 Проверка JWT_SECRET/ADMIN_PASSWORD при старте.
018 null в PATCH очищает текстовые поля; нейтральный текст конфликта БД.
019 Пакетная загрузка в списках вместо N+1.
020 Автоназначение адреса вне вложенных префиксов, с блокировкой префикса.
021 Advisory-lock при снятии прав администратора, уникальный lower(username) (миграция 0008).
022 Контейнер не от root, healthcheck, блокировка миграций, requirements.lock.
023 Экранирование LIKE, журнал отказов очистки, заголовки безопасности, учёт force-удаления,
отзыв токенов при смене пароля (claim pv, миграция 0005).
024 Исправление находок ревью 011-023 (docs/reviews/2026-09-26-changes-011-023-review.md):
сериализация попыток входа, запрет переноса адресов в адрес сети/broadcast, журнал входов,
запрет смены своего пароля через PATCH, валидация PATCH устройства, обновлён тест токенов.
Тесты: 14 passed. Документация: README.md, docs/changes/011-024, docs/reviews.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4.2 KiB
Ограничение попыток входа и защита журнала от вытеснения (изменение 012)
Находка ревью № 2, серьёзность — высокая.
Context
POST /auth/login не ограничен: возможен перебор паролей без авторизации. Каждая неудача пишет session.failed в журнал, а ротация по количеству
(max_entries, 100 000) удаляет самые старые записи — анонимный клиент может вытеснить историю изменений. Дополнительно: при несуществующем логине argon2 не
вызывается (разница во времени ответа раскрывает существование логина), у пароля нет ограничения длины.
Решение
- Учёт попыток — таблица
login_attempts (id, ts, client_ip INET, username)(новая миграция), по образцуClearAttempt; индексы по(client_ip, ts)и(lower(username), ts). - Лимиты (константы в
app/api/v1/auth.py, при необходимости — вSettings):- по логину: 5 неудач за 10 минут → блокировка входа для этого логина на 10 минут;
- по IP: 20 неудач за 10 минут → блокировка IP на 10 минут.
Ответ при блокировке — 429
{"message", "retry_after_seconds"}+ заголовокRetry-After; верный пароль во время блокировки тоже отклоняется. Успешный вход очищает счётчик логина.
- Время ответа: для несуществующего логина — проверка по фиктивному argon2-хэшу (вычисляется один раз при старте).
LoginIn:username≤ 100,password≤ 128 символов. - Журнал без вытеснения:
session.failedпишется только для первой неудачи логина/IP в окне; при срабатывании блокировки — одна записьsession.locked(diff: логин, IP, число попыток, до какого времени);rotate()при превышенииmax_entriesсначала удаляет самые старыеsession.failed, затем остальное — события изменений данных уходят последними.
- Очистка
login_attemptsстарше суток — в том же часовом цикле ротации. - 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.