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:
@@ -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()
|
||||
@@ -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:]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user