Первоначальная версия VidConf
This commit is contained in:
188
backend/api/auth.py
Normal file
188
backend/api/auth.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Роутер аутентификации: регистрация, подтверждение email, JWT access/refresh, logout."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.config import get_settings
|
||||
from core.db import get_session
|
||||
from core.redis import redis_client
|
||||
from models.user import User
|
||||
from repositories.admin import TeamRepository
|
||||
from schemas.auth import (
|
||||
RegisterIn,
|
||||
RegistrationOptionsOut,
|
||||
RegistrationTeamOptionOut,
|
||||
TokenOut,
|
||||
UserOut,
|
||||
VerifyEmailIn,
|
||||
)
|
||||
from services.auth import (
|
||||
AuthService,
|
||||
EmailAlreadyRegisteredError,
|
||||
EmailNotVerifiedError,
|
||||
InvalidCredentialsError,
|
||||
InvalidEmailDomainError,
|
||||
InvalidRefreshTokenError,
|
||||
InvalidTeamSelectionError,
|
||||
InvalidVerificationTokenError,
|
||||
)
|
||||
from services.email import create_email_backend
|
||||
from services.instance_settings import InstanceSettingsService
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
REFRESH_COOKIE_NAME = "refresh_token"
|
||||
REFRESH_COOKIE_PATH = "/api/v1/auth"
|
||||
|
||||
|
||||
def get_auth_service(session: Annotated[AsyncSession, Depends(get_session)]) -> AuthService:
|
||||
"""Собрать `AuthService` с реальными зависимостями (БД, Redis, email-бэкенд из настроек)."""
|
||||
return AuthService(
|
||||
session=session, redis=redis_client, email_backend=create_email_backend(get_settings())
|
||||
)
|
||||
|
||||
|
||||
@router.get("/registration-options", response_model=RegistrationOptionsOut)
|
||||
async def registration_options(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> RegistrationOptionsOut:
|
||||
"""Публичные опции карточки регистрации: выбор команды и верификация домена email.
|
||||
|
||||
Список команд отдаётся только при включённой настройке инстанса
|
||||
`registration_team_choice` — иначе пустой массив (справочник команд не
|
||||
раскрывается, пока выбор выключен). `email_domain` — эталонный домен при
|
||||
включённой настройке `registration_email_domain`, иначе `None`.
|
||||
"""
|
||||
cfg = await InstanceSettingsService(session).get()
|
||||
teams: list[RegistrationTeamOptionOut] = []
|
||||
if cfg.registration_team_choice:
|
||||
items, _ = await TeamRepository(session).list_all()
|
||||
teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items]
|
||||
email_domain = cfg.registration_email_domain if cfg.registration_email_domain_enabled else None
|
||||
return RegistrationOptionsOut(
|
||||
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domain=email_domain
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED, response_model=UserOut)
|
||||
async def register(
|
||||
data: RegisterIn, service: Annotated[AuthService, Depends(get_auth_service)]
|
||||
) -> User:
|
||||
"""Зарегистрировать нового пользователя и отправить письмо для подтверждения email."""
|
||||
try:
|
||||
return await service.register(
|
||||
email=data.email,
|
||||
name_user=data.name_user,
|
||||
password=data.password,
|
||||
team_id=data.team_id,
|
||||
)
|
||||
except EmailAlreadyRegisteredError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="email_already_registered"
|
||||
) from exc
|
||||
except InvalidTeamSelectionError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_team_selection"
|
||||
) from exc
|
||||
except InvalidEmailDomainError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_email_domain"
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def verify_email(
|
||||
data: VerifyEmailIn, service: Annotated[AuthService, Depends(get_auth_service)]
|
||||
) -> None:
|
||||
"""Подтвердить email по токену, полученному в письме."""
|
||||
try:
|
||||
await service.verify_email(data.token)
|
||||
except InvalidVerificationTokenError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_or_expired_token"
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/token", response_model=TokenOut)
|
||||
async def login(
|
||||
response: Response,
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
service: Annotated[AuthService, Depends(get_auth_service)],
|
||||
) -> TokenOut:
|
||||
"""OAuth2 password flow: вход по email (передаётся как `username`) и паролю."""
|
||||
try:
|
||||
pair = await service.login(email=form_data.username, password=form_data.password)
|
||||
except InvalidCredentialsError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_credentials"
|
||||
) from exc
|
||||
except EmailNotVerifiedError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="email_not_verified"
|
||||
) from exc
|
||||
|
||||
_set_refresh_cookie(response, pair.refresh_token)
|
||||
return TokenOut(access_token=pair.access_token)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenOut)
|
||||
async def refresh(
|
||||
response: Response,
|
||||
service: Annotated[AuthService, Depends(get_auth_service)],
|
||||
refresh_token: Annotated[str | None, Cookie(alias=REFRESH_COOKIE_NAME)] = None,
|
||||
) -> TokenOut:
|
||||
"""Ротировать refresh-токен из cookie и выдать новый access-токен."""
|
||||
if refresh_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_refresh_token"
|
||||
)
|
||||
try:
|
||||
pair = await service.refresh(refresh_token)
|
||||
except InvalidRefreshTokenError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_refresh_token"
|
||||
) from exc
|
||||
|
||||
_set_refresh_cookie(response, pair.refresh_token)
|
||||
return TokenOut(access_token=pair.access_token)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
response: Response,
|
||||
service: Annotated[AuthService, Depends(get_auth_service)],
|
||||
refresh_token: Annotated[str | None, Cookie(alias=REFRESH_COOKIE_NAME)] = None,
|
||||
) -> None:
|
||||
"""Отозвать refresh-токен (удалить из Redis) и погасить cookie."""
|
||||
if refresh_token is not None:
|
||||
await service.logout(refresh_token)
|
||||
settings = get_settings()
|
||||
response.delete_cookie(
|
||||
REFRESH_COOKIE_NAME,
|
||||
path=REFRESH_COOKIE_PATH,
|
||||
secure=settings.auth_cookie_secure,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
)
|
||||
|
||||
|
||||
def _set_refresh_cookie(response: Response, refresh_token: str) -> None:
|
||||
"""Установить httpOnly SameSite=Strict cookie с refresh-токеном.
|
||||
|
||||
Флаг `Secure` управляется настройкой `auth_cookie_secure` — в dev по
|
||||
`http://localhost` его нужно отключать (см. `core/config.py`), т.к.
|
||||
Safari (в отличие от Chrome) не сохраняет Secure-cookie без HTTPS.
|
||||
"""
|
||||
settings = get_settings()
|
||||
response.set_cookie(
|
||||
key=REFRESH_COOKIE_NAME,
|
||||
value=refresh_token,
|
||||
httponly=True,
|
||||
secure=settings.auth_cookie_secure,
|
||||
samesite="strict",
|
||||
path=REFRESH_COOKIE_PATH,
|
||||
max_age=settings.refresh_token_ttl_days * 24 * 3600,
|
||||
)
|
||||
Reference in New Issue
Block a user