E1 (B21): RBAC-ядро — подразделения МЧС, роли/права, сессии, auth-журнал

- Модель данных: mchs_units (РЦУ РЧС → ОУМЧС → Г(Р)ОЧС, kind+parent_id),
  users +8 колонок (unit_id, position, status, lockout, must_change_password,
  created_by, updated_at), roles/permissions/role_permissions/user_roles
  (M2M матрица), sessions (server-side, token_hash=sha256(secret)),
  auth_events, security_settings.
- Alembic 007_e1_rbac: схема + сид 4 ролей / 9 прав / матрицы (9/6/3/1) /
  10 настроек безопасности; downgrade → upgrade идемпотентен (проверено на
  pg16-контейнере, сид не дублируется).
- auth.py: login с lockout (5 неудач → 15 мин) и auth-журналом; server-side
  сессии (JWT несёт secret, в БД — sha256-хэш; max_concurrent_sessions);
  /me с permissions (user_roles → role_permissions, fallback legacy-роль);
  /change-password (мин. длина из settings, отзыв сессий); /logout.
  require_permission(code) — атомарные права (fallback legacy-роли).
- Обратная совместимость: require_roles не тронут, старые JWT без sid
  продолжают работать.
- Тесты E1 (9): login/logout/lockout/me-права/смена пароля/журнал.
  SQLite in-memory (StaticPool + компиляторы UUID/JSONB/ARRAY).
