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

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

View File

@@ -1,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