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
View File
+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)]
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)
+72
View File
@@ -0,0 +1,72 @@
"""Служебные команды.
python -m app.cli bootstrap # создать первого администратора
python -m app.cli gen-keys # сгенерировать SECRET_KEY и ENCRYPTION_KEY
"""
from __future__ import annotations
import argparse
import asyncio
import secrets
import sys
from sqlalchemy import select
from app.core.config import settings
from app.core.security import hash_password
from app.db.models.user import User, UserRole
from app.db.session import session_scope
async def bootstrap() -> int:
"""Создаёт первого администратора из переменных окружения.
Идемпотентна: повторный запуск ничего не меняет и пароль не сбрасывает.
"""
if not settings.first_admin_password:
print("FIRST_ADMIN_PASSWORD не задан в .env", file=sys.stderr)
return 1
email = settings.first_admin_email.strip().lower()
async with session_scope() as session:
existing = await session.scalar(select(User).where(User.email == email))
if existing is not None:
print(f"Пользователь {email} уже существует — ничего не изменено.")
return 0
session.add(
User(
email=email,
full_name=settings.first_admin_name,
password_hash=hash_password(settings.first_admin_password),
role=UserRole.ADMIN,
)
)
print(f"Администратор {email} создан.")
print("Смените пароль после первого входа и уберите FIRST_ADMIN_PASSWORD из .env.")
return 0
def gen_keys() -> int:
from cryptography.fernet import Fernet
print(f"SECRET_KEY={secrets.token_urlsafe(64)}")
print(f"ENCRYPTION_KEY={Fernet.generate_key().decode()}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(prog="app.cli", description="Служебные команды lux_fiscal")
parser.add_argument("command", choices=["bootstrap", "gen-keys"])
args = parser.parse_args()
if args.command == "bootstrap":
return asyncio.run(bootstrap())
return gen_keys()
if __name__ == "__main__":
raise SystemExit(main())
View File
+96
View File
@@ -0,0 +1,96 @@
"""Конфигурация приложения. Единственное место, читающее окружение."""
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from urllib.parse import quote
from pydantic import Field, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
Environment = Literal["local", "staging", "production"]
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(".env", "../.env"),
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# --- Общее ---
project_name: str = "lux_fiscal"
environment: Environment = "local"
debug: bool = False
log_level: str = "INFO"
base_url: str = "http://localhost:8000"
# Хранится строкой, а не list[str]: для сложных типов pydantic-settings
# пытается разобрать значение как JSON до валидаторов, и привычная запись
# "a,b" из .env приводит к ошибке запуска. Разбор — в свойстве ниже.
cors_origins_raw: str = Field(default="", alias="CORS_ORIGINS")
# --- Postgres ---
postgres_host: str = "postgres"
postgres_port: int = 5432
postgres_db: str = "lux_fiscal"
postgres_user: str = "lux_fiscal"
postgres_password: str = "postgres"
# --- Redis ---
redis_host: str = "redis"
redis_port: int = 6379
redis_db: int = 0
# --- Криптография ---
secret_key: str
encryption_key: str
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 7
# --- Первый администратор (только для команды bootstrap) ---
first_admin_email: str = "admin@example.com"
first_admin_password: str = ""
first_admin_name: str = "Администратор"
@property
def cors_origins(self) -> list[str]:
"""Список разрешённых origin'ов из строки через запятую."""
return [item.strip() for item in self.cors_origins_raw.split(",") if item.strip()]
@property
def database_url(self) -> str:
"""DSN для асинхронного драйвера (приложение и воркер).
Логин и пароль экранируются вручную: `PostgresDsn.build` не делает
percent-encoding сам, и любой спецсимвол в пароле (`,`, `@`, `:`, `/`)
ломает разбор URL — в т.ч. молча указывая неверный порт.
"""
return str(
PostgresDsn.build(
scheme="postgresql+asyncpg",
username=quote(self.postgres_user, safe=""),
password=quote(self.postgres_password, safe=""),
host=self.postgres_host,
port=self.postgres_port,
path=self.postgres_db,
)
)
@property
def redis_url(self) -> str:
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
@property
def is_production(self) -> bool:
return self.environment == "production"
@lru_cache
def get_settings() -> Settings:
return Settings() # type: ignore[call-arg]
settings = get_settings()
+59
View File
@@ -0,0 +1,59 @@
"""Симметричное шифрование секретов, хранимых в БД.
Ключи лицензий Checkbox и токены CRM попадают в таблицы. В открытом виде
они там лежать не должны: дамп базы не обязан давать возможность пробивать
чеки от лица клиента.
Используется Fernet (AES-128-CBC + HMAC-SHA256) из `cryptography`.
"""
from __future__ import annotations
from functools import lru_cache
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings
class DecryptionError(Exception):
"""Значение не расшифровывается — как правило, сменился ENCRYPTION_KEY."""
@lru_cache
def _fernet() -> Fernet:
try:
return Fernet(settings.encryption_key.encode())
except (ValueError, TypeError) as exc:
raise RuntimeError(
"ENCRYPTION_KEY некорректен. Сгенерируйте валидный ключ: "
"python -c \"from cryptography.fernet import Fernet; "
'print(Fernet.generate_key().decode())"'
) from exc
def encrypt(value: str) -> str:
return _fernet().encrypt(value.encode()).decode()
def decrypt(value: str) -> str:
try:
return _fernet().decrypt(value.encode()).decode()
except InvalidToken as exc:
raise DecryptionError(
"Не удалось расшифровать значение. Вероятная причина — ENCRYPTION_KEY "
"изменился с момента сохранения. Секрет нужно ввести заново."
) from exc
def mask(value: str, visible: int = 4) -> str:
"""Маска для показа секрета в интерфейсе: `****ab12`.
Наружу секреты отдаются только в таком виде — расшифрованное значение
не покидает бэкенд.
"""
if not value:
return ""
if len(value) <= visible:
return "*" * len(value)
return "*" * (len(value) - visible) + value[-visible:]
+53
View File
@@ -0,0 +1,53 @@
"""Структурное логирование.
Локально — человекочитаемый вывод с цветом, в проде — JSON: логи с VPS
уходят в агрегатор, и парсить их глазами никто не будет.
"""
from __future__ import annotations
import logging
import sys
import structlog
from app.core.config import settings
def configure_logging() -> None:
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=getattr(logging, settings.log_level.upper(), logging.INFO),
)
shared_processors: list[structlog.typing.Processor] = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
]
renderer: structlog.typing.Processor = (
structlog.processors.JSONRenderer()
if settings.is_production
else structlog.dev.ConsoleRenderer(colors=True)
)
structlog.configure(
processors=[*shared_processors, renderer],
wrapper_class=structlog.stdlib.BoundLogger,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
# Uvicorn дублирует каждый запрос своим access-логом — глушим, свой
# middleware пишет то же самое в структурном виде.
logging.getLogger("uvicorn.access").handlers.clear()
logging.getLogger("uvicorn.access").propagate = False
def get_logger(name: str = "lux_fiscal") -> structlog.stdlib.BoundLogger:
return structlog.get_logger(name)
+97
View File
@@ -0,0 +1,97 @@
"""Пароли, JWT и refresh-токены."""
from __future__ import annotations
import hashlib
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any, Literal
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
from app.core.config import settings
ALGORITHM = "HS256"
TokenType = Literal["access", "refresh"]
_hasher = PasswordHasher()
class TokenError(Exception):
"""Токен отсутствует, просрочен, повреждён или имеет неверный тип."""
# --- Пароли -----------------------------------------------------------------
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
_hasher.verify(password_hash, password)
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False
return True
def password_needs_rehash(password_hash: str) -> bool:
"""True, если хеш создан старыми параметрами argon2 и его стоит обновить."""
try:
return _hasher.check_needs_rehash(password_hash)
except InvalidHashError:
return True
# --- Access-токены ----------------------------------------------------------
def create_access_token(user_id: uuid.UUID, role: str) -> str:
now = datetime.now(UTC)
payload = {
"sub": str(user_id),
"role": role,
"type": "access",
"iat": now,
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
"jti": secrets.token_urlsafe(16),
}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def decode_access_token(token: str) -> dict[str, Any]:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
except jwt.ExpiredSignatureError as exc:
raise TokenError("Срок действия токена истёк") from exc
except jwt.InvalidTokenError as exc:
raise TokenError("Некорректный токен") from exc
# Без этой проверки refresh-токен можно было бы предъявить как access.
if payload.get("type") != "access":
raise TokenError("Ожидался access-токен")
return payload
# --- Refresh-токены ---------------------------------------------------------
def generate_refresh_token() -> tuple[str, str]:
"""Возвращает (сырой токен для клиента, sha256-хеш для хранения в БД).
Сырое значение не сохраняется нигде: в БД лежит только хеш.
"""
raw = secrets.token_urlsafe(48)
return raw, hash_refresh_token(raw)
def hash_refresh_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
def refresh_token_expiry() -> datetime:
return datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days)
View File
+45
View File
@@ -0,0 +1,45 @@
"""Базовый класс моделей и общие типы колонок."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, MetaData, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
# Явные шаблоны имён — без них Alembic генерирует безымянные constraint'ы,
# которые потом невозможно удалить в миграции отката.
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)
class UUIDPrimaryKeyMixin:
"""PK-идентификатор, генерируемый приложением.
Генерируем на стороне Python, а не в БД: для чеков UUID должен быть известен
ДО обращения к Checkbox — он служит ключом идемпотентности.
"""
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
+18
View File
@@ -0,0 +1,18 @@
"""Реестр моделей.
Alembic автогенерирует миграции по `Base.metadata`, поэтому каждая модель
обязана быть импортирована здесь — иначе её таблица молча не попадёт в миграцию.
"""
from app.db.base import Base
from app.db.models.audit import AuditAction, AuditLog
from app.db.models.user import RefreshToken, User, UserRole
__all__ = [
"AuditAction",
"AuditLog",
"Base",
"RefreshToken",
"User",
"UserRole",
]
+58
View File
@@ -0,0 +1,58 @@
"""Журнал аудита.
Для фискального приложения это не опциональная возможность: по каждому чеку
должно быть видно, кто и когда его инициировал. Записи только добавляются —
API на изменение и удаление не существует.
"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import DateTime, ForeignKey, Index, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, UUIDPrimaryKeyMixin
class AuditAction(str):
"""Значения действий. Намеренно обычные строки, а не Enum.
Добавление нового типа события не должно требовать миграции БД.
"""
LOGIN_SUCCESS = "auth.login.success"
LOGIN_FAILED = "auth.login.failed"
LOGOUT = "auth.logout"
TOKEN_REFRESH = "auth.token.refresh"
TOKEN_REUSE_DETECTED = "auth.token.reuse_detected"
USER_CREATED = "user.created"
USER_UPDATED = "user.updated"
USER_DEACTIVATED = "user.deactivated"
class AuditLog(UUIDPrimaryKeyMixin, Base):
__tablename__ = "audit_log"
# Пользователь может быть удалён, журнал — нет: ondelete=SET NULL,
# а человекочитаемый актор дублируется в actor_label.
user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), index=True
)
actor_label: Mapped[str | None] = mapped_column(String(320))
action: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
entity_type: Mapped[str | None] = mapped_column(String(64))
entity_id: Mapped[str | None] = mapped_column(String(64))
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
ip: Mapped[str | None] = mapped_column(String(64))
user_agent: Mapped[str | None] = mapped_column(String(512))
created_at: Mapped[Any] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
)
__table_args__ = (
# Основной запрос админки: «покажи историю по этой сущности».
Index("ix_audit_log_entity", "entity_type", "entity_id"),
)
+74
View File
@@ -0,0 +1,74 @@
"""Пользователи, роли и refresh-токены."""
from __future__ import annotations
import enum
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class UserRole(enum.StrEnum):
"""Роли фиксированы кодом: набор прав завязан на фискальную ответственность."""
ADMIN = "admin" # всё, включая админку, кассы и ключи
CASHIER = "cashier" # очередь и пробитие чеков
VIEWER = "viewer" # только чтение
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(
Enum(UserRole, name="user_role", values_callable=lambda e: [i.value for i in e]),
nullable=False,
default=UserRole.CASHIER,
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
refresh_tokens: Mapped[list[RefreshToken]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<User {self.email} ({self.role.value})>"
class RefreshToken(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Выданные refresh-токены.
Храним SHA-256 хеш, а не сам токен: утечка дампа БД не должна давать
возможность войти. Ротация — выдача нового токена с проставлением
`replaced_by_id` у старого, что позволяет обнаружить повторное
использование уже израсходованного токена.
"""
__tablename__ = "refresh_tokens"
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
replaced_by_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("refresh_tokens.id", ondelete="SET NULL")
)
user_agent: Mapped[str | None] = mapped_column(String(512))
ip: Mapped[str | None] = mapped_column(String(64))
user: Mapped[User] = relationship(back_populates="refresh_tokens")
@property
def is_active(self) -> bool:
from datetime import UTC
return self.revoked_at is None and self.expires_at > datetime.now(UTC)
+47
View File
@@ -0,0 +1,47 @@
"""Фабрика асинхронных сессий SQLAlchemy."""
from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
engine = create_async_engine(
settings.database_url,
echo=False,
pool_pre_ping=True, # VPS-соединения умеют отваливаться молча
pool_size=10,
max_overflow=20,
)
SessionFactory = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False, # объекты остаются пригодными после commit
autoflush=False,
)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""Зависимость FastAPI. Откатывает транзакцию при любом исключении."""
async with SessionFactory() as session:
try:
yield session
except Exception:
await session.rollback()
raise
@asynccontextmanager
async def session_scope() -> AsyncGenerator[AsyncSession, None]:
"""Сессия для воркеров и CLI, где механизма зависимостей FastAPI нет."""
async with SessionFactory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
+104
View File
@@ -0,0 +1,104 @@
"""Точка входа FastAPI."""
from __future__ import annotations
import time
import uuid
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
import structlog
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from app.api.v1.router import api_router
from app.core.config import settings
from app.core.logging import configure_logging, get_logger
from app.db.session import engine
log = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
configure_logging()
log.info("app_starting", environment=settings.environment, version=app.version)
yield
await engine.dispose()
log.info("app_stopped")
class RequestContextMiddleware(BaseHTTPMiddleware):
"""Присваивает каждому запросу идентификатор и пишет структурный access-лог.
request_id возвращается заголовком `X-Request-ID`: по нему оператор,
столкнувшийся с ошибкой, находится в логах за одну команду.
"""
async def dispatch(
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(request_id=request_id)
started = time.perf_counter()
try:
response = await call_next(request)
except Exception:
log.exception(
"request_failed",
method=request.method,
path=request.url.path,
duration_ms=round((time.perf_counter() - started) * 1000, 2),
)
raise
duration_ms = round((time.perf_counter() - started) * 1000, 2)
# Health-check'и опрашиваются постоянно и засоряют лог.
if not request.url.path.startswith("/api/v1/health"):
log.info(
"request",
method=request.method,
path=request.url.path,
status=response.status_code,
duration_ms=duration_ms,
)
response.headers["X-Request-ID"] = request_id
return response
def create_app() -> FastAPI:
configure_logging()
app = FastAPI(
title=settings.project_name,
version="0.1.0",
description="Фискализация заказов через Checkbox",
lifespan=lifespan,
# В проде интерактивная документация закрыта: схема API — лишняя
# подсказка для того, кто ищет незащищённый эндпоинт.
docs_url=None if settings.is_production else "/docs",
redoc_url=None if settings.is_production else "/redoc",
openapi_url=None if settings.is_production else "/openapi.json",
)
app.add_middleware(RequestContextMiddleware)
if settings.cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Request-ID"],
)
app.include_router(api_router, prefix="/api/v1")
return app
app = create_app()
View File
+44
View File
@@ -0,0 +1,44 @@
"""Схемы запросов и ответов аутентификации."""
from __future__ import annotations
import uuid
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from app.db.models.user import UserRole
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=128)
class RefreshRequest(BaseModel):
refresh_token: str = Field(min_length=1)
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int = Field(description="Время жизни access-токена в секундах")
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: EmailStr
full_name: str
role: UserRole
is_active: bool
class LoginResponse(TokenPair):
user: UserOut
class ChangePasswordRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=128)
new_password: str = Field(min_length=10, max_length=128)
View File
+72
View File
@@ -0,0 +1,72 @@
"""Запись событий в журнал аудита."""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.audit import AuditLog
from app.db.models.user import User
# Ключи, значения которых не должны попасть в журнал даже случайно.
_REDACTED_KEYS = {
"password",
"new_password",
"current_password",
"token",
"access_token",
"refresh_token",
"secret",
"license_key",
"api_key",
}
def _sanitize(payload: dict[str, Any]) -> dict[str, Any]:
"""Журнал аудита читают люди и он живёт годами — секретам там не место."""
return {
key: ("***" if key.lower() in _REDACTED_KEYS else value) for key, value in payload.items()
}
def client_ip(request: Request | None) -> str | None:
if request is None:
return None
# За nginx реальный адрес приходит в X-Forwarded-For; первый элемент — клиент.
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else None
async def record(
session: AsyncSession,
*,
action: str,
user: User | None = None,
actor_label: str | None = None,
entity_type: str | None = None,
entity_id: str | uuid.UUID | None = None,
payload: dict[str, Any] | None = None,
request: Request | None = None,
) -> AuditLog:
"""Добавляет запись в журнал.
Без commit: вызывающий код сам решает границы транзакции, чтобы событие
и изменение состояния фиксировались вместе либо не фиксировались вовсе.
"""
entry = AuditLog(
user_id=user.id if user else None,
actor_label=actor_label or (user.email if user else None),
action=action,
entity_type=entity_type,
entity_id=str(entity_id) if entity_id is not None else None,
payload=_sanitize(payload or {}),
ip=client_ip(request),
user_agent=(request.headers.get("user-agent") if request else None),
)
session.add(entry)
return entry
+166
View File
@@ -0,0 +1,166 @@
"""Логика аутентификации: вход, ротация refresh-токенов, выход."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from fastapi import Request
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.logging import get_logger
from app.core.security import (
create_access_token,
generate_refresh_token,
hash_password,
hash_refresh_token,
password_needs_rehash,
refresh_token_expiry,
verify_password,
)
from app.db.models.audit import AuditAction
from app.db.models.user import RefreshToken, User
from app.schemas.auth import TokenPair
from app.services import audit
log = get_logger(__name__)
class AuthError(Exception):
"""Вход или обновление токена отклонены."""
async def authenticate(
session: AsyncSession, *, email: str, password: str, request: Request | None = None
) -> User:
"""Проверяет учётные данные.
Все отказы — с одинаковым текстом: различающиеся сообщения позволили бы
перебором выяснить, какие адреса зарегистрированы.
"""
normalized = email.strip().lower()
user = await session.scalar(select(User).where(User.email == normalized))
if user is None or not verify_password(password, user.password_hash):
await audit.record(
session,
action=AuditAction.LOGIN_FAILED,
user=user,
actor_label=normalized,
payload={"reason": "bad_credentials"},
request=request,
)
await session.commit()
raise AuthError("Неверный email или пароль")
if not user.is_active:
await audit.record(
session,
action=AuditAction.LOGIN_FAILED,
user=user,
payload={"reason": "inactive"},
request=request,
)
await session.commit()
raise AuthError("Учётная запись отключена")
# Параметры argon2 со временем ужесточаются — обновляем хеш на живом пароле.
if password_needs_rehash(user.password_hash):
user.password_hash = hash_password(password)
user.last_login_at = datetime.now(UTC)
return user
async def issue_token_pair(
session: AsyncSession,
user: User,
*,
request: Request | None = None,
replaces: RefreshToken | None = None,
) -> TokenPair:
"""Выдаёт пару токенов и сохраняет хеш refresh-токена."""
raw_refresh, token_hash = generate_refresh_token()
stored = RefreshToken(
user_id=user.id,
token_hash=token_hash,
expires_at=refresh_token_expiry(),
user_agent=(request.headers.get("user-agent") if request else None),
ip=audit.client_ip(request),
)
session.add(stored)
await session.flush() # нужен stored.id для ссылки replaced_by_id
if replaces is not None:
replaces.revoked_at = datetime.now(UTC)
replaces.replaced_by_id = stored.id
return TokenPair(
access_token=create_access_token(user.id, user.role.value),
refresh_token=raw_refresh,
expires_in=settings.access_token_expire_minutes * 60,
)
async def rotate_refresh_token(
session: AsyncSession, *, raw_token: str, request: Request | None = None
) -> tuple[User, TokenPair]:
"""Обменивает refresh-токен на новую пару, отзывая предъявленный.
Если предъявлен уже отозванный токен, это признак кражи: отзываем всю
цепочку сессий пользователя и требуем полноценного входа.
"""
token_hash = hash_refresh_token(raw_token)
stored = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
if stored is None:
raise AuthError("Некорректный refresh-токен")
if stored.revoked_at is not None:
await revoke_all_for_user(session, stored.user_id)
await audit.record(
session,
action=AuditAction.TOKEN_REUSE_DETECTED,
entity_type="refresh_token",
entity_id=stored.id,
payload={"user_id": str(stored.user_id)},
request=request,
)
await session.commit()
log.warning("refresh_token_reuse_detected", user_id=str(stored.user_id))
raise AuthError("Сессия отозвана, требуется повторный вход")
if stored.expires_at <= datetime.now(UTC):
raise AuthError("Срок действия refresh-токена истёк")
user = await session.get(User, stored.user_id)
if user is None or not user.is_active:
raise AuthError("Учётная запись недоступна")
pair = await issue_token_pair(session, user, request=request, replaces=stored)
await audit.record(
session, action=AuditAction.TOKEN_REFRESH, user=user, request=request
)
return user, pair
async def revoke_refresh_token(session: AsyncSession, *, raw_token: str) -> None:
"""Выход. Отсутствующий или уже отозванный токен ошибкой не считается."""
token_hash = hash_refresh_token(raw_token)
await session.execute(
update(RefreshToken)
.where(RefreshToken.token_hash == token_hash, RefreshToken.revoked_at.is_(None))
.values(revoked_at=datetime.now(UTC))
)
async def revoke_all_for_user(session: AsyncSession, user_id: uuid.UUID) -> None:
"""Отзывает все активные refresh-токены пользователя (выход со всех устройств)."""
await session.execute(
update(RefreshToken)
.where(RefreshToken.user_id == user_id, RefreshToken.revoked_at.is_(None))
.values(revoked_at=datetime.now(UTC))
)