feat(auth): one-time login links, JWT + refresh, roles, service token, tests
This commit is contained in:
1 parent
4a610ba94a
commit
049b679654
12 files changed
+553
-1
No files matched your search
@@ -0,0 +1,74 @@
|
||||
"""Зависимости FastAPI: аутентификация и авторизация."""
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import decode_access_token
|
||||
from app.db.session import get_db
|
||||
from app.models.enums import RoleCode
|
||||
from app.models.user import Role, User, UserRole
|
||||
from app.models.user import Session as UserSession
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_session(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> UserSession:
|
||||
"""Текущая сессия по access-токену."""
|
||||
if not credentials:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Требуется авторизация")
|
||||
try:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Недействительный токен") from exc
|
||||
|
||||
session = db.get(UserSession, payload.get("sid"))
|
||||
if not session or session.revoked_at:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Сессия завершена")
|
||||
return session
|
||||
|
||||
|
||||
def get_current_user(
|
||||
session: UserSession = Depends(get_current_session),
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Текущий пользователь."""
|
||||
user = db.get(User, session.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Пользователь не найден")
|
||||
return user
|
||||
|
||||
|
||||
def get_user_role_codes(db: DBSession, user_id: int) -> set[RoleCode]:
|
||||
"""Множество ролей пользователя."""
|
||||
rows = db.scalars(select(UserRole).where(UserRole.user_id == user_id)).all()
|
||||
codes: set[RoleCode] = set()
|
||||
for row in rows:
|
||||
role = db.get(Role, row.role_id)
|
||||
if role:
|
||||
codes.add(role.code)
|
||||
return codes
|
||||
|
||||
|
||||
def require_roles(*codes: RoleCode):
|
||||
"""Фабрика зависимости: доступ только для указанных ролей."""
|
||||
|
||||
def checker(
|
||||
user: User = Depends(get_current_user),
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> User:
|
||||
if not get_user_role_codes(db, user.id).intersection(set(codes)):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Недостаточно прав")
|
||||
return user
|
||||
|
||||
return checker
|
||||
|
||||
|
||||
def require_service_token(x_service_token: str | None = Header(default=None)) -> None:
|
||||
"""Проверка сервисного токена бота."""
|
||||
if not settings.bot_service_token or x_service_token != settings.bot_service_token:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Недействительный сервисный токен")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Эндпоинты аутентификации."""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.api.deps import get_current_session, get_current_user, require_roles
|
||||
from app.db.session import get_db
|
||||
from app.models.enums import RoleCode
|
||||
from app.models.user import User
|
||||
from app.models.user import Session as UserSession
|
||||
from app.schemas.auth import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LoginTokenCreate,
|
||||
LoginTokenOut,
|
||||
RefreshRequest,
|
||||
TokenPair,
|
||||
UserOut,
|
||||
)
|
||||
from app.services import auth as auth_service
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login-token", response_model=LoginTokenOut, status_code=201)
|
||||
def create_login_token(
|
||||
data: LoginTokenCreate,
|
||||
user: User = Depends(require_roles(RoleCode.COORDINATOR, RoleCode.ADMIN)),
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> LoginTokenOut:
|
||||
lt, url = auth_service.create_login_token(db, data, issued_by=user.id)
|
||||
return LoginTokenOut(id=lt.id, url=url, expires_at=lt.expires_at)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(data: LoginRequest, db: DBSession = Depends(get_db)) -> LoginResponse:
|
||||
tokens, user = auth_service.login(db, data)
|
||||
return LoginResponse(
|
||||
**tokens.model_dump(), user=auth_service.build_user_out(db, user)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenPair)
|
||||
def refresh(data: RefreshRequest, db: DBSession = Depends(get_db)) -> TokenPair:
|
||||
return auth_service.refresh(db, data.refresh_token)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
def logout(
|
||||
session: UserSession = Depends(get_current_session),
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> None:
|
||||
auth_service.logout(db, session.id)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(
|
||||
user: User = Depends(get_current_user),
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> UserOut:
|
||||
return auth_service.build_user_out(db, user)
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Корневой роутер API v1."""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import health
|
||||
from app.api.v1 import auth, health
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router, tags=["system"])
|
||||
api_router.include_router(auth.router)
|
||||
Reference in new issue
Block a user