Отключаемый модуль (instance_settings.consent_policy): галочка + ссылка на публичную страницу регламента на форме регистрации, редактируемый в админке текст с типовым шаблоном по умолчанию (плейсхолдеры под организацию, не проходил юридическую проверку), версия текста растёт при каждой правке. Факт согласия хранится в users (consent_version, consent_given_at) — второй эшелон проверки на сервере, как и для отключаемых модулей ранее. Дефолт (выключено) сохраняет поведение существующих инсталляций, у уже зарегистрированных пользователей согласие не запрашивается.
200 lines
7.9 KiB
Python
200 lines
7.9 KiB
Python
"""Роутер аутентификации: регистрация, подтверждение 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,
|
||
ConsentRequiredError,
|
||
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,
|
||
consent_required=cfg.consent_required,
|
||
consent_text=cfg.consent_policy_text,
|
||
consent_version=cfg.consent_policy_version,
|
||
)
|
||
|
||
|
||
@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,
|
||
consent_accepted=data.consent_accepted,
|
||
)
|
||
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
|
||
except ConsentRequiredError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST, detail="consent_required"
|
||
) 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,
|
||
)
|