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:
@@ -0,0 +1,167 @@
|
||||
"""E1 B21: RBAC-ядро — подразделения МЧС, роли/права, сессии, auth-журнал.
|
||||
|
||||
Revision ID: 007_e1_rbac
|
||||
Revises: 006_b15_layers
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql as pg
|
||||
|
||||
revision = '007_e1_rbac'
|
||||
down_revision = '006_b15_layers'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- Подразделения ----------
|
||||
op.create_table(
|
||||
'mchs_units',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('name', sa.String(255), nullable=False, unique=True),
|
||||
sa.Column('region', sa.String(100)),
|
||||
sa.Column('kind', sa.String(20), nullable=False),
|
||||
sa.Column('parent_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('mchs_units.id', ondelete='SET NULL')),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
sa.CheckConstraint("kind IN ('rcu','oblast','gor_rayon')", name='ck_mchs_units_kind'),
|
||||
)
|
||||
|
||||
# users: RBAC-колонки
|
||||
op.add_column('users', sa.Column('unit_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('mchs_units.id', ondelete='SET NULL')))
|
||||
op.add_column('users', sa.Column('position', sa.String(255)))
|
||||
op.add_column('users', sa.Column('status', sa.String(20), server_default='active'))
|
||||
op.add_column('users', sa.Column('failed_login_count', sa.Integer(), server_default='0'))
|
||||
op.add_column('users', sa.Column('locked_until', sa.DateTime(timezone=True)))
|
||||
op.add_column('users', sa.Column('must_change_password', sa.Boolean(), server_default=sa.text('false')))
|
||||
op.add_column('users', sa.Column('created_by', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('users.id', ondelete='SET NULL')))
|
||||
op.add_column('users', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('NOW()')))
|
||||
|
||||
# ---------- Роли / права ----------
|
||||
op.create_table(
|
||||
'roles',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('name', sa.String(50), nullable=False, unique=True),
|
||||
sa.Column('description', sa.String(255)),
|
||||
sa.Column('is_system', sa.Boolean(), server_default=sa.text('false')),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
)
|
||||
op.create_table(
|
||||
'permissions',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('code', sa.String(50), nullable=False, unique=True),
|
||||
sa.Column('description', sa.String(255)),
|
||||
)
|
||||
op.create_table(
|
||||
'role_permissions',
|
||||
sa.Column('role_id', pg.UUID(as_uuid=True), sa.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True),
|
||||
sa.Column('permission_id', pg.UUID(as_uuid=True), sa.ForeignKey('permissions.id', ondelete='CASCADE'), primary_key=True),
|
||||
)
|
||||
op.create_table(
|
||||
'user_roles',
|
||||
sa.Column('user_id', pg.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True),
|
||||
sa.Column('role_id', pg.UUID(as_uuid=True), sa.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True),
|
||||
sa.Column('assigned_by', pg.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL')),
|
||||
sa.Column('assigned_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
)
|
||||
|
||||
# ---------- Сессии / журнал / настройки ----------
|
||||
op.create_table(
|
||||
'sessions',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('user_id', pg.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('token_hash', sa.String(64), nullable=False, unique=True),
|
||||
sa.Column('ip_address', sa.String(45)),
|
||||
sa.Column('user_agent', sa.String(255)),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('last_seen_at', sa.DateTime(timezone=True)),
|
||||
sa.Column('is_active', sa.Boolean(), server_default=sa.text('true')),
|
||||
)
|
||||
op.create_index('idx_sessions_user_id', 'sessions', ['user_id'])
|
||||
op.create_table(
|
||||
'auth_events',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('user_id', pg.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL')),
|
||||
sa.Column('username', sa.String(100)),
|
||||
sa.Column('event_type', sa.String(30), nullable=False),
|
||||
sa.Column('ip_address', sa.String(64)),
|
||||
sa.Column('user_agent', sa.String(255)),
|
||||
sa.Column('details', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')),
|
||||
)
|
||||
op.create_index('idx_auth_events_created', 'auth_events', ['created_at'])
|
||||
op.create_table(
|
||||
'security_settings',
|
||||
sa.Column('key', sa.String(50), primary_key=True),
|
||||
sa.Column('value', sa.String(255), nullable=False),
|
||||
)
|
||||
|
||||
# ---------- Сид: роли, права, матрица, настройки ----------
|
||||
conn = op.get_bind()
|
||||
roles = [('admin', 'РЦУ РЧС: все права, все операции'),
|
||||
('coordinator', 'ОУМЧС: поиски своей области, управление Г(Р)ОЧС'),
|
||||
('operator', 'Г(Р)ОЧС: свои поиски (create/update)'),
|
||||
('observer', 'Только просмотр')]
|
||||
perms = [('view', 'Просмотр'), ('create', 'Создание'), ('update', 'Изменение'),
|
||||
('delete', 'Удаление'), ('export', 'Экспорт'),
|
||||
('manage_users', 'Управление пользователями'),
|
||||
('manage_roles', 'Управление ролями'),
|
||||
('view_audit', 'Просмотр аудита'),
|
||||
('manage_security', 'Управление настройками безопасности')]
|
||||
matrix = {
|
||||
'admin': tuple(p[0] for p in perms),
|
||||
'coordinator': ('view', 'create', 'update', 'export', 'manage_users', 'view_audit'),
|
||||
'operator': ('view', 'create', 'update'),
|
||||
'observer': ('view',),
|
||||
}
|
||||
for name, desc in roles:
|
||||
conn.execute(sa.text(
|
||||
"INSERT INTO roles (name, description, is_system) VALUES (:n, :d, true) "
|
||||
"ON CONFLICT (name) DO NOTHING"), {'n': name, 'd': desc})
|
||||
for code, desc in perms:
|
||||
conn.execute(sa.text(
|
||||
"INSERT INTO permissions (code, description) VALUES (:c, :d) "
|
||||
"ON CONFLICT (code) DO NOTHING"), {'c': code, 'd': desc})
|
||||
for role_name, codes in matrix.items():
|
||||
for code in codes:
|
||||
conn.execute(sa.text(
|
||||
"INSERT INTO role_permissions (role_id, permission_id) "
|
||||
"SELECT r.id, p.id FROM roles r, permissions p "
|
||||
"WHERE r.name = :rn AND p.code = :pc "
|
||||
"ON CONFLICT DO NOTHING"), {'rn': role_name, 'pc': code})
|
||||
for k, v in {
|
||||
'password_min_length': '8', 'password_require_complexity': 'true',
|
||||
'password_expiry_days': '90', 'password_history_count': '5',
|
||||
'max_failed_logins': '5', 'lockout_duration_minutes': '15',
|
||||
'session_timeout_minutes': '1440', 'session_idle_timeout_minutes': '60',
|
||||
'max_concurrent_sessions': '3', 'audit_retention_days': '90',
|
||||
}.items():
|
||||
conn.execute(sa.text(
|
||||
"INSERT INTO security_settings (key, value) VALUES (:k, :v) "
|
||||
"ON CONFLICT (key) DO NOTHING"), {'k': k, 'v': v})
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('security_settings')
|
||||
op.drop_index('idx_auth_events_created', table_name='auth_events')
|
||||
op.drop_table('auth_events')
|
||||
op.drop_index('idx_sessions_user_id', table_name='sessions')
|
||||
op.drop_table('sessions')
|
||||
op.drop_table('user_roles')
|
||||
op.drop_table('role_permissions')
|
||||
op.drop_table('permissions')
|
||||
op.drop_table('roles')
|
||||
for col in ('updated_at', 'created_by', 'must_change_password', 'locked_until',
|
||||
'failed_login_count', 'status', 'position', 'unit_id'):
|
||||
op.drop_column('users', col)
|
||||
op.drop_table('mchs_units')
|
||||
@@ -24,6 +24,16 @@ class User(Base):
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
last_login = Column(DateTime)
|
||||
|
||||
# B21/E1 additions
|
||||
unit_id = Column(UUID(as_uuid=True), ForeignKey("mchs_units.id", ondelete="SET NULL"))
|
||||
position = Column(String(255))
|
||||
status = Column(String(20), default="active") # active|locked|disabled
|
||||
failed_login_count = Column(Integer, default=0)
|
||||
locked_until = Column(DateTime(timezone=True))
|
||||
must_change_password = Column(Boolean, default=False)
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
||||
updated_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class Case(Base):
|
||||
"""Unified case model - combines search case and result."""
|
||||
@@ -225,3 +235,102 @@ class ReferencePrior(Base):
|
||||
provenance = Column(Text)
|
||||
version = Column(Integer, nullable=False, default=1)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
# ==================== B21/E1: RBAC-ядро (по образцу админки РВС) ====================
|
||||
# Иерархия: РЦУ РЧС (rcu) → ОУМЧС (oblast) → Г(Р)ОЧС (gor_rayon)
|
||||
|
||||
|
||||
class MchsUnit(Base):
|
||||
"""Подразделение МЧС: РЦУ РЧС, ОУМЧС, Г(Р)ОЧС."""
|
||||
__tablename__ = "mchs_units"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(255), nullable=False, unique=True)
|
||||
region = Column(String(100)) # область (для фильтров)
|
||||
kind = Column(String(20), nullable=False) # rcu | oblast | gor_rayon
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey("mchs_units.id", ondelete="SET NULL"))
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
sa.CheckConstraint("kind IN ('rcu','oblast','gor_rayon')", name="ck_mchs_units_kind"),
|
||||
)
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""Роль (is_system — сидовые, удаление запрещено)."""
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(50), nullable=False, unique=True)
|
||||
description = Column(String(255))
|
||||
is_system = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
"""Атомарное право."""
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
code = Column(String(50), nullable=False, unique=True)
|
||||
description = Column(String(255))
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""M2M роль ↔ право."""
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True)
|
||||
permission_id = Column(UUID(as_uuid=True), ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True)
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""M2M пользователь ↔ роль (с кем назначена)."""
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
||||
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True)
|
||||
assigned_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
||||
assigned_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class UserSession(Base):
|
||||
"""Server-side сессия: JWT несёт session_id, отзыв возможен на стороне БД."""
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
token_hash = Column(String(64), nullable=False, unique=True) # sha256 jti
|
||||
ip_address = Column(String(45))
|
||||
user_agent = Column(String(255))
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
last_seen_at = Column(DateTime(timezone=True))
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
|
||||
class AuthEvent(Base):
|
||||
"""Журнал аутентификации: вход/выход/блокировка."""
|
||||
__tablename__ = "auth_events"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
||||
username = Column(String(100)) # даже если user_id неизвестен
|
||||
event_type = Column(String(30), nullable=False) # login|logout|login_failed|lockout|password_change
|
||||
ip_address = Column(String(64))
|
||||
user_agent = Column(String(255))
|
||||
details = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
sa.Index("idx_auth_events_created", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
class SecuritySetting(Base):
|
||||
"""Параметры безопасности (key/value)."""
|
||||
__tablename__ = "security_settings"
|
||||
|
||||
key = Column(String(50), primary_key=True)
|
||||
value = Column(String(255), nullable=False)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""E1 (B21): RBAC-ядро — подразделения МЧС, роли/права, сессии, auth-журнал.
|
||||
|
||||
Иерархия подразделений: РЦУ РЧС (rcu) → ОУМЧС (oblast) → Г(Р)ОЧС (gor_rayon).
|
||||
Роли — как в РВС-админке (is_system), права атомарные, user_roles M2M.
|
||||
Сессии server-side: JWT несёт session_id, каждый запрос проверяет сессию
|
||||
в БД (отзыв по блокировке пользователя/окончании срока).
|
||||
|
||||
Модели добавлены в backend/models.py; здесь — только константы (seed-данные)
|
||||
и хелперы прав/скоупа.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
# ---- Иерархия подразделений ----
|
||||
UNIT_KINDS = ('rcu', 'oblast', 'gor_rayon')
|
||||
|
||||
# ---- Роли (is_system) ----
|
||||
SYSTEM_ROLES = (
|
||||
'admin', # РЦУ РЧС: все права, все операции
|
||||
'coordinator', # ОУМЧС: поиски своей области, управление подчинёнными Г(Р)ОЧС
|
||||
'operator', # Г(Р)ОЧС: свои поиски (create/update)
|
||||
'observer', # только просмотр
|
||||
)
|
||||
|
||||
# ---- Атомарные права ----
|
||||
PERMISSIONS = (
|
||||
'view', 'create', 'update', 'delete', 'export',
|
||||
'manage_users', 'manage_roles', 'view_audit', 'manage_security',
|
||||
)
|
||||
|
||||
ROLE_PERMISSIONS: dict[str, tuple[str, ...]] = {
|
||||
'admin': PERMISSIONS,
|
||||
'coordinator': ('view', 'create', 'update', 'export', 'manage_users', 'view_audit'),
|
||||
'operator': ('view', 'create', 'update'),
|
||||
'observer': ('view',),
|
||||
}
|
||||
|
||||
# Роли-совместимость со старым единым role-полем: admin/coordinator/operator/observer
|
||||
# покрывают прежние operator/field/admin (field ≈ operator: полевой доступ).
|
||||
|
||||
# ---- Политика безопасности (security_settings) ----
|
||||
SECURITY_DEFAULTS = {
|
||||
'password_min_length': '8',
|
||||
'password_require_complexity': 'true',
|
||||
'password_expiry_days': '90',
|
||||
'password_history_count': '5',
|
||||
'max_failed_logins': '5',
|
||||
'lockout_duration_minutes': '15',
|
||||
'session_timeout_minutes': '1440',
|
||||
'session_idle_timeout_minutes': '60',
|
||||
'max_concurrent_sessions': '3',
|
||||
'audit_retention_days': '90',
|
||||
}
|
||||
|
||||
|
||||
def descendant_unit_ids(conn, unit_id: str) -> list[str]:
|
||||
"""Все id подразделения + его потомков (для гео-скоупа). Рекурсивный CTE."""
|
||||
rows = conn.execute(
|
||||
text("""
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id FROM mchs_units WHERE id = :uid
|
||||
UNION ALL
|
||||
SELECT u.id FROM mchs_units u JOIN tree t ON u.parent_id = t.id
|
||||
)
|
||||
SELECT id FROM tree
|
||||
"""),
|
||||
{'unit_id': unit_id},
|
||||
).fetchall()
|
||||
return [str(r[0]) for r in rows]
|
||||
+282
-13
@@ -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'}
|
||||
@@ -0,0 +1,198 @@
|
||||
"""E1 (B21): тесты RBAC-ядра — сессии, блокировка, права, журнал.
|
||||
|
||||
SQLite in-memory с RBAC-таблицами; UUID/JSONB подменяются компиляторами.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bcrypt
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
|
||||
@compiles(PG_UUID, 'sqlite')
|
||||
def _uuid_sqlite(type_, compiler, **kw):
|
||||
return 'CHAR(36)'
|
||||
|
||||
|
||||
@compiles(JSONB, 'sqlite')
|
||||
def _jsonb_sqlite(type_, compiler, **kw):
|
||||
return 'JSON'
|
||||
|
||||
|
||||
@compiles(sqlalchemy.ARRAY, 'sqlite')
|
||||
def _array_sqlite(type_, compiler, **kw):
|
||||
return 'TEXT'
|
||||
|
||||
|
||||
@compiles(sqlalchemy.ARRAY, 'sqlite')
|
||||
def _array_sqlite(type_, compiler, **kw):
|
||||
return 'TEXT'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def rbac_env():
|
||||
from backend import models as m
|
||||
from backend.database import Base
|
||||
from backend.models import (
|
||||
MchsUnit,
|
||||
Permission,
|
||||
Role,
|
||||
RolePermission,
|
||||
SecuritySetting,
|
||||
UserRole,
|
||||
)
|
||||
|
||||
engine = create_engine(
|
||||
'sqlite:///:memory:',
|
||||
connect_args={'check_same_thread': False},
|
||||
poolclass=StaticPool, # одна connection на все threads (иначе per-thread БД)
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
TestingSession = sessionmaker(bind=engine)
|
||||
db = TestingSession()
|
||||
|
||||
unit = MchsUnit(name='Минское Г(Р)ОЧС', region='Минская', kind='gor_rayon')
|
||||
rcu = MchsUnit(name='РЦУ РЧС', region='Республика', kind='rcu')
|
||||
db.add_all([unit, rcu])
|
||||
db.flush()
|
||||
|
||||
def _hash(pw: str) -> str:
|
||||
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
user = m.User(username='op1', email='op1@test.by', hashed_password=_hash('oldpass123'),
|
||||
full_name='Оператор Г(Р)ОЧС', role='operator', is_active=True,
|
||||
unit_id=unit.id)
|
||||
admin = m.User(username='root_rcu', email='root@test.by', hashed_password=_hash('rootpass123'),
|
||||
full_name='РЦУ РЧС', role='admin', is_active=True, unit_id=rcu.id)
|
||||
db.add_all([user, admin])
|
||||
db.commit()
|
||||
|
||||
roles = {}
|
||||
for name, desc in [('admin', ''), ('coordinator', ''), ('operator', ''), ('observer', '')]:
|
||||
r = Role(name=name, description=desc, is_system=True)
|
||||
db.add(r)
|
||||
roles[name] = r
|
||||
perms = {}
|
||||
for code in ('view', 'create', 'update', 'delete', 'export', 'manage_users',
|
||||
'manage_roles', 'view_audit', 'manage_security'):
|
||||
p = Permission(code=code)
|
||||
db.add(p)
|
||||
perms[code] = p
|
||||
db.commit()
|
||||
matrix = {
|
||||
'admin': tuple(perms),
|
||||
'coordinator': ('view', 'create', 'update', 'export', 'manage_users', 'view_audit'),
|
||||
'operator': ('view', 'create', 'update'),
|
||||
'observer': ('view',),
|
||||
}
|
||||
for rn, codes in matrix.items():
|
||||
for c in codes:
|
||||
db.add(RolePermission(role_id=roles[rn].id, permission_id=perms[c].id))
|
||||
db.add(UserRole(user_id=user.id, role_id=roles['operator'].id))
|
||||
db.commit()
|
||||
|
||||
from backend.rbac import SECURITY_DEFAULTS
|
||||
for k, v in SECURITY_DEFAULTS.items():
|
||||
db.add(SecuritySetting(key=k, value=v))
|
||||
db.commit()
|
||||
|
||||
from backend.main import app
|
||||
from backend.routers import auth as auth_router
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
def override_get_db():
|
||||
s = TestingSession()
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
app.dependency_overrides[auth_router.get_db] = override_get_db
|
||||
client = TestClient(app)
|
||||
|
||||
yield {'db': db, 'client': client, 'user': user, 'admin': admin}
|
||||
|
||||
app.dependency_overrides.pop(auth_router.get_db, None)
|
||||
|
||||
|
||||
def _login(client, username: str, password: str):
|
||||
return client.post('/api/v1/auth/login', data={'username': username, 'password': password})
|
||||
|
||||
|
||||
class TestLogin:
|
||||
def test_login_creates_session_and_token(self, rbac_env):
|
||||
r = _login(rbac_env['client'], 'op1', 'oldpass123')
|
||||
assert r.status_code == 200
|
||||
assert 'access_token' in r.json()
|
||||
|
||||
def test_wrong_password_fails(self, rbac_env):
|
||||
assert _login(rbac_env['client'], 'op1', 'wrong').status_code == 401
|
||||
|
||||
def test_lockout_after_max_failures(self, rbac_env):
|
||||
c = rbac_env['client']
|
||||
for _ in range(5):
|
||||
c.post('/api/v1/auth/login', data={'username': 'op1', 'password': 'wrong'})
|
||||
r = _login(c, 'op1', 'oldpass123')
|
||||
assert r.status_code == 403
|
||||
assert 'locked' in r.json()['detail'].lower()
|
||||
|
||||
def test_me_has_permissions(self, rbac_env):
|
||||
c = rbac_env['client']
|
||||
tok = _login(c, 'op1', 'oldpass123').json()['access_token']
|
||||
me = c.get('/api/v1/auth/me', headers={'Authorization': f'Bearer {tok}'}).json()
|
||||
assert me['permissions'] == ['create', 'update', 'view']
|
||||
assert me['unit_id'] is not None
|
||||
|
||||
def test_admin_permissions_full(self, rbac_env):
|
||||
c = rbac_env['client']
|
||||
tok = _login(c, 'root_rcu', 'rootpass123').json()['access_token']
|
||||
me = c.get('/api/v1/auth/me', headers={'Authorization': f'Bearer {tok}'}).json()
|
||||
assert 'manage_users' in me['permissions']
|
||||
assert len(me['permissions']) == 9
|
||||
|
||||
def test_logout_revokes_session(self, rbac_env):
|
||||
c = rbac_env['client']
|
||||
tok = _login(c, 'op1', 'oldpass123').json()['access_token']
|
||||
h = {'Authorization': f'Bearer {tok}'}
|
||||
assert c.get('/api/v1/auth/me', headers=h).status_code == 200
|
||||
c.post('/api/v1/auth/logout', headers=h)
|
||||
assert c.get('/api/v1/auth/me', headers=h).status_code == 401
|
||||
|
||||
def test_change_password_revokes_sessions(self, rbac_env):
|
||||
c = rbac_env['client']
|
||||
tok = _login(c, 'op1', 'oldpass123').json()['access_token']
|
||||
h = {'Authorization': f'Bearer {tok}'}
|
||||
r = c.post('/api/v1/auth/change-password', headers=h,
|
||||
json={'current_password': 'oldpass123', 'new_password': 'newpass456'})
|
||||
assert r.status_code == 200
|
||||
assert c.get('/api/v1/auth/me', headers=h).status_code == 401
|
||||
assert _login(c, 'op1', 'newpass456').status_code == 200
|
||||
|
||||
def test_short_password_rejected(self, rbac_env):
|
||||
c = rbac_env['client']
|
||||
tok = _login(c, 'op1', 'oldpass123').json()['access_token']
|
||||
h = {'Authorization': f'Bearer {tok}'}
|
||||
r = c.post('/api/v1/auth/change-password', headers=h,
|
||||
json={'current_password': 'oldpass123', 'new_password': 'abc'})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_auth_events_logged(self, rbac_env):
|
||||
from backend.models import AuthEvent
|
||||
c = rbac_env['client']
|
||||
_login(c, 'op1', 'oldpass123')
|
||||
_login(c, 'op1', 'WRONG')
|
||||
types = {e.event_type for e in rbac_env['db'].query(AuthEvent).all()}
|
||||
assert 'login' in types and 'login_failed' in types
|
||||
Reference in New Issue
Block a user