Files
vidconf/backend/services/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

280 lines
13 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.
Refresh-токены хранятся server-side в Redis (`refresh:{jti}` -> user_id) с
TTL, равным сроку жизни refresh-токена. Каждое успешное использование
refresh-токена ротирует его: старый `jti` немедленно удаляется, выдаётся
новый; повторное использование уже потраченного refresh-токена (reuse)
обнаруживается по отсутствию ключа в Redis.
"""
import hashlib
import secrets
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import jwt
from redis.asyncio import Redis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
needs_rehash,
verify_password,
)
from models.email_verification import EmailVerificationToken
from models.user import User
from repositories.admin import TeamRepository
from repositories.users import UserRepository
from services.email import EmailBackend
from services.instance_settings import InstanceSettingsService
REFRESH_KEY_PREFIX = "refresh:"
class EmailAlreadyRegisteredError(Exception):
"""Пользователь с таким email уже зарегистрирован."""
class InvalidTeamSelectionError(Exception):
"""Выбор команды при регистрации недоступен или команда не существует.
Публичный эндпоинт `/auth/register` не должен различать эти две причины
в ответе (не раскрываем администраторскую настройку/список команд
перебором id) — единая ошибка для обоих случаев.
"""
class InvalidEmailDomainError(Exception):
"""Домен email регистрирующегося не совпадает с эталонным доменом инстанса.
Поднимается только при включённой настройке инстанса
`registration_email_domain_enabled` (см. `InstanceSettingsService`).
"""
class ConsentRequiredError(Exception):
"""Согласие на обработку персональных данных не отмечено.
Поднимается только при включённой настройке инстанса `consent_required`
(см. `InstanceSettingsService`) — второй эшелон проверки, фронт уже не
даёт отправить форму без галочки, но сервер не полагается на это.
"""
class InvalidVerificationTokenError(Exception):
"""Токен подтверждения email не найден, просрочен или уже использован."""
class InvalidCredentialsError(Exception):
"""Неверный email или пароль."""
class EmailNotVerifiedError(Exception):
"""Email пользователя ещё не подтверждён."""
class InvalidRefreshTokenError(Exception):
"""Refresh-токен невалиден, просрочен, отозван или уже был использован (reuse)."""
@dataclass(frozen=True, slots=True)
class TokenPair:
"""Пара выданных JWT-токенов (access — в теле ответа, refresh — в cookie)."""
access_token: str
refresh_token: str
class AuthService:
"""Инкапсулирует сценарии регистрации, входа, обновления и отзыва токенов."""
def __init__(self, session: AsyncSession, redis: Redis, email_backend: EmailBackend) -> None:
self._session = session
self._redis = redis
self._email_backend = email_backend
self._users = UserRepository(session)
self._settings = get_settings()
async def register(
self,
*,
email: str,
name_user: str,
password: str,
team_id: uuid.UUID | None = None,
consent_accepted: bool = False,
) -> User:
"""Зарегистрировать пользователя и отправить письмо для подтверждения email.
`team_id` допустим, только если в настройках инстанса включён выбор
команды при регистрации (`registration_team_choice`) и команда
существует — иначе `InvalidTeamSelectionError` (публичный
эндпоинт, деталей не раскрываем). Если включена верификация домена
email (`registration_email_domain_enabled`), домен `email` (часть
после `@`, без учёта регистра) должен совпадать с одним из
эталонных доменов (`registration_email_domains`) — иначе
`InvalidEmailDomainError`. Если включено согласие на обработку
персональных данных (`consent_required`), `consent_accepted` обязан
быть `True` — иначе `ConsentRequiredError`; при принятии согласия
в `User` пишутся `consent_version`/`consent_given_at` (редакция
регламента на момент согласия и время). Все проверки — до создания
пользователя.
"""
existing = await self._users.get_by_email(email)
if existing is not None:
raise EmailAlreadyRegisteredError(email)
cfg = await InstanceSettingsService(self._session).get()
if cfg.registration_email_domain_enabled:
email_domain = email.rsplit("@", 1)[-1].lower()
if email_domain not in cfg.registration_email_domains:
raise InvalidEmailDomainError(email)
if team_id is not None:
if not cfg.registration_team_choice:
raise InvalidTeamSelectionError(team_id)
team = await TeamRepository(self._session).get(team_id)
if team is None:
raise InvalidTeamSelectionError(team_id)
if cfg.consent_required and not consent_accepted:
raise ConsentRequiredError
consent_version: int | None = None
consent_given_at: datetime | None = None
if cfg.consent_required and consent_accepted:
consent_version = cfg.consent_policy_version
consent_given_at = datetime.now(UTC)
user = await self._users.create(
email=email,
name_user=name_user,
password_hash=await hash_password(password),
team_id=team_id,
consent_version=consent_version,
consent_given_at=consent_given_at,
)
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
await self._issue_verification_email(user, reply_to=reply_to)
await self._session.commit()
return user
async def verify_email(self, token: str) -> None:
"""Подтвердить email пользователя по токену из письма."""
token_hash = _hash_token(token)
result = await self._session.execute(
select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash)
)
record = result.scalar_one_or_none()
now = datetime.now(UTC)
if record is None or record.used_at is not None or record.expires_at < now:
raise InvalidVerificationTokenError
user = await self._users.get_by_id(record.user_id)
if user is None:
raise InvalidVerificationTokenError
record.used_at = now
user.email_verified = True
await self._session.commit()
async def login(self, *, email: str, password: str) -> TokenPair:
"""Проверить учётные данные и выдать пару access/refresh токенов."""
user = await self._users.get_by_email(email)
if user is None or not await verify_password(password, user.password_hash):
raise InvalidCredentialsError
if not user.email_verified:
raise EmailNotVerifiedError
# Постепенная миграция на актуальные параметры argon2 (см. core/security.py):
# параметры зашиты в саму строку хэша, поэтому старые записи так и
# проверялись бы вдвое дольше. Открытый пароль есть только здесь и
# только сейчас — другого места для перевыпуска не будет.
if needs_rehash(user.password_hash):
user.password_hash = await hash_password(password)
# Явный commit: выдача токенов идёт через Redis и БД не трогает,
# поэтому без него перевыпущенный хэш откатился бы вместе с сессией.
await self._session.commit()
return await self._issue_token_pair(user.id, user.role)
async def refresh(self, refresh_token: str) -> TokenPair:
"""Провалидировать refresh-токен, ротировать его и выдать новую пару токенов."""
user_id = await self._validate_and_consume(refresh_token)
user = await self._users.get_by_id(user_id)
if user is None:
raise InvalidRefreshTokenError
return await self._issue_token_pair(user.id, user.role)
async def logout(self, refresh_token: str) -> None:
"""Отозвать refresh-токен (удалить его из Redis), если он вообще декодируется."""
try:
payload = decode_token(refresh_token)
except jwt.PyJWTError:
return
jti = payload.get("jti")
if jti:
await self._redis.delete(f"{REFRESH_KEY_PREFIX}{jti}")
async def _validate_and_consume(self, refresh_token: str) -> uuid.UUID:
"""Проверить refresh JWT и его наличие в Redis, затем сразу удалить (ротация)."""
try:
payload = decode_token(refresh_token)
except jwt.PyJWTError as exc:
raise InvalidRefreshTokenError from exc
if payload.get("type") != "refresh":
raise InvalidRefreshTokenError
jti = payload.get("jti")
sub = payload.get("sub")
if not jti or not sub:
raise InvalidRefreshTokenError
redis_key = f"{REFRESH_KEY_PREFIX}{jti}"
stored_user_id = await self._redis.get(redis_key)
if stored_user_id is None or stored_user_id != sub:
raise InvalidRefreshTokenError
# Немедленное удаление использованного jti: повторное предъявление
# того же refresh-токена (reuse) после этой точки всегда даст 401.
await self._redis.delete(redis_key)
return uuid.UUID(sub)
async def _issue_token_pair(self, user_id: uuid.UUID, role: str) -> TokenPair:
access_token = create_access_token(user_id, role)
refresh_token, jti = create_refresh_token(user_id)
ttl_seconds = self._settings.refresh_token_ttl_days * 24 * 3600
await self._redis.set(f"{REFRESH_KEY_PREFIX}{jti}", str(user_id), ex=ttl_seconds)
return TokenPair(access_token=access_token, refresh_token=refresh_token)
async def _issue_verification_email(self, user: User, *, reply_to: str | None = None) -> None:
token = secrets.token_urlsafe(32) # 256 бит случайности
expires_at = datetime.now(UTC) + timedelta(
hours=self._settings.email_verification_ttl_hours
)
self._session.add(
EmailVerificationToken(
user_id=user.id, token_hash=_hash_token(token), expires_at=expires_at
)
)
link = f"{self._settings.frontend_url}/verify-email?token={token}"
await self._email_backend.send(
to=user.email,
subject="Подтверждение регистрации VidConf",
body=f"Для подтверждения email перейдите по ссылке: {link}",
reply_to=reply_to,
)
def _hash_token(token: str) -> str:
"""Захэшировать токен подтверждения email алгоритмом sha256 (hex-строка)."""
return hashlib.sha256(token.encode()).hexdigest()