- 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>
93 lines
3.3 KiB
Python
93 lines
3.3 KiB
Python
"""Эндпоинты аутентификации."""
|
|
|
|
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)
|