Задачи 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:
1 parent
cd09ef0805
commit
13e17fbb47
57 files changed
+1748
-166
No files matched your search
+72
-4
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user