"""Вход в UI. Перебор паролей ограничен тремя областями (окно {WINDOW_MIN} минут → 429, изменения 012, 026, 027): {MAX_PER_LOGIN_IP} неудач на пару логин+IP, {MAX_PER_IP} на IP (любые логины), {MAX_PER_LOGIN} на логин со всех IP, кроме «известных» (IP, с которого этот пользователь успешно входил за последние {KNOWN_IP_DAYS} дн.) — иначе анонимный клиент, знающий логин, мог бы держать чужую учётную запись заблокированной с любого IP (находка №2 ревью 2026-09-26).""" from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import and_, delete, func, select from sqlalchemy.dialects.postgresql import insert from sqlalchemy.orm import Session from app import totp from app.config import settings from app.db import get_db from app.models import KnownLogin, LoginAttempt, User from app.request_context import request_meta from app.schemas import LoginIn, MfaLoginIn, TokenOut, UserOut from app.security import create_mfa_token, create_token, current_user, decode_mfa_token, hash_password, verify_password from app.services import ANONYMOUS, KNOWN_IP_DAYS, LOGIN_WINDOW, MAX_PER_IP, MAX_PER_LOGIN, MAX_PER_LOGIN_IP, audit router = APIRouter(prefix="/auth", tags=["auth"]) # пороги перебора (LOGIN_WINDOW, MAX_PER_*, KNOWN_IP_DAYS) — в app.services (изменение 030, ревью 025-029 находка №2) # пространства ключей двухаргументного advisory-lock (не пересекаются с одноаргументными: LOCK_KEY/USERS_ADMIN_LOCK) LOGIN_LOCK_NS = 7031 IP_LOCK_NS = 7032 __doc__ = __doc__.format(MAX_PER_LOGIN_IP=MAX_PER_LOGIN_IP, MAX_PER_IP=MAX_PER_IP, MAX_PER_LOGIN=MAX_PER_LOGIN, WINDOW_MIN=int(LOGIN_WINDOW.total_seconds() // 60), KNOWN_IP_DAYS=KNOWN_IP_DAYS) _DUMMY_HASH = hash_password("dummy-password-for-timing") # выравнивает время ответа для несуществующего логина def _condition(login: str, ip: str | None, scope: str): if scope == "login_ip": return and_(LoginAttempt.username == login, LoginAttempt.client_ip == ip) if scope == "ip": return LoginAttempt.client_ip == ip return LoginAttempt.username == login # "login" — по всем IP def _is_known(db: Session, login: str, ip: str | None) -> bool: """IP считается известным для логина, если вход с него был успешен не более KNOWN_IP_DAYS дней назад.""" if ip is None: return False cutoff = datetime.now(timezone.utc) - timedelta(days=KNOWN_IP_DAYS) return db.scalar(select(KnownLogin.username).where(KnownLogin.username == login, KnownLogin.client_ip == ip, KnownLogin.last_seen > cutoff)) is not None def _retry_after(db: Session, login: str, ip: str | None, known: bool) -> tuple[int, str | None, dict[str, int]]: """(секунд до конца блокировки, область 'login_ip'|'ip'|'login', счётчики по каждой области, до лимита). Блокировка длится окно после последней неудачи. Область 'login' не блокирует, если IP запроса известен (изменение 026, находка №2); 'login_ip' и 'ip' действуют всегда. Счётчики считаются отдельно (изменение 024, находка №4): решение о записи в журнал принимается по конкретной области, а не по максимуму среди них.""" now = datetime.now(timezone.utc) worst, why, counts = 0, None, {} for scope, limit in (("login_ip", MAX_PER_LOGIN_IP), ("ip", MAX_PER_IP), ("login", MAX_PER_LOGIN)): if scope in ("login_ip", "ip") and ip is None: continue rows = db.scalars(select(LoginAttempt.ts).where(_condition(login, ip, scope), LoginAttempt.ts > now - LOGIN_WINDOW).order_by(LoginAttempt.ts.desc()).limit(limit)).all() counts[scope] = len(rows) if len(rows) >= limit and not (scope == "login" and known): left = int((rows[0] + LOGIN_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 - LOGIN_WINDOW)) or 0 def _remember_login(db: Session, login: str, ip: str | None) -> None: """Отмечает IP как известный для логина (upsert last_seen); вызывается при успешном входе.""" if ip is None: return stmt = insert(KnownLogin).values(username=login, client_ip=ip, last_seen=func.now()) db.execute(stmt.on_conflict_do_update(index_elements=[KnownLogin.username, KnownLogin.client_ip], set_={"last_seen": func.now()})) def _locked(retry: int) -> HTTPException: return HTTPException(429, {"message": f"Слишком много неудачных попыток входа. Повторите через {max(1, -(-retry // 60))} мин.", "retry_after_seconds": retry}, headers={"Retry-After": str(retry)}) def _lock_pair(db: Session, name: str, ip: str | None) -> None: """Сериализация попыток одного логина (изменение 024, находка №1), затем — этого IP (изменение 027, находка №7): порядок всегда «логин, затем IP» исключает взаимную блокировку; без второй блокировки параллельный перебор разных логинов с одного IP проходит проверку лимита по IP одновременно, и лимит превышается на степень параллелизма — ценой служит то, что попытки с одного IP (в том числе за NAT) обрабатываются по одной, и при массовом входе время ответа растёт на время проверки argon2/TOTP.""" db.execute(select(func.pg_advisory_xact_lock(LOGIN_LOCK_NS, func.hashtext(name)))) if ip is not None: db.execute(select(func.pg_advisory_xact_lock(IP_LOCK_NS, func.hashtext(ip)))) def _record_failure(db: Session, name: str, ip: str | None, known: bool, label: str, action: str, message: str) -> int: """Общий учёт неудачной попытки (пароль или второй фактор, изменение 037): та же запись в LoginAttempt и тот же выбор события в журнал (`session.locked`, если попытка впервые пересекла лимит; иначе `session.`, только первая неудача по этому логину в окне). Возвращает секунды до конца блокировки (0 — не заблокирован).""" _, _, before = _retry_after(db, name, ip, known) db.add(LoginAttempt(client_ip=ip, username=name)) db.flush() retry, why, after = _retry_after(db, name, ip, known) 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) scope_ru = {"login_ip": "по логину и IP", "ip": "с IP", "login": "по логину"}[why] audit(db, ANONYMOUS, "session", None, "locked", label, diff, message=f"Вход заблокирован на {max(1, -(-retry // 60))} мин.: {attempts} неудачных попыток ({scope_ru})") elif before["login"] == 0: # в журнал — только первая неудача по этому логину в окне (не по IP: иначе перебор логинов с одного IP её не оставит) audit(db, ANONYMOUS, "session", None, action, label, message=message) db.commit() return retry def _complete_login(db: Session, user: User, ip: str | None, name: str, diff: dict | None = None) -> TokenOut: """Общее завершение успешного входа — паролем (без 2FA) или вторым фактором (изменение 037): очистка попыток, отметка известного IP, `last_login_*`, запись `session.login` в журнал, выдача токена доступа.""" db.execute(delete(LoginAttempt).where(LoginAttempt.username == name)) _remember_login(db, name, ip) user.last_login_at = func.now() # изменение 035 user.last_login_ip = ip # изменение 035 audit(db, user, "session", None, "login", user.username, diff, message=f"Вход в UI: {user.username}") db.commit() return TokenOut(access_token=create_token(user)) @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() _lock_pair(db, name, ip) known = _is_known(db, name, ip) retry, _, _ = _retry_after(db, name, ip, known) 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): retry = _record_failure(db, name, ip, known, body.username[:100], "failed", "Неудачная попытка входа в UI") if retry: raise _locked(retry) raise HTTPException(401, "Неверный логин или пароль") if user.totp_enabled: # изменение 037: второй фактор — токен доступа не выдаётся, попытки/last_login_* не трогаем if not settings.totp_enc_key: raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)") return TokenOut(mfa_required=True, mfa_token=create_mfa_token(user)) return _complete_login(db, user, ip, name) @router.post("/login/2fa", response_model=TokenOut) def login_2fa(body: MfaLoginIn, db: Session = Depends(get_db)): """Второй шаг входа (изменение 037): код TOTP либо код восстановления. Те же лимиты и advisory-lock, что у пароля — неверный код тоже считается неудачной попыткой входа этого логина.""" ctx = request_meta.get() ip = ctx["client_ip"] if ctx else None user = decode_mfa_token(body.mfa_token, db) name = user.username.lower() _lock_pair(db, name, ip) # изменение 037: user был загружен ДО advisory-lock — под конкурентным запросом с тем же кодом это устаревший # снимок (totp_last_step/totp_enabled ещё не видят чужой commit). После получения блокировки перечитываем # пользователя из БД, иначе два параллельных запроса с одинаковым кодом оба могут пройти проверку (гонка). db.refresh(user) known = _is_known(db, name, ip) retry, _, _ = _retry_after(db, name, ip, known) if retry: raise _locked(retry) if not user.totp_enabled: # 2FA отключена между шагами входа — токен больше не действителен raise HTTPException(401, "Недействительный токен") if not settings.totp_enc_key: raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)") step = totp.verify(user, body.code) if step is not None: user.totp_last_step = step return _complete_login(db, user, ip, name) remaining = totp.use_recovery_code(db, user, body.code) if remaining is not None: return _complete_login(db, user, ip, name, diff={"recovery_code": True, "recovery_codes_left": remaining}) retry = _record_failure(db, name, ip, known, user.username, "2fa_failed", "Неверный код 2FA при входе в UI") if retry: raise _locked(retry) raise HTTPException(401, "Неверный код") @router.get("/me", response_model=UserOut) def me(user: User = Depends(current_user)): return user