"""Пользователи: учётные записи 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}