diff --git a/backend/alembic/versions/007_e1_rbac.py b/backend/alembic/versions/007_e1_rbac.py new file mode 100644 index 0000000..ad5cfef --- /dev/null +++ b/backend/alembic/versions/007_e1_rbac.py @@ -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') \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index 0f36b44..2a9357d 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) diff --git a/backend/rbac.py b/backend/rbac.py new file mode 100644 index 0000000..75408c0 --- /dev/null +++ b/backend/rbac.py @@ -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] diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 1031bee..21b75ef 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -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'} \ No newline at end of file diff --git a/backend/tests/test_e1_rbac.py b/backend/tests/test_e1_rbac.py new file mode 100644 index 0000000..4e77e43 --- /dev/null +++ b/backend/tests/test_e1_rbac.py @@ -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 \ No newline at end of file diff --git a/scripts/e1-verify.sql b/scripts/e1-verify.sql new file mode 100644 index 0000000..d533766 --- /dev/null +++ b/scripts/e1-verify.sql @@ -0,0 +1,9 @@ +-- Верификация 007: таблицы, сид ролей/прав/матрицы +SELECT 'roles' AS t, count(*) FROM roles +UNION ALL SELECT 'permissions', count(*) FROM permissions +UNION ALL SELECT 'role_permissions', count(*) FROM role_permissions +UNION ALL SELECT 'security_settings', count(*) FROM security_settings +UNION ALL SELECT 'users_new_cols', count(*) FROM information_schema.columns WHERE table_name='users' AND column_name IN ('unit_id','status','failed_login_count','locked_until','must_change_password','created_by','updated_at','position'); +SELECT r.name, count(rp.permission_id) AS perms FROM roles r LEFT JOIN role_permissions rp ON rp.role_id = r.id GROUP BY r.name ORDER BY r.name; +SELECT name, kind FROM mchs_units; +SELECT version_num FROM alembic_version; \ No newline at end of file