Files
vidconf/backend/api/auth.py
Max Ronzhin 4f82ebe17a
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
feat(auth): согласие на обработку персональных данных при регистрации
Отключаемый модуль (instance_settings.consent_policy): галочка + ссылка на
публичную страницу регламента на форме регистрации, редактируемый в админке
текст с типовым шаблоном по умолчанию (плейсхолдеры под организацию, не
проходил юридическую проверку), версия текста растёт при каждой правке.
Факт согласия хранится в users (consent_version, consent_given_at) — второй
эшелон проверки на сервере, как и для отключаемых модулей ранее. Дефолт
(выключено) сохраняет поведение существующих инсталляций, у уже
зарегистрированных пользователей согласие не запрашивается.
2026-08-04 22:20:00 +03:00

200 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Роутер аутентификации: регистрация, подтверждение 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,
)