Files
ipam_control/app/api/v1/auth.py
T
ayurishchevandClaude Opus 5.5 d3495fb077 Задача 037: двухфакторная аутентификация TOTP по выбору пользователя
Пользователь сам включает TOTP (QR, приложение-аутентификатор); 10 одноразовых кодов
восстановления; superadmin сбрасывает 2FA другому пользователю. Вход в два шага:
/auth/login → mfa_token, /auth/login/2fa — под теми же лимитами и advisory-lock, что пароль.
Секрет шифруется Fernet-ключом TOTP_ENC_KEY, защита от повторного использования кода
(totp_last_step), лимиты перебора кода при отключении. Миграция 0015, зависимости pyotp,
segno, cryptography. UI: второй шаг входа, диалоги 2FA в меню логина, бейдж и сброс
в «Пользователях»; submitDialog не закрывает окно, открытое следующим шагом цепочки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:57:14 +03:00

192 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Вход в 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.<action>`,
только первая неудача по этому логину в окне). Возвращает секунды до конца блокировки (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