Первоначальная версия VidConf
This commit is contained in:
239
backend/services/auth.py
Normal file
239
backend/services/auth.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Бизнес-логика аутентификации: регистрация, подтверждение 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,
|
||||
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` (часть
|
||||
после `@`, без учёта регистра) должен совпадать с эталонным —
|
||||
иначе `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 != cfg.registration_email_domain:
|
||||
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=hash_password(password),
|
||||
team_id=team_id,
|
||||
)
|
||||
await self._issue_verification_email(user)
|
||||
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 verify_password(password, user.password_hash):
|
||||
raise InvalidCredentialsError
|
||||
if not user.email_verified:
|
||||
raise EmailNotVerifiedError
|
||||
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) -> 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}",
|
||||
)
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
"""Захэшировать токен подтверждения email алгоритмом sha256 (hex-строка)."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
Reference in New Issue
Block a user