Initial commit: backend scaffold, auth, frontend login

- FastAPI + SQLAlchemy async + Alembic + Postgres backend
- Auth: JWT access + rotating refresh tokens, argon2, roles, audit log
- React 19 + Vite frontend: login page, protected route, auth context
- Docker Compose: postgres, redis, migrate, api, worker (placeholder), frontend/nginx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 15:07:15 +03:00
co-authored by Claude Sonnet 5
commit 2664cb8213
71 changed files with 4999 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
"""Зависимости FastAPI: сессия БД, текущий пользователь, проверка ролей."""
from __future__ import annotations
import uuid
from collections.abc import Callable, Coroutine
from typing import Annotated, Any
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import TokenError, decode_access_token
from app.db.models.user import User, UserRole
from app.db.session import get_session
bearer_scheme = HTTPBearer(auto_error=False)
SessionDep = Annotated[AsyncSession, Depends(get_session)]
_UNAUTHORIZED = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Требуется аутентификация",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user(
session: SessionDep,
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
) -> User:
if credentials is None:
raise _UNAUTHORIZED
try:
payload = decode_access_token(credentials.credentials)
except TokenError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(exc),
headers={"WWW-Authenticate": "Bearer"},
) from exc
try:
user_id = uuid.UUID(payload["sub"])
except (KeyError, ValueError) as exc:
raise _UNAUTHORIZED from exc
# Пользователь читается из БД на каждый запрос, а не берётся из токена:
# отключённая учётка должна терять доступ немедленно, не дожидаясь
# истечения access-токена.
user = await session.get(User, user_id)
if user is None or not user.is_active:
raise _UNAUTHORIZED
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
def require_roles(*roles: UserRole) -> Callable[..., Coroutine[Any, Any, User]]:
"""Ограничивает эндпоинт набором ролей.
Пример:
@router.post("/", dependencies=[Depends(require_roles(UserRole.ADMIN))])
"""
allowed = set(roles)
async def _guard(user: CurrentUser) -> User:
if user.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав для этого действия",
)
return user
return _guard
# Готовые зависимости под роли из плана.
require_admin = require_roles(UserRole.ADMIN)
require_cashier = require_roles(UserRole.ADMIN, UserRole.CASHIER)
require_any = require_roles(UserRole.ADMIN, UserRole.CASHIER, UserRole.VIEWER)
AdminUser = Annotated[User, Depends(require_admin)]
CashierUser = Annotated[User, Depends(require_cashier)]