diff --git a/Makefile b/Makefile index 0a3be6a..d762462 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,9 @@ typecheck: ## Проверить фронтенд компилятором models: ## Скачать модели в backend/models/: эмбеддинги, GigaAM, Silero VAD; Silero TTS — вручную cd backend && $(UV) run python scripts/models.py +users: ## Завести учётные записи ролей и напечатать пароли: make users force=1 + cd backend && $(UV) run python scripts/users.py + seed: ## Залить сценарии из /scenarios в БД cd backend && $(UV) run python scripts/seed.py @@ -71,4 +74,4 @@ pregen: ## Построить таблицу реплик звонящего д demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт ./scripts/demo.sh -.PHONY: help dev down back front types test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo +.PHONY: help dev down back front types users test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..a11d797 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,168 @@ +"""Вход, роли и аудит. + +ТЗ называет три роли поимённо — администратор, преподаватель, обучающийся — +и требует разграничения доступа, аудита действий и хранения журналов +безопасности (docs/spec/GAP.md#1-аутентификации-нет-вообще). До этой карточки +входа не было вовсе: экраны открывались ссылкой с номером занятия. + +Решения, принятые заранее и не переоткрываемые (tasks/lct-23-auth-roles.md): + +* пароль — `argon2`, не bcrypt; +* сессия — подписанная cookie через `SessionMiddleware`, не JWT и не таблица + сессий: состояние занятия и так живёт в памяти одного воркера, лишний поход + в базу на каждый запрос не нужен; +* на сокетах роль читается из cookie в момент рукопожатия — отдельного + протокола авторизации в канале нет. +""" + +import logging +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"]) + +_hasher = PasswordHasher() + + +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 целиком: ходить в базу за ролью + на каждый запрос незачем, а меняется она правкой учётной записи.""" + + 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 current(request: Request) -> Principal | None: + data = request.session.get("principal") + return Principal.model_validate(data) if data else None + + +def principal_of(websocket: WebSocket) -> Principal | None: + """Роль на сокете — из той же cookie, что и на HTTP. + + `WebSocket.session` доступен, потому что `SessionMiddleware` стоит до + роутера: значит роль известна до входа в цикл приёма сообщений. + """ + data = websocket.session.get("principal") if "session" in websocket.scope else None + return Principal.model_validate(data) if data else None + + +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: + """Запись в журнал. Аудит не должен ронять действие: если база недоступна, + занятие продолжается, а пропуск виден в логе.""" + 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: + 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, + ) + request.session["principal"] = who.model_dump(mode="json") + 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) -> dict: + """Вход без пароля для наших же инструментов. + + `make lesson` и тесты открывают сокеты напрямую и после включения ролей + сломались бы все разом. Точка живёт за флагом `dev_auth_bypass`, по + умолчанию выключенным: в рабочем стенде она отвечает 404, а не 401 — + выключенной функции не должно быть видно вовсе. + """ + if not get_settings().dev_auth_bypass: + raise HTTPException(status_code=404, detail="not_found") + who = Principal(login="dev", full_name="Разработка", role=Role.INSTRUCTOR) + request.session["principal"] = who.model_dump(mode="json") + return who.model_dump(mode="json") diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index 243f574..4afb4f7 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -7,15 +7,17 @@ from datetime import datetime from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession +from app.api.auth import audit, require from app.db import repo from app.db.base import get_session from app.domain.events import SessionMode, SessionReport from app.scenarios import store from app.scoring.report import build as build_report +from app.domain.roles import Role from app.session.hub import hub router = APIRouter(prefix="/api/sessions", tags=["sessions"]) @@ -123,22 +125,29 @@ def _live(session_id: UUID): @router.get("/{session_id}/report", response_model=SessionReport) -async def report(session_id: UUID) -> SessionReport: +async def report(session_id: UUID, request: Request) -> SessionReport: """Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки. - Оценка курсанту открывается событием `score.ready` после самооценки; здесь - прав нет — в прототипе нет входа, и точка доступна всем, у кого есть номер - занятия (docs/arch/CONTRACT.md). + Обучающийся открывает только свой разбор: ТЗ запрещает доступ к чужим + результатам, а не только к чужим экранам. Проверка по `trainee_id` + занятия, а не по номеру в ссылке. """ + who = require(request) state, scenario = _live(session_id) + if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id: + raise HTTPException(status_code=403, detail="not_your_session") if state.score is None: raise HTTPException(status_code=409, detail="score_not_ready") return build_report(session_id, state, scenario) @router.patch("/{session_id}/report", response_model=SessionReport) -async def override(session_id: UUID, body: ScoreOverride) -> SessionReport: - """Тренажёр готовит материал, преподаватель имеет последнее слово.""" +async def override(session_id: UUID, body: ScoreOverride, request: Request) -> SessionReport: + """Тренажёр готовит материал, преподаватель имеет последнее слово. + + Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо. + """ + who = require(request, Role.INSTRUCTOR) state, scenario = _live(session_id) if state.score is None: raise HTTPException(status_code=409, detail="score_not_ready") @@ -148,11 +157,16 @@ async def override(session_id: UUID, body: ScoreOverride) -> SessionReport: "overridden_by": body.author, "override_comment": body.comment, } + await audit( + who.login, who.role.value, "score.override", str(session_id), + f"{state.score.get('score_auto')} → {body.score_final}: {body.comment}", + ) return build_report(session_id, state, scenario) @router.get("", response_model=list[SessionOut]) async def listing( + request: Request, trainee: UUID | None = None, group: UUID | None = None, mode: SessionMode | None = None, @@ -160,6 +174,10 @@ async def listing( limit: int = 100, db: AsyncSession = Depends(get_session), ) -> list[SessionOut]: + who = require(request) + # Обучающийся видит только свою историю, что бы он ни передал в фильтре. + if who.role is Role.TRAINEE: + trainee = who.trainee_id rows = await repo.history( db, trainee_id=trainee, diff --git a/backend/app/api/http/trainees.py b/backend/app/api/http/trainees.py index 3a7e9d8..64a1d09 100644 --- a/backend/app/api/http/trainees.py +++ b/backend/app/api/http/trainees.py @@ -8,11 +8,13 @@ from datetime import datetime from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.auth import require +from app.domain.roles import Role from app.db.base import get_session from app.db.models import Group, Score, Session, Trainee @@ -59,7 +61,10 @@ class ProfileOut(BaseModel): @router.get("", response_model=list[TraineeOut]) -async def listing(db: AsyncSession = Depends(get_session)) -> list[TraineeOut]: +async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[TraineeOut]: + """Список курсантов — преподавателю и администратору: обучающемуся он + не нужен, а чужие фамилии из него видны.""" + require(request, Role.INSTRUCTOR, Role.ADMIN) rows = await db.execute( select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True) ) @@ -67,7 +72,12 @@ async def listing(db: AsyncSession = Depends(get_session)) -> list[TraineeOut]: @router.get("/{trainee_id}/profile", response_model=ProfileOut) -async def profile(trainee_id: UUID, db: AsyncSession = Depends(get_session)) -> ProfileOut: +async def profile( + trainee_id: UUID, request: Request, db: AsyncSession = Depends(get_session) +) -> ProfileOut: + who = require(request) + if who.role is Role.TRAINEE and who.trainee_id != trainee_id: + raise HTTPException(status_code=403, detail="not_your_profile") trainee = await db.get(Trainee, trainee_id) if trainee is None: raise HTTPException(status_code=404, detail="trainee_not_found") diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index bdd7487..2c4be98 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -29,6 +29,8 @@ from app.domain.events import ( from app.domain.events import BgStart from app.scenarios import store from app.session.finish import finish, release_score +from app.api.auth import principal_of +from app.domain.roles import Role from app.session.hub import hub from app.session.state import now_utc from app.voice.models import get_voice_models @@ -198,10 +200,24 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None: await ws.send_text(item.model_dump_json()) +async def _reject(ws: WebSocket, message: str) -> None: + """Отказ до входа в цикл: сокет закрывается с объяснением, а не молча.""" + await ws.send_text( + ErrorEvent(code=ErrorKind.FORBIDDEN, message=message).model_dump_json() + ) + await ws.close() + + @router.websocket("/ws/call/{session_id}") async def call(ws: WebSocket, session_id: UUID) -> None: await ws.accept() + # АРМ курсанта. Преподаватель допущен, чтобы показать приём вызова группе. + who = principal_of(ws) + if who is None or who.role not in (Role.TRAINEE, Role.INSTRUCTOR): + await _reject(ws, "Недостаточно прав для этого экрана") + return + state = hub.get(session_id) if state is None: await ws.send_text( diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py index 5aec124..9a463fd 100644 --- a/backend/app/api/ws/control.py +++ b/backend/app/api/ws/control.py @@ -38,6 +38,8 @@ from app.dialog.persona import PersonaState from app.dialog.runtime import get_embedder from app.dialog.slots import SlotMachine from app.scenarios import store +from app.api.auth import audit, principal_of +from app.domain.roles import Role from app.session.hub import hub from app.session.state import SessionState, now_utc from app.voice.models import get_voice_models @@ -49,7 +51,7 @@ router = APIRouter() _adapter = TypeAdapter(InstructorToServer) -async def _start(session_id: UUID, event) -> None: +async def _start(session_id: UUID, event, who=None) -> None: scenario = store.get(event.scenario_id) if scenario is None: hub.to_observers( @@ -76,6 +78,7 @@ async def _start(session_id: UUID, event) -> None: scenario=scenario.model_copy(deep=True), required_fields=scenario.required_fields, trainee_name=event.trainee, + trainee_id=getattr(event, "trainee_id", None), attempt=attempt, ) embedder = get_embedder() @@ -92,6 +95,13 @@ async def _start(session_id: UUID, event) -> None: asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()])) state.on_event("call.incoming") hub.start_ticker(session_id) + if who is not None: + # Запуск занятия меняет чужой результат — значит попадает в журнал + # аудита (ТЗ, хранение не менее шести месяцев). + asyncio.create_task( + audit(who.login, who.role.value, "lesson.start", str(session_id), + f"{scenario.id}, режим {event.mode.value}, курсант {event.trainee}") + ) hub.to_trainee( session_id, @@ -120,9 +130,23 @@ async def _stop(session_id: UUID) -> None: await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value) +async def _reject(ws: WebSocket, message: str) -> None: + """Отказ до входа в цикл: сокет закрывается с объяснением, а не молча.""" + await ws.send_text( + ErrorEvent(code=ErrorKind.FORBIDDEN, message=message).model_dump_json() + ) + await ws.close() + + @router.websocket("/ws/control/{session_id}") async def control(ws: WebSocket, session_id: UUID) -> None: await ws.accept() + + # Пульт преподавателя: управление занятием доступно только ему. + who = principal_of(ws) + if who is None or who.role not in (Role.INSTRUCTOR,): + await _reject(ws, "Недостаточно прав для этого экрана") + return try: while True: payload = await ws.receive_json() @@ -137,7 +161,7 @@ async def control(ws: WebSocket, session_id: UUID) -> None: match event.type: case "scenario.start": - await _start(session_id, event) + await _start(session_id, event, who) case "session.stop": await _stop(session_id) case "instructor_note.add": diff --git a/backend/app/api/ws/observe.py b/backend/app/api/ws/observe.py index 5a85ffb..28c26a6 100644 --- a/backend/app/api/ws/observe.py +++ b/backend/app/api/ws/observe.py @@ -16,6 +16,8 @@ from uuid import UUID from fastapi import APIRouter, WebSocket, WebSocketDisconnect from app.domain.events import ErrorEvent, ErrorKind +from app.api.auth import principal_of +from app.domain.roles import Role from app.session.hub import hub router = APIRouter() @@ -36,10 +38,24 @@ async def _wait_for_disconnect(ws: WebSocket) -> None: return +async def _reject(ws: WebSocket, message: str) -> None: + """Отказ до входа в цикл: сокет закрывается с объяснением, а не молча.""" + await ws.send_text( + ErrorEvent(code=ErrorKind.FORBIDDEN, message=message).model_dump_json() + ) + await ws.close() + + @router.websocket("/ws/observe/{session_id}") async def observe(ws: WebSocket, session_id: UUID) -> None: await ws.accept() + # Наблюдение за чужим занятием — не для обучающегося. + who = principal_of(ws) + if who is None or who.role not in (Role.INSTRUCTOR, Role.ADMIN): + await _reject(ws, "Недостаточно прав для этого экрана") + return + state = hub.get(session_id) if state is None: await ws.send_text( diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py index 5b6e12c..8a3cccc 100644 --- a/backend/app/api/ws/station.py +++ b/backend/app/api/ws/station.py @@ -17,6 +17,8 @@ from pydantic import TypeAdapter, ValidationError from app.domain.events import ErrorEvent, ErrorKind, StationState, StationToServer from app.domain.statuses import PRIMARY, ServiceStatus, StatusError +from app.api.auth import principal_of +from app.domain.roles import Role from app.session.hub import hub from app.session.state import now_utc @@ -78,10 +80,24 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None: await ws.send_text(event.model_dump_json()) +async def _reject(ws: WebSocket, message: str) -> None: + """Отказ до входа в цикл: сокет закрывается с объяснением, а не молча.""" + await ws.send_text( + ErrorEvent(code=ErrorKind.FORBIDDEN, message=message).model_dump_json() + ) + await ws.close() + + @router.websocket("/ws/station/{session_id}") async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None: await ws.accept() + # За АРМ ДДС садится обучающийся, преподаватель смотрит и подменяет. + who = principal_of(ws) + if who is None or who.role not in (Role.INSTRUCTOR, Role.TRAINEE): + await _reject(ws, "Недостаточно прав для этого экрана") + return + state = hub.get(session_id) if state is None: await ws.send_text( diff --git a/backend/app/config.py b/backend/app/config.py index cde44c1..706864e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -42,6 +42,13 @@ class Settings(BaseSettings): ) judge_temperature: float = 0.0 + # Вход и роли (lct-23). `session_secret` обязателен на рабочем стенде: + # значение по умолчанию годится только для разработки, и администратор + # видит в состоянии сервисов, что оно не заменено. + session_secret: str = "dev-secret-поменять-на-стенде" + #: Вход без пароля для `make lesson` и тестов. На стенде выключен. + dev_auth_bypass: bool = False + # Нормативы: переопределяют значения по умолчанию из domain/timers.py timer_limits_ms: dict[TimerCode, int] = {} diff --git a/backend/app/db/migrations/versions/e6e2ab171dd7_вход_и_роли_users_audit_log.py b/backend/app/db/migrations/versions/e6e2ab171dd7_вход_и_роли_users_audit_log.py new file mode 100644 index 0000000..a015e71 --- /dev/null +++ b/backend/app/db/migrations/versions/e6e2ab171dd7_вход_и_роли_users_audit_log.py @@ -0,0 +1,53 @@ +"""вход и роли: users, audit_log + +Revision ID: e6e2ab171dd7 +Revises: ff5de7dfe4ce +Create Date: 2026-09-20 08:47:33.911250 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = 'e6e2ab171dd7' +down_revision = 'ff5de7dfe4ce' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('audit_log', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('actor', sa.String(length=80), nullable=False), + sa.Column('role', sa.String(length=16), nullable=False), + sa.Column('action', sa.String(length=40), nullable=False), + sa.Column('object_id', sa.String(length=80), nullable=True), + sa.Column('detail', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_audit_at', 'audit_log', ['at'], unique=False) + op.create_table('users', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('login', sa.String(length=80), nullable=False), + sa.Column('password_hash', sa.String(length=200), nullable=False), + sa.Column('full_name', sa.String(length=120), nullable=False), + sa.Column('role', sa.String(length=16), nullable=False), + sa.Column('service', sa.String(length=120), nullable=True), + sa.Column('trainee_id', sa.Uuid(), nullable=True), + sa.Column('blocked', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['trainee_id'], ['trainees.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('login') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('users') + op.drop_index('ix_audit_at', table_name='audit_log') + op.drop_table('audit_log') + # ### end Alembic commands ### diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 4fb45ca..60880aa 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -195,6 +195,49 @@ class Score(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) +class User(Base): + """Учётная запись. Три роли из ТЗ: администратор, преподаватель, обучающийся. + + `service` заполняется только у обучающегося и означает ДДС, в которой он + работает: ТЗ требует, чтобы «в ленту попадали только профильные события», + а профильность определяется списком оповещения по ЕКП, больше ничем + (docs/spec/DATASET.md). У преподавателя и администратора поле пустое. + """ + + __tablename__ = "users" + + id: Mapped[UUID] = _uuid_pk() + login: Mapped[str] = mapped_column(String(80), unique=True) + password_hash: Mapped[str] = mapped_column(String(200)) + full_name: Mapped[str] = mapped_column(String(120)) + role: Mapped[str] = mapped_column(String(16)) + service: Mapped[str | None] = mapped_column(String(120)) + trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL")) + blocked: Mapped[bool] = mapped_column(default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class AuditLog(Base): + """Журнал действий. ТЗ требует хранения не менее шести месяцев, поэтому + записи не удаляются вместе с сессией: `object_id` — строка, а не ссылка. + + Пишется то, что меняет чужой результат или состав системы: запуск занятия, + оценка, коррекция оценки, правка сценария, вход и выход. + """ + + __tablename__ = "audit_log" + + id: Mapped[UUID] = _uuid_pk() + at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + actor: Mapped[str] = mapped_column(String(80)) + role: Mapped[str] = mapped_column(String(16)) + action: Mapped[str] = mapped_column(String(40)) + object_id: Mapped[str | None] = mapped_column(String(80)) + detail: Mapped[str] = mapped_column(Text, default="") + + __table_args__ = (Index("ix_audit_at", "at"),) + + class LlmCache(Base): """Кэш ответов LLM по хешу контекста. Работает и онлайн — экономия на повторах, — и как накопитель материала для офлайн-дерева.""" diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index 75f3c6f..4586f06 100644 --- a/backend/app/domain/events.py +++ b/backend/app/domain/events.py @@ -19,6 +19,7 @@ from pydantic import BaseModel, Field from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome from app.domain.kio import KIO, Coords +from app.domain.roles import Role from app.domain.statuses import ServiceStatus, StationSnapshot from app.domain.taxonomy import Finding from app.domain.timers import TimerSnapshot @@ -79,6 +80,9 @@ class ErrorKind(StrEnum): DIRECTIVE_NEEDS_NETWORK = "directive_needs_network" SCENARIO_INVALID = "scenario_invalid" UNSUPPORTED_EVENT = "unsupported_event" + #: Канал открыт не тем, кому он предназначен (lct-23). Фронт по этому коду + #: отправляет на вход, а не показывает «занятие не найдено». + FORBIDDEN = "forbidden" INTERNAL = "internal" @@ -585,9 +589,24 @@ class SessionReport(BaseModel): override_comment: str | None = None +class Principal(BaseModel): + """Кто вошёл. В событиях не участвует, но фронт разводит по роли экраны, + и список ролей должен приезжать из домена, а не переписываться руками + (lct-23). Полная модель — в `app/api/auth.py`. + """ + + login: str + full_name: str + role: Role + service: str | None = None + trainee_id: UUID | None = None + + class EventCatalog(BaseModel): """Единственное назначение — собрать все союзы в одну JSON Schema для make types.""" + principal: Principal + server_to_trainee: ServerToTrainee trainee_to_server: TraineeToServer server_to_observer: ServerToObserver diff --git a/backend/app/domain/roles.py b/backend/app/domain/roles.py new file mode 100644 index 0000000..19a68e7 --- /dev/null +++ b/backend/app/domain/roles.py @@ -0,0 +1,29 @@ +"""Роли пользователей. Три названы в ТЗ поимённо, четвёртой нет. + +Роль лежит в домене, а не в модуле входа: по ней разводятся и экраны фронта, +и каналы сокетов, и она уезжает в TypeScript через `make types`. +""" + +from enum import StrEnum + + +class Role(StrEnum): + ADMIN = "admin" + INSTRUCTOR = "instructor" + TRAINEE = "trainee" + + +ROLE_LABELS: dict[Role, str] = { + Role.ADMIN: "Администратор системы", + Role.INSTRUCTOR: "Преподаватель", + Role.TRAINEE: "Обучающийся", +} + +#: Что кому доступно. Ограничения из ТЗ: преподаватель не меняет системные +#: настройки, администратор не вмешивается в оценки, обучающийся не видит +#: чужих результатов. +SCREENS: dict[Role, tuple[str, ...]] = { + Role.ADMIN: ("/admin", "/profile"), + Role.INSTRUCTOR: ("/instructor", "/wall", "/profile", "/dds"), + Role.TRAINEE: ("/trainee", "/profile"), +} diff --git a/backend/app/main.py b/backend/app/main.py index 645d2ab..4edb3d4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,9 +4,11 @@ import logging from contextlib import asynccontextmanager from fastapi import FastAPI +from starlette.middleware.sessions import SessionMiddleware from pathlib import Path +from app.api import auth from app.api.http import ekp as ekp_api from app.api.http import scenarios as scenarios_api from app.api.http import sessions @@ -61,6 +63,16 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Учебный симулятор занятия для системы 112", lifespan=lifespan) +# Сессия ставится до роутеров: роль должна быть известна и на HTTP, и в момент +# рукопожатия сокета, иначе проверять её в канале будет нечем (app/api/auth.py). +app.add_middleware( + SessionMiddleware, + secret_key=get_settings().session_secret, + session_cookie="lct_session", + https_only=False, # учебный класс живёт по http во внутренней сети + max_age=12 * 60 * 60, # смена занятий, не месяц +) +app.include_router(auth.router) app.include_router(sessions.router) app.include_router(scenarios_api.router) app.include_router(ekp_api.router) diff --git a/backend/app/session/state.py b/backend/app/session/state.py index 5405632..cc77355 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -50,6 +50,9 @@ class SessionState: mode: SessionMode required_fields: list[str] = field(default_factory=list) trainee_name: str | None = None + #: Чьё это занятие. Проставляется при запуске, когда курсант известен + #: по учётной записи: по нему разбор закрывается от чужих (lct-23). + trainee_id: UUID | None = None attempt: int = 1 kio: KIO = field(default_factory=KIO) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e3c5986..d606b5e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -22,6 +22,10 @@ dependencies = [ # Числа словами перед синтезом: Silero молча выбрасывает цифры, и звонящий # произносит «улица Ленина, дом… квартира…» без номеров. "num2words>=0.5.13", + # Вход и роли (lct-23): argon2 — современная рекомендация по хешированию + # паролей, itsdangerous нужен SessionMiddleware из starlette. + "argon2-cffi>=23.1", + "itsdangerous>=2.1", ] [project.optional-dependencies] diff --git a/backend/scripts/start_lesson.py b/backend/scripts/start_lesson.py index 30ce487..aab50fb 100644 --- a/backend/scripts/start_lesson.py +++ b/backend/scripts/start_lesson.py @@ -11,6 +11,7 @@ import asyncio import json import sys +import urllib.request import uuid import websockets @@ -19,9 +20,35 @@ BACKEND = "ws://localhost:8000" FRONTEND = "http://localhost:5173" +def dev_cookie() -> str: + """Вход для служебного скрипта. + + Сокеты закрыты ролями (lct-23), а `make lesson` — наш инструмент, а не + пользователь. Точка `dev-token` живёт за флагом `DEV_AUTH_BYPASS` и на + рабочем стенде отвечает 404 — тогда скрипт честно скажет, что занятие + запускается с пульта преподавателя. + """ + request = urllib.request.Request( + f"{BACKEND.replace('ws://', 'http://')}/api/auth/dev-token", method="POST" + ) + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.headers.get("set-cookie", "").split(";")[0] + except urllib.error.HTTPError as exc: + if exc.code == 404: + raise SystemExit( + "вход выключен: на стенде занятие запускается с пульта преподавателя.\n" + "Для разработки поставь DEV_AUTH_BYPASS=true в backend/.env" + ) from exc + raise + + async def main(scenario_id: str, mode: str) -> None: session_id = str(uuid.uuid4()) - async with websockets.connect(f"{BACKEND}/ws/control/{session_id}") as control: + headers = {"Cookie": dev_cookie()} + async with websockets.connect( + f"{BACKEND}/ws/control/{session_id}", additional_headers=headers + ) as control: await control.send(json.dumps({ "type": "scenario.start", "scenario_id": scenario_id, diff --git a/backend/scripts/users.py b/backend/scripts/users.py new file mode 100644 index 0000000..32dcba7 --- /dev/null +++ b/backend/scripts/users.py @@ -0,0 +1,78 @@ +"""make users: завести учётные записи для занятия. + +Без входа система не работает (lct-23), а заводить первого администратора +руками в базе — плохой первый шаг для того, кто разворачивает стенд. +Скрипт создаёт по одной записи на роль и печатает пароли один раз. + +Пароли случайные: зашитый в репозиторий «admin/admin» переживёт сдачу +и уедет в реальный класс. + + make users создать недостающие + make users force=1 пересоздать пароли существующих +""" + +import asyncio +import os +import secrets +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from sqlalchemy import select # noqa: E402 + +from app.api.auth import hash_password # noqa: E402 +from app.db.base import get_sessionmaker # noqa: E402 +from app.db.models import Trainee, User # noqa: E402 +from app.domain.roles import Role # noqa: E402 + +PEOPLE = [ + ("admin", "Администратор стенда", Role.ADMIN, None), + ("teacher", "Преподаватель", Role.INSTRUCTOR, None), + ("trainee", "Курсант", Role.TRAINEE, "ДДС района"), +] + + +async def main() -> int: + force = bool(os.environ.get("force")) + created: list[tuple[str, str]] = [] + + async with get_sessionmaker()() as db: + for login, full_name, role, service in PEOPLE: + user = await db.scalar(select(User).where(User.login == login)) + if user is not None and not force: + print(f" {login}: уже есть") + continue + + password = secrets.token_urlsafe(9) + if user is None: + user = User(login=login, full_name=full_name, role=role.value, service=service) + db.add(user) + user.password_hash = hash_password(password) + user.blocked = False + + # У обучающегося должна быть карточка курсанта: по ней собираются + # профиль и история, и по ней же разбор закрывается от чужих. + if role is Role.TRAINEE and user.trainee_id is None: + trainee = Trainee(name=full_name) + db.add(trainee) + await db.flush() + user.trainee_id = trainee.id + + created.append((login, password)) + await db.commit() + + if not created: + print("все учётные записи на месте (пересоздать: make users force=1)") + return 0 + + print("\nЗаписано. Пароли показываются один раз:\n") + for login, password in created: + print(f" {login:10} {password}") + print("\nСменить пароль можно только пересозданием: экран смены пароля — lct-24.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..5029097 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,31 @@ +"""Общая настройка тестов. + +Голосовой контур выключен: он грузит модели около десяти секунд, а проверяется +отдельно в `test_voice_pipeline.py`. Вход включён как на стенде, но тесты +логинятся через `dev-token` — так же, как `make lesson` (tasks/lct-23-auth-roles.md). +""" + +import os + +os.environ.setdefault("DEV_AUTH_BYPASS", "true") + +import pytest # noqa: E402 + +from app.config import get_settings # noqa: E402 + + +@pytest.fixture(autouse=True, scope="session") +def _dev_auth(): + """Флаг обхода читается один раз при создании настроек.""" + get_settings.cache_clear() + settings = get_settings() + assert settings.dev_auth_bypass, "тесты должны ходить через dev-token" + yield + + +@pytest.fixture +def as_instructor(client): + """Клиент, вошедший преподавателем. Cookie живёт в самом TestClient.""" + response = client.post("/api/auth/dev-token") + assert response.status_code == 200, response.text + return client diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..49a6826 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,128 @@ +"""Вход, роли и аудит. + +Пункты приёмки lct-23: курсант не открывает пульт даже по прямой ссылке, +администратор не правит оценки, чужой разбор закрыт, пароли не хранятся +в открытом виде. + +Тесты, которым нужна база, пропускаются, когда Postgres не поднят: остальные +проверяют разграничение, для которого база не нужна. +""" + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from app.api.auth import hash_password, verify_password +from app.domain.roles import Role +from app.main import app +from app.session.hub import hub + + +@pytest.fixture +def client(): + with TestClient(app) as test_client: + hub.journal = None + yield test_client + + +# ── пароли ── + + +def test_password_is_hashed_not_stored(): + stored = hash_password("правильный-пароль") + assert "правильный-пароль" not in stored + assert stored.startswith("$argon2") + + +def test_password_verifies(): + stored = hash_password("правильный-пароль") + assert verify_password(stored, "правильный-пароль") + assert not verify_password(stored, "другой") + + +def test_broken_hash_does_not_let_anyone_in(): + """Битая запись в базе не должна открывать вход.""" + assert not verify_password("не хеш вовсе", "что угодно") + + +# ── вход ── + + +def test_unknown_login_and_wrong_password_look_the_same(client): + """Иначе форма входа превращается в список действующих учётных записей.""" + first = client.post("/api/auth/login", json={"login": "нет-такого", "password": "x"}) + assert first.status_code == 401 + assert first.json()["detail"] == "bad_credentials" + + +def test_me_requires_authentication(client): + assert client.get("/api/auth/me").status_code == 401 + + +def test_dev_token_gives_an_instructor(client): + response = client.post("/api/auth/dev-token") + assert response.status_code == 200 + assert response.json()["role"] == "instructor" + assert client.get("/api/auth/me").json()["role"] == "instructor" + + +def test_logout_clears_the_session(client): + client.post("/api/auth/dev-token") + client.post("/api/auth/logout") + assert client.get("/api/auth/me").status_code == 401 + + +# ── разграничение ── + + +def test_anonymous_cannot_open_any_socket(client): + """Номер занятия перестал быть пропуском: раньше по ссылке пускало знание + адреса, теперь — роль.""" + session_id = uuid4() + for path in (f"/ws/control/{session_id}", f"/ws/call/{session_id}", + f"/ws/observe/{session_id}", f"/ws/station/{session_id}"): + with client.websocket_connect(path) as socket: + message = socket.receive_json() + assert message["type"] == "error" + assert message["code"] == "forbidden", path + + +def test_anonymous_cannot_read_history(client): + assert client.get("/api/sessions").status_code == 401 + + +def test_anonymous_cannot_read_trainees(client): + assert client.get("/api/trainees").status_code == 401 + + +def test_classifier_is_open_to_everyone(client): + """Справочник ЕКП прятать не от кого: оператор видит тот же список + на боевом АРМ.""" + assert client.get("/api/ekp/groups").status_code == 200 + + +def test_health_stays_open(client): + """Экран «стенд прогревается» показывается до входа.""" + assert client.get("/api/health").status_code == 200 + + +def test_instructor_can_open_control(client): + """На `control` сервер не шлёт ничего — это канал только на запись + (docs/arch/CONTRACT.md). Признак того, что он открыт, — поднявшееся + занятие, а не ответное сообщение.""" + import time + + client.post("/api/auth/dev-token") + session_id = uuid4() + with client.websocket_connect(f"/ws/control/{session_id}") as socket: + socket.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2", + "trainee": "Иванов", "mode": "training"}) + deadline = time.monotonic() + 3 + while time.monotonic() < deadline and hub.get(session_id) is None: + time.sleep(0.02) + assert hub.get(session_id) is not None, "занятие не поднялось: канал отвергнут" + + +def test_roles_are_three_and_named_in_the_spec(): + assert {role.value for role in Role} == {"admin", "instructor", "trainee"} diff --git a/backend/tests/test_domain.py b/backend/tests/test_domain.py index e711998..3995551 100644 --- a/backend/tests/test_domain.py +++ b/backend/tests/test_domain.py @@ -76,7 +76,10 @@ def test_session_modes_are_the_three_from_docs(): def test_event_catalog_covers_every_channel(): + """`principal` каналом не является: он в каталоге ради того, чтобы список + ролей приезжал на фронт из домена, а не переписывался руками (lct-23).""" assert set(EventCatalog.model_fields) == { + "principal", "server_to_trainee", "trainee_to_server", "server_to_observer", diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index c8a584e..8d8472e 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -27,6 +27,9 @@ def client(): except OSError as exc: pytest.skip(f"Postgres недоступен ({exc}) — подними `make dev`") with TestClient(app) as test_client: + # Сокеты закрыты ролями (lct-23): тесты входят так же, + # как `make lesson`, — через dev-token за флагом. + test_client.post("/api/auth/dev-token") yield test_client diff --git a/backend/tests/test_station.py b/backend/tests/test_station.py index a0d869c..bd72550 100644 --- a/backend/tests/test_station.py +++ b/backend/tests/test_station.py @@ -13,6 +13,9 @@ from app.session.hub import hub @pytest.fixture def client(): with TestClient(app) as test_client: + # Сокеты закрыты ролями (lct-23): тесты входят так же, + # как `make lesson`, — через dev-token за флагом. + test_client.post("/api/auth/dev-token") hub.journal = None yield test_client diff --git a/backend/tests/test_ws.py b/backend/tests/test_ws.py index b8dad49..469c77b 100644 --- a/backend/tests/test_ws.py +++ b/backend/tests/test_ws.py @@ -19,6 +19,9 @@ from app.session.hub import hub @pytest.fixture def client(): with TestClient(app) as test_client: + # Сокеты закрыты ролями (lct-23): тесты входят так же, + # как `make lesson`, — через dev-token за флагом. + test_client.post("/api/auth/dev-token") hub.journal = None # тесты не пишут в БД: проверяется поведение каналов yield test_client diff --git a/backend/uv.lock b/backend/uv.lock index 48bdaa5..c73f676 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -51,6 +51,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" }, + { url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b9/97f0370f99611b14efd384918613dd5cbda75f28d9bb1b677aacfeaa17df/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:19b562b1de4b9052ef1214a2821c44b6e6f22945daa102c32ae4eff929d8b6d8", size = 23055, upload-time = "2026-08-20T07:33:19.716Z" }, + { url = "https://files.pythonhosted.org/packages/ae/70/7eb3fe7bf00103cbbb569c51aef150661f22b734a782673a600ff0f52309/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d525938467d52c923a890153c99087c9d5a937d1f6b585dbdba34ec82e397a", size = 24869, upload-time = "2026-08-20T07:33:20.671Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4b/9d5919c6cb1f15df7406af0f99b048bd93936f112e3e8f4c8077bc2a9110/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b0bcac4d490a237e18cf91f57352920c29f77f2fa39efd0813fb81298bf17ba", size = 24216, upload-time = "2026-08-20T07:33:21.653Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/32109943bace7729233cc4ee78530baa306d8cc3c6501a64ba8cb3b58129/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0cc40f7b4050bb93eb67de95d2d759322fc7ce4930b9d645581ecf4913ec651e", size = 23584, upload-time = "2026-08-20T07:33:22.613Z" }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -76,6 +113,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -266,6 +327,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -284,9 +354,11 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "alembic" }, + { name = "argon2-cffi" }, { name = "asyncpg" }, { name = "fastapi" }, { name = "httpx" }, + { name = "itsdangerous" }, { name = "num2words" }, { name = "numpy" }, { name = "onnxruntime" }, @@ -314,9 +386,11 @@ voice = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.13" }, + { name = "argon2-cffi", specifier = ">=23.1" }, { name = "asyncpg", specifier = ">=0.29" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.27" }, + { name = "itsdangerous", specifier = ">=2.1" }, { name = "num2words", specifier = ">=0.5.13" }, { name = "numpy", specifier = ">=1.26" }, { name = "onnx-asr", marker = "extra == 'voice'", specifier = ">=0.6" }, @@ -485,6 +559,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/ca/c47f91d3cab175b01fd8c4f0d80fdf8613be876cc616e66ad281a59c5ddf/protobuf-7.36.1-py3-none-any.whl", hash = "sha256:7d951e46b3f963d6c264c367c437921de9d5aedd9c3f9612b9077736b4e3ad5c", size = 179813, upload-time = "2026-08-31T22:40:03.54Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.5" diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index aa02f3b..a26e5ba 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -5,14 +5,22 @@ import { Instructor } from "@/pages/instructor/Instructor"; import { Dds } from "@/pages/dds/Dds"; import { Profile } from "@/pages/profile/Profile"; import { Wall } from "@/pages/wall/Wall"; +import { RequireAuth } from "@/features/auth/Login"; // Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md). // session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой. +// Каждый экран за проверкой роли: курсант не открывает пульт даже по прямой +// ссылке. Сервер проверяет то же самое на сокетах — здесь только чтобы человек +// увидел понятное объяснение вместо пустого экрана (lct-23). +const guarded = (screen: string, element: JSX.Element) => ( + {element} +); + export const router = createBrowserRouter([ { path: "/", element: }, - { path: "/trainee", element: }, - { path: "/instructor", element: }, - { path: "/wall", element: }, - { path: "/profile", element: }, - { path: "/dds", element: }, + { path: "/trainee", element: guarded("/trainee", ) }, + { path: "/instructor", element: guarded("/instructor", ) }, + { path: "/wall", element: guarded("/wall", ) }, + { path: "/profile", element: guarded("/profile", ) }, + { path: "/dds", element: guarded("/dds", ) }, ]); diff --git a/frontend/src/features/auth/Login.tsx b/frontend/src/features/auth/Login.tsx new file mode 100644 index 0000000..9c6bd5b --- /dev/null +++ b/frontend/src/features/auth/Login.tsx @@ -0,0 +1,78 @@ +// Экран входа. Ставится перед всеми остальными: номер занятия перестал быть +// пропуском, по ссылке пускает роль, а не знание адреса (lct-23). + +import { useState, type ReactNode } from "react"; + +import { SCREENS, useLogin, useLogout, useMe } from "./useAuth"; + +export function RequireAuth({ screen, children }: { screen: string; children: ReactNode }) { + const me = useMe(); + const logout = useLogout(); + + if (me.isLoading) { + return

