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
View File
+92
View File
@@ -0,0 +1,92 @@
"""Эндпоинты аутентификации."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response, status
from app.api.deps import CurrentUser, SessionDep
from app.core.security import hash_password, verify_password
from app.db.models.audit import AuditAction
from app.schemas.auth import (
ChangePasswordRequest,
LoginRequest,
LoginResponse,
RefreshRequest,
TokenPair,
UserOut,
)
from app.services import audit, auth
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=LoginResponse)
async def login(payload: LoginRequest, request: Request, session: SessionDep) -> LoginResponse:
try:
user = await auth.authenticate(
session, email=payload.email, password=payload.password, request=request
)
except auth.AuthError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
pair = await auth.issue_token_pair(session, user, request=request)
await audit.record(session, action=AuditAction.LOGIN_SUCCESS, user=user, request=request)
await session.commit()
return LoginResponse(**pair.model_dump(), user=UserOut.model_validate(user))
@router.post("/refresh", response_model=TokenPair)
async def refresh(payload: RefreshRequest, request: Request, session: SessionDep) -> TokenPair:
try:
_, pair = await auth.rotate_refresh_token(
session, raw_token=payload.refresh_token, request=request
)
except auth.AuthError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
await session.commit()
return pair
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
payload: RefreshRequest, request: Request, session: SessionDep, user: CurrentUser
) -> Response:
await auth.revoke_refresh_token(session, raw_token=payload.refresh_token)
await audit.record(session, action=AuditAction.LOGOUT, user=user, request=request)
await session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/me", response_model=UserOut)
async def me(user: CurrentUser) -> UserOut:
return UserOut.model_validate(user)
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
async def change_password(
payload: ChangePasswordRequest, request: Request, session: SessionDep, user: CurrentUser
) -> Response:
if not verify_password(payload.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Текущий пароль указан неверно"
)
user.password_hash = hash_password(payload.new_password)
# Смена пароля завершает все остальные сессии: если пароль меняют из-за
# подозрения на компрометацию, чужой refresh-токен обязан перестать работать.
await auth.revoke_all_for_user(session, user.id)
await audit.record(
session,
action=AuditAction.USER_UPDATED,
user=user,
entity_type="user",
entity_id=user.id,
payload={"change": "password"},
request=request,
)
await session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+69
View File
@@ -0,0 +1,69 @@
"""Проверки живости и готовности."""
from __future__ import annotations
from typing import Literal
import redis.asyncio as aioredis
from fastapi import APIRouter, Response, status
from pydantic import BaseModel
from sqlalchemy import text
from app.api.deps import SessionDep
from app.core.config import settings
from app.core.logging import get_logger
router = APIRouter(tags=["health"])
log = get_logger(__name__)
class HealthResponse(BaseModel):
status: Literal["ok", "degraded"]
environment: str
database: bool
redis: bool
@router.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
"""Liveness: процесс жив. Внешние зависимости не проверяются.
Оркестратор не должен перезапускать контейнер из-за недоступного Postgres —
перезапуск приложения проблему БД не решает.
"""
return HealthResponse(
status="ok", environment=settings.environment, database=True, redis=True
)
@router.get("/health/ready", response_model=HealthResponse)
async def readiness(session: SessionDep, response: Response) -> HealthResponse:
"""Readiness: приложение способно обслуживать запросы."""
db_ok = True
redis_ok = True
try:
await session.execute(text("SELECT 1"))
except Exception as exc: # noqa: BLE001 — health-check не должен падать сам
db_ok = False
log.warning("readiness_db_failed", error=str(exc))
client = aioredis.from_url(settings.redis_url)
try:
await client.ping()
except Exception as exc: # noqa: BLE001
redis_ok = False
log.warning("readiness_redis_failed", error=str(exc))
finally:
await client.aclose()
healthy = db_ok and redis_ok
if not healthy:
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return HealthResponse(
status="ok" if healthy else "degraded",
environment=settings.environment,
database=db_ok,
redis=redis_ok,
)
+10
View File
@@ -0,0 +1,10 @@
"""Сборка роутеров версии v1."""
from fastapi import APIRouter
from app.api.v1 import auth, health, users
api_router = APIRouter()
api_router.include_router(health.router)
api_router.include_router(auth.router)
api_router.include_router(users.router)
+133
View File
@@ -0,0 +1,133 @@
"""Управление пользователями. Доступно только администратору."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.api.deps import CurrentUser, SessionDep, require_admin
from app.core.security import hash_password
from app.db.models.audit import AuditAction
from app.db.models.user import User, UserRole
from app.schemas.auth import UserOut
from app.services import audit, auth
router = APIRouter(
prefix="/users", tags=["users"], dependencies=[Depends(require_admin)]
)
class UserCreate(BaseModel):
email: EmailStr
full_name: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=10, max_length=128)
role: UserRole = UserRole.CASHIER
class UserUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
full_name: str | None = Field(default=None, min_length=1, max_length=255)
role: UserRole | None = None
is_active: bool | None = None
password: str | None = Field(default=None, min_length=10, max_length=128)
@router.get("", response_model=list[UserOut])
async def list_users(session: SessionDep) -> list[UserOut]:
users = await session.scalars(select(User).order_by(User.created_at.desc()))
return [UserOut.model_validate(user) for user in users]
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def create_user(
payload: UserCreate, request: Request, session: SessionDep, actor: CurrentUser
) -> UserOut:
user = User(
email=payload.email.strip().lower(),
full_name=payload.full_name,
password_hash=hash_password(payload.password),
role=payload.role,
)
session.add(user)
await audit.record(
session,
action=AuditAction.USER_CREATED,
user=actor,
entity_type="user",
entity_id=user.id,
payload={"email": user.email, "role": user.role.value},
request=request,
)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Пользователь с таким email уже существует",
) from exc
return UserOut.model_validate(user)
@router.patch("/{user_id}", response_model=UserOut)
async def update_user(
user_id: uuid.UUID,
payload: UserUpdate,
request: Request,
session: SessionDep,
actor: CurrentUser,
) -> UserOut:
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
changes = payload.model_dump(exclude_unset=True)
# Администратор, снявший с себя роль или отключивший себя, потеряет доступ
# к админке — и вернуть его будет некому.
if user.id == actor.id:
if changes.get("is_active") is False:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя отключить собственную учётную запись",
)
if "role" in changes and changes["role"] != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя снять с себя роль администратора",
)
if (new_password := changes.pop("password", None)) is not None:
user.password_hash = hash_password(new_password)
await auth.revoke_all_for_user(session, user.id)
for field, value in changes.items():
setattr(user, field, value)
# Отключение учётки обязано немедленно завершить её активные сессии.
if changes.get("is_active") is False:
await auth.revoke_all_for_user(session, user.id)
await audit.record(
session,
action=AuditAction.USER_UPDATED,
user=actor,
entity_type="user",
entity_id=user.id,
payload={
"fields": sorted(changes),
"password_changed": new_password is not None,
},
request=request,
)
await session.commit()
return UserOut.model_validate(user)