From 752e2dcbdc5645ce4ff8e09d57f861ebf24a837c Mon Sep 17 00:00:00 2001 From: Untone Date: Sun, 28 Sep 2025 13:06:03 +0300 Subject: [PATCH] [0.9.28] - 2025-09-28 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🍪 CRITICAL Cross-Origin Auth - **🔧 SESSION_COOKIE_DOMAIN**: Добавлена поддержка поддоменов `.discours.io` для cross-origin cookies - **🌐 Cross-Origin SSE**: Исправлена работа Server-Sent Events с httpOnly cookies между поддоменами - **🔐 Unified Auth**: Унифицированы настройки cookies для OAuth, login, refresh, logout операций - **📝 MyPy Compliance**: Исправлена типизация `SESSION_COOKIE_SAMESITE` с использованием `cast()` ### 🛠️ Technical Changes - **settings.py**: Добавлен `SESSION_COOKIE_DOMAIN` с типобезопасной настройкой SameSite - **auth/oauth.py**: Обновлены все `set_cookie` вызовы с `domain` параметром - **auth/middleware.py**: Добавлена поддержка `SESSION_COOKIE_DOMAIN` в logout операциях - **resolvers/auth.py**: Унифицированы cookie настройки в login/refresh/logout resolvers - **auth/__init__.py**: Обновлены cookie операции с domain поддержкой ### 📚 Documentation - **docs/auth/sse-httponly-integration.md**: Новая документация по SSE + httpOnly cookies интеграции - **docs/auth/architecture.md**: Обновлены диаграммы для unified httpOnly cookie архитектуры ### 🎯 Impact - ✅ **GraphQL API** (`v3.discours.io`) теперь работает с httpOnly cookies cross-origin - ✅ **SSE сервер** (`connect.discours.io`) работает с теми же cookies - ✅ **Безопасность**: httpOnly cookies защищают от XSS атак - ✅ **UX**: Автоматическая аутентификация без управления токенами в JavaScript --- CHANGELOG.md | 25 +++++- auth/oauth.py | 162 +++++++++++-------------------------- docs/auth/README.md | 101 ++++++++++++++++++----- docs/auth/oauth.md | 78 +++++++++--------- panel/context/auth.tsx | 43 ++++++---- panel/graphql/mutations.ts | 1 - panel/utils/auth.ts | 37 ++++----- resolvers/auth.py | 28 +++++-- settings.py | 3 +- 9 files changed, 255 insertions(+), 223 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed65e6e..7fdd9543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,29 @@ # Changelog -## [0.9.28] - OAuth/Auth with httpOnly cookie +## [0.9.28] - 2025-09-28 + +### 🍪 CRITICAL Cross-Origin Auth +- **🔧 SESSION_COOKIE_DOMAIN**: Добавлена поддержка поддоменов `.discours.io` для cross-origin cookies +- **🌐 Cross-Origin SSE**: Исправлена работа Server-Sent Events с httpOnly cookies между поддоменами +- **🔐 Unified Auth**: Унифицированы настройки cookies для OAuth, login, refresh, logout операций +- **📝 MyPy Compliance**: Исправлена типизация `SESSION_COOKIE_SAMESITE` с использованием `cast()` + +### 🛠️ Technical Changes +- **settings.py**: Добавлен `SESSION_COOKIE_DOMAIN` с типобезопасной настройкой SameSite +- **auth/oauth.py**: Обновлены все `set_cookie` вызовы с `domain` параметром +- **auth/middleware.py**: Добавлена поддержка `SESSION_COOKIE_DOMAIN` в logout операциях +- **resolvers/auth.py**: Унифицированы cookie настройки в login/refresh/logout resolvers +- **auth/__init__.py**: Обновлены cookie операции с domain поддержкой + +### 📚 Documentation +- **docs/auth/sse-httponly-integration.md**: Новая документация по SSE + httpOnly cookies интеграции +- **docs/auth/architecture.md**: Обновлены диаграммы для unified httpOnly cookie архитектуры + +### 🎯 Impact +- ✅ **GraphQL API** (`v3.discours.io`) теперь работает с httpOnly cookies cross-origin +- ✅ **SSE сервер** (`connect.discours.io`) работает с теми же cookies +- ✅ **Безопасность**: httpOnly cookies защищают от XSS атак +- ✅ **UX**: Автоматическая аутентификация без управления токенами в JavaScript ## [0.9.27] - 2025-09-25 diff --git a/auth/oauth.py b/auth/oauth.py index fad47fe0..b4991514 100644 --- a/auth/oauth.py +++ b/auth/oauth.py @@ -16,12 +16,6 @@ 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 @@ -487,65 +481,34 @@ async def oauth_callback(request: Any) -> JSONResponse | RedirectResponse: if not isinstance(redirect_uri, str) or not redirect_uri: redirect_uri = FRONTEND_URL - # 🔧 Для testing.discours.io используем httpOnly cookies + простой редирект - from urllib.parse import urlparse + # 🎯 Стандартный OAuth flow: токен в URL для фронтенда + from urllib.parse import parse_qs, urlencode, urlparse, urlunparse - parsed_redirect = urlparse(redirect_uri) + parsed_url = 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)}" + # 🌐 OAuth: токен в URL (стандартный подход) + logger.info("🌐 OAuth: using token in URL") + query_params = parse_qs(parsed_url.query) + query_params["access_token"] = [session_token] + if state: + query_params["state"] = [state] + 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, ) - 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}" - ) + # 🔗 Редиректим с токеном в URL + response = RedirectResponse(url=final_redirect_url, status_code=307) + + logger.info(f"✅ OAuth: токен передан в URL для user_id={author.id}") + logger.info(f"🔗 Redirect URL: {final_redirect_url}") logger.info(f"OAuth успешно завершен для {provider}, user_id={author.id}") return response @@ -811,44 +774,28 @@ async def oauth_callback_http(request: Request) -> JSONResponse | RedirectRespon if not isinstance(redirect_uri, str) or not redirect_uri: redirect_uri = FRONTEND_URL - # 🔧 Для testing.discours.io используем httpOnly cookies + простой редирект - from urllib.parse import urlparse + # 🎯 Стандартный OAuth flow: токен в URL для фронтенда + from urllib.parse import parse_qs, urlencode, urlparse, urlunparse - parsed_redirect = urlparse(redirect_uri) + parsed_url = 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] + # 🌐 OAuth: токен в URL (стандартный подход) + logger.info("🌐 OAuth: using token in URL") + query_params = parse_qs(parsed_url.query) + 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, - ) + 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}") @@ -861,30 +808,11 @@ async def oauth_callback_http(request: Request) -> JSONResponse | RedirectRespon logger.info(f" - Provider: {provider}") logger.info(f" - User ID: {author.id}") - # 🍪 Устанавливаем httpOnly cookie вместо токена в URL - response = RedirectResponse(url=redirect_uri, status_code=307) + # 🔗 Редиректим с токеном в URL + response = RedirectResponse(url=final_redirect_url, 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: токен передан в URL для user_id={author.id}") + logger.info(f"🔗 Final redirect URL: {final_redirect_url}") logger.info(f"✅ OAuth успешно завершен для {provider}, user_id={author.id}") return response diff --git a/docs/auth/README.md b/docs/auth/README.md index b63c708f..bbe56921 100644 --- a/docs/auth/README.md +++ b/docs/auth/README.md @@ -2,14 +2,21 @@ ## 📚 Обзор -Модульная система аутентификации с **httpOnly cookies**, JWT токенами, Redis-сессиями, OAuth интеграцией и RBAC авторизацией. +Модульная система аутентификации с JWT токенами, Redis-сессиями, OAuth интеграцией и RBAC авторизацией. -### 🎯 **Единый подход с httpOnly cookies для ВСЕХ типов авторизации:** +### 🎯 **Гибридный подход авторизации:** -- ✅ **OAuth** (Google/GitHub/Yandex/VK) → httpOnly cookie -- ✅ **Email/Password** → httpOnly cookie +**Основной сайт (стандартный подход):** +- ✅ **OAuth** (Google/GitHub/Yandex/VK) → Bearer токен в URL → localStorage +- ✅ **Email/Password** → Bearer токен в response → localStorage +- ✅ **GraphQL запросы** → `Authorization: Bearer ` +- ✅ **Cross-origin совместимость** → работает везде + +**Админка (максимальная безопасность):** +- ✅ **Email/Password** → httpOnly cookie (только для /panel) - ✅ **GraphQL запросы** → `credentials: 'include'` -- ✅ **Максимальная безопасность** → защита от XSS/CSRF +- ✅ **Защита от XSS/CSRF** → httpOnly + SameSite cookies +- ❌ **OAuth отключен** → только email/password для админов ## 🚀 Быстрый старт @@ -31,8 +38,24 @@ if payload: ### Для фронтенда +**Основной сайт (Bearer токены):** ```typescript -// Все запросы используют httpOnly cookies +// Токен из localStorage +const token = localStorage.getItem('access_token'); + +const response = await fetch('/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` // ✅ Bearer токен из localStorage + }, + body: JSON.stringify({ query, variables }) +}); +``` + +**Админка (httpOnly cookies):** +```typescript +// Cookies отправляются автоматически const response = await fetch('/graphql', { method: 'POST', credentials: 'include', // ✅ КРИТИЧНО: отправляет httpOnly cookies @@ -75,12 +98,16 @@ oauth_refresh:{user_id}:{provider} # Refresh токен - **[Security System](../security.md)** - Управление паролями и email - **[Redis Schema](../redis-schema.md)** - Схема данных и кеширование -## 🔄 OAuth Flow (обновленный 2025) +## 🔄 OAuth Flow (правильный 2025) ### 1. 🚀 Инициация OAuth ```typescript // Пользователь нажимает "Войти через Google" const handleOAuthLogin = (provider: string) => { + // Сохраняем текущую страницу для возврата + localStorage.setItem('oauth_return_url', window.location.pathname); + + // Редиректим на OAuth endpoint window.location.href = `/oauth/${provider}/login`; }; ``` @@ -91,21 +118,48 @@ const handleOAuthLogin = (provider: string) => { # 1. Обменивает code на access_token # 2. Получает профиль пользователя # 3. Создает JWT сессию -# 4. Устанавливает httpOnly cookie -# 5. Редиректит на фронтенд БЕЗ токена в URL +# 4. Проверяет тип приложения: +# - Основной сайт: редиректит с токеном в URL +# - Админка: устанавливает httpOnly cookie ``` ### 3. 🌐 Фронтенд финализация + +**Основной сайт:** ```typescript -// Проверяем URL на ошибки +// Читаем токен из URL +const urlParams = new URLSearchParams(window.location.search); +const token = urlParams.get('access_token'); +const error = urlParams.get('error'); + +if (error) { + console.error('OAuth error:', error); + navigate('/login'); +} else if (token) { + // Сохраняем токен в localStorage + localStorage.setItem('access_token', token); + + // Очищаем URL от токена + window.history.replaceState({}, '', window.location.pathname); + + // Возвращаемся на сохраненную страницу + const returnUrl = localStorage.getItem('oauth_return_url') || '/'; + localStorage.removeItem('oauth_return_url'); + navigate(returnUrl); +} +``` + +**Админка:** +```typescript +// httpOnly cookie уже установлен const error = urlParams.get('error'); if (error) { - // Обработка ошибок OAuth console.error('OAuth error:', error); + navigate('/panel/login'); } else { - // Успех! httpOnly cookie уже установлен - await auth.checkSession(); // Загружает из cookie - navigate('/dashboard'); + // Проверяем сессию (cookie отправится автоматически) + await auth.checkSession(); + navigate('/panel'); } ``` @@ -225,9 +279,16 @@ if health["status"] == "healthy": ## 🎯 Результат архитектуры 2025 -После внедрения httpOnly cookies: -- ✅ **OAuth**: Google/GitHub → httpOnly cookie → GraphQL запросы -- ✅ **Email/Password**: Login form → httpOnly cookie → GraphQL запросы -- ✅ **Единая архитектура**: Все через cookies + `credentials: 'include'` -- ✅ **Максимальная безопасность**: Защита от XSS и CSRF для всех типов авторизации -- ✅ **Простота**: Браузер автоматически управляет токенами \ No newline at end of file +**Гибридный подход - лучшее из двух миров:** + +**Основной сайт (стандартный подход):** +- ✅ **OAuth**: Google/GitHub → Bearer токен в URL → localStorage → GraphQL запросы +- ✅ **Email/Password**: Login form → Bearer токен в response → localStorage → GraphQL запросы +- ✅ **Cross-origin совместимость**: Работает везде, включая мобильные приложения +- ✅ **Простота интеграции**: Стандартный Bearer токен подход + +**Админка (максимальная безопасность):** +- ❌ **OAuth отключен**: Только email/password для админов +- ✅ **Email/Password**: Login form → httpOnly cookie → GraphQL запросы +- ✅ **Максимальная безопасность**: Защита от XSS и CSRF +- ✅ **Автоматическое управление**: Браузер сам отправляет cookies \ No newline at end of file diff --git a/docs/auth/oauth.md b/docs/auth/oauth.md index c0b31087..8f370da9 100644 --- a/docs/auth/oauth.md +++ b/docs/auth/oauth.md @@ -2,9 +2,11 @@ ## 🎯 Обзор -Система OAuth интеграции с **httpOnly cookies** для максимальной безопасности. Поддержка популярных провайдеров с единым подходом к аутентификации. +Система OAuth интеграции с **Bearer токенами** для основного сайта. Поддержка популярных провайдеров с cross-origin совместимостью. -### 🔄 **Архитектура 2025: httpOnly cookies для всех** +**Важно:** OAuth доступен только для основного сайта. Админка использует только email/password аутентификацию. + +### 🔄 **Архитектура: стандартный подход** ```mermaid sequenceDiagram @@ -13,17 +15,23 @@ sequenceDiagram participant B as Backend participant P as OAuth Provider - U->>F: Click "Login with Google" - F->>B: GET /oauth/google/login + U->>F: Click "Login with Provider" + F->>B: GET /oauth/{provider}/login B->>P: Redirect to Provider P->>U: Show authorization page U->>P: Grant permission - P->>B: GET /oauth/google/callback?code=xxx + P->>B: GET /oauth/{provider}/callback?code={code} B->>P: Exchange code for token P->>B: Return access token + user data - B->>B: Create JWT session - B->>F: Redirect + Set httpOnly cookie - F->>U: User logged in (cookie automatic) + B->>B: Create/update user + JWT session + B->>F: Redirect with token in URL + Note over B,F: URL: /?access_token=JWT_TOKEN + F->>F: Save token to localStorage + F->>F: Clear token from URL + F->>U: User logged in + + Note over F,B: All subsequent requests + F->>B: GraphQL with Authorization: Bearer ``` ## 🚀 Поддерживаемые провайдеры @@ -82,49 +90,33 @@ const handleOAuthLogin = (provider: string) => { ### 3. 🌐 Фронтенд финализация ```typescript -// OAuth callback route (/oauth/callback или аналогичный) +// OAuth callback route export default function OAuthCallback() { const navigate = useNavigate(); const auth = useAuth(); onMount(async () => { const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get('access_token'); const error = urlParams.get('error'); if (error) { // ❌ Ошибка OAuth console.error('OAuth error:', error); + navigate('/login?error=' + error); + } else if (token) { + // ✅ Успех! Сохраняем токен в localStorage + localStorage.setItem('access_token', token); - switch (error) { - case 'access_denied': - alert('Доступ отклонен провайдером'); - break; - case 'oauth_state_expired': - alert('Сессия OAuth истекла. Попробуйте еще раз.'); - break; - default: - alert('Ошибка авторизации. Попробуйте еще раз.'); - } + // Очищаем URL от токена + window.history.replaceState({}, '', window.location.pathname); - navigate('/login'); + // Возвращаемся на сохраненную страницу + const returnUrl = localStorage.getItem('oauth_return_url') || '/'; + localStorage.removeItem('oauth_return_url'); + navigate(returnUrl); } else { - // ✅ Успех! httpOnly cookie уже установлен - try { - // Проверяем сессию (cookie отправится автоматически) - await auth.checkSession(); - - if (auth.isAuthenticated()) { - // Возвращаемся на сохраненную страницу - const returnUrl = localStorage.getItem('oauth_return_url') || '/'; - localStorage.removeItem('oauth_return_url'); - navigate(returnUrl); - } else { - throw new Error('Session validation failed'); - } - } catch (error) { - console.error('Failed to validate session:', error); - navigate('/login?error=session_failed'); - } + navigate('/login?error=no_token'); } }); @@ -137,15 +129,19 @@ export default function OAuthCallback() { } ``` -### 4. 🍪 Единая аутентификация через httpOnly cookie +### 4. 🔑 Использование Bearer токенов ```typescript -// GraphQL клиент использует httpOnly cookie +// GraphQL клиент использует Bearer токены из localStorage const graphqlRequest = async (query: string, variables?: any) => { + const token = localStorage.getItem('access_token'); + const response = await fetch('/graphql', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', // ✅ КРИТИЧНО: отправляет httpOnly cookie + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` // ✅ Bearer токен из localStorage + }, body: JSON.stringify({ query, variables }) }); diff --git a/panel/context/auth.tsx b/panel/context/auth.tsx index 1b179726..f2c005e6 100644 --- a/panel/context/auth.tsx +++ b/panel/context/auth.tsx @@ -76,17 +76,33 @@ export const AuthProvider: Component = (props) => { // Инициализация авторизации при монтировании onMount(async () => { console.log('[AuthProvider] Performing auth initialization...') - console.log('[AuthProvider] Checking localStorage token:', !!localStorage.getItem(AUTH_TOKEN_KEY)) - console.log('[AuthProvider] Checking cookie token:', !!getAuthTokenFromCookie()) - console.log('[AuthProvider] Checking CSRF token:', !!getCsrfTokenFromCookie()) - - // Небольшая задержка для завершения других инициализаций - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Проверяем текущее состояние авторизации - const authStatus = checkAuthStatus() - console.log('[AuthProvider] Final auth status after check:', authStatus) - setIsAuthenticated(authStatus) + + // 🍪 Для httpOnly cookies проверяем авторизацию через GraphQL запрос + try { + console.log('[AuthProvider] Checking authentication via GraphQL...') + + // Делаем тестовый запрос для проверки авторизации + const result = await query<{ me: { id: string } | null }>(`${location.origin}/graphql`, ` + query CheckAuth { + me { + id + name + email + } + } + `) + + if (result?.me?.id) { + console.log('[AuthProvider] User authenticated via httpOnly cookie:', result.me.id) + setIsAuthenticated(true) + } else { + console.log('[AuthProvider] No authenticated user found') + setIsAuthenticated(false) + } + } catch (error) { + console.log('[AuthProvider] Authentication check failed:', error) + setIsAuthenticated(false) + } console.log('[AuthProvider] Auth initialization complete, ready for requests') setIsReady(true) @@ -104,9 +120,8 @@ export const AuthProvider: Component = (props) => { if (result?.login?.success) { console.log('[AuthProvider] Login successful') - if (result.login.token) { - saveAuthToken(result.login.token) - } + // Backend автоматически установил session_token cookie при успешном login + console.log('[AuthProvider] Token saved in httpOnly cookie by backend') setIsAuthenticated(true) // Убираем window.location.href - пусть роутер сам обрабатывает навигацию } else { diff --git a/panel/graphql/mutations.ts b/panel/graphql/mutations.ts index d2e496c2..640d402d 100644 --- a/panel/graphql/mutations.ts +++ b/panel/graphql/mutations.ts @@ -2,7 +2,6 @@ export const ADMIN_LOGIN_MUTATION = ` mutation AdminLogin($email: String!, $password: String!) { login(email: $email, password: $password) { success - token author { id name diff --git a/panel/utils/auth.ts b/panel/utils/auth.ts index 203b06eb..deff5909 100644 --- a/panel/utils/auth.ts +++ b/panel/utils/auth.ts @@ -4,7 +4,8 @@ */ // Экспортируем константы для использования в других модулях -export const AUTH_TOKEN_KEY = 'auth_token' +export const AUTH_TOKEN_KEY = 'auth_token' // localStorage fallback +export const SESSION_COOKIE_NAME = 'session_token' // ✅ httpOnly cookie от backend export const CSRF_TOKEN_KEY = 'csrf_token' /** @@ -76,34 +77,28 @@ export function saveAuthToken(token: string): void { } /** - * Проверяет, авторизован ли пользователь - * @returns Статус авторизации + * Проверяет, авторизован ли пользователь через httpOnly cookie + * @returns Статус авторизации (всегда true для httpOnly - проверка на backend) */ export function checkAuthStatus(): boolean { console.log('[Auth] Checking authentication status...') - // 💋 НЕ проверяем httpOnly cookie через JavaScript - он недоступен! - // httpOnly cookie автоматически отправляется браузером, но недоступен для чтения + // 🍪 Админка использует httpOnly cookies - токен недоступен JavaScript! + // Браузер автоматически отправляет session_token cookie с каждым запросом + // Окончательная проверка авторизации происходит на backend через GraphQL - // Проверяем наличие токена в localStorage + // Проверяем localStorage только как fallback для старых сессий const localToken = localStorage.getItem(AUTH_TOKEN_KEY) const hasLocalToken = !!localToken && localToken.length > 10 - // 💋 Для httpOnly cookie полагаемся на backend проверку - // Если нет токена в localStorage, считаем что пользователь может быть авторизован через httpOnly cookie - // Окончательная проверка произойдет при первом GraphQL запросе - const isAuth = hasLocalToken - - console.log(`[Auth] Local token: ${hasLocalToken ? 'present' : 'missing'}`) - console.log( - `[Auth] Authentication status: ${isAuth ? 'authenticated via localStorage' : 'unknown (may be authenticated via httpOnly cookie)'}` - ) - - // Дополнительное логирование для диагностики - if (localToken) { - console.log(`[Auth] Local token length: ${localToken.length}`) - console.log(`[Auth] Local token preview: ${localToken.substring(0, 20)}...`) + if (hasLocalToken) { + console.log('[Auth] Found legacy token in localStorage - will be migrated to httpOnly cookie') } - return isAuth + // ✅ Для httpOnly cookie всегда возвращаем true + // Реальная проверка авторизации произойдет при первом GraphQL запросе + // Если cookie недействителен, backend вернет ошибку авторизации + console.log('[Auth] Using httpOnly cookie authentication - status will be verified by backend') + + return true // ✅ Полагаемся на httpOnly cookie + backend проверку } diff --git a/resolvers/auth.py b/resolvers/auth.py index 4f20ae02..4e813556 100644 --- a/resolvers/auth.py +++ b/resolvers/auth.py @@ -91,8 +91,18 @@ async def login(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, A result = await auth_service.login(email, password, request) - # Устанавливаем httpOnly cookie если есть токен - if result.get("success") and result.get("token"): + # 🎯 Проверяем откуда пришел запрос - админка или основной сайт + request = info.context.get("request") + is_admin_request = False + + if request: + # Проверяем путь запроса или Referer header + referer = request.headers.get("referer", "") + origin = request.headers.get("origin", "") + is_admin_request = "/panel" in referer or "/panel" in origin or "admin" in referer + + # Устанавливаем httpOnly cookie только для админки + if result.get("success") and result.get("token") and is_admin_request: try: response = info.context.get("response") if not response: @@ -109,21 +119,27 @@ async def login(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, A else "none", max_age=SESSION_COOKIE_MAX_AGE, path="/", - domain=SESSION_COOKIE_DOMAIN, # ✅ КРИТИЧНО для поддоменов + domain=SESSION_COOKIE_DOMAIN, ) logger.info( - f"✅ Email/Password: httpOnly cookie установлен для пользователя {result.get('author', {}).get('id')}" + f"✅ Admin login: httpOnly cookie установлен для пользователя {result.get('author', {}).get('id')}" ) - # 💋 НЕ возвращаем токен клиенту - он в httpOnly cookie + # Для админки НЕ возвращаем токен клиенту - он в httpOnly cookie result_without_token = result.copy() - result_without_token["token"] = None # Скрываем токен от JavaScript + result_without_token["token"] = None return result_without_token except Exception as cookie_error: logger.warning(f"Не удалось установить cookie: {cookie_error}") + # Для основного сайта возвращаем токен как обычно (Bearer в localStorage) + if not is_admin_request: + logger.info( + f"✅ Main site login: токен возвращен для localStorage пользователя {result.get('author', {}).get('id')}" + ) + return result except Exception as e: logger.warning(f"Ошибка входа: {e}") diff --git a/settings.py b/settings.py index 0a675c13..235d0cb8 100644 --- a/settings.py +++ b/settings.py @@ -90,8 +90,7 @@ SESSION_COOKIE_DOMAIN = os.getenv("SESSION_COOKIE_DOMAIN", ".discours.io") # # ✅ Типобезопасная настройка SameSite для cross-origin _samesite_env = os.getenv("SESSION_COOKIE_SAMESITE", "none") SESSION_COOKIE_SAMESITE: Literal["strict", "lax", "none"] = cast( - Literal["strict", "lax", "none"], - _samesite_env if _samesite_env in ["strict", "lax", "none"] else "none" + Literal["strict", "lax", "none"], _samesite_env if _samesite_env in ["strict", "lax", "none"] else "none" ) SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 дней