"""Вход, роли и аудит. ТЗ называет три роли поимённо — администратор, преподаватель, обучающийся — и требует разграничения доступа, аудита действий и хранения журналов безопасности (docs/spec/GAP.md#1-аутентификации-нет-вообще). До этой карточки входа не было вовсе: экраны открывались ссылкой с номером занятия. Решения, принятые заранее и не переоткрываемые (tasks/lct-23-auth-roles.md): * пароль — `argon2`, не bcrypt; * сессия — подписанная cookie через `SessionMiddleware`, не JWT и не таблица сессий: состояние занятия и так живёт в памяти одного воркера, лишний поход в базу на каждый запрос не нужен; * на сокетах роль читается из cookie в момент рукопожатия — отдельного протокола авторизации в канале нет. """ import asyncio import logging import secrets import weakref from uuid import UUID from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError from fastapi import APIRouter, HTTPException, Request, WebSocket from pydantic import BaseModel from sqlalchemy import select from app.config import get_settings from app.db.base import get_sessionmaker from app.db.models import AuditLog, User from app.domain.roles import Role log = logging.getLogger(__name__) router = APIRouter(prefix="/api/auth", tags=["auth"]) DEMO_TRAINEE_ID = UUID("00000000-0000-4000-8000-000000000112") _hasher = PasswordHasher() # Cookie is signed, but carries a role snapshot. A changed account must not # keep its old privileges for the full 12-hour cookie lifetime. Compose runs # one worker; the process marker also invalidates all cookies after restart. _INSTANCE = secrets.token_urlsafe(32) _generations: dict[str, int] = {} _active_sockets: dict[str, weakref.WeakKeyDictionary] = {} async def _close_revoked(ws: WebSocket) -> None: try: await ws.close(code=1008, reason="Учётная запись изменена: войдите снова") except (RuntimeError, OSError): # The peer may already have disconnected; revocation still stands. pass def invalidate_login(login: str) -> None: """Revoke previously issued cookies after account/role/password changes.""" _generations[login] = _generations.get(login, 0) + 1 for ws, loop in list(_active_sockets.get(login, {}).items()): try: if not loop.is_closed(): loop.call_soon_threadsafe(lambda socket=ws: asyncio.create_task(_close_revoked(socket))) except RuntimeError: pass # loop closed between the check and scheduling def _session_principal(session: dict) -> "Principal | None": data = session.get("principal") if not isinstance(data, dict): return None login = data.get("login") if not isinstance(login, str): return None if session.get("auth_instance") != _INSTANCE: return None if session.get("auth_generation") != _generations.get(login, 0): return None try: return Principal.model_validate(data) except Exception: # malformed signed cookie: reject, do not 500 log.warning("неверный формат principal в cookie") return None def _issue_session(request: Request, who: "Principal") -> None: request.session["principal"] = who.model_dump(mode="json") request.session["auth_instance"] = _INSTANCE request.session["auth_generation"] = _generations.get(who.login, 0) def hash_password(password: str) -> str: return _hasher.hash(password) def verify_password(password_hash: str, password: str) -> bool: try: return _hasher.verify(password_hash, password) except VerifyMismatchError: return False except Exception: # noqa: BLE001 — битый хеш не должен пускать в систему log.exception("проверка пароля не удалась") return False class Principal(BaseModel): """Кто действует. Cookie stores a snapshot, revoked on account edits.""" login: str full_name: str role: Role service: str | None = None trainee_id: UUID | None = None @property def is_staff(self) -> bool: return self.role in (Role.ADMIN, Role.INSTRUCTOR) class LoginIn(BaseModel): login: str password: str def _demo_local(request: Request) -> None: # При ошибочном bind 0.0.0.0 удалённый клиент всё равно не получит cookie. if request.client is None or request.client.host not in {"127.0.0.1", "::1", "localhost", "testclient"}: raise HTTPException(status_code=403, detail="demo_local_only") def _demo_principal(role: Role) -> Principal: return Principal( login="demo-trainee" if role is Role.TRAINEE else "demo-instructor", full_name="Демо-курсант" if role is Role.TRAINEE else "Демо-преподаватель", role=role, trainee_id=DEMO_TRAINEE_ID if role is Role.TRAINEE else None, ) def current(request: Request) -> Principal | None: return _session_principal(request.session) def principal_of(websocket: WebSocket) -> Principal | None: """Роль на сокете — из той же cookie, что и на HTTP. `WebSocket.session` доступен, потому что `SessionMiddleware` стоит до роутера: значит роль известна до входа в цикл приёма сообщений. """ who = _session_principal(websocket.session) if "session" in websocket.scope else None if who is not None: _active_sockets.setdefault(who.login, weakref.WeakKeyDictionary())[websocket] = asyncio.get_running_loop() return who def require(request: Request, *roles: Role) -> Principal: """Принципал нужной роли или отказ. Единственная точка проверки на HTTP.""" who = current(request) if who is None: raise HTTPException(status_code=401, detail="not_authenticated") if roles and who.role not in roles: raise HTTPException(status_code=403, detail="forbidden") return who async def audit( actor: str, role: str, action: str, object_id: str | None = None, detail: str = "" ) -> None: """Запись в журнал. Аудит не должен ронять действие: если база недоступна, занятие продолжается, а пропуск виден в логе.""" if get_settings().demo_no_db: return # в явном demo-режиме запись и долговременный аудит недоступны try: async with get_sessionmaker()() as db: db.add( AuditLog( actor=actor, role=role, action=action, object_id=object_id, detail=detail[:2000] ) ) await db.commit() except Exception: # noqa: BLE001 log.exception("аудит: запись %s не удалась", action) @router.post("/login") async def login(payload: LoginIn, request: Request) -> dict: if get_settings().demo_no_db: _demo_local(request) demo_roles = {"demo-instructor": Role.INSTRUCTOR, "demo-trainee": Role.TRAINEE} role = demo_roles.get(payload.login) if role is None or not secrets.compare_digest(payload.password, "demo"): raise HTTPException(status_code=401, detail="bad_credentials") who = _demo_principal(role) _issue_session(request, who) return who.model_dump(mode="json") async with get_sessionmaker()() as db: user = await db.scalar(select(User).where(User.login == payload.login)) # Одинаковый ответ на неизвестный логин и неверный пароль: иначе форма # входа превращается в список действующих учётных записей. if user is None or not verify_password(user.password_hash, payload.password): raise HTTPException(status_code=401, detail="bad_credentials") if user.blocked: raise HTTPException(status_code=403, detail="blocked") who = Principal( login=user.login, full_name=user.full_name, role=Role(user.role), service=user.service, trainee_id=user.trainee_id, ) _issue_session(request, who) await audit(who.login, who.role.value, "login") return who.model_dump(mode="json") @router.post("/logout") async def logout(request: Request) -> dict: who = current(request) request.session.clear() if who: await audit(who.login, who.role.value, "logout") return {"ok": True} @router.get("/me") async def me(request: Request) -> dict: who = current(request) if who is None: raise HTTPException(status_code=401, detail="not_authenticated") return who.model_dump(mode="json") @router.post("/dev-token") async def dev_token(request: Request, role: Role = Role.INSTRUCTOR) -> dict: """Вход без пароля для наших же инструментов. `make lesson` и тесты открывают сокеты напрямую и после включения ролей сломались бы все разом. Точка живёт за флагом `dev_auth_bypass`, по умолчанию выключенным: в рабочем стенде она отвечает 404, а не 401 — выключенной функции не должно быть видно вовсе. """ settings = get_settings() if not settings.dev_auth_bypass: raise HTTPException(status_code=404, detail="not_found") if role is not Role.INSTRUCTOR and not settings.demo_no_db: raise HTTPException(status_code=404, detail="not_found") if settings.demo_no_db: _demo_local(request) if role not in {Role.INSTRUCTOR, Role.TRAINEE}: raise HTTPException(status_code=403, detail="demo_role_forbidden") who = _demo_principal(role) if settings.demo_no_db else Principal( login="dev", full_name="Разработка", role=Role.INSTRUCTOR ) _issue_session(request, who) return who.model_dump(mode="json")