Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

188
backend/api/auth.py Normal file
View 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,
)