token-storage-refactored
Some checks failed
Deploy on push / type-check (push) Failing after 8s
Deploy on push / deploy (push) Has been skipped

This commit is contained in:
2025-06-02 21:50:58 +03:00
parent cca2f71c59
commit 21d28a0d8b
33 changed files with 2934 additions and 1533 deletions

View File

@@ -1,10 +1,9 @@
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse, Response
from starlette.routing import Route
from auth.internal import verify_internal_auth
from auth.orm import Author
from auth.sessions import SessionManager
from auth.tokens.storage import TokenStorage
from services.db import local_session
from settings import (
SESSION_COOKIE_HTTPONLY,
@@ -57,7 +56,7 @@ async def logout(request: Request) -> Response:
user_id, _, _ = await verify_internal_auth(token)
if user_id:
# Отзываем сессию
await SessionManager.revoke_session(str(user_id), token)
await TokenStorage.revoke_session(token)
logger.info(f"[auth] logout: Токен успешно отозван для пользователя {user_id}")
else:
logger.warning("[auth] logout: Не удалось получить user_id из токена")
@@ -146,7 +145,7 @@ async def refresh_token(request: Request) -> JSONResponse:
"ip": request.client.host if request.client else "unknown",
"user_agent": request.headers.get("user-agent"),
}
new_token = await SessionManager.refresh_session(user_id, token, device_info)
new_token = await TokenStorage.refresh_session(user_id, token, device_info)
if not new_token:
logger.error(f"[auth] refresh_token: Не удалось обновить токен для пользователя {user_id}")

View File

@@ -6,8 +6,8 @@ from passlib.hash import bcrypt
from auth.exceptions import ExpiredToken, InvalidPassword, InvalidToken
from auth.jwtcodec import JWTCodec
from auth.tokenstorage import TokenStorage
from services.db import local_session
from services.redis import redis
from utils.logger import root_logger as logger
# Для типизации
@@ -146,8 +146,7 @@ class Identity:
# Проверяем существование токена в хранилище
token_key = f"{payload.user_id}-{payload.username}-{token}"
token_storage = TokenStorage()
if not await token_storage.exists(token_key):
if not await redis.exists(token_key):
logger.warning(f"[Identity.token] Токен не найден в хранилище: {token_key}")
return {"error": "Token not found"}

View File

