Задачи 011-024: доработки по ревью кодовой базы и исправление находок

Ревью кодовой базы (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>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-26 21:33:50 +03:00
1 parent cd09ef0805
commit 13e17fbb47
57 files changed
+1748 -166

No files matched your search

+72 -4
View File
@@ -1,23 +1,91 @@
"""Вход в UI. Перебор паролей ограничен: {MAX_PER_LOGIN} неудач на логин и {MAX_PER_IP} на IP за {WINDOW_MIN} минут → 429 (изменение 012)."""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.db import get_db
from app.models import User
from app.models import LoginAttempt, User
from app.request_context import request_meta
from app.schemas import LoginIn, TokenOut, UserOut
from app.security import create_token, current_user, verify_password
from app.security import create_token, current_user, hash_password, verify_password
from app.services import ANONYMOUS, audit
router = APIRouter(prefix="/auth", tags=["auth"])
WINDOW = timedelta(minutes=10)
MAX_PER_LOGIN = 5
MAX_PER_IP = 20
__doc__ = __doc__.format(MAX_PER_LOGIN=MAX_PER_LOGIN, MAX_PER_IP=MAX_PER_IP, WINDOW_MIN=int(WINDOW.total_seconds() // 60))
_DUMMY_HASH = hash_password("dummy-password-for-timing") # выравнивает время ответа для несуществующего логина
def _condition(login: str, ip: str | None, scope: str):
return (LoginAttempt.username == login) if scope == "login" else (LoginAttempt.client_ip == ip)
def _retry_after(db: Session, login: str, ip: str | None) -> tuple[int, str | None, dict[str, int]]:
"""(секунд до конца блокировки, причина 'login'|'ip', {"login": n, "ip": m} — число неудач в каждой области, до лимита).
Блокировка длится окно после последней неудачи. Счётчики по областям отдельно (изменение 024, находка №4):
решение о записи в журнал принимается по конкретному логину, а не по максимуму среди логина и IP."""
now = datetime.now(timezone.utc)
worst, why, counts = 0, None, {}
for scope, limit in (("login", MAX_PER_LOGIN), ("ip", MAX_PER_IP)):
if scope == "ip" and ip is None:
continue
rows = db.scalars(select(LoginAttempt.ts).where(_condition(login, ip, scope), LoginAttempt.ts > now - WINDOW).order_by(LoginAttempt.ts.desc()).limit(limit)).all()
counts[scope] = len(rows)
if len(rows) >= limit:
left = int((rows[0] + WINDOW - now).total_seconds()) + 1
if left > worst:
worst, why = left, scope
return worst, why, counts
def _distinct_logins(db: Session, ip: str) -> int:
"""Число различных логинов, для которых была неудачная попытка с этого IP в окне (для diff записи session.locked)."""
now = datetime.now(timezone.utc)
return db.scalar(select(func.count(func.distinct(LoginAttempt.username))).where(LoginAttempt.client_ip == ip, LoginAttempt.ts > now - WINDOW)) or 0
def _locked(retry: int) -> HTTPException:
return HTTPException(429, {"message": f"Слишком много неудачных попыток входа. Повторите через {max(1, -(-retry // 60))} мин.", "retry_after_seconds": retry},
headers={"Retry-After": str(retry)})
@router.post("/login", response_model=TokenOut)
def login(body: LoginIn, db: Session = Depends(get_db)):
ctx = request_meta.get()
ip = ctx["client_ip"] if ctx else None
name = body.username.strip().lower()
# сериализация попыток одного логина (изменение 024, находка №1): без этого параллельные запросы
# проходят проверку блокировки одновременно, и лимит «N за окно» превращается в «N + степень параллелизма»
db.execute(select(func.pg_advisory_xact_lock(func.hashtext(name))))
retry, _, _ = _retry_after(db, name, ip)
if retry: # блокировка: пароль не проверяем, в журнал не пишем (запись о блокировке уже есть)
raise _locked(retry)
user = db.scalar(select(User).where(User.username == body.username, User.is_active))
if user is None:
verify_password(body.password, _DUMMY_HASH)
if user is None or not verify_password(body.password, user.password_hash):
audit(db, ANONYMOUS, "session", None, "failed", body.username[:100], message="Неудачная попытка входа в UI")
_, _, before = _retry_after(db, name, ip)
db.add(LoginAttempt(client_ip=ip, username=name))
db.flush()
retry, why, after = _retry_after(db, name, ip)
if retry and before[why] < after[why]: # именно этот запрос впервые пересёк лимит — запись пишем один раз
attempts = after[why]
diff = {"scope": why, "attempts": attempts, "retry_after_seconds": retry}
if why == "ip":
diff["distinct_logins"] = _distinct_logins(db, ip)
audit(db, ANONYMOUS, "session", None, "locked", body.username[:100], diff,
message=f"Вход заблокирован на {max(1, -(-retry // 60))} мин.: {attempts} неудачных попыток ({'по логину' if why == 'login' else 'с IP'})")
elif before["login"] == 0: # в журнал — только первая неудача по этому логину в окне (не по IP: иначе перебор логинов с одного IP её не оставит)
audit(db, ANONYMOUS, "session", None, "failed", body.username[:100], message="Неудачная попытка входа в UI")
db.commit()
if retry:
raise _locked(retry)
raise HTTPException(401, "Неверный логин или пароль")
db.execute(delete(LoginAttempt).where(LoginAttempt.username == name))
audit(db, user, "session", None, "login", user.username, message=f"Вход в UI: {user.username}")
db.commit()
return TokenOut(access_token=create_token(user))