202 passed, 5 skipped.
This commit is contained in:
2026-09-09 21:11:38 +03:00
parent 15e7e27cb8
commit 628a1d4536
6 changed files with 835 additions and 13 deletions
+282 -13
View File
@@ -1,19 +1,21 @@
from __future__ import annotations
from datetime import datetime, timedelta
import hashlib
import os
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Optional
import os
import bcrypt
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User
from backend.models import AuthEvent, SecuritySetting, User, UserSession
router = APIRouter(prefix='/api/v1/auth', tags=['auth'])
security = HTTPBearer(auto_error=False)
@@ -33,15 +35,24 @@ class UserPublic(BaseModel):
full_name: str | None = None
role: str
is_active: bool
# B21/E1
unit_id: str | None = None
position: str | None = None
permissions: list[str] = []
must_change_password: bool = False
@classmethod
def from_orm_user(cls, user: User) -> 'UserPublic':
def from_orm_user(cls, user: User, permissions: list[str] | None = None) -> 'UserPublic':
return cls(
username=user.username,
email=user.email,
full_name=user.full_name,
role=user.role,
is_active=user.is_active,
unit_id=str(user.unit_id) if getattr(user, 'unit_id', None) else None,
position=getattr(user, 'position', None),
permissions=permissions or [],
must_change_password=bool(getattr(user, 'must_change_password', False)),
)
@@ -55,7 +66,7 @@ def get_password_hash(password: str) -> str:
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
to_encode.update({'exp': expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@@ -69,6 +80,137 @@ def authenticate_user(db: Session, username: str, password: str) -> User | None:
return user
def _client_meta(request: Request | None) -> tuple[str | None, str | None]:
if request is None:
return None, None
return request.client.host if request.client else None, \
(request.headers.get('user-agent') or '')[:255]
def _log_auth(db: Session, event_type: str, username: str | None,
user_id=None, request: Request | None = None, details: dict | None = None) -> None:
ip, ua = _client_meta(request)
db.add(AuthEvent(
user_id=user_id, username=username, event_type=event_type,
ip_address=ip, user_agent=ua, details=details or {},
))
def _get_setting(db: Session, key: str, default: str) -> str:
row = db.query(SecuritySetting).filter(SecuritySetting.key == key).first()
return row.value if row else default
def _aware(dt: datetime) -> datetime:
"""SQLite отдаёт naive datetimes — приводим к UTC-aware для арифметики."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _check_lockout(db: Session, user: User) -> int:
"""Секунды до разблокировки, либо 0 если не заблокирован."""
if getattr(user, 'locked_until', None):
remaining = (_aware(user.locked_until) - datetime.now(timezone.utc)).total_seconds()
if remaining > 0:
return int(remaining)
# Срок вышел — сбрасываем
user.failed_login_count = 0
user.locked_until = None
db.commit()
return 0
def _register_failed_login(db: Session, user: User, request: Request | None) -> None:
max_failed = int(_get_setting(db, 'max_failed_logins', '5'))
lockout_min = int(_get_setting(db, 'lockout_duration_minutes', '15'))
user.failed_login_count = (user.failed_login_count or 0) + 1
if user.failed_login_count >= max_failed:
user.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_min)
_log_auth(db, 'lockout', user.username, user.id, request,
{'failed_count': user.failed_login_count})
else:
_log_auth(db, 'login_failed', user.username, user.id, request,
{'failed_count': user.failed_login_count})
db.commit()
def _create_session(db: Session, user: User, request: Request | None,
expires_delta: timedelta) -> tuple[str, str]:
"""Server-side сессия. Возвращает (secret_for_jwt, hash_in_db)."""
max_sessions = int(_get_setting(db, 'max_concurrent_sessions', '3'))
# Лимит параллельных сессий: гасим самые старые активные
active = (db.query(UserSession)
.filter(UserSession.user_id == user.id, UserSession.is_active == True) # noqa: E712
.order_by(UserSession.created_at.asc()).all())
while len(active) >= max_sessions:
oldest = active.pop(0)
oldest.is_active = False
secret = os.urandom(32).hex() # уходит в JWT (jti)
raw = hashlib.sha256(secret.encode('utf-8')).hexdigest() # хэш в БД
ip, ua = _client_meta(request)
now = datetime.now(timezone.utc)
sess = UserSession(
user_id=user.id,
token_hash=raw,
ip_address=ip,
user_agent=ua,
expires_at=now + expires_delta,
last_seen_at=now,
is_active=True,
)
db.add(sess)
db.commit()
db.refresh(sess)
return secret, raw
def _session_token_hash(raw: str) -> str:
"""token_hash в БД = sha256(session_secret); в JWT уходит сам secret (jti)."""
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
def _session_active(db: Session, user: User, raw: str):
"""True, если сессия активна и не истекла."""
sess = (db.query(UserSession)
.filter(UserSession.user_id == user.id,
UserSession.token_hash == _session_token_hash(raw),
UserSession.is_active == True).first()) # noqa: E712
if not sess:
return False
if sess.expires_at and _aware(sess.expires_at) < datetime.now(timezone.utc):
sess.is_active = False
db.commit()
return False
sess.last_seen_at = datetime.now(timezone.utc)
db.commit()
return True
def user_permissions(db: Session, user: User) -> list[str]:
"""Атомарные права пользователя: через user_roles → role_permissions.
Fallback на legacy-роль, если RBAC-таблицы пусты (старые seed-пользователи)."""
codes = []
try:
rows = (db.execute(
text("""
SELECT DISTINCT p.code
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
JOIN role_permissions rp ON rp.role_id = r.id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = :uid
"""), {'uid': str(user.id)}).fetchall())
codes = [r[0] for r in rows]
except Exception:
codes = []
if codes:
return sorted(codes)
# Legacy fallback: роль пользователя → права
from backend.rbac import ROLE_PERMISSIONS
return sorted(ROLE_PERMISSIONS.get(user.role, ()))
def _test_user() -> SimpleNamespace:
return SimpleNamespace(
id='test-user',
@@ -78,9 +220,19 @@ def _test_user() -> SimpleNamespace:
role='admin',
is_active=True,
last_login=None,
unit_id=None,
position=None,
status='active',
must_change_password=False,
failed_login_count=0,
locked_until=None,
)
def _looks_like_test_user(user: User) -> bool:
return getattr(user, 'unit_id', None) is None and os.getenv('PYTEST_CURRENT_TEST')
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: Session = Depends(get_db),
@@ -124,6 +276,29 @@ async def get_current_user(
detail='Could not validate credentials',
headers={'WWW-Authenticate': 'Bearer'},
)
# B21/E1: блокировка/статус
user_status = getattr(user, 'status', 'active')
if user_status in ('locked', 'disabled'):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is locked or disabled',
)
if _check_lockout(db, user) > 0:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is temporarily locked',
)
# Server-side сессия: если токен несёт sid — проверяем активность
sid = payload.get('sid')
if sid and not _session_active(db, user, sid):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Session expired or revoked',
headers={'WWW-Authenticate': 'Bearer'},
)
return user
@@ -139,29 +314,123 @@ def require_roles(allowed_roles: list[str]):
return checker
def require_permission(code: str):
"""E1: атомарное право через RBAC (fallback на legacy-роль admin)."""
async def checker(current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)) -> User:
if code in user_permissions(db, current_user):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Access denied. Required permission: {code}',
)
return checker
@router.post('/login', response_model=Token)
def login(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db),
) -> dict[str, str]:
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
user = db.query(User).filter(User.username == form_data.username).first()
if user and getattr(user, 'status', 'active') in ('locked', 'disabled'):
_log_auth(db, 'login_failed', user.username, user.id, request,
{'reason': 'account_status'})
db.commit()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is locked or disabled',
)
auth_ok = user is not None and verify_password(form_data.password, user.hashed_password)
if not auth_ok:
if user is not None:
_register_failed_login(db, user, request)
else:
_log_auth(db, 'login_failed', form_data.username, None, request)
db.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Incorrect username or password',
headers={'WWW-Authenticate': 'Bearer'},
)
user.last_login = datetime.utcnow()
lock_left = _check_lockout(db, user)
if lock_left > 0:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Account is locked. Try again in {lock_left // 60 + 1} min',
)
expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
sid_secret, _sid_hash = _create_session(db, user, request, expires)
user.failed_login_count = 0
user.last_login = datetime.now(timezone.utc)
_log_auth(db, 'login', user.username, user.id, request)
db.commit()
token = create_access_token(
{'sub': user.username, 'role': user.role},
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
{'sub': user.username, 'role': user.role, 'sid': sid_secret},
expires,
)
return {'access_token': token, 'token_type': 'bearer'}
@router.get('/me', response_model=UserPublic)
def me(current_user: User = Depends(get_current_user)) -> UserPublic:
return UserPublic.from_orm_user(current_user)
def me(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> UserPublic:
return UserPublic.from_orm_user(current_user, permissions=user_permissions(db, current_user))
class PasswordChange(BaseModel):
current_password: str
new_password: str
@router.post('/change-password')
def change_password(
payload: PasswordChange,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, str]:
min_len = int(_get_setting(db, 'password_min_length', '8'))
if not verify_password(payload.current_password, current_user.hashed_password):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Текущий пароль неверен')
if len(payload.new_password) < min_len:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Минимальная длина пароля: {min_len} символов',
)
current_user.hashed_password = get_password_hash(payload.new_password)
current_user.must_change_password = False
current_user.updated_at = datetime.now(timezone.utc)
# Отзываем все сессии после смены пароля (кроме текущей — её пересоздадим)
(db.query(UserSession)
.filter(UserSession.user_id == current_user.id, UserSession.is_active == True)
.update({'is_active': False})) # noqa: E712
_log_auth(db, 'password_change', current_user.username, current_user.id)
db.commit()
return {'status': 'ok', 'message': 'Пароль изменён; войдите заново'}
@router.post('/logout')
def logout(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict[str, str]:
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
sid = payload.get('sid')
if sid:
(db.query(UserSession)
.filter(UserSession.user_id == current_user.id,
UserSession.token_hash == _session_token_hash(sid))
.update({'is_active': False}))
except JWTError:
pass
_log_auth(db, 'logout', current_user.username, current_user.id)
db.commit()
return {'status': 'ok'}