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,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))
|
||||
)
|
||||
Reference in New Issue
Block a user