"""Роутер аутентификации: регистрация, подтверждение 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_domains` — эталонные домены при включённой настройке `registration_email_domain`, иначе пустой список. """ 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_domains = cfg.registration_email_domains if cfg.registration_email_domain_enabled else [] return RegistrationOptionsOut( team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domains=email_domains ) @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, )