token-storage-refactored
This commit is contained in:
@@ -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}")
|
||||
|
@@ -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"}
|
||||
|
||||
|
@@ -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"
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
588
auth/oauth.py
588
auth/oauth.py
@@ -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
|
||||
|
419
auth/sessions.py
419
auth/sessions.py
@@ -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
0
auth/tokens/__init__.py
Normal file
54
auth/tokens/base.py
Normal file
54
auth/tokens/base.py
Normal 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
197
auth/tokens/batch.py
Normal 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
189
auth/tokens/monitoring.py
Normal 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
157
auth/tokens/oauth.py
Normal 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
253
auth/tokens/sessions.py
Normal 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
114
auth/tokens/storage.py
Normal 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
23
auth/tokens/types.py
Normal 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
161
auth/tokens/verification.py
Normal 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)
|
@@ -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()
|
Reference in New Issue
Block a user