@@ -10,8 +10,8 @@ from sqlalchemy.orm import exc
from auth.credentials import AuthCredentials
from auth.orm import Author
from auth.sessions import SessionManager
from auth.state import AuthState
from auth.tokens.storage import TokenStorage as TokenManager
from services.db import local_session
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER
@@ -38,7 +38,7 @@ async def verify_internal_auth(token: str) -> tuple[int, list, bool]:
token = token.replace("Bearer ", "", 1).strip()
# Проверяем сессию
payload = await SessionManager.verify_session(token)
payload = await TokenManager.verify_session(token)
if not payload:
logger.warning("[verify_internal_auth] Недействительный токен: payload не получен")
return 0, [], False
@@ -83,7 +83,7 @@ async def create_internal_session(author: Author, device_info: Optional[dict] =
author.last_seen = int(time.time()) # type: ignore[assignment]
# Создаем сессию, используя token для идентификации
return await SessionManager.create_session(
return await TokenManager.create_session(
user_id=str(author.id),
username=str(author.slug or author.email or author.phone or ""),
device_info=device_info,
@@ -142,8 +142,8 @@ async def authenticate(request: Any) -> AuthState:
logger.debug("[auth.authenticate] Токен не найден")
return state
# Проверяем токен через SessionManager, который теперь совместим с TokenStorage
payload = await SessionManager.verify_session(token)
# Проверяем токен через TokenStorage, который теперь совместим с TokenStorage
payload = await TokenManager.verify_session(token)
if not payload:
logger.warning("[auth.authenticate] Токен не валиден: не найдена сессия")
state.error = "Invalid or expired token"

View File

@@ -21,7 +21,7 @@ class JWTCodec:
def encode(user: Union[dict[str, Any], Any], exp: Optional[datetime] = None) -> str:
# Поддержка как объектов, так и словарей
if isinstance(user, dict):
# В SessionManager.create_session передается словарь {"id": user_id, "email": username}
# В TokenStorage.create_session передается словарь {"id": user_id, "email": username}
user_id = str(user.get("id", ""))
username = user.get("email", "") or user.get("username", "")
else:

View File

@@ -16,7 +16,7 @@ from starlette.types import ASGIApp
from auth.credentials import AuthCredentials
from auth.orm import Author
from auth.sessions import SessionManager
from auth.tokens.storage import TokenStorage as TokenManager
from services.db import local_session
from settings import (
ADMIN_EMAILS as ADMIN_EMAILS_LIST,
@@ -70,7 +70,7 @@ class AuthMiddleware:
Основные функции:
1. Извлечение Bearer токена из заголовка Authorization или cookie
2. Проверка сессии через SessionManager
2. Проверка сессии через TokenStorage
3. Создание request.user и request.auth
4. Предоставление методов для установки/удаления cookies
"""
@@ -87,7 +87,7 @@ class AuthMiddleware:
), UnauthenticatedUser()
# Проверяем сессию в Redis
payload = await SessionManager.verify_session(token)
payload = await TokenManager.verify_session(token)
if not payload:
logger.debug("[auth.authenticate] Недействительный токен")
return AuthCredentials(
@@ -230,7 +230,7 @@ class AuthMiddleware:
self._context = context
logger.debug(f"[middleware] Установлен контекст GraphQL: {bool(context)}")
def set_cookie(self, key, value, **options) -> None:
def set_cookie(self, key: str, value: str, **options: Any) -> None:
"""
Устанавливает cookie в ответе
@@ -262,13 +262,9 @@ class AuthMiddleware:
if not success:
logger.error(f"[middleware] Не удалось установить cookie {key}: объекты response недоступны")
def delete_cookie(self, key, **options) -> None:
def delete_cookie(self, key: str, **options: Any) -> None:
"""
Удаляет cookie из ответа
Args:
key: Имя cookie для удаления
**options: Дополнительные параметры
"""
success = False
@@ -294,7 +290,7 @@ class AuthMiddleware:
logger.error(f"[middleware] Не удалось удалить cookie {key}: объекты response недоступны")
async def resolve(
self, next: Callable[..., Any], root: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any
self, next_resolver: Callable[..., Any], root: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any
) -> Any:
"""
Middleware для обработки запросов GraphQL.
@@ -319,7 +315,7 @@ class AuthMiddleware:
logger.debug("[middleware] GraphQL resolve: контекст подготовлен, добавлены расширения для работы с cookie")
return await next(root, info, *args, **kwargs)
return await next_resolver(root, info, *args, **kwargs)
except Exception as e:
logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {e!s}")
raise

View File

@@ -1,226 +1,249 @@
import time
from secrets import token_urlsafe
from typing import Any, Optional
from typing import Any, Callable, Optional
import orjson
from authlib.integrations.starlette_client import OAuth
from authlib.oauth2.rfc7636 import create_s256_code_challenge
from graphql import GraphQLResolveInfo
from sqlalchemy.orm import Session
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse
from auth.orm import Author
from auth.tokenstorage import TokenStorage
from auth.tokens.storage import TokenStorage
from resolvers.auth import generate_unique_slug
from services.db import local_session
from services.redis import redis
from settings import FRONTEND_URL, OAUTH_CLIENTS
from utils.logger import root_logger as logger
# Type для dependency injection сессии
SessionFactory = Callable[[], Session]
class SessionManager:
"""Менеджер сессий для dependency injection с поддержкой тестирования"""
def __init__(self) -> None:
self._factory: SessionFactory = local_session
def set_factory(self, factory: SessionFactory) -> None:
"""Устанавливает фабрику сессий для dependency injection"""
self._factory = factory
def get_session(self) -> Session:
"""Получает сессию БД через dependency injection"""
return self._factory()
# Глобальный менеджер сессий
session_manager = SessionManager()
def set_session_factory(factory: SessionFactory) -> None:
"""
Устанавливает фабрику сессий для dependency injection.
Используется в тестах для подмены реальной БД на тестовую.
"""
session_manager.set_factory(factory)
def get_session() -> Session:
"""
Получает сессию БД через dependency injection.
Возвращает сессию которую нужно явно закрывать после использования.
Внимание: не забывайте закрывать сессию после использования!
Рекомендуется использовать try/finally блок.
"""
return session_manager.get_session()
oauth = OAuth()
# OAuth state management через Redis (TTL 10 минут)
OAUTH_STATE_TTL = 600 # 10 минут
# Конфигурация провайдеров
PROVIDERS = {
# Конфигурация провайдеров для регистрации
PROVIDER_CONFIGS = {
"google": {
"name": "google",
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
"client_kwargs": {"scope": "openid email profile", "prompt": "select_account"},
},
"github": {
"name": "github",
"access_token_url": "https://github.com/login/oauth/access_token",
"authorize_url": "https://github.com/login/oauth/authorize",
"api_base_url": "https://api.github.com/",
"client_kwargs": {"scope": "user:email"},
},
"facebook": {
"name": "facebook",
"access_token_url": "https://graph.facebook.com/v13.0/oauth/access_token",
"authorize_url": "https://www.facebook.com/v13.0/dialog/oauth",
"api_base_url": "https://graph.facebook.com/",
"client_kwargs": {"scope": "public_profile email"},
},
"x": {
"name": "x",
"access_token_url": "https://api.twitter.com/2/oauth2/token",
"authorize_url": "https://twitter.com/i/oauth2/authorize",
"api_base_url": "https://api.twitter.com/2/",
"client_kwargs": {"scope": "tweet.read users.read offline.access"},
},
"telegram": {
"name": "telegram",
"authorize_url": "https://oauth.telegram.org/auth",
"api_base_url": "https://api.telegram.org/",
"client_kwargs": {"scope": "user:read"},
},
"vk": {
"name": "vk",
"access_token_url": "https://oauth.vk.com/access_token",
"authorize_url": "https://oauth.vk.com/authorize",
"api_base_url": "https://api.vk.com/method/",
"client_kwargs": {"scope": "email", "v": "5.131"},
},
"yandex": {
"name": "yandex",
"access_token_url": "https://oauth.yandex.ru/token",
"authorize_url": "https://oauth.yandex.ru/authorize",
"api_base_url": "https://login.yandex.ru/info",
"client_kwargs": {"scope": "login:email login:info"},
},
}
# Регистрация провайдеров
for provider, config in PROVIDERS.items():
# Константы для генерации временного email
TEMP_EMAIL_SUFFIX = "@oauth.local"
def _generate_temp_email(provider: str, user_id: str) -> str:
"""Генерирует временный email для OAuth провайдеров без email"""
return f"{provider}_{user_id}@oauth.local"
def _register_oauth_provider(provider: str, client_config: dict) -> None:
"""Регистрирует OAuth провайдер в зависимости от его типа"""
try:
provider_config = PROVIDER_CONFIGS.get(provider, {})
if not provider_config:
logger.warning(f"Unknown OAuth provider: {provider}")
return
# Базовые параметры для всех провайдеров
register_params = {
"name": provider,
"client_id": client_config["id"],
"client_secret": client_config["key"],
**provider_config,
}
oauth.register(**register_params)
logger.info(f"OAuth provider {provider} registered successfully")
except Exception as e:
logger.error(f"Failed to register OAuth provider {provider}: {e}")
for provider in PROVIDER_CONFIGS:
if provider in OAUTH_CLIENTS and OAUTH_CLIENTS[provider.upper()]:
client_config = OAUTH_CLIENTS[provider.upper()]
if "id" in client_config and "key" in client_config:
try:
# Регистрируем провайдеров вручную для избежания проблем типизации
if provider == "google":
oauth.register(
name="google",
client_id=client_config["id"],
client_secret=client_config["key"],
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
)
elif provider == "github":
oauth.register(
name="github",
client_id=client_config["id"],
client_secret=client_config["key"],
access_token_url="https://github.com/login/oauth/access_token",
authorize_url="https://github.com/login/oauth/authorize",
api_base_url="https://api.github.com/",
)
elif provider == "facebook":
oauth.register(
name="facebook",
client_id=client_config["id"],
client_secret=client_config["key"],
access_token_url="https://graph.facebook.com/v13.0/oauth/access_token",
authorize_url="https://www.facebook.com/v13.0/dialog/oauth",
api_base_url="https://graph.facebook.com/",
)
elif provider == "x":
oauth.register(
name="x",
client_id=client_config["id"],
client_secret=client_config["key"],
access_token_url="https://api.twitter.com/2/oauth2/token",
authorize_url="https://twitter.com/i/oauth2/authorize",
api_base_url="https://api.twitter.com/2/",
)
elif provider == "telegram":
oauth.register(
name="telegram",
client_id=client_config["id"],
client_secret=client_config["key"],
authorize_url="https://oauth.telegram.org/auth",
api_base_url="https://api.telegram.org/",
)
elif provider == "vk":
oauth.register(
name="vk",
client_id=client_config["id"],
client_secret=client_config["key"],
access_token_url="https://oauth.vk.com/access_token",
authorize_url="https://oauth.vk.com/authorize",
api_base_url="https://api.vk.com/method/",
)
elif provider == "yandex":
oauth.register(
name="yandex",
client_id=client_config["id"],
client_secret=client_config["key"],
access_token_url="https://oauth.yandex.ru/token",
authorize_url="https://oauth.yandex.ru/authorize",
api_base_url="https://login.yandex.ru/info",
)
logger.info(f"OAuth provider {provider} registered successfully")
except Exception as e:
logger.error(f"Failed to register OAuth provider {provider}: {e}")
continue
_register_oauth_provider(provider, client_config)
async def get_user_profile(provider: str, client, token) -> dict:
# Провайдеры со специальной обработкой данных
PROVIDER_HANDLERS = {
"google": lambda token, _: {
"id": token.get("userinfo", {}).get("sub"),
"email": token.get("userinfo", {}).get("email"),
"name": token.get("userinfo", {}).get("name"),
"picture": token.get("userinfo", {}).get("picture", "").replace("=s96", "=s600"),
},
"telegram": lambda token, _: {
"id": str(token.get("id", "")),
"email": None,
"phone": str(token.get("phone_number", "")),
"name": token.get("first_name", "") + " " + token.get("last_name", ""),
"picture": token.get("photo_url"),
},
"x": lambda _, profile_data: {
"id": profile_data.get("data", {}).get("id"),
"email": None,
"name": profile_data.get("data", {}).get("name") or profile_data.get("data", {}).get("username"),
"picture": profile_data.get("data", {}).get("profile_image_url", "").replace("_normal", "_400x400"),
},
}
async def _fetch_github_profile(client: Any, token: Any) -> dict:
"""Получает профиль из GitHub API"""
profile = await client.get("user", token=token)
profile_data = profile.json()
emails = await client.get("user/emails", token=token)
emails_data = emails.json()
primary_email = next((email["email"] for email in emails_data if email["primary"]), None)
return {
"id": str(profile_data["id"]),
"email": primary_email or profile_data.get("email"),
"name": profile_data.get("name") or profile_data.get("login"),
"picture": profile_data.get("avatar_url"),
}
async def _fetch_facebook_profile(client: Any, token: Any) -> dict:
"""Получает профиль из Facebook API"""
profile = await client.get("me?fields=id,name,email,picture.width(600)", token=token)
profile_data = profile.json()
return {
"id": profile_data["id"],
"email": profile_data.get("email"),
"name": profile_data.get("name"),
"picture": profile_data.get("picture", {}).get("data", {}).get("url"),
}
async def _fetch_x_profile(client: Any, token: Any) -> dict:
"""Получает профиль из X (Twitter) API"""
profile = await client.get("users/me?user.fields=id,name,username,profile_image_url", token=token)
profile_data = profile.json()
return PROVIDER_HANDLERS["x"](token, profile_data)
async def _fetch_vk_profile(client: Any, token: Any) -> dict:
"""Получает профиль из VK API"""
profile = await client.get("users.get?fields=photo_400_orig,contacts&v=5.131", token=token)
profile_data = profile.json()
if profile_data.get("response"):
user_data = profile_data["response"][0]
return {
"id": str(user_data["id"]),
"email": user_data.get("contacts", {}).get("email"),
"name": f"{user_data.get('first_name', '')} {user_data.get('last_name', '')}".strip(),
"picture": user_data.get("photo_400_orig"),
}
return {}
async def _fetch_yandex_profile(client: Any, token: Any) -> dict:
"""Получает профиль из Yandex API"""
profile = await client.get("?format=json", token=token)
profile_data = profile.json()
return {
"id": profile_data.get("id"),
"email": profile_data.get("default_email"),
"name": profile_data.get("display_name") or profile_data.get("real_name"),
"picture": f"https://avatars.yandex.net/get-yapic/{profile_data.get('default_avatar_id')}/islands-200"
if profile_data.get("default_avatar_id")
else None,
}
async def get_user_profile(provider: str, client: Any, token: Any) -> dict:
"""Получает профиль пользователя от провайдера OAuth"""
if provider == "google":
userinfo = token.get("userinfo", {})
return {
"id": userinfo.get("sub"),
"email": userinfo.get("email"),
"name": userinfo.get("name"),
"picture": userinfo.get("picture", "").replace("=s96", "=s600"),
}
if provider == "github":
profile = await client.get("user", token=token)
profile_data = profile.json()
emails = await client.get("user/emails", token=token)
emails_data = emails.json()
primary_email = next((email["email"] for email in emails_data if email["primary"]), None)
return {
"id": str(profile_data["id"]),
"email": primary_email or profile_data.get("email"),
"name": profile_data.get("name") or profile_data.get("login"),
"picture": profile_data.get("avatar_url"),
}
if provider == "facebook":
profile = await client.get("me?fields=id,name,email,picture.width(600)", token=token)
profile_data = profile.json()
return {
"id": profile_data["id"],
"email": profile_data.get("email"),
"name": profile_data.get("name"),
"picture": profile_data.get("picture", {}).get("data", {}).get("url"),
}
if provider == "x":
# Twitter/X API v2
profile = await client.get("users/me?user.fields=id,name,username,profile_image_url", token=token)
profile_data = profile.json()
user_data = profile_data.get("data", {})
return {
"id": user_data.get("id"),
"email": None, # X не предоставляет email через API
"name": user_data.get("name") or user_data.get("username"),
"picture": user_data.get("profile_image_url", "").replace("_normal", "_400x400"),
}
if provider == "telegram":
# Telegram OAuth (через Telegram Login Widget)
# Данные обычно приходят в token параметрах
return {
"id": str(token.get("id", "")),
"email": None, # Telegram не предоставляет email
"phone": str(token.get("phone_number", "")),
"name": token.get("first_name", "") + " " + token.get("last_name", ""),
"picture": token.get("photo_url"),
}
if provider == "vk":
# VK API
profile = await client.get("users.get?fields=photo_400_orig,contacts&v=5.131", token=token)
profile_data = profile.json()
if profile_data.get("response"):
user_data = profile_data["response"][0]
return {
"id": str(user_data["id"]),
"email": user_data.get("contacts", {}).get("email"),
"name": f"{user_data.get('first_name', '')} {user_data.get('last_name', '')}".strip(),
"picture": user_data.get("photo_400_orig"),
}
if provider == "yandex":
# Yandex API
profile = await client.get("?format=json", token=token)
profile_data = profile.json()
return {
"id": profile_data.get("id"),
"email": profile_data.get("default_email"),
"name": profile_data.get("display_name") or profile_data.get("real_name"),
"picture": f"https://avatars.yandex.net/get-yapic/{profile_data.get('default_avatar_id')}/islands-200"
if profile_data.get("default_avatar_id")
else None,
}
# Простые провайдеры с обработкой через lambda
if provider in PROVIDER_HANDLERS:
return PROVIDER_HANDLERS[provider](token, None)
# Провайдеры требующие API вызовов
profile_fetchers = {
"github": _fetch_github_profile,
"facebook": _fetch_facebook_profile,
"x": _fetch_x_profile,
"vk": _fetch_vk_profile,
"yandex": _fetch_yandex_profile,
}
if provider in profile_fetchers:
return await profile_fetchers[provider](client, token)
return {}
@@ -235,7 +258,7 @@ async def oauth_login(_: None, _info: GraphQLResolveInfo, provider: str, callbac
Returns:
dict: Результат авторизации с токеном или ошибкой
"""
if provider not in PROVIDERS:
if provider not in PROVIDER_CONFIGS:
return JSONResponse({"error": "Invalid provider"}, status_code=400)
client = oauth.create_client(provider)
@@ -278,7 +301,7 @@ async def oauth_login(_: None, _info: GraphQLResolveInfo, provider: str, callbac
return JSONResponse({"error": str(e)}, status_code=500)
async def oauth_callback(request):
async def oauth_callback(request: Any) -> JSONResponse | RedirectResponse:
"""Обрабатывает callback от OAuth провайдера"""
try:
# Получаем state из query параметров
@@ -308,69 +331,8 @@ async def oauth_callback(request):
# Получаем профиль пользователя
profile = await get_user_profile(provider, client, token)
# Для некоторых провайдеров (X, Telegram) email может отсутствовать
email = profile.get("email")
if not email:
# Генерируем временный email на основе провайдера и ID
email = f"{provider}_{profile.get('id', 'unknown')}@oauth.local"
logger.info(f"Generated temporary email for {provider} user: {email}")
# Создаем или обновляем пользователя
with local_session() as session:
# Сначала ищем пользователя по OAuth
author = Author.find_by_oauth(provider, profile["id"], session)
if author:
# Пользователь найден по OAuth - обновляем данные
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
# Обновляем основные данные автора если они пустые
if profile.get("name") and not author.name:
author.name = profile["name"] # type: ignore[assignment]
if profile.get("picture") and not author.pic:
author.pic = profile["picture"] # type: ignore[assignment]
author.updated_at = int(time.time()) # type: ignore[assignment]
author.last_seen = int(time.time()) # type: ignore[assignment]
else:
# Ищем пользователя по email если есть настоящий email
author = None
if email and email != f"{provider}_{profile.get('id', 'unknown')}@oauth.local":
author = session.query(Author).filter(Author.email == email).first()
if author:
# Пользователь найден по email - добавляем OAuth данные
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
# Обновляем данные автора если нужно
if profile.get("name") and not author.name:
author.name = profile["name"] # type: ignore[assignment]
if profile.get("picture") and not author.pic:
author.pic = profile["picture"] # type: ignore[assignment]
author.updated_at = int(time.time()) # type: ignore[assignment]
author.last_seen = int(time.time()) # type: ignore[assignment]
else:
# Создаем нового пользователя
slug = generate_unique_slug(profile["name"] or f"{provider}_{profile.get('id', 'user')}")
author = Author(
email=email,
name=profile["name"] or f"{provider.title()} User",
slug=slug,
pic=profile.get("picture"),
email_verified=True if profile.get("email") else False,
created_at=int(time.time()),
updated_at=int(time.time()),
last_seen=int(time.time()),
)
session.add(author)
session.flush() # Получаем ID автора
# Добавляем OAuth данные для нового пользователя
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
session.commit()
# Создаем или обновляем пользователя используя helper функцию
author = await _create_or_update_user(provider, profile)
# Создаем токен сессии
session_token = await TokenStorage.create_session(str(author.id))
@@ -416,7 +378,7 @@ async def oauth_login_http(request: Request) -> JSONResponse | RedirectResponse:
"""HTTP handler для OAuth login"""
try:
provider = request.path_params.get("provider")
if not provider or provider not in PROVIDERS:
if not provider or provider not in PROVIDER_CONFIGS:
return JSONResponse({"error": "Invalid provider"}, status_code=400)
client = oauth.create_client(provider)
@@ -484,89 +446,103 @@ async def oauth_callback_http(request: Request) -> JSONResponse | RedirectRespon
if not profile:
return JSONResponse({"error": "Failed to get user profile"}, status_code=400)
# Для некоторых провайдеров (X, Telegram) email может отсутствовать
email = profile.get("email")
if not email:
# Генерируем временный email на основе провайдера и ID
email = f"{provider}_{profile.get('id', 'unknown')}@oauth.local"
# Создаем или обновляем пользователя используя helper функцию
author = await _create_or_update_user(provider, profile)
# Регистрируем/обновляем пользователя
with local_session() as session:
# Сначала ищем пользователя по OAuth
author = Author.find_by_oauth(provider, profile["id"], session)
# Создаем токен сессии
session_token = await TokenStorage.create_session(str(author.id))
if author:
# Пользователь найден по OAuth - обновляем данные
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
# Очищаем OAuth сессию
request.session.pop("code_verifier", None)
request.session.pop("provider", None)
request.session.pop("state", None)
# Обновляем основные данные автора если они пустые
if profile.get("name") and not author.name:
author.name = profile["name"] # type: ignore[assignment]
if profile.get("picture") and not author.pic:
author.pic = profile["picture"] # type: ignore[assignment]
author.updated_at = int(time.time()) # type: ignore[assignment]
author.last_seen = int(time.time()) # type: ignore[assignment]
else:
# Ищем пользователя по email если есть настоящий email
author = None
if email and email != f"{provider}_{profile.get('id', 'unknown')}@oauth.local":
author = session.query(Author).filter(Author.email == email).first()
if author:
# Пользователь найден по email - добавляем OAuth данные
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
# Обновляем данные автора если нужно
if profile.get("name") and not author.name:
author.name = profile["name"] # type: ignore[assignment]
if profile.get("picture") and not author.pic:
author.pic = profile["picture"] # type: ignore[assignment]
author.updated_at = int(time.time()) # type: ignore[assignment]
author.last_seen = int(time.time()) # type: ignore[assignment]
else:
# Создаем нового пользователя
slug = generate_unique_slug(profile["name"] or f"{provider}_{profile.get('id', 'user')}")
author = Author(
email=email,
name=profile["name"] or f"{provider.title()} User",
slug=slug,
pic=profile.get("picture"),
email_verified=True if profile.get("email") else False,
created_at=int(time.time()),
updated_at=int(time.time()),
last_seen=int(time.time()),
)
session.add(author)
session.flush() # Получаем ID автора
# Добавляем OAuth данные для нового пользователя
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
session.commit()
# Создаем токен сессии
session_token = await TokenStorage.create_session(str(author.id))
# Очищаем OAuth сессию
request.session.pop("code_verifier", None)
request.session.pop("provider", None)
request.session.pop("state", None)
# Возвращаем redirect с cookie
response = RedirectResponse(url="/auth/success", status_code=307)
response.set_cookie(
"session_token",
session_token,
httponly=True,
secure=True,
samesite="lax",
max_age=30 * 24 * 60 * 60, # 30 дней
)
return response
# Возвращаем redirect с cookie
response = RedirectResponse(url="/auth/success", status_code=307)
response.set_cookie(
"session_token",
session_token,
httponly=True,
secure=True,
samesite="lax",
max_age=30 * 24 * 60 * 60, # 30 дней
)
return response
except Exception as e:
logger.error(f"OAuth callback error: {e}")
return JSONResponse({"error": "OAuth callback failed"}, status_code=500)
async def _create_or_update_user(provider: str, profile: dict) -> Author:
"""
Создает или обновляет пользователя на основе OAuth профиля.
Возвращает объект Author.
"""
# Для некоторых провайдеров (X, Telegram) email может отсутствовать
email = profile.get("email")
if not email:
# Генерируем временный email на основе провайдера и ID
email = _generate_temp_email(provider, profile.get("id", "unknown"))
logger.info(f"Generated temporary email for {provider} user: {email}")
# Создаем или обновляем пользователя
session = get_session()
try:
# Сначала ищем пользователя по OAuth
author = Author.find_by_oauth(provider, profile["id"], session)
if author:
# Пользователь найден по OAuth - обновляем данные
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
_update_author_profile(author, profile)
else:
# Ищем пользователя по email если есть настоящий email
author = None
if email and not email.endswith(TEMP_EMAIL_SUFFIX):
author = session.query(Author).filter(Author.email == email).first()
if author:
# Пользователь найден по email - добавляем OAuth данные
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
_update_author_profile(author, profile)
else:
# Создаем нового пользователя
author = _create_new_oauth_user(provider, profile, email, session)
session.commit()
return author
finally:
session.close()
def _update_author_profile(author: Author, profile: dict) -> None:
"""Обновляет профиль автора данными из OAuth"""
if profile.get("name") and not author.name:
author.name = profile["name"] # type: ignore[assignment]
if profile.get("picture") and not author.pic:
author.pic = profile["picture"] # type: ignore[assignment]
author.updated_at = int(time.time()) # type: ignore[assignment]
author.last_seen = int(time.time()) # type: ignore[assignment]
def _create_new_oauth_user(provider: str, profile: dict, email: str, session: Any) -> Author:
"""Создает нового пользователя из OAuth профиля"""
slug = generate_unique_slug(profile["name"] or f"{provider}_{profile.get('id', 'user')}")
author = Author(
email=email,
name=profile["name"] or f"{provider.title()} User",
slug=slug,
pic=profile.get("picture"),
email_verified=bool(profile.get("email")),
created_at=int(time.time()),
updated_at=int(time.time()),
last_seen=int(time.time()),
)
session.add(author)
session.flush() # Получаем ID автора
# Добавляем OAuth данные для нового пользователя
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
return author

View File

@@ -1,419 +0,0 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from pydantic import BaseModel
from auth.jwtcodec import JWTCodec, TokenPayload
from services.redis import redis
from utils.logger import root_logger as logger
class SessionData(BaseModel):
"""Модель данных сессии"""
user_id: str
username: str
created_at: datetime
expires_at: datetime
device_info: Optional[dict] = None
class SessionManager:
"""
Менеджер сессий в Redis.
Управляет созданием, проверкой и отзывом сессий пользователей.
"""
@staticmethod
def _make_session_key(user_id: str, token: str) -> str:
"""
Создаёт ключ для сессии в Redis.
Args:
user_id: ID пользователя
token: JWT токен сессии
Returns:
str: Ключ сессии
"""
session_key = f"session:{user_id}:{token}"
logger.debug(f"[SessionManager._make_session_key] Сформирован ключ сессии: {session_key}")
return session_key
@staticmethod
def _make_user_sessions_key(user_id: str) -> str:
"""
Создаёт ключ для списка активных сессий пользователя.
Args:
user_id: ID пользователя
Returns:
str: Ключ списка сессий
"""
return f"user_sessions:{user_id}"
@classmethod
async def create_session(cls, user_id: str, username: str, device_info: Optional[dict] = None) -> str:
"""
Создаёт новую сессию.
Args:
user_id: ID пользователя
username: Имя пользователя
device_info: Информация об устройстве (опционально)
Returns:
str: JWT токен сессии
"""
# Создаём токен с явным указанием срока действия (30 дней)
expiration_date = datetime.now(tz=timezone.utc) + timedelta(days=30)
token = JWTCodec.encode({"id": user_id, "email": username}, exp=expiration_date)
# Сохраняем сессию в Redis
session_key = cls._make_session_key(user_id, token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Сохраняем информацию о сессии
session_data = {
"user_id": user_id,
"username": username,
"created_at": datetime.now(tz=timezone.utc).isoformat(),
"expires_at": expiration_date.isoformat(),
}
# Добавляем информацию об устройстве, если она есть
if device_info:
for key, value in device_info.items():
session_data[f"device_{key}"] = value
# Сохраняем сессию в Redis
pipeline = redis.pipeline()
# Сохраняем данные сессии
pipeline.hset(session_key, mapping=session_data)
# Добавляем токен в список сессий пользователя
pipeline.sadd(user_sessions_key, token)
# Устанавливаем время жизни ключей (30 дней)
pipeline.expire(session_key, 30 * 24 * 60 * 60)
pipeline.expire(user_sessions_key, 30 * 24 * 60 * 60)
# Также создаем ключ в формате, совместимом с TokenStorage для обратной совместимости
token_key = f"{user_id}-{username}-{token}"
pipeline.hset(token_key, mapping={"user_id": user_id, "username": username})
pipeline.expire(token_key, 30 * 24 * 60 * 60)
await pipeline.execute()
logger.info(f"[SessionManager.create_session] Сессия успешно создана для пользователя {user_id}")
return token
@classmethod
async def verify_session(cls, token: str) -> Optional[TokenPayload]:
"""
Проверяет сессию по токену.
Args:
token: JWT токен
Returns:
Optional[TokenPayload]: Данные токена или None, если сессия недействительна
"""
logger.debug(f"[SessionManager.verify_session] Проверка сессии для токена: {token[:20]}...")
# Декодируем токен для получения payload
try:
payload = JWTCodec.decode(token)
if not payload:
logger.error("[SessionManager.verify_session] Не удалось декодировать токен")
return None
logger.debug(f"[SessionManager.verify_session] Успешно декодирован токен, user_id={payload.user_id}")
except Exception as e:
logger.error(f"[SessionManager.verify_session] Ошибка при декодировании токена: {e!s}")
return None
# Получаем данные из payload
user_id = payload.user_id
# Формируем ключ сессии
session_key = cls._make_session_key(user_id, token)
logger.debug(f"[SessionManager.verify_session] Сформирован ключ сессии: {session_key}")
# Проверяем существование сессии в Redis
exists = await redis.exists(session_key)
if not exists:
logger.warning(f"[SessionManager.verify_session] Сессия не найдена: {user_id}. Ключ: {session_key}")
# Проверяем также ключ в старом формате TokenStorage для обратной совместимости
token_key = f"{user_id}-{payload.username}-{token}"
old_format_exists = await redis.exists(token_key)
if old_format_exists:
logger.info(f"[SessionManager.verify_session] Найдена сессия в старом формате: {token_key}")
# Миграция: создаем запись в новом формате
session_data = {
"user_id": user_id,
"username": payload.username,
}
# Копируем сессию в новый формат
pipeline = redis.pipeline()
pipeline.hset(session_key, mapping=session_data)
pipeline.expire(session_key, 30 * 24 * 60 * 60)
pipeline.sadd(cls._make_user_sessions_key(user_id), token)
await pipeline.execute()
logger.info(f"[SessionManager.verify_session] Сессия мигрирована в новый формат: {session_key}")
return payload
# Если сессия не найдена ни в новом, ни в старом формате, проверяем все ключи в Redis
keys = await redis.keys("session:*")
logger.debug(f"[SessionManager.verify_session] Все ключи сессий в Redis: {keys}")
# Проверяем, можно ли доверять токену напрямую
# Если токен валидный и не истек, мы можем доверять ему даже без записи в Redis
if payload and payload.exp and payload.exp > datetime.now(tz=timezone.utc):
logger.info(f"[SessionManager.verify_session] Токен валиден по JWT, создаем сессию для {user_id}")
# Создаем сессию на основе валидного токена
session_data = {
"user_id": user_id,
"username": payload.username,
"created_at": datetime.now(tz=timezone.utc).isoformat(),
"expires_at": payload.exp.isoformat()
if isinstance(payload.exp, datetime)
else datetime.fromtimestamp(payload.exp, tz=timezone.utc).isoformat(),
}
# Сохраняем сессию в Redis
pipeline = redis.pipeline()
pipeline.hset(session_key, mapping=session_data)
pipeline.expire(session_key, 30 * 24 * 60 * 60)
pipeline.sadd(cls._make_user_sessions_key(user_id), token)
await pipeline.execute()
logger.info(f"[SessionManager.verify_session] Создана новая сессия для валидного токена: {session_key}")
return payload
# Если сессии нет, возвращаем None
return None
# Если сессия найдена, возвращаем payload
logger.debug(f"[SessionManager.verify_session] Сессия найдена для пользователя {user_id}")
return payload
@classmethod
async def get_user_sessions(cls, user_id: str) -> list[dict[str, Any]]:
"""
Получает все активные сессии пользователя.
Args:
user_id: ID пользователя
Returns:
List[Dict[str, Any]]: Список сессий
"""
user_sessions_key = cls._make_user_sessions_key(user_id)
tokens = await redis.smembers(user_sessions_key)
sessions = []
# Convert set to list for iteration
for token in list(tokens):
token_str: str = str(token)
session_key = cls._make_session_key(user_id, token_str)
session_data = await redis.hgetall(session_key)
if session_data and token:
session = dict(session_data)
session["token"] = token_str
sessions.append(session)
return sessions
@classmethod
async def delete_session(cls, user_id: str, token: str) -> bool:
"""
Удаляет сессию.
Args:
user_id: ID пользователя
token: JWT токен
Returns:
bool: True, если сессия успешно удалена
"""
session_key = cls._make_session_key(user_id, token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Удаляем данные сессии и токен из списка сессий пользователя
pipeline = redis.pipeline()
pipeline.delete(session_key)
pipeline.srem(user_sessions_key, token)
# Также удаляем ключ в формате TokenStorage для полной очистки
token_payload = JWTCodec.decode(token)
if token_payload:
token_key = f"{user_id}-{token_payload.username}-{token}"
pipeline.delete(token_key)
results = await pipeline.execute()
return bool(results[0]) or bool(results[1])
@classmethod
async def delete_all_sessions(cls, user_id: str) -> int:
"""
Удаляет все сессии пользователя.
Args:
user_id: ID пользователя
Returns:
int: Количество удаленных сессий
"""
user_sessions_key = cls._make_user_sessions_key(user_id)
tokens = await redis.smembers(user_sessions_key)
count = 0
# Convert set to list for iteration
for token in list(tokens):
token_str: str = str(token)
session_key = cls._make_session_key(user_id, token_str)
# Удаляем данные сессии
deleted = await redis.delete(session_key)
count += deleted
# Также удаляем ключ в формате TokenStorage
token_payload = JWTCodec.decode(token_str)
if token_payload:
token_key = f"{user_id}-{token_payload.username}-{token_str}"
await redis.delete(token_key)
# Очищаем список токенов
await redis.delete(user_sessions_key)
return count
@classmethod
async def get_session_data(cls, user_id: str, token: str) -> Optional[dict[str, Any]]:
"""
Получает данные сессии.
Args:
user_id: ID пользователя
token: Токен сессии
Returns:
dict: Данные сессии или None, если сессия не найдена
"""
try:
session_key = cls._make_session_key(user_id, token)
session_data = await redis.execute("HGETALL", session_key)
return session_data if session_data else None
except Exception as e:
logger.error(f"[SessionManager.get_session_data] Ошибка: {e!s}")
return None
@classmethod
async def revoke_session(cls, user_id: str, token: str) -> bool:
"""
Отзывает конкретную сессию.
Args:
user_id: ID пользователя
token: Токен сессии
Returns:
bool: True, если сессия успешно отозвана
"""
try:
session_key = cls._make_session_key(user_id, token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Удаляем сессию и запись из списка сессий пользователя
pipe = redis.pipeline()
await pipe.delete(session_key)
await pipe.srem(user_sessions_key, token)
await pipe.execute()
return True
except Exception as e:
logger.error(f"[SessionManager.revoke_session] Ошибка: {e!s}")
return False
@classmethod
async def revoke_all_sessions(cls, user_id: str) -> bool:
"""
Отзывает все сессии пользователя.
Args:
user_id: ID пользователя
Returns:
bool: True, если все сессии успешно отозваны
"""
try:
user_sessions_key = cls._make_user_sessions_key(user_id)
# Получаем все токены пользователя
tokens = await redis.smembers(user_sessions_key)
if not tokens:
return True
# Создаем команды для удаления всех сессий
pipe = redis.pipeline()
# Формируем список ключей для удаления
# Convert set to list for iteration
for token in list(tokens):
token_str: str = str(token)
session_key = cls._make_session_key(user_id, token_str)
await pipe.delete(session_key)
# Удаляем список сессий
await pipe.delete(user_sessions_key)
await pipe.execute()
return True
except Exception as e:
logger.error(f"[SessionManager.revoke_all_sessions] Ошибка: {e!s}")
return False
@classmethod
async def refresh_session(cls, user_id: int, old_token: str, device_info: Optional[dict] = None) -> Optional[str]:
"""
Обновляет сессию пользователя, заменяя старый токен новым.
Args:
user_id: ID пользователя
old_token: Старый токен сессии
device_info: Информация об устройстве (опционально)
Returns:
str: Новый токен сессии или None в случае ошибки
"""
try:
user_id_str = str(user_id)
# Получаем данные старой сессии
old_session_key = cls._make_session_key(user_id_str, old_token)
old_session_data = await redis.hgetall(old_session_key)
if not old_session_data:
logger.warning(f"[SessionManager.refresh_session] Сессия не найдена: {user_id}")
return None
# Используем старые данные устройства, если новые не предоставлены
if not device_info and "device_info" in old_session_data:
device_info = old_session_data.get("device_info")
# Создаем новую сессию
new_token = await cls.create_session(user_id_str, old_session_data.get("username", ""), device_info)
# Отзываем старую сессию
await cls.revoke_session(user_id_str, old_token)
return new_token
except Exception as e:
logger.error(f"[SessionManager.refresh_session] Ошибка: {e!s}")
return None

0
auth/tokens/__init__.py Normal file
View File

54
auth/tokens/base.py Normal file
View File

@@ -0,0 +1,54 @@
"""
Базовый класс для работы с токенами
"""
import secrets
from functools import lru_cache
from typing import Optional
from .types import TokenType
class BaseTokenManager:
"""
Базовый класс с общими методами для всех типов токенов
"""
@staticmethod
@lru_cache(maxsize=1000)
def _make_token_key(token_type: TokenType, identifier: str, token: Optional[str] = None) -> str:
"""
Создает унифицированный ключ для токена с кэшированием
Args:
token_type: Тип токена
identifier: Идентификатор (user_id, user_id:provider, etc)
token: Сам токен (для session и verification)
Returns:
str: Ключ токена
"""
if token_type == TokenType.SESSION:
return f"session:{identifier}:{token}"
if token_type == TokenType.VERIFICATION:
return f"verification_token:{token}"
if token_type == TokenType.OAUTH_ACCESS:
return f"oauth_access:{identifier}"
if token_type == TokenType.OAUTH_REFRESH:
return f"oauth_refresh:{identifier}"
error_msg = f"Неизвестный тип токена: {token_type}"
raise ValueError(error_msg)
@staticmethod
@lru_cache(maxsize=500)
def _make_user_tokens_key(user_id: str, token_type: TokenType) -> str:
"""Создает ключ для списка токенов пользователя"""
if token_type == TokenType.SESSION:
return f"user_sessions:{user_id}"
return f"user_tokens:{user_id}:{token_type}"
@staticmethod
def generate_token() -> str:
"""Генерирует криптографически стойкий токен"""
return secrets.token_urlsafe(32)

197
auth/tokens/batch.py Normal file
View File

@@ -0,0 +1,197 @@
"""
Батчевые операции с токенами для оптимизации производительности
"""
import asyncio
from typing import Any, Dict, List, Optional
from auth.jwtcodec import JWTCodec
from services.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager
from .types import BATCH_SIZE
class BatchTokenOperations(BaseTokenManager):
"""
Класс для пакетных операций с токенами
"""
async def batch_validate_tokens(self, tokens: List[str]) -> Dict[str, bool]:
"""
Пакетная валидация токенов для улучшения производительности
Args:
tokens: Список токенов для валидации
Returns:
Dict[str, bool]: Словарь {токен: валиден}
"""
if not tokens:
return {}
results = {}
# Разбиваем на батчи для избежания блокировки Redis
for i in range(0, len(tokens), BATCH_SIZE):
batch = tokens[i : i + BATCH_SIZE]
batch_results = await self._validate_token_batch(batch)
results.update(batch_results)
return results
async def _validate_token_batch(self, token_batch: List[str]) -> Dict[str, bool]:
"""Валидация батча токенов"""
results = {}
# Создаем задачи для декодирования токенов пакетно
decode_tasks = [asyncio.create_task(self._safe_decode_token(token)) for token in token_batch]
decoded_payloads = await asyncio.gather(*decode_tasks, return_exceptions=True)
# Подготавливаем ключи для проверки
token_keys = []
valid_tokens = []
for token, payload in zip(token_batch, decoded_payloads):
if isinstance(payload, Exception) or not payload or not hasattr(payload, "user_id"):
results[token] = False
continue
token_key = self._make_token_key("session", payload.user_id, token)
token_keys.append(token_key)
valid_tokens.append(token)
# Проверяем существование ключей пакетно
if token_keys:
async with redis_adapter.pipeline() as pipe:
for key in token_keys:
await pipe.exists(key)
existence_results = await pipe.execute()
for token, exists in zip(valid_tokens, existence_results):
results[token] = bool(exists)
return results
async def _safe_decode_token(self, token: str) -> Optional[Any]:
"""Безопасное декодирование токена"""
try:
return JWTCodec.decode(token)
except Exception:
return None
async def batch_revoke_tokens(self, tokens: List[str]) -> int:
"""
Пакетный отзыв токенов
Args:
tokens: Список токенов для отзыва
Returns:
int: Количество отозванных токенов
"""
if not tokens:
return 0
revoked_count = 0
# Обрабатываем батчами
for i in range(0, len(tokens), BATCH_SIZE):
batch = tokens[i : i + BATCH_SIZE]
batch_count = await self._revoke_token_batch(batch)
revoked_count += batch_count
return revoked_count
async def _revoke_token_batch(self, token_batch: List[str]) -> int:
"""Отзыв батча токенов"""
keys_to_delete = []
user_updates: Dict[str, set[str]] = {} # {user_id: {tokens_to_remove}}
# Декодируем токены и подготавливаем операции
for token in token_batch:
payload = await self._safe_decode_token(token)
if payload:
user_id = payload.user_id
username = payload.username
# Ключи для удаления
new_key = self._make_token_key("session", user_id, token)
old_key = f"{user_id}-{username}-{token}"
keys_to_delete.extend([new_key, old_key])
# Обновления пользовательских списков
if user_id not in user_updates:
user_updates[user_id] = set()
user_updates[user_id].add(token)
if not keys_to_delete:
return 0
# Выполняем удаление пакетно
async with redis_adapter.pipeline() as pipe:
# Удаляем ключи токенов
await pipe.delete(*keys_to_delete)
# Обновляем пользовательские списки
for user_id, tokens_to_remove in user_updates.items():
user_tokens_key = self._make_user_tokens_key(user_id, "session")
for token in tokens_to_remove:
await pipe.srem(user_tokens_key, token)
results = await pipe.execute()
return len([r for r in results if r > 0])
async def cleanup_expired_tokens(self) -> int:
"""Оптимизированная очистка истекших токенов с использованием SCAN"""
try:
cleaned_count = 0
cursor = 0
# Ищем все ключи пользовательских сессий
while True:
cursor, keys = await redis_adapter.execute("scan", cursor, "user_sessions:*", 100)
for user_tokens_key in keys:
tokens = await redis_adapter.smembers(user_tokens_key)
active_tokens = []
# Проверяем активность токенов пакетно
if tokens:
async with redis_adapter.pipeline() as pipe:
for token in tokens:
token_str = token if isinstance(token, str) else str(token)
session_key = self._make_token_key("session", user_tokens_key.split(":")[1], token_str)
await pipe.exists(session_key)
results = await pipe.execute()
for token, exists in zip(tokens, results):
if exists:
active_tokens.append(token)
else:
cleaned_count += 1
# Обновляем список активных токенов
if active_tokens:
async with redis_adapter.pipeline() as pipe:
await pipe.delete(user_tokens_key)
for token in active_tokens:
await pipe.sadd(user_tokens_key, token)
await pipe.execute()
else:
await redis_adapter.delete(user_tokens_key)
if cursor == 0:
break
if cleaned_count > 0:
logger.info(f"Очищено {cleaned_count} ссылок на истекшие токены")
return cleaned_count
except Exception as e:
logger.error(f"Ошибка очистки токенов: {e}")
return 0

189
auth/tokens/monitoring.py Normal file
View File

@@ -0,0 +1,189 @@
"""
Статистика и мониторинг системы токенов
"""
import asyncio
from typing import Any, Dict
from services.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager
from .types import SCAN_BATCH_SIZE
class TokenMonitoring(BaseTokenManager):
"""
Класс для мониторинга и статистики токенов
"""
async def get_token_statistics(self) -> Dict[str, Any]:
"""
Получает статистику по токенам для мониторинга
Returns:
Dict: Статистика токенов
"""
stats = {
"session_tokens": 0,
"verification_tokens": 0,
"oauth_access_tokens": 0,
"oauth_refresh_tokens": 0,
"user_sessions": 0,
"memory_usage": 0,
}
try:
# Считаем токены по типам используя SCAN
patterns = {
"session_tokens": "session:*",
"verification_tokens": "verification_token:*",
"oauth_access_tokens": "oauth_access:*",
"oauth_refresh_tokens": "oauth_refresh:*",
"user_sessions": "user_sessions:*",
}
count_tasks = [self._count_keys_by_pattern(pattern) for pattern in patterns.values()]
counts = await asyncio.gather(*count_tasks)
for (stat_name, _), count in zip(patterns.items(), counts):
stats[stat_name] = count
# Получаем информацию о памяти Redis
memory_info = await redis_adapter.execute("INFO", "MEMORY")
stats["memory_usage"] = memory_info.get("used_memory", 0)
except Exception as e:
logger.error(f"Ошибка получения статистики токенов: {e}")
return stats
async def _count_keys_by_pattern(self, pattern: str) -> int:
"""Подсчет ключей по паттерну используя SCAN"""
count = 0
cursor = 0
while True:
cursor, keys = await redis_adapter.execute("scan", cursor, pattern, SCAN_BATCH_SIZE)
count += len(keys)
if cursor == 0:
break
return count
async def optimize_memory_usage(self) -> Dict[str, Any]:
"""
Оптимизирует использование памяти Redis
Returns:
Dict: Результаты оптимизации
"""
results = {"cleaned_expired": 0, "optimized_structures": 0, "memory_saved": 0}
try:
# Очищаем истекшие токены
from .batch import BatchTokenOperations
batch_ops = BatchTokenOperations()
cleaned = await batch_ops.cleanup_expired_tokens()
results["cleaned_expired"] = cleaned
# Оптимизируем структуры данных
optimized = await self._optimize_data_structures()
results["optimized_structures"] = optimized
# Запускаем сборку мусора Redis
await redis_adapter.execute("MEMORY", "PURGE")
logger.info(f"Оптимизация памяти завершена: {results}")
except Exception as e:
logger.error(f"Ошибка оптимизации памяти: {e}")
return results
async def _optimize_data_structures(self) -> int:
"""Оптимизирует структуры данных Redis"""
optimized_count = 0
cursor = 0
# Оптимизируем пользовательские списки сессий
while True:
cursor, keys = await redis_adapter.execute("scan", cursor, "user_sessions:*", SCAN_BATCH_SIZE)
for key in keys:
try:
# Проверяем размер множества
size = await redis_adapter.execute("scard", key)
if size == 0:
await redis_adapter.delete(key)
optimized_count += 1
elif size > 100: # Слишком много сессий у одного пользователя
# Оставляем только последние 50 сессий
members = await redis_adapter.execute("smembers", key)
if len(members) > 50:
members_list = list(members)
to_remove = members_list[:-50]
if to_remove:
await redis_adapter.srem(key, *to_remove)
optimized_count += len(to_remove)
except Exception as e:
logger.error(f"Ошибка оптимизации ключа {key}: {e}")
continue
if cursor == 0:
break
return optimized_count
async def health_check(self) -> Dict[str, Any]:
"""
Проверка здоровья системы токенов
Returns:
Dict: Результаты проверки
"""
health: Dict[str, Any] = {
"status": "healthy",
"redis_connected": False,
"token_operations": False,
"errors": [],
}
try:
# Проверяем подключение к Redis
await redis_adapter.ping()
health["redis_connected"] = True
# Тестируем основные операции с токенами
from .sessions import SessionTokenManager
session_manager = SessionTokenManager()
test_user_id = "health_check_user"
test_token = await session_manager.create_session(test_user_id)
if test_token:
# Проверяем валидацию
valid, _ = await session_manager.validate_session_token(test_token)
if valid:
# Проверяем отзыв
revoked = await session_manager.revoke_session_token(test_token)
if revoked:
health["token_operations"] = True
else:
health["errors"].append("Failed to revoke test token") # type: ignore[misc]
else:
health["errors"].append("Failed to validate test token") # type: ignore[misc]
else:
health["errors"].append("Failed to create test token") # type: ignore[misc]
except Exception as e:
health["errors"].append(f"Health check error: {e}") # type: ignore[misc]
if health["errors"]:
health["status"] = "unhealthy"
return health

157
auth/tokens/oauth.py Normal file
View File

@@ -0,0 +1,157 @@
"""
Управление OAuth токенов
"""
import json
import time
from typing import Optional
from services.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager
from .types import DEFAULT_TTL, TokenData, TokenType
class OAuthTokenManager(BaseTokenManager):
"""
Менеджер OAuth токенов
"""
async def store_oauth_tokens(
self,
user_id: str,
provider: str,
access_token: str,
refresh_token: Optional[str] = None,
expires_in: Optional[int] = None,
additional_data: Optional[TokenData] = None,
) -> bool:
"""Сохраняет OAuth токены"""
try:
# Сохраняем access token
access_data = {
"token": access_token,
"provider": provider,
"expires_in": expires_in,
**(additional_data or {}),
}
access_ttl = expires_in if expires_in else DEFAULT_TTL["oauth_access"]
await self._create_oauth_token(user_id, access_data, access_ttl, provider, "oauth_access")
# Сохраняем refresh token если есть
if refresh_token:
refresh_data = {
"token": refresh_token,
"provider": provider,
}
await self._create_oauth_token(
user_id, refresh_data, DEFAULT_TTL["oauth_refresh"], provider, "oauth_refresh"
)
return True
except Exception as e:
logger.error(f"Ошибка сохранения OAuth токенов: {e}")
return False
async def _create_oauth_token(
self, user_id: str, token_data: TokenData, ttl: int, provider: str, token_type: TokenType
) -> str:
"""Оптимизированное создание OAuth токена"""
if not provider:
error_msg = "OAuth токены требуют указания провайдера"
raise ValueError(error_msg)
identifier = f"{user_id}:{provider}"
token_key = self._make_token_key(token_type, identifier)
# Добавляем метаданные
token_data.update(
{"user_id": user_id, "token_type": token_type, "provider": provider, "created_at": int(time.time())}
)
# Используем SETEX для атомарной операции
serialized_data = json.dumps(token_data, ensure_ascii=False)
await redis_adapter.execute("setex", token_key, ttl, serialized_data)
logger.info(f"Создан {token_type} токен для пользователя {user_id}, провайдер {provider}")
return token_key
async def get_token(self, user_id: int, provider: str, token_type: TokenType) -> Optional[TokenData]:
"""Получает токен"""
if isinstance(token_type, TokenType):
if token_type.startswith("oauth_"):
return await self._get_oauth_data_optimized(token_type, str(user_id), provider) # type: ignore[arg-type]
return await self._get_token_data_optimized(token_type, str(user_id), provider) # type: ignore[arg-type]
return None
async def _get_oauth_data_optimized(
self, token_type: TokenType, user_id: str, provider: str
) -> Optional[TokenData]:
"""Оптимизированное получение OAuth данных"""
if not user_id or not provider:
error_msg = "OAuth токены требуют user_id и provider"
raise ValueError(error_msg)
identifier = f"{user_id}:{provider}"
token_key = self._make_token_key(token_type, identifier)
# Получаем данные и TTL в одном pipeline
async with redis_adapter.pipeline() as pipe:
await pipe.get(token_key)
await pipe.ttl(token_key)
results = await pipe.execute()
if results[0]:
token_data = json.loads(results[0])
if results[1] > 0:
token_data["ttl_remaining"] = results[1]
return token_data
return None
async def revoke_oauth_tokens(self, user_id: str, provider: str) -> bool:
"""Удаляет все OAuth токены для провайдера"""
try:
result1 = await self._revoke_oauth_token_optimized("oauth_access", user_id, provider)
result2 = await self._revoke_oauth_token_optimized("oauth_refresh", user_id, provider)
return result1 or result2
except Exception as e:
logger.error(f"Ошибка удаления OAuth токенов: {e}")
return False
async def _revoke_oauth_token_optimized(self, token_type: TokenType, user_id: str, provider: str) -> bool:
"""Оптимизированный отзыв OAuth токена"""
if not user_id or not provider:
error_msg = "OAuth токены требуют user_id и provider"
raise ValueError(error_msg)
identifier = f"{user_id}:{provider}"
token_key = self._make_token_key(token_type, identifier)
result = await redis_adapter.delete(token_key)
return result > 0
async def revoke_user_oauth_tokens(self, user_id: str, token_type: TokenType) -> int:
"""Оптимизированный отзыв OAuth токенов пользователя используя SCAN"""
count = 0
cursor = 0
delete_keys = []
pattern = f"{token_type}:{user_id}:*"
# Используем SCAN для безопасного поиска токенов
while True:
cursor, keys = await redis_adapter.execute("scan", cursor, pattern, 100)
if keys:
delete_keys.extend(keys)
count += len(keys)
if cursor == 0:
break
# Удаляем найденные токены пакетно
if delete_keys:
await redis_adapter.delete(*delete_keys)
return count

253
auth/tokens/sessions.py Normal file
View File

@@ -0,0 +1,253 @@
"""
Управление токенами сессий
"""
import json
import time
from typing import Any, List, Optional, Union
from auth.jwtcodec import JWTCodec
from services.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager
from .types import DEFAULT_TTL, TokenData
class SessionTokenManager(BaseTokenManager):
"""
Менеджер токенов сессий
"""
async def create_session(
self,
user_id: str,
auth_data: Optional[dict] = None,
username: Optional[str] = None,
device_info: Optional[dict] = None,
) -> str:
"""Создает токен сессии"""
session_data = {}
if auth_data:
session_data["auth_data"] = json.dumps(auth_data)
if username:
session_data["username"] = username
if device_info:
session_data["device_info"] = json.dumps(device_info)
return await self.create_session_token(user_id, session_data)
async def create_session_token(self, user_id: str, token_data: TokenData) -> str:
"""Создание JWT токена сессии"""
username = token_data.get("username", "")
# Создаем JWT токен
jwt_token = JWTCodec.encode(
{
"id": user_id,
"username": username,
}
)
session_token = jwt_token
token_key = self._make_token_key("session", user_id, session_token)
user_tokens_key = self._make_user_tokens_key(user_id, "session")
ttl = DEFAULT_TTL["session"]
# Добавляем метаданные
token_data.update({"user_id": user_id, "token_type": "session", "created_at": int(time.time())})
# Используем новый метод execute_pipeline для избежания deprecated warnings
commands: list[tuple[str, tuple[Any, ...]]] = []
# Сохраняем данные сессии в hash, преобразуя значения в строки
for field, value in token_data.items():
commands.append(("hset", (token_key, field, str(value))))
commands.append(("expire", (token_key, ttl)))
# Добавляем в список сессий пользователя
commands.append(("sadd", (user_tokens_key, session_token)))
commands.append(("expire", (user_tokens_key, ttl)))
await redis_adapter.execute_pipeline(commands)
logger.info(f"Создан токен сессии для пользователя {user_id}")
return session_token
async def get_session_data(self, token: str, user_id: Optional[str] = None) -> Optional[TokenData]:
"""Получение данных сессии"""
if not user_id:
# Извлекаем user_id из JWT
payload = JWTCodec.decode(token)
if payload:
user_id = payload.user_id
else:
return None
token_key = self._make_token_key("session", user_id, token)
# Используем новый метод execute_pipeline для избежания deprecated warnings
commands: list[tuple[str, tuple[Any, ...]]] = [
("hgetall", (token_key,)),
("hset", (token_key, "last_activity", str(int(time.time())))),
]
results = await redis_adapter.execute_pipeline(commands)
token_data = results[0] if results else None
return dict(token_data) if token_data else None
async def validate_session_token(self, token: str) -> tuple[bool, Optional[TokenData]]:
"""
Проверяет валидность токена сессии
"""
try:
# Декодируем JWT токен
payload = JWTCodec.decode(token)
if not payload:
return False, None
user_id = payload.user_id
token_key = self._make_token_key("session", user_id, token)
# Проверяем существование и получаем данные
commands: list[tuple[str, tuple[Any, ...]]] = [("exists", (token_key,)), ("hgetall", (token_key,))]
results = await redis_adapter.execute_pipeline(commands)
if results and results[0]: # exists
return True, dict(results[1])
return False, None
except Exception as e:
logger.error(f"Ошибка валидации токена сессии: {e}")
return False, None
async def revoke_session_token(self, token: str) -> bool:
"""Отзыв токена сессии"""
payload = JWTCodec.decode(token)
if not payload:
return False
user_id = payload.user_id
# Используем новый метод execute_pipeline для избежания deprecated warnings
token_key = self._make_token_key("session", user_id, token)
user_tokens_key = self._make_user_tokens_key(user_id, "session")
commands: list[tuple[str, tuple[Any, ...]]] = [("delete", (token_key,)), ("srem", (user_tokens_key, token))]
results = await redis_adapter.execute_pipeline(commands)
return any(result > 0 for result in results if result is not None)
async def revoke_user_sessions(self, user_id: str) -> int:
"""Отзыв всех сессий пользователя"""
user_tokens_key = self._make_user_tokens_key(user_id, "session")
tokens = await redis_adapter.smembers(user_tokens_key)
if not tokens:
return 0
# Используем пакетное удаление
keys_to_delete = []
for token in tokens:
token_str = token if isinstance(token, str) else str(token)
keys_to_delete.append(self._make_token_key("session", user_id, token_str))
# Добавляем ключ списка токенов
keys_to_delete.append(user_tokens_key)
# Удаляем все ключи пакетно
if keys_to_delete:
await redis_adapter.delete(*keys_to_delete)
return len(tokens)
async def get_user_sessions(self, user_id: Union[int, str]) -> List[TokenData]:
"""Получение сессий пользователя"""
try:
user_tokens_key = self._make_user_tokens_key(str(user_id), "session")
tokens = await redis_adapter.smembers(user_tokens_key)
if not tokens:
return []
# Получаем данные всех сессий пакетно
sessions = []
async with redis_adapter.pipeline() as pipe:
for token in tokens:
token_str = token if isinstance(token, str) else str(token)
await pipe.hgetall(self._make_token_key("session", str(user_id), token_str))
results = await pipe.execute()
for token, session_data in zip(tokens, results):
if session_data:
token_str = token if isinstance(token, str) else str(token)
session_dict = dict(session_data)
session_dict["token"] = token_str
sessions.append(session_dict)
return sessions
except Exception as e:
logger.error(f"Ошибка получения сессий пользователя: {e}")
return []
async def refresh_session(self, user_id: int, old_token: str, device_info: Optional[dict] = None) -> Optional[str]:
"""
Обновляет сессию пользователя, заменяя старый токен новым
"""
try:
user_id_str = str(user_id)
# Получаем данные старой сессии
old_session_data = await self.get_session_data(old_token)
if not old_session_data:
logger.warning(f"Сессия не найдена: {user_id}")
return None
# Используем старые данные устройства, если новые не предоставлены
if not device_info and "device_info" in old_session_data:
try:
device_info = json.loads(old_session_data.get("device_info", "{}"))
except (json.JSONDecodeError, TypeError):
device_info = None
# Создаем новую сессию
new_token = await self.create_session(
user_id_str, device_info=device_info, username=old_session_data.get("username", "")
)
# Отзываем старую сессию
await self.revoke_session_token(old_token)
return new_token
except Exception as e:
logger.error(f"Ошибка обновления сессии: {e}")
return None
async def verify_session(self, token: str) -> Optional[Any]:
"""
Проверяет сессию по токену для совместимости с TokenStorage
"""
logger.debug(f"Проверка сессии для токена: {token[:20]}...")
# Декодируем токен для получения payload
try:
payload = JWTCodec.decode(token)
if not payload:
logger.error("Не удалось декодировать токен")
return None
logger.debug(f"Успешно декодирован токен, user_id={payload.user_id}")
except Exception as e:
logger.error(f"Ошибка при декодировании токена: {e}")
return None
# Проверяем валидность токена
valid, _ = await self.validate_session_token(token)
if valid:
logger.debug(f"Сессия найдена для пользователя {payload.user_id}")
return payload
logger.warning(f"Сессия не найдена: {payload.user_id}")
return None

114
auth/tokens/storage.py Normal file
View File

@@ -0,0 +1,114 @@
"""
Простой интерфейс для системы токенов
"""
from typing import Any, Optional
from .batch import BatchTokenOperations
from .monitoring import TokenMonitoring
from .oauth import OAuthTokenManager
from .sessions import SessionTokenManager
from .verification import VerificationTokenManager
class _TokenStorageImpl:
"""
Внутренний класс для фасада токенов.
Использует композицию вместо наследования.
"""
def __init__(self) -> None:
self._sessions = SessionTokenManager()
self._verification = VerificationTokenManager()
self._oauth = OAuthTokenManager()
self._batch = BatchTokenOperations()
self._monitoring = TokenMonitoring()
# === МЕТОДЫ ДЛЯ СЕССИЙ ===
async def create_session(
self,
user_id: str,
auth_data: Optional[dict] = None,
username: Optional[str] = None,
device_info: Optional[dict] = None,
) -> str:
"""Создание сессии пользователя"""
return await self._sessions.create_session(user_id, auth_data, username, device_info)
async def verify_session(self, token: str) -> Optional[Any]:
"""Проверка сессии по токену"""
return await self._sessions.verify_session(token)
async def refresh_session(self, user_id: int, old_token: str, device_info: Optional[dict] = None) -> Optional[str]:
"""Обновление сессии пользователя"""
return await self._sessions.refresh_session(user_id, old_token, device_info)
async def revoke_session(self, session_token: str) -> bool:
"""Отзыв сессии"""
return await self._sessions.revoke_session_token(session_token)
async def revoke_user_sessions(self, user_id: str) -> int:
"""Отзыв всех сессий пользователя"""
return await self._sessions.revoke_user_sessions(user_id)
# === ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ===
async def cleanup_expired_tokens(self) -> int:
"""Очистка истекших токенов"""
return await self._batch.cleanup_expired_tokens()
async def get_token_statistics(self) -> dict:
"""Получение статистики токенов"""
return await self._monitoring.get_token_statistics()
# Глобальный экземпляр фасада
_token_storage = _TokenStorageImpl()
class TokenStorage:
"""
Статический фасад для системы токенов.
Все методы делегируются глобальному экземпляру.
"""
@staticmethod
async def create_session(
user_id: str,
auth_data: Optional[dict] = None,
username: Optional[str] = None,
device_info: Optional[dict] = None,
) -> str:
"""Создание сессии пользователя"""
return await _token_storage.create_session(user_id, auth_data, username, device_info)
@staticmethod
async def verify_session(token: str) -> Optional[Any]:
"""Проверка сессии по токену"""
return await _token_storage.verify_session(token)
@staticmethod
async def refresh_session(user_id: int, old_token: str, device_info: Optional[dict] = None) -> Optional[str]:
"""Обновление сессии пользователя"""
return await _token_storage.refresh_session(user_id, old_token, device_info)
@staticmethod
async def revoke_session(session_token: str) -> bool:
"""Отзыв сессии"""
return await _token_storage.revoke_session(session_token)
@staticmethod
async def revoke_user_sessions(user_id: str) -> int:
"""Отзыв всех сессий пользователя"""
return await _token_storage.revoke_user_sessions(user_id)
@staticmethod
async def cleanup_expired_tokens() -> int:
"""Очистка истекших токенов"""
return await _token_storage.cleanup_expired_tokens()
@staticmethod
async def get_token_statistics() -> dict:
"""Получение статистики токенов"""
return await _token_storage.get_token_statistics()

23
auth/tokens/types.py Normal file
View File

@@ -0,0 +1,23 @@
"""
Типы и константы для системы токенов
"""
from typing import Any, Dict, Literal
# Типы токенов
TokenType = Literal["session", "verification", "oauth_access", "oauth_refresh"]
# TTL по умолчанию для разных типов токенов
DEFAULT_TTL = {
"session": 30 * 24 * 60 * 60, # 30 дней
"verification": 3600, # 1 час
"oauth_access": 3600, # 1 час
"oauth_refresh": 86400 * 30, # 30 дней
}
# Размеры батчей для оптимизации Redis операций
BATCH_SIZE = 100 # Размер батча для пакетной обработки токенов
SCAN_BATCH_SIZE = 1000 # Размер батча для SCAN операций
# Общие типы данных
TokenData = Dict[str, Any]

161
auth/tokens/verification.py Normal file
View File

@@ -0,0 +1,161 @@
"""
Управление токенами подтверждения
"""
import json
import secrets
import time
from typing import Optional
from services.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager
from .types import TokenData
class VerificationTokenManager(BaseTokenManager):
"""
Менеджер токенов подтверждения
"""
async def create_verification_token(
self,
user_id: str,
verification_type: str,
data: TokenData,
ttl: Optional[int] = None,
) -> str:
"""Создает токен подтверждения"""
token_data = {"verification_type": verification_type, **data}
# TTL по типу подтверждения
if ttl is None:
verification_ttls = {
"email_change": 3600, # 1 час
"phone_change": 600, # 10 минут
"password_reset": 1800, # 30 минут
}
ttl = verification_ttls.get(verification_type, 3600)
return await self._create_verification_token(user_id, token_data, ttl)
async def _create_verification_token(
self, user_id: str, token_data: TokenData, ttl: int, token: Optional[str] = None
) -> str:
"""Оптимизированное создание токена подтверждения"""
verification_token = token or secrets.token_urlsafe(32)
token_key = self._make_token_key("verification", user_id, verification_token)
# Добавляем метаданные
token_data.update({"user_id": user_id, "token_type": "verification", "created_at": int(time.time())})
# Отменяем предыдущие токены того же типа
verification_type = token_data.get("verification_type", "unknown")
await self._cancel_verification_tokens_optimized(user_id, verification_type)
# Используем SETEX для атомарной операции установки с TTL
serialized_data = json.dumps(token_data, ensure_ascii=False)
await redis_adapter.execute("setex", token_key, ttl, serialized_data)
logger.info(f"Создан токен подтверждения {verification_type} для пользователя {user_id}")
return verification_token
async def get_verification_token_data(self, token: str) -> Optional[TokenData]:
"""Получает данные токена подтверждения"""
token_key = self._make_token_key("verification", "", token)
return await redis_adapter.get_and_deserialize(token_key)
async def validate_verification_token(self, token_str: str) -> tuple[bool, Optional[TokenData]]:
"""Проверяет валидность токена подтверждения"""
token_key = self._make_token_key("verification", "", token_str)
token_data = await redis_adapter.get_and_deserialize(token_key)
if token_data:
return True, token_data
return False, None
async def confirm_verification_token(self, token_str: str) -> Optional[TokenData]:
"""Подтверждает и использует токен подтверждения (одноразовый)"""
token_data = await self.get_verification_token_data(token_str)
if token_data:
# Удаляем токен после использования
await self.revoke_verification_token(token_str)
return token_data
return None
async def revoke_verification_token(self, token: str) -> bool:
"""Отзывает токен подтверждения"""
token_key = self._make_token_key("verification", "", token)
result = await redis_adapter.delete(token_key)
return result > 0
async def revoke_user_verification_tokens(self, user_id: str) -> int:
"""Оптимизированный отзыв токенов подтверждения пользователя используя SCAN вместо KEYS"""
count = 0
cursor = 0
delete_keys = []
# Используем SCAN для безопасного поиска токенов
while True:
cursor, keys = await redis_adapter.execute("scan", cursor, "verification_token:*", 100)
# Проверяем каждый ключ в пакете
if keys:
async with redis_adapter.pipeline() as pipe:
for key in keys:
await pipe.get(key)
results = await pipe.execute()
for key, data in zip(keys, results):
if data:
try:
token_data = json.loads(data)
if token_data.get("user_id") == user_id:
delete_keys.append(key)
count += 1
except (json.JSONDecodeError, TypeError):
continue
if cursor == 0:
break
# Удаляем найденные токены пакетно
if delete_keys:
await redis_adapter.delete(*delete_keys)
return count
async def _cancel_verification_tokens_optimized(self, user_id: str, verification_type: str) -> None:
"""Оптимизированная отмена токенов подтверждения используя SCAN"""
cursor = 0
delete_keys = []
while True:
cursor, keys = await redis_adapter.execute("scan", cursor, "verification_token:*", 100)
if keys:
# Получаем данные пакетно
async with redis_adapter.pipeline() as pipe:
for key in keys:
await pipe.get(key)
results = await pipe.execute()
# Проверяем какие токены нужно удалить
for key, data in zip(keys, results):
if data:
try:
token_data = json.loads(data)
if (
token_data.get("user_id") == user_id
and token_data.get("verification_type") == verification_type
):
delete_keys.append(key)
except (json.JSONDecodeError, TypeError):
continue
if cursor == 0:
break
# Удаляем найденные токены пакетно
if delete_keys:
await redis_adapter.delete(*delete_keys)

View File

@@ -1,671 +0,0 @@
import json
import secrets
import time
from typing import Any, Dict, Literal, Optional, Union
from auth.jwtcodec import JWTCodec
from auth.validations import AuthInput
from services.redis import redis
from utils.logger import root_logger as logger
# Типы токенов
TokenType = Literal["session", "verification", "oauth_access", "oauth_refresh"]
# TTL по умолчанию для разных типов токенов
DEFAULT_TTL = {
"session": 30 * 24 * 60 * 60, # 30 дней
"verification": 3600, # 1 час
"oauth_access": 3600, # 1 час
"oauth_refresh": 86400 * 30, # 30 дней
}
class TokenStorage:
"""
Единый менеджер всех типов токенов в системе:
- Токены сессий (session)
- Токены подтверждения (verification)
- OAuth токены (oauth_access, oauth_refresh)
"""
@staticmethod
def _make_token_key(token_type: TokenType, identifier: str, token: Optional[str] = None) -> str:
"""
Создает унифицированный ключ для токена
Args:
token_type: Тип токена
identifier: Идентификатор (user_id, user_id:provider, etc)
token: Сам токен (для session и verification)
Returns:
str: Ключ токена
"""
if token_type == "session":
return f"session:{token}"
if token_type == "verification":
return f"verification_token:{token}"
if token_type == "oauth_access":
return f"oauth_access:{identifier}"
if token_type == "oauth_refresh":
return f"oauth_refresh:{identifier}"
raise ValueError(f"Неизвестный тип токена: {token_type}")
@staticmethod
def _make_user_tokens_key(user_id: str, token_type: TokenType) -> str:
"""Создает ключ для списка токенов пользователя"""
return f"user_tokens:{user_id}:{token_type}"
@classmethod
async def create_token(
cls,
token_type: TokenType,
user_id: str,
data: Dict[str, Any],
ttl: Optional[int] = None,
token: Optional[str] = None,
provider: Optional[str] = None,
) -> str:
"""
Универсальный метод создания токена любого типа
Args:
token_type: Тип токена
user_id: ID пользователя
data: Данные токена
ttl: Время жизни (по умолчанию из DEFAULT_TTL)
token: Существующий токен (для verification)
provider: OAuth провайдер (для oauth токенов)
Returns:
str: Токен или ключ токена
"""
if ttl is None:
ttl = DEFAULT_TTL[token_type]
# Подготавливаем данные токена
token_data = {"user_id": user_id, "token_type": token_type, "created_at": int(time.time()), **data}
if token_type == "session":
# Генерируем новый токен сессии
session_token = cls.generate_token()
token_key = cls._make_token_key(token_type, user_id, session_token)
# Сохраняем данные сессии
for field, value in token_data.items():
await redis.hset(token_key, field, str(value))
await redis.expire(token_key, ttl)
# Добавляем в список сессий пользователя
user_tokens_key = cls._make_user_tokens_key(user_id, token_type)
await redis.sadd(user_tokens_key, session_token)
await redis.expire(user_tokens_key, ttl)
logger.info(f"Создан токен сессии для пользователя {user_id}")
return session_token
if token_type == "verification":
# Используем переданный токен или генерируем новый
verification_token = token or secrets.token_urlsafe(32)
token_key = cls._make_token_key(token_type, user_id, verification_token)
# Отменяем предыдущие токены того же типа
verification_type = data.get("verification_type", "unknown")
await cls._cancel_verification_tokens(user_id, verification_type)
# Сохраняем токен подтверждения
await redis.serialize_and_set(token_key, token_data, ex=ttl)
logger.info(f"Создан токен подтверждения {verification_type} для пользователя {user_id}")
return verification_token
if token_type in ["oauth_access", "oauth_refresh"]:
if not provider:
raise ValueError("OAuth токены требуют указания провайдера")
identifier = f"{user_id}:{provider}"
token_key = cls._make_token_key(token_type, identifier)
# Добавляем провайдера в данные
token_data["provider"] = provider
# Сохраняем OAuth токен
await redis.serialize_and_set(token_key, token_data, ex=ttl)
logger.info(f"Создан {token_type} токен для пользователя {user_id}, провайдер {provider}")
return token_key
raise ValueError(f"Неподдерживаемый тип токена: {token_type}")
@classmethod
async def get_token_data(
cls,
token_type: TokenType,
token_or_identifier: str,
user_id: Optional[str] = None,
provider: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""
Универсальный метод получения данных токена
Args:
token_type: Тип токена
token_or_identifier: Токен или идентификатор
user_id: ID пользователя (для OAuth)
provider: OAuth провайдер
Returns:
Dict с данными токена или None
"""
try:
if token_type == "session":
token_key = cls._make_token_key(token_type, "", token_or_identifier)
token_data = await redis.hgetall(token_key)
if token_data:
# Обновляем время последней активности
await redis.hset(token_key, "last_activity", str(int(time.time())))
return {k: v for k, v in token_data.items()}
return None
if token_type == "verification":
token_key = cls._make_token_key(token_type, "", token_or_identifier)
return await redis.get_and_deserialize(token_key)
if token_type in ["oauth_access", "oauth_refresh"]:
if not user_id or not provider:
raise ValueError("OAuth токены требуют user_id и provider")
identifier = f"{user_id}:{provider}"
token_key = cls._make_token_key(token_type, identifier)
token_data = await redis.get_and_deserialize(token_key)
if token_data:
# Добавляем информацию о TTL
ttl = await redis.execute("TTL", token_key)
if ttl > 0:
token_data["ttl_remaining"] = ttl
return token_data
return None
except Exception as e:
logger.error(f"Ошибка получения токена {token_type}: {e}")
return None
@classmethod
async def validate_token(
cls, token: str, token_type: Optional[TokenType] = None
) -> tuple[bool, Optional[dict[str, Any]]]:
"""
Проверяет валидность токена
Args:
token: Токен для проверки
token_type: Тип токена (если не указан - определяется автоматически)
Returns:
Tuple[bool, Dict]: (Валиден ли токен, данные токена)
"""
try:
# Для JWT токенов (сессии) - декодируем
if not token_type or token_type == "session":
payload = JWTCodec.decode(token)
if payload:
user_id = payload.user_id
username = payload.username
# Проверяем в разных форматах для совместимости
old_token_key = f"{user_id}-{username}-{token}"
new_token_key = cls._make_token_key("session", user_id, token)
old_exists = await redis.exists(old_token_key)
new_exists = await redis.exists(new_token_key)
if old_exists or new_exists:
# Получаем данные из актуального хранилища
if new_exists:
token_data = await redis.hgetall(new_token_key)
else:
token_data = await redis.hgetall(old_token_key)
# Миграция в новый формат
if not new_exists:
for field, value in token_data.items():
await redis.hset(new_token_key, field, value)
await redis.expire(new_token_key, DEFAULT_TTL["session"])
return True, {k: v for k, v in token_data.items()}
# Для токенов подтверждения - прямая проверка
if not token_type or token_type == "verification":
token_key = cls._make_token_key("verification", "", token)
token_data = await redis.get_and_deserialize(token_key)
if token_data:
return True, token_data
return False, None
except Exception as e:
logger.error(f"Ошибка валидации токена: {e}")
return False, None
@classmethod
async def revoke_token(
cls,
token_type: TokenType,
token_or_identifier: str,
user_id: Optional[str] = None,
provider: Optional[str] = None,
) -> bool:
"""
Универсальный метод отзыва токена
Args:
token_type: Тип токена
token_or_identifier: Токен или идентификатор
user_id: ID пользователя
provider: OAuth провайдер
Returns:
bool: Успех операции
"""
try:
if token_type == "session":
# Декодируем JWT для получения данных
payload = JWTCodec.decode(token_or_identifier)
if payload:
user_id = payload.user_id
username = payload.username
# Удаляем в обоих форматах
old_token_key = f"{user_id}-{username}-{token_or_identifier}"
new_token_key = cls._make_token_key(token_type, user_id, token_or_identifier)
user_tokens_key = cls._make_user_tokens_key(user_id, token_type)
result1 = await redis.delete(old_token_key)
result2 = await redis.delete(new_token_key)
result3 = await redis.srem(user_tokens_key, token_or_identifier)
return result1 > 0 or result2 > 0 or result3 > 0
elif token_type == "verification":
token_key = cls._make_token_key(token_type, "", token_or_identifier)
result = await redis.delete(token_key)
return result > 0
elif token_type in ["oauth_access", "oauth_refresh"]:
if not user_id or not provider:
raise ValueError("OAuth токены требуют user_id и provider")
identifier = f"{user_id}:{provider}"
token_key = cls._make_token_key(token_type, identifier)
result = await redis.delete(token_key)
return result > 0
return False
except Exception as e:
logger.error(f"Ошибка отзыва токена {token_type}: {e}")
return False
@classmethod
async def revoke_user_tokens(cls, user_id: str, token_type: Optional[TokenType] = None) -> int:
"""
Отзывает все токены пользователя определенного типа или все
Args:
user_id: ID пользователя
token_type: Тип токенов для отзыва (None = все типы)
Returns:
int: Количество отозванных токенов
"""
count = 0
try:
types_to_revoke = (
[token_type] if token_type else ["session", "verification", "oauth_access", "oauth_refresh"]
)
for t_type in types_to_revoke:
if t_type == "session":
user_tokens_key = cls._make_user_tokens_key(user_id, t_type)
tokens = await redis.smembers(user_tokens_key)
for token in tokens:
token_str = token.decode("utf-8") if isinstance(token, bytes) else str(token)
success = await cls.revoke_token(t_type, token_str, user_id)
if success:
count += 1
await redis.delete(user_tokens_key)
elif t_type == "verification":
# Ищем все токены подтверждения пользователя
pattern = "verification_token:*"
keys = await redis.keys(pattern)
for key in keys:
token_data = await redis.get_and_deserialize(key)
if token_data and token_data.get("user_id") == user_id:
await redis.delete(key)
count += 1
elif t_type in ["oauth_access", "oauth_refresh"]:
# Ищем OAuth токены по паттерну
pattern = f"{t_type}:{user_id}:*"
keys = await redis.keys(pattern)
for key in keys:
await redis.delete(key)
count += 1
logger.info(f"Отозвано {count} токенов для пользователя {user_id}")
return count
except Exception as e:
logger.error(f"Ошибка отзыва токенов пользователя: {e}")
return count
@staticmethod
async def _cancel_verification_tokens(user_id: str, verification_type: str) -> None:
"""Отменяет предыдущие токены подтверждения определенного типа"""
try:
pattern = "verification_token:*"
keys = await redis.keys(pattern)
for key in keys:
token_data = await redis.get_and_deserialize(key)
if (
token_data
and token_data.get("user_id") == user_id
and token_data.get("verification_type") == verification_type
):
await redis.delete(key)
except Exception as e:
logger.error(f"Ошибка отмены токенов подтверждения: {e}")
# === УДОБНЫЕ МЕТОДЫ ДЛЯ СЕССИЙ ===
@classmethod
async def create_session(
cls,
user_id: str,
auth_data: Optional[dict] = None,
username: Optional[str] = None,
device_info: Optional[dict] = None,
) -> str:
"""Создает токен сессии"""
session_data = {}
if auth_data:
session_data["auth_data"] = json.dumps(auth_data)
if username:
session_data["username"] = username
if device_info:
session_data["device_info"] = json.dumps(device_info)
return await cls.create_token("session", user_id, session_data)
@classmethod
async def get_session_data(cls, token: str) -> Optional[Dict[str, Any]]:
"""Получает данные сессии"""
valid, data = await cls.validate_token(token, "session")
return data if valid else None
# === УДОБНЫЕ МЕТОДЫ ДЛЯ ТОКЕНОВ ПОДТВЕРЖДЕНИЯ ===
@classmethod
async def create_verification_token(
cls,
user_id: str,
verification_type: str,
data: Dict[str, Any],
ttl: Optional[int] = None,
) -> str:
"""Создает токен подтверждения"""
token_data = {"verification_type": verification_type, **data}
# TTL по типу подтверждения
if ttl is None:
verification_ttls = {
"email_change": 3600, # 1 час
"phone_change": 600, # 10 минут
"password_reset": 1800, # 30 минут
}
ttl = verification_ttls.get(verification_type, 3600)
return await cls.create_token("verification", user_id, token_data, ttl)
@classmethod
async def confirm_verification_token(cls, token_str: str) -> Optional[Dict[str, Any]]:
"""Подтверждает и использует токен подтверждения (одноразовый)"""
token_data = await cls.get_token_data("verification", token_str)
if token_data:
# Удаляем токен после использования
await cls.revoke_token("verification", token_str)
return token_data
return None
# === УДОБНЫЕ МЕТОДЫ ДЛЯ OAUTH ТОКЕНОВ ===
@classmethod
async def store_oauth_tokens(
cls,
user_id: str,
provider: str,
access_token: str,
refresh_token: Optional[str] = None,
expires_in: Optional[int] = None,
additional_data: Optional[Dict[str, Any]] = None,
) -> bool:
"""Сохраняет OAuth токены"""
try:
# Сохраняем access token
access_data = {
"token": access_token,
"provider": provider,
"expires_in": expires_in,
**(additional_data or {}),
}
access_ttl = expires_in if expires_in else DEFAULT_TTL["oauth_access"]
await cls.create_token("oauth_access", user_id, access_data, access_ttl, provider=provider)
# Сохраняем refresh token если есть
if refresh_token:
refresh_data = {
"token": refresh_token,
"provider": provider,
}
await cls.create_token("oauth_refresh", user_id, refresh_data, provider=provider)
return True
except Exception as e:
logger.error(f"Ошибка сохранения OAuth токенов: {e}")
return False
@classmethod
async def get_oauth_token(cls, user_id: int, provider: str, token_type: str = "access") -> Optional[Dict[str, Any]]:
"""Получает OAuth токен"""
oauth_type = f"oauth_{token_type}"
if oauth_type in ["oauth_access", "oauth_refresh"]:
return await cls.get_token_data(oauth_type, "", user_id, provider) # type: ignore[arg-type]
return None
@classmethod
async def revoke_oauth_tokens(cls, user_id: str, provider: str) -> bool:
"""Удаляет все OAuth токены для провайдера"""
try:
result1 = await cls.revoke_token("oauth_access", "", user_id, provider)
result2 = await cls.revoke_token("oauth_refresh", "", user_id, provider)
return result1 or result2
except Exception as e:
logger.error(f"Ошибка удаления OAuth токенов: {e}")
return False
# === ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ===
@staticmethod
def generate_token() -> str:
"""Генерирует криптографически стойкий токен"""
return secrets.token_urlsafe(32)
@staticmethod
async def cleanup_expired_tokens() -> int:
"""Очищает истекшие токены (Redis делает это автоматически)"""
# Redis автоматически удаляет истекшие ключи
# Здесь можем очистить связанные структуры данных
try:
user_session_keys = await redis.keys("user_tokens:*:session")
cleaned_count = 0
for user_tokens_key in user_session_keys:
tokens = await redis.smembers(user_tokens_key)
active_tokens = []
for token in tokens:
token_str = token.decode("utf-8") if isinstance(token, bytes) else str(token)
session_key = f"session:{token_str}"
exists = await redis.exists(session_key)
if exists:
active_tokens.append(token_str)
else:
cleaned_count += 1
# Обновляем список активных токенов
if active_tokens:
await redis.delete(user_tokens_key)
for token in active_tokens:
await redis.sadd(user_tokens_key, token)
else:
await redis.delete(user_tokens_key)
if cleaned_count > 0:
logger.info(f"Очищено {cleaned_count} ссылок на истекшие токены")
return cleaned_count
except Exception as e:
logger.error(f"Ошибка очистки токенов: {e}")
return 0
# === ОБРАТНАЯ СОВМЕСТИМОСТЬ ===
@staticmethod
async def get(token_key: str) -> Optional[str]:
"""Обратная совместимость - получение токена по ключу"""
result = await redis.get(token_key)
if isinstance(result, bytes):
return result.decode("utf-8")
return result
@staticmethod
async def save_token(token_key: str, token_data: Dict[str, Any], life_span: int = 3600) -> bool:
"""Обратная совместимость - сохранение токена"""
try:
return await redis.serialize_and_set(token_key, token_data, ex=life_span)
except Exception as e:
logger.error(f"Ошибка сохранения токена {token_key}: {e}")
return False
@staticmethod
async def get_token(token_key: str) -> Optional[Dict[str, Any]]:
"""Обратная совместимость - получение данных токена"""
try:
return await redis.get_and_deserialize(token_key)
except Exception as e:
logger.error(f"Ошибка получения токена {token_key}: {e}")
return None
@staticmethod
async def delete_token(token_key: str) -> bool:
"""Обратная совместимость - удаление токена"""
try:
result = await redis.delete(token_key)
return result > 0
except Exception as e:
logger.error(f"Ошибка удаления токена {token_key}: {e}")
return False
# Остальные методы для обратной совместимости...
async def exists(self, token_key: str) -> bool:
"""Совместимость - проверка существования"""
return bool(await redis.exists(token_key))
async def invalidate_token(self, token: str) -> bool:
"""Совместимость - инвалидация токена"""
return await self.revoke_token("session", token)
async def invalidate_all_tokens(self, user_id: str) -> int:
"""Совместимость - инвалидация всех токенов"""
return await self.revoke_user_tokens(user_id)
def generate_session_token(self) -> str:
"""Совместимость - генерация токена сессии"""
return self.generate_token()
async def get_session(self, session_token: str) -> Optional[Dict[str, Any]]:
"""Совместимость - получение сессии"""
return await self.get_session_data(session_token)
async def revoke_session(self, session_token: str) -> bool:
"""Совместимость - отзыв сессии"""
return await self.revoke_token("session", session_token)
async def revoke_all_user_sessions(self, user_id: Union[int, str]) -> bool:
"""Совместимость - отзыв всех сессий"""
count = await self.revoke_user_tokens(str(user_id), "session")
return count > 0
async def get_user_sessions(self, user_id: Union[int, str]) -> list[Dict[str, Any]]:
"""Совместимость - получение сессий пользователя"""
try:
user_tokens_key = f"user_tokens:{user_id}:session"
tokens = await redis.smembers(user_tokens_key)
sessions = []
for token in tokens:
token_str = token.decode("utf-8") if isinstance(token, bytes) else str(token)
session_data = await self.get_session_data(token_str)
if session_data:
session_data["token"] = token_str
sessions.append(session_data)
return sessions
except Exception as e:
logger.error(f"Ошибка получения сессий пользователя: {e}")
return []
async def revoke_all_tokens_for_user(self, user: AuthInput) -> bool:
"""Совместимость - отзыв всех токенов пользователя"""
user_id = getattr(user, "id", 0) or 0
count = await self.revoke_user_tokens(str(user_id))
return count > 0
async def get_one_time_token_value(self, token_key: str) -> Optional[str]:
"""Совместимость - одноразовые токены"""
token_data = await self.get_token(token_key)
if token_data and token_data.get("valid"):
return "TRUE"
return None
async def save_one_time_token(self, user: AuthInput, one_time_token: str, life_span: int = 300) -> bool:
"""Совместимость - сохранение одноразового токена"""
user_id = getattr(user, "id", 0) or 0
token_key = f"{user_id}-{user.username}-{one_time_token}"
token_data = {"valid": True, "user_id": user_id, "username": user.username}
return await self.save_token(token_key, token_data, life_span)
async def extend_token_lifetime(self, token_key: str, additional_seconds: int = 3600) -> bool:
"""Совместимость - продление времени жизни"""
token_data = await self.get_token(token_key)
if not token_data:
return False
return await self.save_token(token_key, token_data, additional_seconds)
async def cleanup_expired_sessions(self) -> None:
"""Совместимость - очистка сессий"""
await self.cleanup_expired_tokens()