Files
vidconf/backend/services/auth.py
Max Ronzhin 84b7f807f7 fix(auth): проверка пароля больше не блокирует весь backend
На нагрузочном тесте 31.07.2026 около 70 человек заходили одновременно.
Вход развалился: p95 `/api/v1/auth/token` — 7.28 с, p95 `guest-join` —
7.06 с, в БД 33 соединения `idle in transaction` при ОДНОМ активном
запросе. Люди попадали внутрь с пятой-десятой попытки, часть не попала
вовсе. Медиа при этом работало штатно: 30 участников с 27 камерами в
следующем окне прошли без единого лага.

Причина — argon2 считался синхронно внутри async-обработчика. Замер на
боевом сервере: 95–155 мс на одну проверку, и всё это время event loop
процесса стоит целиком. Транзакция БД к тому моменту уже открыта
(`get_by_email` сделал SELECT), поэтому соединение висело без работы, пул
из 40 выбирался, и отказы получали совершенно посторонние ручки — включая
вход в конференцию, где никакого пароля не проверялось.

Что изменилось:
- `hash_password`/`verify_password` стали асинхронными и считаются в пуле
  потоков (`asyncio.to_thread`). argon2-cffi освобождает GIL, поэтому
  проверки идут по-настоящему параллельно;
- параметры argon2id заменены с дефолтов библиотеки (t=3, m=64 МБ, p=4) на
  рекомендацию OWASP (t=2, m=19 МБ, p=1): 95 мс → 42 мс. Отдельно важен
  `parallelism`: при p=4 одна проверка пароля занимала все четыре ядра
  сервера — те же, на которых работает LiveKit;
- добавлен `needs_rehash`: существующие хэши проверяются как прежде
  (параметры зашиты в саму строку) и лениво перевыпускаются при первом
  успешном входе.

Расчёт по замерам: пачка из 70 логинов — 6.7–10.9 с блокировки против
~0.36 с без неё.

Тесты: event loop продолжает тикать во время проверки; 8 параллельных
проверок укладываются заметно быстрее восьми последовательных; хэш со
старыми параметрами принимается и перевыпускается при входе.
2026-08-01 23:19:52 +03:00

254 lines
12 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 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()