Files
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

226 lines
15 KiB
Python
Raw Permalink 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 (роли superadmin/admin/viewer), смена своего пароля.
Правила: логин после создания не меняется (он же `sub` в JWT), нельзя отключить/понизить/удалить
свою учётную запись и последнего активного суперадминистратора; зарезервированные логины `system`
и `anonymous` запрещены — журнал различает по ним служебные события (`actor_of` в app/services.py).
Управление пользователями — только superadmin (изменение 032).
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
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, 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
router = APIRouter(dependencies=[Depends(current_user)], tags=["users"])
USERS_LOCK = 703002 # advisory lock: изменения прав суперадминистраторов идут по одному (иначе двое отключат друг друга одновременно)
def _lock_users(db: Session) -> None:
db.execute(select(func.pg_advisory_xact_lock(USERS_LOCK)))
def _other_active_superadmins(db: Session, user_id: int) -> int:
"""Активные суперадминистраторы, кроме указанного: 0 — система осталась бы без прав записи (изменение 032)."""
return count(db, select(User.id).where(User.role == Role.superadmin, User.is_active, User.id != user_id))
@router.get("/users", response_model=s.Page[s.UserOut])
def list_users(
q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
db: Session = Depends(get_db), user: User = Depends(superadmin_user), # изменение 032
):
stmt = select(User)
if q.strip():
stmt = stmt.where(contains(User.username, q))
total = count(db, stmt)
rows = db.scalars(stmt.order_by(User.username).limit(limit).offset(offset)).all()
return s.Page(items=[s.UserOut.model_validate(u) for u in rows], total=total)
@router.post("/users", response_model=s.UserOut, status_code=201)
def create_user(body: s.UserIn, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032
# логин уникален без учёта регистра: в токене и в журнале он должен опознаваться однозначно
if db.scalar(select(User.id).where(func.lower(User.username) == body.username.lower())):
raise HTTPException(409, "Пользователь с таким логином уже существует")
if body.organization_id is not None:
get_or_404(db, Organization, body.organization_id, "Организация") # изменение 033, находка №10
u = User(username=body.username, password_hash=hash_password(body.password), role=body.role, organization_id=body.organization_id, is_active=body.is_active) # изменение 032
db.add(u)
flush(db) # изменение 033, находка №10: логин уже проверен выше, гонку покрывает CONFLICT_MSG
audit(db, user, "user", u, "created", u.username)
commit(db)
return u
@router.patch("/users/{id}", response_model=s.UserOut)
def update_user(id: int, body: s.UserUpdate, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032
data = body.model_dump(exclude_unset=True, exclude_none=True)
if "role" in data or "is_active" in data:
_lock_users(db) # до чтения пользователя и подсчёта суперадминистраторов (изменение 032)
u = get_or_404(db, User, id, "Пользователь")
if u.id == user.id and "password" in data:
raise HTTPException(422, "Свой пароль меняется через /users/me/password (с подтверждением текущего)")
pwd = data.pop("password", None)
role, is_active = data.get("role", u.role), data.get("is_active", u.is_active)
loses_superadmin = u.role == Role.superadmin and u.is_active and (role != Role.superadmin or not is_active) # изменение 032
if u.id == user.id and (role != Role.superadmin or not is_active): # изменение 032
raise HTTPException(409, "Нельзя отключить или понизить свою учётную запись")
if loses_superadmin and _other_active_superadmins(db, u.id) == 0: # изменение 032
raise HTTPException(409, "Нельзя отключить или понизить единственного активного суперадминистратора")
# реконсиляция «роль — организация» по итоговому состоянию, до apply_update (изменение 033, находка №3)
org_id_passed = "organization_id" in data # exclude_none=True выше уже отбросил явный null
if role == Role.superadmin:
if org_id_passed:
raise HTTPException(422, "Суперадминистратор не привязан к организации")
data["organization_id"] = None # повышение снимает организацию само
else:
org_f = data.get("organization_id", u.organization_id)
if org_f is None and (is_active or "role" in data or org_id_passed):
raise HTTPException(422, "Администратор и просмотрщик должны быть привязаны к организации")
if org_id_passed:
get_or_404(db, Organization, data["organization_id"], "Организация")
changed = apply_update(u, data)
if pwd:
u.password_hash = hash_password(pwd)
u.password_changed_at = datetime.now(timezone.utc)
if changed:
audit(db, user, "user", u, "updated", u.username, changed)
if pwd:
audit(db, user, "user", u, "password_reset", u.username, message=f"Пользователь {u.username}: пароль изменён администратором")
commit(db)
return u
@router.delete("/users/{id}", status_code=204)
def delete_user(id: int, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032
_lock_users(db)
u = get_or_404(db, User, id, "Пользователь")
if u.id == user.id:
refuse_delete(db, user, "user", u, u.username, "Нельзя удалить свою учётную запись")
if u.role == Role.superadmin and u.is_active and _other_active_superadmins(db, u.id) == 0: # изменение 032
refuse_delete(db, user, "user", u, u.username, "Нельзя удалить единственного активного суперадминистратора")
audit(db, user, "user", u, "deleted", u.username)
db.delete(u)
commit(db)
@router.post("/users/me/password")
def change_own_password(body: s.PasswordChange, db: Session = Depends(get_db), user: User = Depends(current_user)):
"""Смена своего пароля: доступна любой роли, требует текущий пароль."""
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(403, "Неверный текущий пароль")
user.password_hash = hash_password(body.new_password)
user.password_changed_at = datetime.now(timezone.utc)
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}