Initial commit: backend scaffold, auth, frontend login

- FastAPI + SQLAlchemy async + Alembic + Postgres backend
- Auth: JWT access + rotating refresh tokens, argon2, roles, audit log
- React 19 + Vite frontend: login page, protected route, auth context
- Docker Compose: postgres, redis, migrate, api, worker (placeholder), frontend/nginx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 15:07:15 +03:00
co-authored by Claude Sonnet 5
commit 2664cb8213
71 changed files with 4999 additions and 0 deletions
+92
View File
@@ -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)