import time from secrets import token_urlsafe from typing import Any, Callable 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.tokens.storage import TokenStorage from orm.author import Author from orm.community import Community, CommunityAuthor, CommunityFollower from settings import ( FRONTEND_URL, OAUTH_CLIENTS, SESSION_COOKIE_DOMAIN, SESSION_COOKIE_HTTPONLY, SESSION_COOKIE_MAX_AGE, SESSION_COOKIE_NAME, SESSION_COOKIE_SAMESITE, SESSION_COOKIE_SECURE, ) from storage.db import local_session from storage.redis import redis from utils.generate_slug import generate_unique_slug 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 минут # Конфигурация провайдеров для регистрации PROVIDER_CONFIGS = { "google": { "server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration", "client_kwargs": { "scope": "openid email profile", }, }, "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": "read:user user:email", }, }, "facebook": { "access_token_url": "https://graph.facebook.com/v18.0/oauth/access_token", "authorize_url": "https://www.facebook.com/v18.0/dialog/oauth", "api_base_url": "https://graph.facebook.com/", "scope": "email public_profile", # Явно указываем необходимые scope }, "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", # Базовые scope для X API v2 }, }, "telegram": { "access_token_url": "https://oauth.telegram.org/auth/request", "authorize_url": "https://oauth.telegram.org/auth", "api_base_url": "https://api.telegram.org/", "client_kwargs": { "scope": "read", # Базовый scope для Telegram }, }, "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", # Минимальный scope для получения email }, }, "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 login:avatar", # Scope для получения профиля }, }, } # Константы для генерации временного 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 # 🔍 Отладочная информация logger.info( f"Registering OAuth provider {provider} with client_id: {client_config['id'][:8] if client_config['id'] else 'EMPTY'}..." ) # Базовые параметры для всех провайдеров register_params: dict[str, Any] = { "name": provider, "client_id": client_config["id"], "client_secret": client_config["key"], } # Добавляем конфигурацию провайдера с явной типизацией if isinstance(provider_config, dict): register_params.update(provider_config) # 🔒 Для Facebook добавляем дополнительные параметры безопасности if provider == "facebook": register_params.update( { "client_kwargs": { "scope": "email public_profile", "token_endpoint_auth_method": "client_secret_post", } } ) oauth.register(**register_params) logger.info(f"OAuth provider {provider} registered successfully") # 🔍 Проверяем что клиент действительно создался test_client = oauth.create_client(provider) if test_client: logger.info(f"OAuth client {provider} created successfully") else: logger.error(f"OAuth client {provider} failed to create after registration") except Exception as e: logger.error(f"Failed to register OAuth provider {provider}: {e}") # 🔍 Диагностика OAuth конфигурации logger.info(f"Available OAuth providers in config: {list(PROVIDER_CONFIGS.keys())}") logger.info(f"Available OAuth clients: {list(OAUTH_CLIENTS.keys())}") for provider in PROVIDER_CONFIGS: if provider.upper() in OAUTH_CLIENTS: client_config = OAUTH_CLIENTS[provider.upper()] # 🔍 Проверяем что id и key не пустые client_id = client_config.get("id", "").strip() client_key = client_config.get("key", "").strip() logger.info( f"OAuth provider {provider}: id={'SET' if client_id else 'EMPTY'}, key={'SET' if client_key else 'EMPTY'}" ) if client_id and client_key: _register_oauth_provider(provider, client_config) else: logger.warning(f"OAuth provider {provider} skipped: id={bool(client_id)}, key={bool(client_key)}") else: logger.warning(f"OAuth provider {provider} not found in OAUTH_CLIENTS") # Провайдеры со специальной обработкой данных 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""" try: # Получаем основной профиль profile = await client.get("user", token=token) profile_data = profile.json() # Проверяем наличие ошибок в ответе GitHub if "message" in profile_data: logger.error(f"GitHub API error: {profile_data['message']}") return {} # Получаем email адреса (требует scope user:email) emails = await client.get("user/emails", token=token) emails_data = emails.json() # Ищем основной email primary_email = None if isinstance(emails_data, list): primary_email = next((email["email"] for email in emails_data if email.get("primary")), None) return { "id": str(profile_data.get("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"), } except Exception as e: logger.error(f"Error fetching GitHub profile: {e}") return {} async def _fetch_facebook_profile(client: Any, token: Any) -> dict: """Получает профиль из Facebook API""" try: # Используем актуальную версию API v18.0+ и расширенные поля profile = await client.get("me?fields=id,name,email,picture.width(600).height(600)", token=token) profile_data = profile.json() # Проверяем наличие ошибок в ответе Facebook if "error" in profile_data: logger.error(f"Facebook API error: {profile_data['error']}") return {} return { "id": str(profile_data.get("id", "")), "email": profile_data.get("email"), # Может быть None если не предоставлен "name": profile_data.get("name", ""), "picture": profile_data.get("picture", {}).get("data", {}).get("url"), } except Exception as e: logger.error(f"Error fetching Facebook profile: {e}") return {} async def _fetch_x_profile(client: Any, token: Any) -> dict: """Получает профиль из X (Twitter) API""" try: # Используем правильный endpoint для X API v2 profile = await client.get("users/me?user.fields=id,name,username,profile_image_url", token=token) profile_data = profile.json() # Проверяем наличие ошибок в ответе X if "errors" in profile_data: logger.error(f"X API error: {profile_data['errors']}") return {} return PROVIDER_HANDLERS["x"](token, profile_data) except Exception as e: logger.error(f"Error fetching X profile: {e}") return {} async def _fetch_vk_profile(client: Any, token: Any) -> dict: """Получает профиль из VK API""" try: # Используем актуальную версию API v5.199+ profile = await client.get("users.get?fields=photo_400_orig,contacts&v=5.199", token=token) profile_data = profile.json() # Проверяем наличие ошибок в ответе VK if "error" in profile_data: logger.error(f"VK API error: {profile_data['error']}") return {} 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 {} except Exception as e: logger.error(f"Error fetching VK profile: {e}") 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""" # Простые провайдеры с обработкой через 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 {} async def oauth_login(_: None, _info: GraphQLResolveInfo, provider: str, callback_data: dict[str, Any]) -> JSONResponse: """ Обработка OAuth авторизации Args: provider: Провайдер OAuth (google, github, etc.) callback_data: Данные из callback-а Returns: dict: Результат авторизации с токеном или ошибкой """ if provider not in PROVIDER_CONFIGS: return JSONResponse({"error": "Invalid provider"}, status_code=400) client = oauth.create_client(provider) if not client: logger.error(f"OAuth client for {provider} not found. Available clients: {list(oauth._clients.keys())}") return JSONResponse({"error": "Provider not configured"}, status_code=400) # Получаем параметры из query string state = callback_data.get("state") redirect_uri = callback_data.get("redirect_uri", FRONTEND_URL) if not state: return JSONResponse({"error": "State parameter is required"}, status_code=400) # Генерируем PKCE challenge code_verifier = token_urlsafe(32) code_challenge = create_s256_code_challenge(code_verifier) # Сохраняем состояние OAuth в Redis oauth_data = { "code_verifier": code_verifier, "provider": provider, "redirect_uri": redirect_uri, "created_at": int(time.time()), } await store_oauth_state(state, oauth_data) # Callback должен идти на backend с принудительным HTTPS для продакшна # Извлекаем только схему и хост из base_url (убираем путь!) from urllib.parse import urlparse parsed_url = urlparse(callback_data["base_url"]) scheme = "https" if parsed_url.netloc != "localhost:8000" else parsed_url.scheme backend_base_url = f"{scheme}://{parsed_url.netloc}" oauth_callback_uri = f"{backend_base_url}/oauth/{provider}/callback" logger.info(f"🔗 GraphQL callback URI: '{oauth_callback_uri}'") try: return await client.authorize_redirect( callback_data["request"], oauth_callback_uri, code_challenge=code_challenge, code_challenge_method="S256", state=state, ) except Exception as e: logger.error(f"OAuth redirect error for {provider}: {e!s}") return JSONResponse({"error": str(e)}, status_code=500) async def oauth_callback(request: Any) -> JSONResponse | RedirectResponse: """ Обработчик OAuth callback. Создает или обновляет пользователя и устанавливает сессионный токен. """ try: provider = request.path_params.get("provider") if not provider: return JSONResponse({"error": "Provider not specified"}, status_code=400) # Получаем OAuth клиента client = oauth.create_client(provider) if not client: return JSONResponse({"error": "Invalid provider"}, status_code=400) # Получаем токен token = await client.authorize_access_token(request) if not token: return JSONResponse({"error": "Failed to get access token"}, status_code=400) # Получаем профиль пользователя profile = await get_user_profile(provider, client, token) if not profile: return JSONResponse({"error": "Failed to get user profile"}, status_code=400) # Создаем или обновляем пользователя author = await _create_or_update_user(provider, profile) if not author: return JSONResponse({"error": "Failed to create/update user"}, status_code=500) # Создаем сессию session_token = await TokenStorage.create_session( str(author.id), auth_data={ "provider": provider, "profile": profile, }, username=author.name if isinstance(author.name, str) else str(author.name) if author.name is not None else None, device_info={ "user_agent": request.headers.get("user-agent"), "ip": request.client.host if hasattr(request, "client") and request.client else None, }, ) # Получаем state из Redis для редиректа state = request.query_params.get("state") state_data = await get_oauth_state(state) if state else None redirect_uri = state_data.get("redirect_uri") if state_data else FRONTEND_URL if not isinstance(redirect_uri, str) or not redirect_uri: redirect_uri = FRONTEND_URL # 🔧 Для testing.discours.io используем httpOnly cookies + простой редирект from urllib.parse import urlparse parsed_redirect = urlparse(redirect_uri) # Определяем финальный URL для редиректа if "testing.discours.io" in parsed_redirect.netloc: # 💋 Для testing.discours.io используем httpOnly cookie + токен в URL для фронтенда from urllib.parse import quote final_redirect_url = ( f"https://testing.discours.io/oauth?access_token={session_token}&redirect_url={quote(redirect_uri)}" ) if state: final_redirect_url += f"&state={state}" else: # Для других доменов используем старую логику с токеном в URL from urllib.parse import parse_qs, urlencode, urlunparse parsed_url = urlparse(redirect_uri) query_params = parse_qs(parsed_url.query) # Добавляем access_token и state в URL параметры query_params["access_token"] = [session_token] if state: query_params["state"] = [state] # Собираем новый URL с параметрами new_query = urlencode(query_params, doseq=True) final_redirect_url = urlunparse( ( parsed_url.scheme, parsed_url.netloc, parsed_url.path, parsed_url.params, new_query, parsed_url.fragment, ) ) # 🍪 Устанавливаем httpOnly cookie вместо токена в URL response = RedirectResponse(url=redirect_uri, status_code=307) response.set_cookie( key=SESSION_COOKIE_NAME, value=session_token, httponly=SESSION_COOKIE_HTTPONLY, secure=SESSION_COOKIE_SECURE, samesite=SESSION_COOKIE_SAMESITE if SESSION_COOKIE_SAMESITE in ["strict", "lax", "none"] else "none", max_age=SESSION_COOKIE_MAX_AGE, path="/", domain=SESSION_COOKIE_DOMAIN, # ✅ Для работы с поддоменами ) logger.info(f"✅ OAuth: httpOnly cookie установлен для user_id={author.id}") logger.info(f"🔗 Redirect на фронтенд БЕЗ токена в URL: {redirect_uri}") logger.info( f"🍪 Cookie: {SESSION_COOKIE_NAME}, secure={SESSION_COOKIE_SECURE}, samesite={SESSION_COOKIE_SAMESITE}" ) logger.info(f"OAuth успешно завершен для {provider}, user_id={author.id}") return response except Exception as e: logger.error(f"OAuth callback error for {provider}: {e!s}", exc_info=True) logger.error(f"OAuth callback request URL: {request.url}") logger.error(f"OAuth callback query params: {dict(request.query_params)}") # В случае ошибки редиректим на фронтенд с ошибкой fallback_redirect = request.query_params.get("redirect_uri", FRONTEND_URL) return RedirectResponse(url=f"{fallback_redirect}?error=auth_failed&provider={provider}") async def store_oauth_state(state: str, data: dict) -> None: """Сохраняет OAuth состояние в Redis с TTL""" key = f"oauth_state:{state}" await redis.execute("SETEX", key, OAUTH_STATE_TTL, orjson.dumps(data)) async def get_oauth_state(state: str) -> dict | None: """Получает и удаляет OAuth состояние из Redis (one-time use)""" key = f"oauth_state:{state}" data = await redis.execute("GET", key) if data: await redis.execute("DEL", key) # Одноразовое использование return orjson.loads(data) return None # HTTP handlers для тестирования async def oauth_login_http(request: Request) -> JSONResponse | RedirectResponse: """HTTP handler для OAuth login""" try: provider = request.path_params.get("provider") logger.info( f"🔍 OAuth login request: provider='{provider}', url='{request.url}', path_params={request.path_params}, query_params={dict(request.query_params)}" ) if not provider or provider not in PROVIDER_CONFIGS: logger.error(f"❌ Invalid provider: '{provider}', available: {list(PROVIDER_CONFIGS.keys())}") return JSONResponse({"error": "Invalid provider"}, status_code=400) client = oauth.create_client(provider) if not client: logger.error(f"OAuth client for {provider} not found. Available clients: {list(oauth._clients.keys())}") return JSONResponse({"error": "Provider not configured"}, status_code=400) # Генерируем PKCE challenge code_verifier = token_urlsafe(32) code_challenge = create_s256_code_challenge(code_verifier) state = token_urlsafe(32) # 🔍 Сохраняем redirect_uri из Referer header для testing.discours.io # Приоритет: query параметр → path параметр → Referer header → FRONTEND_URL final_redirect_uri = ( request.query_params.get("redirect_uri") or request.path_params.get("redirect_uri") or request.headers.get("referer") or FRONTEND_URL ) logger.info(f"🎯 Final redirect URI: '{final_redirect_uri}' (from referer: {request.headers.get('referer')})") oauth_data = { "code_verifier": code_verifier, "provider": provider, "redirect_uri": final_redirect_uri, "created_at": int(time.time()), } await store_oauth_state(state, oauth_data) # Получаем БАЗОВЫЙ backend URL (только схема + хост, без пути!) scheme = "https" if request.url.netloc != "localhost:8000" else request.url.scheme backend_base_url = f"{scheme}://{request.url.netloc}" callback_uri = f"{backend_base_url}/oauth/{provider}/callback" logger.info(f"🔗 Backend base URL: '{backend_base_url}'") logger.info(f"🔗 Callback URI for {provider}: '{callback_uri}'") # 🔍 Создаем redirect URL вручную (обходим использование request.session в authlib) # VK, Facebook не поддерживают PKCE, используем code_challenge только для поддерживающих провайдеров if provider in ["vk", "yandex", "telegram", "facebook"]: # Провайдеры без PKCE поддержки logger.info(f"🔧 Creating authorization URL without PKCE for {provider}") authorization_url = await client.create_authorization_url( callback_uri, state=state, ) else: # Провайдеры с PKCE поддержкой (Google, GitHub, X) logger.info(f"🔧 Creating authorization URL with PKCE for {provider}") authorization_url = await client.create_authorization_url( callback_uri, code_challenge=code_challenge, code_challenge_method="S256", state=state, ) logger.info(f"🚀 {provider.title()} authorization URL: '{authorization_url['url']}'") return RedirectResponse(url=authorization_url["url"], status_code=302) except Exception as e: logger.error(f"OAuth login error: {e}") return JSONResponse({"error": "OAuth login failed"}, status_code=500) async def oauth_callback_http(request: Request) -> JSONResponse | RedirectResponse: """HTTP handler для OAuth callback""" try: # 🔍 Диагностика входящего callback запроса logger.info("🔄 OAuth callback received:") logger.info(f" - URL: {request.url}") logger.info(f" - Method: {request.method}") logger.info(f" - Headers: {dict(request.headers)}") logger.info(f" - Query params: {dict(request.query_params)}") logger.info(f" - Path params: {request.path_params}") # 🔍 Получаем состояние OAuth только из Redis (убираем зависимость от request.session) state = request.query_params.get("state") if not state: logger.error("❌ Missing OAuth state parameter") return JSONResponse({"error": "Missing OAuth state parameter"}, status_code=400) oauth_data = await get_oauth_state(state) if not oauth_data: logger.warning(f"🚨 OAuth state {state} not found or expired") # Для testing.discours.io редиректим с ошибкой error_redirect = "https://testing.discours.io/oauth?error=oauth_state_expired" return RedirectResponse(url=error_redirect, status_code=302) provider = oauth_data.get("provider") if not provider: return JSONResponse({"error": "No provider in OAuth state"}, status_code=400) # Дополнительная проверка провайдера из path параметров (для старого формата) provider_from_path = request.path_params.get("provider") if provider_from_path and provider_from_path != provider: return JSONResponse({"error": "Provider mismatch"}, status_code=400) # Используем существующую логику client = oauth.create_client(provider) if not client: logger.warning(f"🚨 OAuth provider {provider} not configured - returning graceful error") # Проверяем конфигурацию провайдера from settings import OAUTH_CLIENTS provider_config = OAUTH_CLIENTS.get(provider.upper(), {}) logger.error( f"🚨 OAuth config for {provider}: client_id={'***' if provider_config.get('id') else 'MISSING'}, client_secret={'***' if provider_config.get('key') else 'MISSING'}" ) # Graceful fallback: редиректим на фронтенд с информативной ошибкой redirect_uri = oauth_data.get("redirect_uri", FRONTEND_URL) error_url = f"{redirect_uri}?error=provider_not_configured&provider={provider}&message=OAuth+provider+credentials+missing" return RedirectResponse(url=error_url, status_code=302) # Получаем authorization code из query параметров code = request.query_params.get("code") if not code: return JSONResponse({"error": "Missing authorization code"}, status_code=400) # 🔍 Обмениваем code на токен - с PKCE или без в зависимости от провайдера logger.info("🔄 Step 1: Exchanging authorization code for access token...") logger.info(f"🔧 Authorization response URL: {request.url}") logger.info(f"🔧 Code parameter: {code[:20]}..." if code and len(code) > 20 else f"🔧 Code parameter: {code}") try: if provider in ["vk", "yandex", "telegram", "facebook"]: # Провайдеры без PKCE поддержки (Facebook может иметь проблемы с PKCE) logger.info(f"🔧 Using OAuth without PKCE for {provider}") token = await client.fetch_access_token( authorization_response=str(request.url), ) else: # Провайдеры с PKCE поддержкой code_verifier = oauth_data.get("code_verifier") if not code_verifier: logger.error(f"❌ Missing code verifier for {provider}") return JSONResponse({"error": "Missing code verifier in OAuth state"}, status_code=400) logger.info(f"🔧 Using OAuth with PKCE for {provider}") logger.info(f"🔧 Code verifier length: {len(code_verifier) if code_verifier else 0}") token = await client.fetch_access_token( authorization_response=str(request.url), code_verifier=code_verifier, ) except Exception as e: logger.error(f"❌ Failed to fetch access token for {provider}: {e}", exc_info=True) logger.error(f"❌ Request URL: {request.url}") logger.error(f"❌ OAuth data: {oauth_data}") raise # Re-raise для обработки в основном except блоке if not token: logger.error(f"❌ Failed to get access token for {provider}") return JSONResponse({"error": "Failed to get access token"}, status_code=400) logger.info(f"✅ Got access token for {provider}: {bool(token)}") # 🔄 Step 2: Getting user profile logger.info(f"🔄 Step 2: Getting user profile from {provider}...") try: profile = await get_user_profile(provider, client, token) except Exception as e: logger.error(f"❌ Exception while getting user profile for {provider}: {e}", exc_info=True) raise # Re-raise для обработки в основном except блоке if not profile: logger.error(f"❌ Failed to get user profile for {provider} - empty profile returned") return JSONResponse({"error": "Failed to get user profile"}, status_code=400) logger.info( f"✅ Got user profile for {provider}: id={profile.get('id')}, email={profile.get('email')}, name={profile.get('name')}" ) # 🔄 Step 3: Creating or updating user logger.info(f"🔄 Step 3: Creating or updating user for {provider}...") try: author = await _create_or_update_user(provider, profile) except Exception as e: logger.error(f"❌ Exception while creating/updating user for {provider}: {e}", exc_info=True) raise # Re-raise для обработки в основном except блоке if not author: logger.error(f"❌ Failed to create/update user for {provider} - no author returned") return JSONResponse({"error": "Failed to create/update user"}, status_code=500) logger.info(f"✅ User created/updated for {provider}: user_id={author.id}, email={author.email}") # 🔄 Step 4: Creating session token logger.info(f"🔄 Step 4: Creating session token for user {author.id}...") try: session_token = await TokenStorage.create_session( str(author.id), auth_data={ "provider": provider, "profile": profile, }, username=author.name if isinstance(author.name, str) else str(author.name) if author.name is not None else None, device_info={ "user_agent": request.headers.get("user-agent"), "ip": request.client.host if hasattr(request, "client") and request.client else None, }, ) except Exception as e: logger.error(f"❌ Exception while creating session token for {provider}: {e}", exc_info=True) raise # Re-raise для обработки в основном except блоке if not session_token: logger.error(f"❌ Session token is empty for {provider}") raise ValueError("Session token creation failed") logger.info(f"✅ Session token created for {provider}: token_length={len(session_token)}") logger.info( f"🔧 Session token preview: {session_token[:20]}..." if len(session_token) > 20 else f"🔧 Session token: {session_token}" ) # Получаем redirect_uri из OAuth данных redirect_uri = oauth_data.get("redirect_uri", FRONTEND_URL) if not isinstance(redirect_uri, str) or not redirect_uri: redirect_uri = FRONTEND_URL # 🔧 Для testing.discours.io используем httpOnly cookies + простой редирект from urllib.parse import urlparse parsed_redirect = urlparse(redirect_uri) # Определяем финальный URL для редиректа if "testing.discours.io" in parsed_redirect.netloc: # 💋 Для testing.discours.io используем httpOnly cookie + токен в URL для фронтенда from urllib.parse import quote final_redirect_url = ( f"https://testing.discours.io/oauth?access_token={session_token}&redirect_url={quote(redirect_uri)}" ) if state: final_redirect_url += f"&state={state}" else: # Для других доменов используем старую логику с токеном в URL from urllib.parse import parse_qs, urlencode, urlunparse parsed_url = urlparse(redirect_uri) query_params = parse_qs(parsed_url.query) # Добавляем access_token и state в URL параметры query_params["access_token"] = [session_token] query_params["state"] = [state] # Собираем новый URL с параметрами new_query = urlencode(query_params, doseq=True) final_redirect_url = urlunparse( ( parsed_url.scheme, parsed_url.netloc, parsed_url.path, parsed_url.params, new_query, parsed_url.fragment, ) ) logger.info(f"🔗 OAuth redirect URL: {final_redirect_url}") # 🔍 Дополнительная диагностика для отладки logger.info("🎯 OAuth callback redirect details:") logger.info(f" - Original redirect_uri: {oauth_data.get('redirect_uri')}") logger.info(f" - Final redirect_uri: {redirect_uri}") logger.info(f" - Session token length: {len(session_token)}") logger.info(f" - State: {state}") logger.info(f" - Provider: {provider}") logger.info(f" - User ID: {author.id}") # 🍪 Устанавливаем httpOnly cookie вместо токена в URL response = RedirectResponse(url=redirect_uri, status_code=307) response.set_cookie( key=SESSION_COOKIE_NAME, value=session_token, httponly=SESSION_COOKIE_HTTPONLY, secure=SESSION_COOKIE_SECURE, samesite=SESSION_COOKIE_SAMESITE, max_age=SESSION_COOKIE_MAX_AGE, path="/", domain=SESSION_COOKIE_DOMAIN, # ✅ Для работы с поддоменами ) logger.info(f"✅ OAuth: httpOnly cookie установлен для user_id={author.id}") logger.info(f"🔗 Redirect на фронтенд БЕЗ токена в URL: {redirect_uri}") logger.info( f"🍪 Cookie: {SESSION_COOKIE_NAME}, secure={SESSION_COOKIE_SECURE}, samesite={SESSION_COOKIE_SAMESITE}" ) logger.info( f"🔍 Session token preview: {session_token[:30]}..." if len(session_token) > 30 else f"🔍 Session token: {session_token}" ) logger.info(f"✅ OAuth успешно завершен для {provider}, user_id={author.id}") return response except Exception as e: logger.error(f"OAuth callback error for {provider}: {e!s}", exc_info=True) logger.error(f"OAuth callback request URL: {request.url}") logger.error(f"OAuth callback query params: {dict(request.query_params)}") # В случае ошибки редиректим на фронтенд с ошибкой # Используем сохраненный redirect_uri из OAuth state или fallback try: state = request.query_params.get("state") oauth_data = await get_oauth_state(state) if state else None fallback_redirect = oauth_data.get("redirect_uri") if oauth_data else FRONTEND_URL except Exception: fallback_redirect = FRONTEND_URL # Обеспечиваем что fallback_redirect это строка if not isinstance(fallback_redirect, str): fallback_redirect = FRONTEND_URL # Для testing.discours.io используем специальный формат if "testing.discours.io" in fallback_redirect: from urllib.parse import quote error_url = f"https://testing.discours.io/oauth?error=auth_failed&provider={provider}&redirect_url={quote(fallback_redirect)}" else: error_url = f"{fallback_redirect}?error=auth_failed&provider={provider}" logger.error(f"🚨 Redirecting to error URL: {error_url}") return RedirectResponse(url=error_url) 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).where(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")) # Добавляем пользователя в основное сообщество с дефолтными ролями target_community_id = 1 # Основное сообщество # Получаем сообщество для назначения дефолтных ролей community = session.query(Community).where(Community.id == target_community_id).first() if community: default_roles = community.get_default_roles() # Проверяем, не существует ли уже запись CommunityAuthor existing_ca = ( session.query(CommunityAuthor).filter_by(community_id=target_community_id, author_id=author.id).first() ) if not existing_ca: # Создаем CommunityAuthor с дефолтными ролями community_author = CommunityAuthor( community_id=target_community_id, author_id=author.id, roles=",".join(default_roles) ) session.add(community_author) logger.info(f"Создана запись CommunityAuthor для OAuth пользователя {author.id} с ролями: {default_roles}") # Проверяем, не существует ли уже запись подписчика existing_follower = ( session.query(CommunityFollower).filter_by(community=target_community_id, follower=int(author.id)).first() ) if not existing_follower: # Добавляем пользователя в подписчики сообщества follower = CommunityFollower(community=target_community_id, follower=int(author.id)) session.add(follower) logger.info(f"OAuth пользователь {author.id} добавлен в подписчики сообщества {target_community_id}") return author