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