Пользователь сам включает 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>
226 lines
15 KiB
Python
226 lines
15 KiB
Python
"""Пользователи: учётные записи 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}
|