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,34 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Зависимости ставятся отдельным слоем от кода: правка исходников
|
||||
# не должна приводить к переустановке пакетов.
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --no-cache-dir "setuptools>=75" && pip install --no-cache-dir .
|
||||
|
||||
COPY alembic.ini ./
|
||||
COPY alembic ./alembic
|
||||
COPY app ./app
|
||||
|
||||
# Процесс не должен работать от root.
|
||||
RUN useradd --create-home --uid 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8000/api/v1/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,42 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
|
||||
# URL берётся из настроек приложения в alembic/env.py — здесь оставлен пустым,
|
||||
# чтобы пароль от БД не оказался в файле, который попадает в git.
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Окружение Alembic (асинхронное, на том же asyncpg, что и приложение)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Импорт реестра моделей обязателен: без него target_metadata пуста
|
||||
# и автогенерация «увидит» удаление всех таблиц.
|
||||
from app.db.models import Base # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
# DSN идёт напрямую в create_async_engine(), а не через
|
||||
# config.set_main_option()/alembic.ini: Alembic хранит sqlalchemy.url в
|
||||
# ConfigParser, для которого "%" — служебный символ интерполяции. Пароль
|
||||
# от Postgres после percent-encoding как раз может содержать "%XX" и ломает
|
||||
# ConfigParser.set() ещё до запуска миграций.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _configure(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True, # ловить смену типа колонки
|
||||
compare_server_default=True,
|
||||
render_as_batch=False,
|
||||
)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Генерация SQL без подключения к БД (`alembic upgrade head --sql`)."""
|
||||
context.configure(
|
||||
url=settings.database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def _run_sync(connection: Connection) -> None:
|
||||
_configure(connection)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_online() -> None:
|
||||
connectable = create_async_engine(settings.database_url, poolclass=pool.NullPool)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(_run_sync)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
${imports if imports else ""}
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Пользователи, refresh-токены и журнал аудита
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-09-22
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
user_role = postgresql.ENUM("admin", "cashier", "viewer", name="user_role", create_type=False)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
user_role.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("email", sa.String(length=320), nullable=False),
|
||||
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column("full_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("role", user_role, nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_users")),
|
||||
)
|
||||
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"refresh_tokens",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("replaced_by_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("user_agent", sa.String(length=512), nullable=True),
|
||||
sa.Column("ip", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
name=op.f("fk_refresh_tokens_user_id_users"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["replaced_by_id"],
|
||||
["refresh_tokens.id"],
|
||||
name=op.f("fk_refresh_tokens_replaced_by_id_refresh_tokens"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_refresh_tokens")),
|
||||
)
|
||||
op.create_index(op.f("ix_refresh_tokens_user_id"), "refresh_tokens", ["user_id"])
|
||||
op.create_index(
|
||||
op.f("ix_refresh_tokens_token_hash"), "refresh_tokens", ["token_hash"], unique=True
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"audit_log",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("actor_label", sa.String(length=320), nullable=True),
|
||||
sa.Column("action", sa.String(length=64), nullable=False),
|
||||
sa.Column("entity_type", sa.String(length=64), nullable=True),
|
||||
sa.Column("entity_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("ip", sa.String(length=64), nullable=True),
|
||||
sa.Column("user_agent", sa.String(length=512), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"], ["users.id"], name=op.f("fk_audit_log_user_id_users"), ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_log")),
|
||||
)
|
||||
op.create_index(op.f("ix_audit_log_user_id"), "audit_log", ["user_id"])
|
||||
op.create_index(op.f("ix_audit_log_action"), "audit_log", ["action"])
|
||||
op.create_index(op.f("ix_audit_log_created_at"), "audit_log", ["created_at"])
|
||||
op.create_index("ix_audit_log_entity", "audit_log", ["entity_type", "entity_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("audit_log")
|
||||
op.drop_table("refresh_tokens")
|
||||
op.drop_table("users")
|
||||
user_role.drop(op.get_bind(), checkfirst=True)
|
||||
@@ -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)]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
[project]
|
||||
name = "lux-fiscal-backend"
|
||||
version = "0.1.0"
|
||||
description = "Фискализация заказов через Checkbox: CRM + Нова Пошта + Checkbox"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"pydantic>=2.9",
|
||||
"pydantic-settings>=2.6",
|
||||
"email-validator>=2.2",
|
||||
"sqlalchemy[asyncio]>=2.0.36",
|
||||
"asyncpg>=0.30",
|
||||
"alembic>=1.14",
|
||||
"httpx>=0.27",
|
||||
"redis>=5.2",
|
||||
"arq>=0.26",
|
||||
"argon2-cffi>=23.1",
|
||||
"pyjwt>=2.10",
|
||||
"cryptography>=43.0",
|
||||
"python-multipart>=0.0.12",
|
||||
"structlog>=24.4",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"pytest-asyncio>=0.24",
|
||||
"respx>=0.21",
|
||||
"ruff>=0.7",
|
||||
"mypy>=1.13",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=75"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["app*"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
plugins = ["pydantic.mypy"]
|
||||
warn_unused_ignores = true
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Общая настройка тестов.
|
||||
|
||||
Переменные окружения проставляются до импорта `app.core.config`, иначе
|
||||
Settings прочитает рабочий .env (или упадёт на его отсутствии).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
os.environ.setdefault("ENVIRONMENT", "local")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-not-for-production")
|
||||
os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
os.environ.setdefault("POSTGRES_HOST", "localhost")
|
||||
os.environ.setdefault("POSTGRES_PASSWORD", "test")
|
||||
os.environ.setdefault("CORS_ORIGINS", "")
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Сборка DSN из настроек.
|
||||
|
||||
Регрессия: `PostgresDsn.build` не экранирует username/password сам —
|
||||
спецсимвол в пароле (запятая, `@`, `:`, `/`) ломает разбор URL. Баг
|
||||
воспроизводится только с «настоящим» паролем и незаметен на тестовых
|
||||
значениях вроде `test` или `postgres`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
def _settings(**overrides: str) -> Settings:
|
||||
defaults = {
|
||||
"secret_key": "test-secret-key",
|
||||
"encryption_key": "dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
|
||||
"postgres_host": "postgres",
|
||||
"postgres_port": 5432,
|
||||
"postgres_db": "lux_fiscal",
|
||||
"postgres_user": "lux_fiscal",
|
||||
"postgres_password": "postgres",
|
||||
}
|
||||
return Settings(**{**defaults, **overrides}) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"password",
|
||||
[
|
||||
"Jvth,fqkjn2020", # запятая — реальный пароль из этого проекта
|
||||
"p@ss:word/with#special?chars",
|
||||
"простой-пароль-с-кириллицей",
|
||||
],
|
||||
)
|
||||
def test_database_url_survives_special_characters_in_password(password: str) -> None:
|
||||
settings = _settings(postgres_password=password)
|
||||
|
||||
url = settings.database_url
|
||||
|
||||
assert url.startswith("postgresql+asyncpg://")
|
||||
# Порт обязан остаться числом 5432 — при поломанном парсинге он
|
||||
# «съезжает» вместе с частью пароля или пропадает совсем.
|
||||
assert "@postgres:5432/lux_fiscal" in url
|
||||
|
||||
|
||||
def test_database_url_roundtrips_via_sqlalchemy_make_url() -> None:
|
||||
"""URL должен оставаться валидным для драйвера, а не только для pydantic."""
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
settings = _settings(postgres_password="Jvth,fqkjn2020")
|
||||
parsed = make_url(settings.database_url)
|
||||
|
||||
assert parsed.password == "Jvth,fqkjn2020"
|
||||
assert parsed.port == 5432
|
||||
assert parsed.database == "lux_fiscal"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Миграция 0001 обязана точно соответствовать моделям.
|
||||
|
||||
Расхождение между `Base.metadata` и миграцией обнаруживается только на живой
|
||||
БД и обычно уже в проде. Проверка статическая: сравниваем таблицы, колонки,
|
||||
nullability и индексы, которые создаёт миграция, с тем, что описано моделями.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.models import Base
|
||||
|
||||
MIGRATION = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0001_users_and_audit.py"
|
||||
SOURCE = MIGRATION.read_text(encoding="utf-8")
|
||||
|
||||
# Миграция 0001 создаёт схему с нуля, поэтому в ней обязаны быть все таблицы.
|
||||
MODEL_TABLES = set(Base.metadata.tables)
|
||||
|
||||
|
||||
def _migration_block(table: str) -> str:
|
||||
"""Тело вызова op.create_table для указанной таблицы.
|
||||
|
||||
Границей служит следующий op.create_table; для последней таблицы — конец файла.
|
||||
"""
|
||||
start = SOURCE.index(f'op.create_table(\n "{table}",')
|
||||
next_table = SOURCE.find("op.create_table(", start + 1)
|
||||
end = next_table if next_table != -1 else len(SOURCE)
|
||||
return SOURCE[start:end]
|
||||
|
||||
|
||||
def test_migration_creates_every_model_table() -> None:
|
||||
created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', SOURCE))
|
||||
assert created == MODEL_TABLES, (
|
||||
f"Миграция и модели разошлись. Только в моделях: {MODEL_TABLES - created}; "
|
||||
f"только в миграции: {created - MODEL_TABLES}"
|
||||
)
|
||||
|
||||
|
||||
def test_downgrade_drops_every_created_table() -> None:
|
||||
downgrade = SOURCE[SOURCE.index("def downgrade()") :]
|
||||
dropped = set(re.findall(r'op\.drop_table\("(\w+)"\)', downgrade))
|
||||
assert dropped == MODEL_TABLES, f"downgrade не удаляет: {MODEL_TABLES - dropped}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("table_name", sorted(MODEL_TABLES))
|
||||
def test_columns_match(table_name: str) -> None:
|
||||
block = _migration_block(table_name)
|
||||
in_migration = set(re.findall(r'sa\.Column\(\n?\s*"(\w+)"', block))
|
||||
in_model = {column.name for column in Base.metadata.tables[table_name].columns}
|
||||
|
||||
assert in_migration == in_model, (
|
||||
f"Таблица {table_name}: только в модели {in_model - in_migration}, "
|
||||
f"только в миграции {in_migration - in_model}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("table_name", sorted(MODEL_TABLES))
|
||||
def test_nullability_matches(table_name: str) -> None:
|
||||
block = _migration_block(table_name)
|
||||
model_table = Base.metadata.tables[table_name]
|
||||
|
||||
for column in model_table.columns:
|
||||
match = re.search(rf'sa\.Column\(\n?\s*"{column.name}".*?\n', block)
|
||||
assert match, f"{table_name}.{column.name} отсутствует в миграции"
|
||||
|
||||
migration_nullable = "nullable=True" in match.group(0)
|
||||
assert migration_nullable == column.nullable, (
|
||||
f"{table_name}.{column.name}: в модели nullable={column.nullable}, "
|
||||
f"в миграции nullable={migration_nullable}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("table_name", sorted(MODEL_TABLES))
|
||||
def test_indexes_match(table_name: str) -> None:
|
||||
model_indexes = {index.name for index in Base.metadata.tables[table_name].indexes}
|
||||
migration_indexes = set(re.findall(r'op\.create_index\(\s*(?:op\.f\()?"(\w+)"', SOURCE))
|
||||
|
||||
missing = model_indexes - migration_indexes
|
||||
assert not missing, f"Таблица {table_name}: миграция не создаёт индексы {missing}"
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Тесты паролей, JWT и шифрования секретов. Без БД и без сети."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from app.core import crypto
|
||||
from app.core.config import settings
|
||||
from app.core.security import (
|
||||
ALGORITHM,
|
||||
TokenError,
|
||||
create_access_token,
|
||||
decode_access_token,
|
||||
generate_refresh_token,
|
||||
hash_password,
|
||||
hash_refresh_token,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
|
||||
class TestPasswords:
|
||||
def test_verifies_correct_password(self) -> None:
|
||||
assert verify_password("correct horse battery", hash_password("correct horse battery"))
|
||||
|
||||
def test_rejects_wrong_password(self) -> None:
|
||||
assert not verify_password("wrong", hash_password("correct horse battery"))
|
||||
|
||||
def test_hash_is_salted(self) -> None:
|
||||
"""Одинаковые пароли обязаны давать разные хеши."""
|
||||
assert hash_password("same") != hash_password("same")
|
||||
|
||||
def test_rejects_garbage_hash_without_raising(self) -> None:
|
||||
"""Повреждённый хеш в БД не должен ронять вход с 500."""
|
||||
assert not verify_password("anything", "not-a-valid-argon2-hash")
|
||||
|
||||
|
||||
class TestAccessToken:
|
||||
def test_roundtrip_carries_identity(self) -> None:
|
||||
user_id = uuid.uuid4()
|
||||
payload = decode_access_token(create_access_token(user_id, "cashier"))
|
||||
assert payload["sub"] == str(user_id)
|
||||
assert payload["role"] == "cashier"
|
||||
|
||||
def test_rejects_expired_token(self) -> None:
|
||||
expired = jwt.encode(
|
||||
{
|
||||
"sub": str(uuid.uuid4()),
|
||||
"role": "admin",
|
||||
"type": "access",
|
||||
"exp": datetime.now(UTC) - timedelta(minutes=1),
|
||||
},
|
||||
settings.secret_key,
|
||||
algorithm=ALGORITHM,
|
||||
)
|
||||
with pytest.raises(TokenError):
|
||||
decode_access_token(expired)
|
||||
|
||||
def test_rejects_token_signed_with_other_key(self) -> None:
|
||||
forged = jwt.encode(
|
||||
{
|
||||
"sub": str(uuid.uuid4()),
|
||||
"role": "admin",
|
||||
"type": "access",
|
||||
"exp": datetime.now(UTC) + timedelta(hours=1),
|
||||
},
|
||||
"attacker-key",
|
||||
algorithm=ALGORITHM,
|
||||
)
|
||||
with pytest.raises(TokenError):
|
||||
decode_access_token(forged)
|
||||
|
||||
def test_refresh_token_is_not_accepted_as_access(self) -> None:
|
||||
"""Ключевая проверка: подмена типа токена не должна давать доступ."""
|
||||
refresh_shaped = jwt.encode(
|
||||
{
|
||||
"sub": str(uuid.uuid4()),
|
||||
"role": "admin",
|
||||
"type": "refresh",
|
||||
"exp": datetime.now(UTC) + timedelta(days=7),
|
||||
},
|
||||
settings.secret_key,
|
||||
algorithm=ALGORITHM,
|
||||
)
|
||||
with pytest.raises(TokenError, match="access"):
|
||||
decode_access_token(refresh_shaped)
|
||||
|
||||
|
||||
class TestRefreshToken:
|
||||
def test_tokens_are_unique(self) -> None:
|
||||
assert generate_refresh_token()[0] != generate_refresh_token()[0]
|
||||
|
||||
def test_hash_matches_raw_value(self) -> None:
|
||||
raw, stored = generate_refresh_token()
|
||||
assert hash_refresh_token(raw) == stored
|
||||
|
||||
def test_raw_token_is_not_recoverable_from_hash(self) -> None:
|
||||
raw, stored = generate_refresh_token()
|
||||
assert raw not in stored
|
||||
|
||||
|
||||
class TestCrypto:
|
||||
def test_roundtrip(self) -> None:
|
||||
secret = "license-key-abc-123"
|
||||
assert crypto.decrypt(crypto.encrypt(secret)) == secret
|
||||
|
||||
def test_ciphertext_hides_plaintext(self) -> None:
|
||||
assert "license-key" not in crypto.encrypt("license-key-abc-123")
|
||||
|
||||
def test_tampered_ciphertext_is_rejected(self) -> None:
|
||||
token = crypto.encrypt("license-key-abc-123")
|
||||
tampered = token[:-4] + ("AAAA" if not token.endswith("AAAA") else "BBBB")
|
||||
with pytest.raises(crypto.DecryptionError):
|
||||
crypto.decrypt(tampered)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[("", ""), ("abc", "***"), ("abcd", "****"), ("abcdefgh", "****efgh")],
|
||||
)
|
||||
def test_mask(self, value: str, expected: str) -> None:
|
||||
assert crypto.mask(value) == expected
|
||||
Reference in New Issue
Block a user