2026-09-27 11:26:57 +03:00
"""Пользователи: учётные записи UI (роли superadmin/admin/viewer), смена своего пароля.
2026-09-26 13:25:33 +03:00
Правила: логин после создания не меняется (он же `sub` в JWT), нельзя отключить/понизить/удалить
2026-09-27 11:26:57 +03:00
свою учётную запись и последнего активного суперадминистратора; зарезервированные логины `system`
2026-09-26 13:25:33 +03:00
и `anonymous` запрещены — журнал различает по ним служебные события (`actor_of` в app/services.py).
2026-09-27 11:26:57 +03:00
Управление пользователями — только superadmin (изменение 032).
2026-09-26 13:25:33 +03:00
"""
2026-09-26 21:33:50 +03:00
from datetime import datetime , timezone
2026-09-26 13:25:33 +03:00
from fastapi import APIRouter , Depends , HTTPException , Query
2026-09-27 16:57:14 +03:00
from sqlalchemy import delete , func , select
2026-09-26 13:25:33 +03:00
from sqlalchemy.orm import Session
from app import schemas as s
2026-09-27 16:57:14 +03:00
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
2026-09-26 13:25:33 +03:00
from app.db import get_db
2026-09-27 16:57:14 +03:00
from app.models import Organization , RecoveryCode , Role , User
from app.request_context import request_meta
2026-09-27 11:26:57 +03:00
from app.security import admin_user , create_token , current_user , hash_password , superadmin_user , verify_password
2026-09-26 21:33:50 +03:00
from app.services import MAX_OFFSET , apply_update , audit , commit , contains , count , flush , get_or_404 , refuse_delete
2026-09-26 13:25:33 +03:00
router = APIRouter ( dependencies = [ Depends ( current_user )], tags = [ "users" ])
2026-09-27 11:26:57 +03:00
USERS_LOCK = 703002 # advisory lock: изменения прав суперадминистраторов идут по одному (иначе двое отключат друг друга одновременно)
2026-09-26 21:33:50 +03:00
2026-09-27 11:26:57 +03:00
def _lock_users ( db : Session ) -> None :
db . execute ( select ( func . pg_advisory_xact_lock ( USERS_LOCK )))
2026-09-26 21:33:50 +03:00
2026-09-27 11:26:57 +03:00
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 ))
2026-09-26 13:25:33 +03:00
@router.get ( "/users" , response_model = s . Page [ s . UserOut ])
def list_users (
2026-09-26 21:33:50 +03:00
q : str = "" , limit : int = Query ( 100 , ge = 1 , le = 500 ), offset : int = Query ( 0 , ge = 0 , le = MAX_OFFSET ),
2026-09-27 11:26:57 +03:00
db : Session = Depends ( get_db ), user : User = Depends ( superadmin_user ), # изменение 032
2026-09-26 13:25:33 +03:00
):
stmt = select ( User )
if q . strip ():
2026-09-26 21:33:50 +03:00
stmt = stmt . where ( contains ( User . username , q ))
2026-09-26 13:25:33 +03:00
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 )
2026-09-27 11:26:57 +03:00
def create_user ( body : s . UserIn , db : Session = Depends ( get_db ), user : User = Depends ( superadmin_user )): # изменение 032
2026-09-26 13:25:33 +03:00
# логин уникален без учёта регистра: в токене и в журнале он должен опознаваться однозначно
if db . scalar ( select ( User . id ) . where ( func . lower ( User . username ) == body . username . lower ())):
raise HTTPException ( 409 , "Пользователь с таким логином уже существует" )
2026-09-27 11:26:57 +03:00
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
2026-09-26 13:25:33 +03:00
db . add ( u )
2026-09-27 11:26:57 +03:00
flush ( db ) # изменение 033, находка №10: логин уже проверен выше, гонку покрывает CONFLICT_MSG
audit ( db , user , "user" , u , "created" , u . username )
commit ( db )
2026-09-26 13:25:33 +03:00
return u
@router.patch ( "/users/ {id} " , response_model = s . UserOut )
2026-09-27 11:26:57 +03:00
def update_user ( id : int , body : s . UserUpdate , db : Session = Depends ( get_db ), user : User = Depends ( superadmin_user )): # изменение 032
2026-09-26 13:25:33 +03:00
data = body . model_dump ( exclude_unset = True , exclude_none = True )
2026-09-26 21:33:50 +03:00
if "role" in data or "is_active" in data :
2026-09-27 11:26:57 +03:00
_lock_users ( db ) # до чтения пользователя и подсчёта суперадминистраторов (изменение 032)
2026-09-26 21:33:50 +03:00
u = get_or_404 ( db , User , id , "Пользователь" )
2026-09-27 11:26:57 +03:00
if u . id == user . id and "password" in data :
2026-09-26 21:33:50 +03:00
raise HTTPException ( 422 , "Свой пароль меняется через /users/me/password (с подтверждением текущего)" )
2026-09-26 13:25:33 +03:00
pwd = data . pop ( "password" , None )
role , is_active = data . get ( "role" , u . role ), data . get ( "is_active" , u . is_active )
2026-09-27 11:26:57 +03:00
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
2026-09-26 13:25:33 +03:00
raise HTTPException ( 409 , "Нельзя отключить или понизить свою учётную запись" )
2026-09-27 11:26:57 +03:00
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" ], "Организация" )
2026-09-26 13:25:33 +03:00
changed = apply_update ( u , data )
if pwd :
u . password_hash = hash_password ( pwd )
2026-09-26 21:33:50 +03:00
u . password_changed_at = datetime . now ( timezone . utc )
2026-09-26 13:25:33 +03:00
if changed :
2026-09-27 11:26:57 +03:00
audit ( db , user , "user" , u , "updated" , u . username , changed )
2026-09-26 13:25:33 +03:00
if pwd :
2026-09-27 11:26:57 +03:00
audit ( db , user , "user" , u , "password_reset" , u . username , message = f "Пользователь { u . username } : пароль изменён администратором" )
2026-09-26 13:25:33 +03:00
commit ( db )
return u
@router.delete ( "/users/ {id} " , status_code = 204 )
2026-09-27 11:26:57 +03:00
def delete_user ( id : int , db : Session = Depends ( get_db ), user : User = Depends ( superadmin_user )): # изменение 032
_lock_users ( db )
2026-09-26 13:25:33 +03:00
u = get_or_404 ( db , User , id , "Пользователь" )
2026-09-27 11:26:57 +03:00
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 )
2026-09-26 13:25:33 +03:00
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 )
2026-09-26 21:33:50 +03:00
user . password_changed_at = datetime . now ( timezone . utc )
2026-09-26 13:25:33 +03:00
audit ( db , user , "user" , user , "password_reset" , user . username , message = f " { user . username } : пароль изменён пользователем" )
commit ( db )
2026-09-26 21:33:50 +03:00
return { "ok" : True , "access_token" : create_token ( user )} # прежние токены недействительны — текущая сессия получает новый
2026-09-27 16:57:14 +03:00
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 }