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