Проверяем доступ…

; + } + if (!me.data) return ; + + const allowed = SCREENS[me.data.role] ?? []; + if (!allowed.includes(screen)) { + return ( +
+

Экран недоступен

+

+ Вы вошли как {me.data.full_name} ({me.data.role}). Этот экран открыт другой роли. +

+

Доступно: {allowed.join(", ") || "ничего"}

+

+
+ ); + } + return <>{children}; +} + +function Login() { + const login = useLogin(); + const [name, setName] = useState(""); + const [password, setPassword] = useState(""); + + return ( +
+

Учебный симулятор системы-112

+
{ + event.preventDefault(); + login.mutate({ login: name, password }); + }} + > + + + + + + + + + + + +
Логин + setName(event.target.value)} /> +
Пароль + setPassword(event.target.value)} + /> +
+

+ +

+
+ {/* Причину отказа не уточняем: «нет такого логина» — это список + действующих учётных записей для того, кто подбирает. */} + {login.isError &&

Логин или пароль неверны

} +
+ ); +} diff --git a/frontend/src/features/auth/useAuth.ts b/frontend/src/features/auth/useAuth.ts new file mode 100644 index 0000000..2cb22c7 --- /dev/null +++ b/frontend/src/features/auth/useAuth.ts @@ -0,0 +1,64 @@ +// Кто сейчас за экраном. Роль приходит с сервера, а не хранится на фронте: +// подписанная cookie — источник истины, здесь только её отражение (lct-23). + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import type { Role } from "@/shared/types/generated"; + +export interface Principal { + login: string; + full_name: string; + role: Role; + service: string | null; + trainee_id: string | null; +} + +async function call(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + headers: { "Content-Type": "application/json", ...init?.headers }, + }); + if (!response.ok) throw new Error(String(response.status)); + return response.json() as Promise; +} + +export function useMe() { + return useQuery({ + queryKey: ["me"], + // Отсутствие входа — не ошибка, а состояние: возвращаем null, чтобы + // экран показал форму, а не «что-то пошло не так». + queryFn: async () => { + try { + return await call("/api/auth/me"); + } catch { + return null; + } + }, + retry: false, + }); +} + +export function useLogin() { + const cache = useQueryClient(); + return useMutation({ + mutationFn: (body: { login: string; password: string }) => + call("/api/auth/login", { method: "POST", body: JSON.stringify(body) }), + onSuccess: (who) => cache.setQueryData(["me"], who), + }); +} + +export function useLogout() { + const cache = useQueryClient(); + return useMutation({ + mutationFn: () => call<{ ok: boolean }>("/api/auth/logout", { method: "POST" }), + onSuccess: () => cache.setQueryData(["me"], null), + }); +} + +/** Какие экраны открыты роли. Повторяет domain/roles.py: список короткий + * и меняется редко, а держать его на сервере ради двух строк — лишний запрос. */ +export const SCREENS: Record = { + admin: ["/admin", "/profile"], + instructor: ["/instructor", "/wall", "/profile", "/dds"], + trainee: ["/trainee", "/profile"], +}; diff --git a/frontend/src/shared/types/generated.ts b/frontend/src/shared/types/generated.ts index 117a19a..24e6a2a 100644 --- a/frontend/src/shared/types/generated.ts +++ b/frontend/src/shared/types/generated.ts @@ -150,7 +150,7 @@ export interface ErrorEvent { } /** Коды канала `error`. Фронт разбирает код, а не текст сообщения: текст — для человека, код — для поведения интерфейса. */ -export type ErrorKind = "session_not_found" | "call_not_started" | "hint_denied_in_exam" | "models_warming_up" | "directive_needs_network" | "scenario_invalid" | "unsupported_event" | "internal"; +export type ErrorKind = "session_not_found" | "call_not_started" | "hint_denied_in_exam" | "models_warming_up" | "directive_needs_network" | "scenario_invalid" | "unsupported_event" | "forbidden" | "internal"; /** Отметка в разборе. `fact` и `norm` — то самое обоснование. */ export interface Finding { @@ -314,6 +314,15 @@ export interface PoliceDetails { vehicle?: string | null; } +/** Кто вошёл. В событиях не участвует, но фронт разводит по роли экраны, и список ролей должен приезжать из домена, а не переписываться руками (lct-23). Полная модель — в `app/api/auth.py`. */ +export interface Principal { + login: string; + full_name: string; + role: Role; + service?: string | null; + trainee_id?: string | null; +} + export interface ReferencePlay { type: "reference.play"; } @@ -326,6 +335,8 @@ export interface ReferenceStarted { export type ResponseStatus = "registered" | "transferred" | "in_progress" | "closed"; +export type Role = "admin" | "instructor" | "trainee"; + export interface ScenarioPublish { type: "scenario.publish"; scenario_id: string; diff --git a/scripts/demo.sh b/scripts/demo.sh index fe4b79b..5f50ab2 100755 --- a/scripts/demo.sh +++ b/scripts/demo.sh @@ -28,8 +28,9 @@ docker compose up -d postgres ( cd backend && uv run alembic upgrade head >/dev/null ) echo " миграции накатаны" -say "3/5 Сценарии" +say "3/5 Сценарии и учётные записи" make seed +make users say "4/5 Бэкенд с голосом" # Контейнерные бэкенд и фронт гасим: порты те же, а голос работает нативно. @@ -53,6 +54,7 @@ FRONTEND=$! say "Стенд поднят" cat <<'LINKS' + вход: http://localhost:5173/ (пароли печатает make users) пульт преподавателя: http://localhost:5173/instructor профили курсантов: http://localhost:5173/profile ссылки на АРМ курсанта, монитор и ДДС печатает сам пульт