"""Вход в 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 delete, func, select from sqlalchemy.orm import Session from app.db import get_db 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, 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): _, _, 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)) @router.get("/me", response_model=UserOut) def me(user: User = Depends(current_user)): return user