"""Бизнес-логика аутентификации: регистрация, подтверждение 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 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, ) -> User: """Зарегистрировать пользователя и отправить письмо для подтверждения email. `team_id` допустим, только если в настройках инстанса включён выбор команды при регистрации (`registration_team_choice`) и команда существует — иначе `InvalidTeamSelectionError` (публичный эндпоинт, деталей не раскрываем). Если включена верификация домена email (`registration_email_domain_enabled`), домен `email` (часть после `@`, без учёта регистра) должен совпадать с одним из эталонных доменов (`registration_email_domains`) — иначе `InvalidEmailDomainError`. Обе проверки — до создания пользователя. """ 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) user = await self._users.create( email=email, name_user=name_user, password_hash=await hash_password(password), team_id=team_id, ) 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()