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
+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)