Задача 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>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-27 16:57:14 +03:00
1 parent c852f47f09
commit d3495fb077
18 files changed
+835 -56

No files matched your search

+90 -32
View File
@@ -9,11 +9,13 @@ 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, TokenOut, UserOut
from app.security import create_token, current_user, hash_password, verify_password
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"])
@@ -80,19 +82,57 @@ def _locked(retry: int) -> HTTPException:
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()
# сериализация попыток одного логина (изменение 024, находка №1), затем — этого IP (изменение 027, находка №7):
# порядок всегда «логин, затем IP» исключает взаимную блокировку; без второй блокировки параллельный перебор
# разных логинов с одного IP проходит проверку лимита по IP одновременно, и лимит превышается на степень
# параллелизма — ценой служит то, что попытки с одного IP (в том числе за NAT) обрабатываются по одной,
# и при массовом входе время ответа растёт на время проверки argon2.
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))))
_lock_pair(db, name, ip)
known = _is_known(db, name, ip)
retry, _, _ = _retry_after(db, name, ip, known)
if retry: # блокировка: пароль не проверяем, в журнал не пишем (запись о блокировке уже есть)
@@ -101,31 +141,49 @@ def login(body: LoginIn, db: Session = Depends(get_db)):
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, 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", body.username[:100], diff,
message=f"Вход заблокирован на {max(1, -(-retry // 60))} мин.: {attempts} неудачных попыток ({scope_ru})")
elif before["login"] == 0: # в журнал — только первая неудача по этому логину в окне (не по IP: иначе перебор логинов с одного IP её не оставит)
audit(db, ANONYMOUS, "session", None, "failed", body.username[:100], message="Неудачная попытка входа в UI")
db.commit()
retry = _record_failure(db, name, ip, known, body.username[:100], "failed", "Неудачная попытка входа в UI")
if retry:
raise _locked(retry)
raise HTTPException(401, "Неверный логин или пароль")
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, message=f"Вход в UI: {user.username}")
db.commit()
return TokenOut(access_token=create_token(user))
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)
+103 -2
View File
@@ -8,12 +8,19 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app import schemas as s
from app import totp
# изменение 037: лимиты и advisory-lock входа переиспользуются для /users/me/2fa/disable (иначе код 2FA
# можно перебирать неограниченно, имея украденные пароль и токен доступа) — импорт из app.api.v1.auth, а не
# дублирование в app/services.py: это специфика входа (LoginAttempt/KnownLogin), auth.py не зависит от users.py
from app.api.v1.auth import _is_known, _lock_pair, _locked, _record_failure, _retry_after
from app.config import settings
from app.db import get_db
from app.models import Organization, Role, User
from app.models import Organization, RecoveryCode, Role, User
from app.request_context import request_meta
from app.security import admin_user, create_token, current_user, hash_password, superadmin_user, verify_password
from app.services import MAX_OFFSET, apply_update, audit, commit, contains, count, flush, get_or_404, refuse_delete
@@ -122,3 +129,97 @@ def change_own_password(body: s.PasswordChange, db: Session = Depends(get_db), u
audit(db, user, "user", user, "password_reset", user.username, message=f"{user.username}: пароль изменён пользователем")
commit(db)
return {"ok": True, "access_token": create_token(user)} # прежние токены недействительны — текущая сессия получает новый
def _clear_totp(user: User) -> None:
"""Полный сброс состояния 2FA (изменение 037): используется и при своём отключении, и при сбросе суперадминистратором."""
user.totp_secret_enc = None
user.totp_pending_enc = None
user.totp_enabled_at = None
user.totp_last_step = None
@router.post("/users/me/2fa/setup", response_model=s.TotpSetupOut)
def setup_totp(body: s.TotpSetupIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
"""Начало включения 2FA (изменение 037): доступно любой роли, требует текущий пароль (как /users/me/password).
Секрет кладётся в totp_pending_enc — действует до подтверждения кодом в /users/me/2fa/enable."""
if not settings.totp_enc_key:
raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)")
if user.totp_enabled:
raise HTTPException(409, "Двухфакторная аутентификация уже включена")
if not verify_password(body.password, user.password_hash):
raise HTTPException(403, "Неверный пароль")
secret = totp.new_secret()
user.totp_pending_enc = totp.encrypt(secret)
commit(db)
uri = totp.provisioning_uri(secret, user.username)
return s.TotpSetupOut(secret=secret, otpauth_uri=uri, qr=totp.qr_svg_data_uri(uri))
@router.post("/users/me/2fa/enable", response_model=s.RecoveryCodesOut)
def enable_totp(body: s.TotpCodeIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
"""Подтверждение включения 2FA кодом по pending-секрету: перенос в totp_secret_enc, выпуск кодов восстановления
(изменение 037). Коды показываются только в этом ответе — сервер их больше не хранит открытым текстом."""
if not settings.totp_enc_key:
raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)")
if user.totp_enabled:
raise HTTPException(409, "Двухфакторная аутентификация уже включена")
if not user.totp_pending_enc:
raise HTTPException(409, "Настройка 2FA не начата: сначала POST /users/me/2fa/setup")
step = totp.check_code(totp.decrypt(user.totp_pending_enc), body.code, None)
if step is None:
raise HTTPException(422, "Неверный код")
user.totp_secret_enc = user.totp_pending_enc
user.totp_pending_enc = None
user.totp_enabled_at = datetime.now(timezone.utc)
user.totp_last_step = step
db.execute(delete(RecoveryCode).where(RecoveryCode.user_id == user.id)) # коды прошлой неудавшейся настройки не остаются
codes = totp.new_recovery_codes()
db.add_all(RecoveryCode(user_id=user.id, code_hash=h) for _, h in codes)
audit(db, user, "user", user, "2fa_enabled", user.username, message=f"{user.username}: включена двухфакторная аутентификация")
commit(db)
return s.RecoveryCodesOut(recovery_codes=[c for c, _ in codes])
@router.post("/users/me/2fa/disable")
def disable_totp(body: s.TotpDisableIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
"""Отключение 2FA: пароль и код (TOTP либо восстановления). Очищает секреты и все коды восстановления (изменение 037).
Код ограничен теми же лимитами перебора, что и вход (`_retry_after`/`LoginAttempt`, изменение 037): без этого
имеющий пароль и (действующий) токен доступа мог бы перебирать 6-значный TOTP-код неограниченно."""
if not user.totp_enabled:
raise HTTPException(409, "Двухфакторная аутентификация не включена")
ctx = request_meta.get()
ip = ctx["client_ip"] if ctx else None
name = user.username.lower()
_lock_pair(db, name, ip)
known = _is_known(db, name, ip)
retry, _, _ = _retry_after(db, name, ip, known)
if retry: # блокировка: пароль и код не проверяем, как в /auth/login
raise _locked(retry)
if not verify_password(body.password, user.password_hash):
raise HTTPException(403, "Неверный пароль") # неверный пароль — как в /users/me/password, без учёта попыток
if not settings.totp_enc_key:
raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)")
if totp.verify(user, body.code) is None and totp.use_recovery_code(db, user, body.code) is None:
retry = _record_failure(db, name, ip, known, user.username, "2fa_failed", "Неверный код 2FA при отключении")
if retry:
raise _locked(retry)
raise HTTPException(422, "Неверный код")
_clear_totp(user)
db.execute(delete(RecoveryCode).where(RecoveryCode.user_id == user.id))
audit(db, user, "user", user, "2fa_disabled", user.username, message=f"{user.username}: отключена двухфакторная аутентификация")
commit(db)
return {"ok": True}
@router.post("/users/{id}/2fa/reset")
def reset_totp(id: int, db: Session = Depends(get_db), user: User = Depends(superadmin_user)):
"""Сброс 2FA другому пользователю — только superadmin; свою 2FA отключают через /users/me/2fa/disable (изменение 037)."""
if id == user.id:
raise HTTPException(409, "Свою двухфакторную аутентификацию отключают через /users/me/2fa/disable")
u = get_or_404(db, User, id, "Пользователь")
_clear_totp(u)
db.execute(delete(RecoveryCode).where(RecoveryCode.user_id == u.id))
audit(db, user, "user", u, "2fa_reset", u.username, message=f"Пользователю {u.username}: двухфакторная аутентификация сброшена суперадминистратором {user.username}")
commit(db)
return {"ok": True}