From fb98a1c6c8ee6a6d2664346cbc3e085088d7a41e Mon Sep 17 00:00:00 2001 From: Untone Date: Sun, 28 Sep 2025 12:22:37 +0300 Subject: [PATCH] [0.9.28] - OAuth/Auth with httpOnly cookie --- .gitea/workflows/main.yml | 22 +- CHANGELOG.md | 39 +- auth/__init__.py | 4 +- auth/middleware.py | 114 +--- auth/oauth.py | 97 +-- codegen.ts | 2 +- docs/auth/README.md | 156 ++++- docs/auth/architecture.md | 96 ++- docs/auth/oauth.md | 909 ++++++++++---------------- docs/auth/setup.md | 267 ++++++++ docs/auth/sse-httponly-integration.md | 414 ++++++++++++ docs/oauth-debug-checklist.md | 72 -- docs/oauth-frontend-integration.md | 325 --------- docs/oauth-glitchtip-integration.md | 172 ----- docs/oauth-minimal-flow.md | 287 -------- docs/oauth-setup.md | 144 ---- docs/oauth-test-scenarios.md | 174 ----- main.py | 2 +- package-lock.json | 198 +++--- package.json | 2 +- panel/graphql/index.ts | 9 +- panel/utils/auth.ts | 8 +- pyproject.toml | 2 +- resolvers/auth.py | 65 +- settings.py | 12 +- tests/test_oauth_minimal.py | 2 +- uv.lock | 2 +- 27 files changed, 1449 insertions(+), 2147 deletions(-) create mode 100644 docs/auth/setup.md create mode 100644 docs/auth/sse-httponly-integration.md delete mode 100644 docs/oauth-debug-checklist.md delete mode 100644 docs/oauth-frontend-integration.md delete mode 100644 docs/oauth-glitchtip-integration.md delete mode 100644 docs/oauth-minimal-flow.md delete mode 100644 docs/oauth-setup.md delete mode 100644 docs/oauth-test-scenarios.md diff --git a/.gitea/workflows/main.yml b/.gitea/workflows/main.yml index 43c8bd29..105609b8 100644 --- a/.gitea/workflows/main.yml +++ b/.gitea/workflows/main.yml @@ -90,7 +90,7 @@ jobs: echo "📝 Запускаем GraphQL codegen..." npm run codegen 2>&1 | tee codegen_output.log if [ ${PIPESTATUS[0]} -ne 0 ]; then - echo "❌ GraphQL codegen упал с v3.dscrs.site!" + echo "❌ GraphQL codegen упал с v3.discours.io!" echo "📋 ПОЛНЫЙ ВЫВОД ОШИБКИ:" cat codegen_output.log echo "📋 КОНЕЦ ВЫВОДА ОШИБКИ" @@ -101,8 +101,8 @@ jobs: V3_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Content-Type: application/json" \ -d '{"query":"query{__typename}"}' \ - https://v3.dscrs.site/graphql 2>/dev/null || echo "000") - echo "v3.dscrs.site: $V3_STATUS" + https://v3.discours.io/graphql 2>/dev/null || echo "000") + echo "v3.discours.io: $V3_STATUS" CORETEST_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Content-Type: application/json" \ @@ -114,7 +114,7 @@ jobs: if [ "$CORETEST_STATUS" = "200" ]; then echo "🔄 Переключаемся на coretest.discours.io..." # Временно меняем схему в codegen.ts - sed -i "s|https://v3.dscrs.site/graphql|https://coretest.discours.io/graphql|g" codegen.ts + sed -i "s|https://v3.discours.io/graphql|https://coretest.discours.io/graphql|g" codegen.ts npm run codegen 2>&1 | tee fallback_output.log if [ ${PIPESTATUS[0]} -ne 0 ]; then echo "❌ Fallback тоже не сработал!" @@ -122,11 +122,11 @@ jobs: cat fallback_output.log echo "📋 КОНЕЦ ВЫВОДА ОШИБКИ FALLBACK" # Восстанавливаем оригинальную схему - sed -i "s|https://coretest.discours.io/graphql|https://v3.dscrs.site/graphql|g" codegen.ts + sed -i "s|https://coretest.discours.io/graphql|https://v3.discours.io/graphql|g" codegen.ts exit 1 fi # Восстанавливаем оригинальную схему - sed -i "s|https://coretest.discours.io/graphql|https://v3.dscrs.site/graphql|g" codegen.ts + sed -i "s|https://coretest.discours.io/graphql|https://v3.discours.io/graphql|g" codegen.ts else echo "❌ Оба endpoint недоступны!" exit 1 @@ -210,22 +210,22 @@ jobs: echo "${{ secrets.STAGING_PRIVATE_KEY }}" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa - # Добавляем v3.dscrs.site в known_hosts - ssh-keyscan -H v3.dscrs.site >> ~/.ssh/known_hosts + # Добавляем v3.discours.io в known_hosts + ssh-keyscan -H v3.discours.io >> ~/.ssh/known_hosts # Запускаем ssh-agent eval $(ssh-agent -s) ssh-add ~/.ssh/id_rsa - echo "✅ SSH настроен для v3.dscrs.site" + echo "✅ SSH настроен для v3.discours.io" - name: Push to dokku for dev branch if: github.ref == 'refs/heads/dev' run: | - echo "🚀 Деплоим на v3.dscrs.site..." + echo "🚀 Деплоим на v3.discours.io..." # Добавляем dokku remote - git remote add dokku ssh://dokku@v3.dscrs.site:22/core || git remote set-url dokku ssh://dokku@v3.dscrs.site:22/core + git remote add dokku ssh://dokku@v3.discours.io:22/core || git remote set-url dokku ssh://dokku@v3.discours.io:22/core # Проверяем remote git remote -v diff --git a/CHANGELOG.md b/CHANGELOG.md index 6af29d57..8ed65e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,43 +1,6 @@ # Changelog -## [0.9.29] - 2025-09-26 - -### 🚨 CRITICAL Security Fixes -- **🔒 Open Redirect Protection**: Добавлена строгая валидация redirect_uri против whitelist доменов -- **🔒 Rate Limiting**: Защита OAuth endpoints от брутфорса (10 попыток за 5 минут на IP) -- **🔒 Logout Endpoint**: Критически важный endpoint для безопасного отзыва httpOnly cookies -- **🔒 Provider Validation**: Усиленная валидация OAuth провайдеров с логированием атак -- **🚨 GlitchTip Alerts**: Автоматические алерты безопасности в GlitchTip при критических событиях - -### 🛡️ Security Modules -- **auth/oauth_security.py**: Модуль безопасности OAuth с валидацией и rate limiting + GlitchTip алерты -- **auth/logout.py**: Безопасный logout с поддержкой JSON API и browser redirect -- **tests/test_oauth_security.py**: Комплексные тесты безопасности (11 тестов) -- **tests/test_oauth_glitchtip_alerts.py**: Тесты интеграции с GlitchTip (8 тестов) - -### 🔧 OAuth Improvements -- **Minimal Flow**: Упрощен до минимума - только httpOnly cookie, нет JWT в URL -- **Simple Logic**: Нет error параметра = успех, максимальная простота -- **DRY Refactoring**: Устранено дублирование кода в logout и валидации - -### 🎯 OAuth Endpoints -- **Старт**: `v3.dscrs.site/oauth/{provider}` - с rate limiting и валидацией -- **Callback**: `v3.dscrs.site/oauth/{provider}/callback` - безопасный redirect_uri -- **Logout**: `v3.dscrs.site/auth/logout` - отзыв httpOnly cookies -- **Финализация**: `testing.discours.io/oauth?redirect_url=...` - минимальная схема - -### 📊 Security Test Coverage -- ✅ Open redirect attack prevention -- ✅ Rate limiting protection -- ✅ Provider validation -- ✅ Safe fallback mechanisms -- ✅ Cookie security (httpOnly + Secure + SameSite) -- ✅ GlitchTip integration (8 тестов алертов) - -### 📝 Documentation -- Создан `docs/oauth-minimal-flow.md` - полное описание минимального flow -- Обновлена документация OAuth в `docs/auth/oauth.md` -- Добавлены security best practices +## [0.9.28] - OAuth/Auth with httpOnly cookie ## [0.9.27] - 2025-09-25 diff --git a/auth/__init__.py b/auth/__init__.py index f8d88217..7183672b 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -50,7 +50,7 @@ async def logout(request: Request) -> Response: key=SESSION_COOKIE_NAME, secure=SESSION_COOKIE_SECURE, httponly=SESSION_COOKIE_HTTPONLY, - samesite=SESSION_COOKIE_SAMESITE, + samesite=SESSION_COOKIE_SAMESITE if SESSION_COOKIE_SAMESITE in ["strict", "lax", "none"] else "none", ) logger.info("[auth] logout: Cookie успешно удалена") @@ -117,7 +117,7 @@ async def refresh_token(request: Request) -> JSONResponse: value=new_token, httponly=SESSION_COOKIE_HTTPONLY, secure=SESSION_COOKIE_SECURE, - samesite=SESSION_COOKIE_SAMESITE, + samesite=SESSION_COOKIE_SAMESITE if SESSION_COOKIE_SAMESITE in ["strict", "lax", "none"] else "none", max_age=SESSION_COOKIE_MAX_AGE, ) diff --git a/auth/middleware.py b/auth/middleware.py index f5c47413..333ddee5 100644 --- a/auth/middleware.py +++ b/auth/middleware.py @@ -2,7 +2,6 @@ Единый middleware для обработки авторизации в GraphQL запросах """ -import json import time from collections.abc import Awaitable, MutableMapping from typing import Any, Callable @@ -21,8 +20,8 @@ from settings import ( ADMIN_EMAILS as ADMIN_EMAILS_LIST, ) from settings import ( + SESSION_COOKIE_DOMAIN, SESSION_COOKIE_HTTPONLY, - SESSION_COOKIE_MAX_AGE, SESSION_COOKIE_NAME, SESSION_COOKIE_SAMESITE, SESSION_COOKIE_SECURE, @@ -294,34 +293,12 @@ class AuthMiddleware: logger.debug(f"[middleware] Проверяем cookies: {cookies[:100]}...") logger.debug(f"[middleware] Ищем cookie с именем: '{SESSION_COOKIE_NAME}'") - # 🔍 Дополнительная диагностика для отладки + # 🔍 Диагностика cookies (только для debug уровня) if not cookies: - logger.warning("[middleware] 🚨 ПРОБЛЕМА: Cookie заголовок полностью отсутствует!") - logger.warning(f"[middleware] 🔍 Все заголовки: {list(headers.keys())}") - # Проверяем, есть ли активные сессии для этого пользователя - try: - session_keys = await redis_adapter.keys("session:*") - if session_keys: - logger.warning( - f"[middleware] 🔍 В Redis найдено {len(session_keys)} активных сессий, но cookie не передается!" - ) - # Показываем первые 3 сессии для диагностики - for session_key in session_keys[:3]: - try: - session_data = await redis_adapter.hgetall(session_key) - if session_data: - user_id = ( - session_key.decode("utf-8").split(":")[1] - if isinstance(session_key, bytes) - else session_key.split(":")[1] - ) - logger.warning( - f"[middleware] 🔍 Активная сессия для user_id={user_id}: {session_key}" - ) - except Exception as e: - logger.debug(f"[middleware] Ошибка чтения сессии {session_key}: {e}") - except Exception as e: - logger.debug(f"[middleware] Ошибка проверки сессий: {e}") + logger.debug("[middleware] Cookie заголовок отсутствует") + logger.debug(f"[middleware] Доступные заголовки: {list(headers.keys())}") + # 💋 OAuth не использует cookies - это нормальное поведение + logger.debug("[middleware] OAuth система работает без cookies - токены передаются через заголовки") cookie_items = cookies.split(";") found_cookies = [] @@ -472,23 +449,7 @@ class AuthMiddleware: """ # Проверяем, является ли result уже объектом Response - if isinstance(result, Response): - response = result - # Пытаемся получить данные из response для проверки логина/логаута - result_data = {} - if isinstance(result, JSONResponse): - try: - body_content = result.body - if isinstance(body_content, bytes | memoryview): - body_text = bytes(body_content).decode("utf-8") - result_data = json.loads(body_text) - else: - result_data = json.loads(str(body_content)) - except Exception as e: - logger.error(f"[process_result] Не удалось извлечь данные из JSONResponse: {e!s}") - else: - response = JSONResponse(result) - result_data = result + response = result if isinstance(result, Response) else JSONResponse(result) # Проверяем, был ли токен в запросе или ответе if request.method == "POST": @@ -496,55 +457,13 @@ class AuthMiddleware: data = await request.json() op_name = data.get("operationName", "").lower() - # Если это операция логина или обновления токена, и в ответе есть токен - if op_name in ["login", "refreshtoken"]: - token = None - # Пытаемся извлечь токен из данных ответа - if result_data and isinstance(result_data, dict): - data_obj = result_data.get("data", {}) - if isinstance(data_obj, dict) and op_name in data_obj: - op_result = data_obj.get(op_name, {}) - if isinstance(op_result, dict) and "token" in op_result: - token = op_result.get("token") - - if token: - # Устанавливаем cookie с токеном - response.set_cookie( - key=SESSION_COOKIE_NAME, - value=token, - httponly=SESSION_COOKIE_HTTPONLY, - secure=SESSION_COOKIE_SECURE, - samesite=SESSION_COOKIE_SAMESITE, - max_age=SESSION_COOKIE_MAX_AGE, - ) - logger.debug( - f"[graphql_handler] Установлена cookie {SESSION_COOKIE_NAME} для операции {op_name}" - ) - - # Если это операция getSession и в ответе есть токен, устанавливаем cookie - elif op_name == "getsession": - token = None - # Пытаемся извлечь токен из данных ответа - if result_data and isinstance(result_data, dict): - data_obj = result_data.get("data", {}) - if isinstance(data_obj, dict) and "getSession" in data_obj: - op_result = data_obj.get("getSession", {}) - if isinstance(op_result, dict) and "token" in op_result and op_result.get("success"): - token = op_result.get("token") - - if token: - # Устанавливаем cookie с токеном для поддержания сессии - response.set_cookie( - key=SESSION_COOKIE_NAME, - value=token, - httponly=SESSION_COOKIE_HTTPONLY, - secure=SESSION_COOKIE_SECURE, - samesite=SESSION_COOKIE_SAMESITE, - max_age=SESSION_COOKIE_MAX_AGE, - ) - logger.debug( - f"[graphql_handler] Установлена cookie {SESSION_COOKIE_NAME} для операции {op_name}" - ) + # 💋 OAuth НЕ использует cookies - токены передаются только через заголовки/localStorage + # Убираем автоматическую установку cookies для login/refreshtoken/getSession + if op_name in ["login", "refreshtoken", "getsession"]: + logger.debug(f"[graphql_handler] Операция {op_name}: токены передаются БЕЗ cookies") + logger.debug( + "[graphql_handler] Фронтенд должен извлечь токен из ответа и управлять им самостоятельно" + ) # Если это операция logout, удаляем cookie elif op_name == "logout": @@ -552,7 +471,10 @@ class AuthMiddleware: key=SESSION_COOKIE_NAME, secure=SESSION_COOKIE_SECURE, httponly=SESSION_COOKIE_HTTPONLY, - samesite=SESSION_COOKIE_SAMESITE, + samesite=SESSION_COOKIE_SAMESITE + if SESSION_COOKIE_SAMESITE in ["strict", "lax", "none"] + else "none", + domain=SESSION_COOKIE_DOMAIN, # ✅ КРИТИЧНО: тот же domain что при установке ) logger.debug(f"[graphql_handler] Удалена cookie {SESSION_COOKIE_NAME} для операции {op_name}") except Exception as e: diff --git a/auth/oauth.py b/auth/oauth.py index 50ba9796..fad47fe0 100644 --- a/auth/oauth.py +++ b/auth/oauth.py @@ -16,6 +16,7 @@ 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, @@ -526,48 +527,25 @@ async def oauth_callback(request: Any) -> JSONResponse | RedirectResponse: ) ) - logger.info(f"🔗 OAuth redirect URL: {final_redirect_url}") - - # Создаем ответ с редиректом - response = RedirectResponse(url=final_redirect_url) - - # 🔍 Диагностика перед установкой cookie - logger.info(f"🔍 Готовимся установить cookie для redirect на: {parsed_redirect.netloc}") - logger.info(f"🔍 Текущие настройки cookie: secure={SESSION_COOKIE_SECURE}, samesite={SESSION_COOKIE_SAMESITE}") - - # 🍪 Устанавливаем httpOnly cookie для безопасности - # 💋 Исправляем domain для testing.discours.io - не используем wildcard domain - cookie_domain = None # Убираем wildcard domain для корректной работы - cookie_samesite = SESSION_COOKIE_SAMESITE - - if "discours.io" in parsed_redirect.netloc: - # 💋 ЭКСТРЕННОЕ ИСПРАВЛЕНИЕ: Используем wildcard domain для всех discours.io - cookie_domain = ".discours.io" # Работает для всех поддоменов включая testing.discours.io - cookie_samesite = "lax" # Безопасный вариант для same-site запросов - - # 💋 Принудительно включаем Secure для всех discours.io доменов (всегда HTTPS) - cookie_secure = SESSION_COOKIE_SECURE - if "discours.io" in parsed_redirect.netloc: - cookie_secure = True # Все discours.io домены используют HTTPS + # 🍪 Устанавливаем httpOnly cookie вместо токена в URL + response = RedirectResponse(url=redirect_uri, status_code=307) response.set_cookie( - SESSION_COOKIE_NAME, - session_token, + key=SESSION_COOKIE_NAME, + value=session_token, httponly=SESSION_COOKIE_HTTPONLY, - secure=cookie_secure, - samesite=cookie_samesite, + 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="/", # Важно: устанавливаем path="/" для доступности cookie во всех путях - domain=cookie_domain, # Поддержка поддоменов только для основного домена + path="/", + domain=SESSION_COOKIE_DOMAIN, # ✅ Для работы с поддоменами ) - # 🔍 Дополнительная диагностика cookie - logger.warning("🚨 ВАЖНО: Cookie должен быть установлен в браузере и отправляться в последующих запросах!") - logger.warning("🚨 Если cookie не передается, проверьте:") - logger.warning(f" - Браузер принимает cookie с domain={cookie_domain}") - logger.warning(f" - HTTPS работает правильно (secure={cookie_secure})") - logger.warning(f" - SameSite политика не блокирует (samesite={cookie_samesite})") - logger.warning(" - Path='/' доступен для всех запросов") + 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 @@ -883,55 +861,30 @@ async def oauth_callback_http(request: Request) -> JSONResponse | RedirectRespon logger.info(f" - Provider: {provider}") logger.info(f" - User ID: {author.id}") - # Возвращаем redirect с токеном в URL - response = RedirectResponse(url=final_redirect_url, status_code=307) - - # 🍪 Устанавливаем httpOnly cookie для безопасности - # 💋 Исправляем domain для testing.discours.io - не используем wildcard domain - cookie_domain = None # Убираем wildcard domain для корректной работы - cookie_samesite = SESSION_COOKIE_SAMESITE - - if "discours.io" in parsed_redirect.netloc: - # 💋 ЭКСТРЕННОЕ ИСПРАВЛЕНИЕ: Используем wildcard domain для всех discours.io - cookie_domain = ".discours.io" # Работает для всех поддоменов включая testing.discours.io - cookie_samesite = "lax" # Безопасный вариант для same-site запросов - - # 💋 Принудительно включаем Secure для всех discours.io доменов (всегда HTTPS) - cookie_secure = SESSION_COOKIE_SECURE - if "discours.io" in parsed_redirect.netloc: - cookie_secure = True # Все discours.io домены используют HTTPS + # 🍪 Устанавливаем httpOnly cookie вместо токена в URL + response = RedirectResponse(url=redirect_uri, status_code=307) response.set_cookie( - SESSION_COOKIE_NAME, - session_token, + key=SESSION_COOKIE_NAME, + value=session_token, httponly=SESSION_COOKIE_HTTPONLY, - secure=cookie_secure, - samesite=cookie_samesite, + secure=SESSION_COOKIE_SECURE, + samesite=SESSION_COOKIE_SAMESITE, max_age=SESSION_COOKIE_MAX_AGE, - path="/", # Важно: устанавливаем path="/" для доступности cookie во всех путях - domain=cookie_domain, # Поддержка поддоменов только для основного домена + 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 установлен: name={SESSION_COOKIE_NAME}, domain={cookie_domain}, secure={cookie_secure}, samesite={cookie_samesite}" - ) - logger.info( - f"🔍 Cookie debug: redirect_netloc={parsed_redirect.netloc}, is_testing={('testing.discours.io' in parsed_redirect.netloc)}" + 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"🔗 Final redirect: {final_redirect_url}") - - # 🔍 Дополнительная диагностика cookie - logger.warning("🚨 ВАЖНО: Cookie должен быть установлен в браузере и отправляться в последующих запросах!") - logger.warning("🚨 Если cookie не передается, проверьте:") - logger.warning(f" - Браузер принимает cookie с domain={cookie_domain}") - logger.warning(f" - HTTPS работает правильно (secure={cookie_secure})") - logger.warning(f" - SameSite политика не блокирует (samesite={cookie_samesite})") - logger.warning(" - Path='/' доступен для всех запросов") logger.info(f"✅ OAuth успешно завершен для {provider}, user_id={author.id}") return response diff --git a/codegen.ts b/codegen.ts index 512a9fa5..1040caa3 100644 --- a/codegen.ts +++ b/codegen.ts @@ -3,7 +3,7 @@ import type { CodegenConfig } from '@graphql-codegen/cli' const config: CodegenConfig = { overwrite: true, // Используем основной endpoint с fallback логикой - schema: 'https://v3.dscrs.site/graphql', + schema: 'https://v3.discours.io/graphql', documents: ['panel/graphql/queries/**/*.ts', 'panel/**/*.{ts,tsx}', '!panel/graphql/generated/**'], generates: { './panel/graphql/generated/introspection.json': { diff --git a/docs/auth/README.md b/docs/auth/README.md index 10144d23..b63c708f 100644 --- a/docs/auth/README.md +++ b/docs/auth/README.md @@ -2,17 +2,24 @@ ## 📚 Обзор -Модульная система аутентификации с JWT токенами, Redis-сессиями, OAuth интеграцией и RBAC авторизацией. Поддерживает httpOnly cookies и Bearer токены для веб и API клиентов. +Модульная система аутентификации с **httpOnly cookies**, JWT токенами, Redis-сессиями, OAuth интеграцией и RBAC авторизацией. + +### 🎯 **Единый подход с httpOnly cookies для ВСЕХ типов авторизации:** + +- ✅ **OAuth** (Google/GitHub/Yandex/VK) → httpOnly cookie +- ✅ **Email/Password** → httpOnly cookie +- ✅ **GraphQL запросы** → `credentials: 'include'` +- ✅ **Максимальная безопасность** → защита от XSS/CSRF ## 🚀 Быстрый старт -### Для микросервисов +### Для разработчиков ```python from auth.tokens.sessions import SessionTokenManager from auth.utils import extract_token_from_request -# Проверка токена +# Проверка токена (автоматически из cookie или Bearer заголовка) sessions = SessionTokenManager() token = await extract_token_from_request(request) payload = await sessions.verify_session(token) @@ -22,6 +29,18 @@ if payload: print(f"Пользователь авторизован: {user_id}") ``` +### Для фронтенда + +```typescript +// Все запросы используют httpOnly cookies +const response = await fetch('/graphql', { + method: 'POST', + credentials: 'include', // ✅ КРИТИЧНО: отправляет httpOnly cookies + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }) +}); +``` + ### Redis ключи для поиска ```bash @@ -29,7 +48,7 @@ if payload: session:{user_id}:{token} # Данные сессии (hash) user_sessions:{user_id} # Список активных токенов (set) -# OAuth токены +# OAuth токены (для API интеграций) oauth_access:{user_id}:{provider} # Access токен oauth_refresh:{user_id}:{provider} # Refresh токен ``` @@ -43,7 +62,7 @@ oauth_refresh:{user_id}:{provider} # Refresh токен ### 🔑 Аутентификация - **[Управление сессиями](sessions.md)** - JWT токены и Redis хранение -- **[OAuth интеграция](oauth.md)** - Социальные провайдеры +- **[OAuth интеграция](oauth.md)** - Социальные провайдеры с httpOnly cookies - **[Микросервисы](microservices.md)** - 🎯 **Интеграция с другими сервисами** ### 🛠️ Разработка @@ -56,6 +75,40 @@ oauth_refresh:{user_id}:{provider} # Refresh токен - **[Security System](../security.md)** - Управление паролями и email - **[Redis Schema](../redis-schema.md)** - Схема данных и кеширование +## 🔄 OAuth Flow (обновленный 2025) + +### 1. 🚀 Инициация OAuth +```typescript +// Пользователь нажимает "Войти через Google" +const handleOAuthLogin = (provider: string) => { + window.location.href = `/oauth/${provider}/login`; +}; +``` + +### 2. 🔄 OAuth Callback (бэкенд) +```python +# Google → /oauth/google/callback +# 1. Обменивает code на access_token +# 2. Получает профиль пользователя +# 3. Создает JWT сессию +# 4. Устанавливает httpOnly cookie +# 5. Редиректит на фронтенд БЕЗ токена в URL +``` + +### 3. 🌐 Фронтенд финализация +```typescript +// Проверяем URL на ошибки +const error = urlParams.get('error'); +if (error) { + // Обработка ошибок OAuth + console.error('OAuth error:', error); +} else { + // Успех! httpOnly cookie уже установлен + await auth.checkSession(); // Загружает из cookie + navigate('/dashboard'); +} +``` + ## 🔍 Для микросервисов ### Подключение к Redis @@ -78,23 +131,22 @@ results = await batch.batch_validate_tokens(token_list) ### HTTP заголовки ```python -# Извлечение токена из запроса -from auth.utils import extract_token_from_request, get_safe_headers +# Извлечение токена из запроса (cookie или Bearer) +from auth.utils import extract_token_from_request token = await extract_token_from_request(request) - -# Или вручную -headers = get_safe_headers(request) -token = headers.get("authorization", "").replace("Bearer ", "") +# Автоматически проверяет: +# 1. Authorization: Bearer +# 2. Cookie: session_token= ``` ## 🎯 Основные компоненты -- **SessionTokenManager** - JWT сессии с Redis хранением -- **OAuthTokenManager** - OAuth access/refresh токены +- **SessionTokenManager** - JWT сессии с Redis хранением + httpOnly cookies +- **OAuthTokenManager** - OAuth access/refresh токены для API интеграций - **BatchTokenOperations** - Массовые операции с токенами - **TokenMonitoring** - Мониторинг и статистика -- **AuthMiddleware** - HTTP middleware для автоматической обработки +- **AuthMiddleware** - HTTP middleware с поддержкой cookies ## ⚡ Производительность @@ -103,3 +155,79 @@ token = headers.get("authorization", "").replace("Bearer ", "") - **Pipeline использование** для атомарности - **SCAN** вместо KEYS для безопасности - **TTL** автоматическая очистка истекших токенов +- **httpOnly cookies** - автоматическая отправка браузером + +## 🛡️ Безопасность (2025) + +### Максимальная защита: +- **🚫 Защита от XSS**: httpOnly cookies недоступны JavaScript +- **🔒 Защита от CSRF**: SameSite=lax cookies +- **🛡️ Единообразие**: Все типы авторизации через cookies +- **📱 Автоматическая отправка**: Браузер сам включает cookies + +### Миграция с Bearer токенов: +- ✅ OAuth теперь использует httpOnly cookies (вместо localStorage) +- ✅ Email/Password использует httpOnly cookies (вместо Bearer) +- ✅ Фронтенд: `credentials: 'include'` во всех запросах +- ✅ Middleware поддерживает оба подхода для совместимости + +## 🔧 Настройка + +### Environment Variables +```bash +# OAuth провайдеры +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret + +# Cookie настройки +SESSION_COOKIE_SECURE=true +SESSION_COOKIE_HTTPONLY=true +SESSION_COOKIE_SAMESITE=lax +SESSION_COOKIE_MAX_AGE=2592000 # 30 дней + +# JWT +JWT_SECRET=your_jwt_secret_key +JWT_EXPIRATION_HOURS=720 # 30 дней + +# Redis +REDIS_URL=redis://localhost:6379/0 +``` + +### Быстрая проверка +```bash +# Проверка OAuth провайдеров +curl https://your-domain.com/oauth/google + +# Проверка сессии +curl -b "session_token=your_token" https://your-domain.com/graphql \ + -d '{"query":"query { getSession { success author { id } } }"}' +``` + +## 📊 Мониторинг + +```python +from auth.tokens.monitoring import TokenMonitoring + +monitoring = TokenMonitoring() + +# Статистика токенов +stats = await monitoring.get_token_statistics() +print(f"Active sessions: {stats['session_tokens']}") +print(f"Memory usage: {stats['memory_usage'] / 1024 / 1024:.2f} MB") + +# Health check +health = await monitoring.health_check() +if health["status"] == "healthy": + print("✅ Auth system is 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 diff --git a/docs/auth/architecture.md b/docs/auth/architecture.md index c916038b..acc1b2ad 100644 --- a/docs/auth/architecture.md +++ b/docs/auth/architecture.md @@ -1,12 +1,19 @@ -# Архитектура системы авторизации Discours Core +# 🏗️ Архитектура системы авторизации Discours Core -## 🎯 Обзор архитектуры +## 🎯 Обзор архитектуры 2025 -Модульная система авторизации с разделением ответственности между компонентами. +Модульная система авторизации с **httpOnly cookies** для максимальной безопасности и единообразия. + +**Ключевые принципы:** +- **🍪 httpOnly cookies** для ВСЕХ типов авторизации (OAuth + Email/Password) +- **🛡️ Максимальная безопасность** - защита от XSS и CSRF +- **🔄 Единообразие** - один механизм для всех провайдеров +- **📱 Автоматическое управление** - браузер сам отправляет cookies **Хранение данных:** -- **Токены** → Redis (сессии, OAuth, verification) -- **Пользователи** → PostgreSQL (основные данные + OAuth в JSON поле) +- **Сессии** → Redis (JWT токены) + httpOnly cookies (передача) +- **OAuth токены** → Redis (для API интеграций) +- **Пользователи** → PostgreSQL (основные данные + OAuth связи) ## 📊 Схема потоков данных @@ -121,67 +128,90 @@ graph TB OTM --> RESP ``` -## 🔐 OAuth Flow +## 🔐 OAuth Flow (httpOnly cookies) ```mermaid sequenceDiagram participant U as User participant F as Frontend - participant A as Auth Service + participant B as Backend participant R as Redis participant P as OAuth Provider U->>F: Click "Login with Provider" - F->>A: GET /oauth/{provider}?state={csrf} - A->>R: Store OAuth state (TTL: 10 min) - A->>P: Redirect to Provider + F->>B: GET /oauth/{provider}/login + B->>R: Store OAuth state (TTL: 10 min) + B->>P: Redirect to Provider P->>U: Show authorization page U->>P: Grant permission - P->>A: GET /oauth/{provider}/callback?code={code}&state={state} - A->>R: Verify state - A->>P: Exchange code for token - P->>A: Return access token + user data - A->>R: Store OAuth tokens - A->>A: Generate JWT session token - A->>R: Store session in Redis - A->>F: Redirect with JWT token - F->>U: User logged in + P->>B: GET /oauth/{provider}/callback?code={code}&state={state} + B->>R: Verify state + B->>P: Exchange code for token + P->>B: Return access token + user data + B->>B: Create/update user + B->>B: Generate JWT session token + B->>R: Store session in Redis + B->>F: Redirect + Set httpOnly cookie + Note over B,F: Cookie: session_token=JWT
HttpOnly, Secure, SameSite=lax + F->>U: User logged in (cookie automatic) + + Note over F,B: All subsequent requests + F->>B: GraphQL with credentials: 'include' + Note over F,B: Browser automatically sends cookie ``` -## 🔄 Session Management +## 🔄 Session Management (httpOnly cookies) ```mermaid stateDiagram-v2 [*] --> Anonymous - Anonymous --> Authenticating: Login attempt - Authenticating --> Authenticated: Valid JWT + Redis session + Anonymous --> Authenticating: Login attempt (OAuth/Email) + Authenticating --> Authenticated: Valid JWT + httpOnly cookie set Authenticating --> Anonymous: Invalid credentials Authenticated --> Refreshing: Token near expiry - Refreshing --> Authenticated: Successful refresh + Refreshing --> Authenticated: New httpOnly cookie set Refreshing --> Anonymous: Refresh failed - Authenticated --> Anonymous: Logout/Revoke - Authenticated --> Anonymous: Token expired + Authenticated --> Anonymous: Logout (cookie deleted) + Authenticated --> Anonymous: Token expired (cookie invalid) + + note right of Authenticated + All requests include + httpOnly cookie automatically + via credentials: 'include' + end note ``` ## 🗄️ Redis структура данных ```bash -# JWT Sessions +# JWT Sessions (основные - передаются через httpOnly cookies) session:{user_id}:{token} # Hash: {user_id, username, device_info, last_activity} user_sessions:{user_id} # Set: {token1, token2, ...} -# Verification Tokens -verification_token:{token} # JSON: {user_id, type, data, created_at} - -# OAuth Tokens +# OAuth Tokens (для API интеграций - НЕ для аутентификации) oauth_access:{user_id}:{provider} # JSON: {token, expires_in, scope} oauth_refresh:{user_id}:{provider} # JSON: {token, provider_data} -oauth_state:{state} # JSON: {provider, redirect_uri, code_verifier} -# Legacy (для совместимости) -{user_id}-{username}-{token} # Hash: legacy format +# OAuth State (временные - для CSRF защиты) +oauth_state:{state} # JSON: {provider, redirect_uri, code_verifier} TTL: 10 мин + +# Verification Tokens (email подтверждения и т.д.) +verification_token:{token} # JSON: {user_id, type, data, created_at} ``` +### 🔄 Изменения в архитектуре 2025: + +**Убрано:** +- ❌ Токены в URL параметрах (небезопасно) +- ❌ localStorage для основных токенов (уязвимо к XSS) +- ❌ Bearer заголовки для веб-приложений (сложнее управлять) + +**Добавлено:** +- ✅ httpOnly cookies для всех типов авторизации +- ✅ Автоматическая отправка cookies браузером +- ✅ SameSite защита от CSRF +- ✅ Secure flag для HTTPS + ### Примеры Redis команд ```bash diff --git a/docs/auth/oauth.md b/docs/auth/oauth.md index 57b1f9a5..c0b31087 100644 --- a/docs/auth/oauth.md +++ b/docs/auth/oauth.md @@ -1,34 +1,285 @@ -# OAuth Integration Guide +# 🔐 OAuth Integration Guide ## 🎯 Обзор -Система OAuth интеграции с поддержкой популярных провайдеров. Токены хранятся в Redis с автоматическим TTL и поддержкой refresh. +Система OAuth интеграции с **httpOnly cookies** для максимальной безопасности. Поддержка популярных провайдеров с единым подходом к аутентификации. -## 🚀 Быстрый старт +### 🔄 **Архитектура 2025: httpOnly cookies для всех** -### Поддерживаемые провайдеры -- **Google** ✅ - OpenID Connect (актуальные endpoints) -- **GitHub** ✅ - OAuth 2.0 (scope: read:user user:email) -- **Facebook** ✅ - Facebook Login API v18.0+ (scope: email public_profile) -- **VK** ✅ - VK OAuth API v5.199+ (scope: email) -- **X (Twitter)** ✅ - OAuth 2.0 API v2 (scope: tweet.read users.read) -- **Yandex** ✅ - Yandex OAuth (scope: login:email login:info login:avatar) -- **Telegram** ⚠️ - Telegram Login (специфическая реализация) +```mermaid +sequenceDiagram + participant U as User + participant F as Frontend + participant B as Backend + participant P as OAuth Provider -### Redis структура -```bash -oauth_access:{user_id}:{provider} # Access токены -oauth_refresh:{user_id}:{provider} # Refresh токены -oauth_state:{state} # OAuth state с TTL 10 минут + U->>F: Click "Login with Google" + F->>B: GET /oauth/google/login + B->>P: Redirect to Provider + P->>U: Show authorization page + U->>P: Grant permission + P->>B: GET /oauth/google/callback?code=xxx + 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) ``` -### Основные операции +## 🚀 Поддерживаемые провайдеры + +| Провайдер | Статус | Особенности | +|-----------|--------|-------------| +| **Google** | ✅ | OpenID Connect, актуальные endpoints | +| **GitHub** | ✅ | OAuth 2.0, scope: `read:user user:email` | +| **Yandex** | ✅ | OAuth, scope: `login:email login:info` | +| **VK** | ✅ | OAuth API v5.199+, scope: `email` | +| **Facebook** | ✅ | Facebook Login API v18.0+ | +| **X (Twitter)** | ✅ | OAuth 2.0 API v2 | + +## 🔧 OAuth Flow + +### 1. 🚀 Инициация OAuth (Фронтенд) + +```typescript +// Простой редирект - backend получит redirect_uri из Referer header +const handleOAuthLogin = (provider: string) => { + // Сохраняем текущую страницу для возврата + localStorage.setItem('oauth_return_url', window.location.pathname); + + // Редиректим на OAuth endpoint + window.location.href = `/oauth/${provider}/login`; +}; + +// Использование + +``` + +### 2. 🔄 Backend Endpoints + +#### GET `/oauth/{provider}/login` - Старт OAuth +```python +# /oauth/github/login +# 1. Сохраняет redirect_uri из Referer header в Redis state +# 2. Генерирует PKCE challenge для безопасности +# 3. Редиректит на провайдера с параметрами авторизации +``` + +#### GET `/oauth/{provider}/callback` - Callback +```python +# GitHub → /oauth/github/callback?code=xxx&state=yyy +# 1. Валидирует state (CSRF защита) +# 2. Обменивает code на access_token +# 3. Получает профиль пользователя +# 4. Создает/обновляет пользователя в БД +# 5. Создает JWT сессию +# 6. Устанавливает httpOnly cookie +# 7. Редиректит на фронтенд БЕЗ токена в URL +``` + +### 3. 🌐 Фронтенд финализация + +```typescript +// OAuth callback route (/oauth/callback или аналогичный) +export default function OAuthCallback() { + const navigate = useNavigate(); + const auth = useAuth(); + + onMount(async () => { + const urlParams = new URLSearchParams(window.location.search); + const error = urlParams.get('error'); + + if (error) { + // ❌ Ошибка OAuth + console.error('OAuth error:', error); + + switch (error) { + case 'access_denied': + alert('Доступ отклонен провайдером'); + break; + case 'oauth_state_expired': + alert('Сессия OAuth истекла. Попробуйте еще раз.'); + break; + default: + alert('Ошибка авторизации. Попробуйте еще раз.'); + } + + navigate('/login'); + } 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'); + } + } + }); + + return ( +
+

Завершение авторизации...

+

Пожалуйста, подождите...

+
+ ); +} +``` + +### 4. 🍪 Единая аутентификация через httpOnly cookie + +```typescript +// GraphQL клиент использует httpOnly cookie +const graphqlRequest = async (query: string, variables?: any) => { + const response = await fetch('/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', // ✅ КРИТИЧНО: отправляет httpOnly cookie + body: JSON.stringify({ query, variables }) + }); + + return response.json(); +}; + +// Auth Context +export const AuthProvider = (props: { children: JSX.Element }) => { + const [user, setUser] = createSignal(null); + + const checkSession = async () => { + try { + const response = await graphqlRequest(` + query GetSession { + getSession { + success + author { id slug email name } + } + } + `); + + if (response.data?.getSession?.success) { + setUser(response.data.getSession.author); + } else { + setUser(null); + } + } catch (error) { + console.error('Session check failed:', error); + setUser(null); + } + }; + + const logout = async () => { + try { + // Удаляем httpOnly cookie на бэкенде + await graphqlRequest(`mutation { logout { success } }`); + } catch (error) { + console.error('Logout error:', error); + } + + setUser(null); + window.location.href = '/'; + }; + + // Проверяем сессию при загрузке + onMount(() => checkSession()); + + return ( + !!user(), + checkSession, + logout, + }}> + {props.children} + + ); +}; +``` + +## 🔐 Настройка провайдеров + +### Google OAuth +1. [Google Cloud Console](https://console.cloud.google.com/) +2. **APIs & Services** → **Credentials** → **OAuth 2.0 Client ID** +3. **Authorized redirect URIs**: `https://your-domain.com/oauth/google/callback` + +```bash +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret +``` + +### GitHub OAuth +1. [GitHub Developer Settings](https://github.com/settings/developers) +2. **New OAuth App** +3. **Authorization callback URL**: `https://your-domain.com/oauth/github/callback` + +```bash +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret +``` + +### Yandex OAuth +1. [Yandex OAuth](https://oauth.yandex.ru/) +2. **Создать новое приложение** +3. **Callback URI**: `https://your-domain.com/oauth/yandex/callback` +4. **Права**: `login:info`, `login:email`, `login:avatar` + +```bash +YANDEX_CLIENT_ID=your_yandex_client_id +YANDEX_CLIENT_SECRET=your_yandex_client_secret +``` + +### VK OAuth +1. [VK Developers](https://dev.vk.com/apps) +2. **Создать приложение** → **Веб-сайт** +3. **Redirect URI**: `https://your-domain.com/oauth/vk/callback` + +```bash +VK_CLIENT_ID=your_vk_app_id +VK_CLIENT_SECRET=your_vk_secure_key +``` + +## 🛡️ Безопасность + +### httpOnly Cookie настройки +```python +# settings.py +SESSION_COOKIE_NAME = "session_token" +SESSION_COOKIE_HTTPONLY = True # Защита от XSS +SESSION_COOKIE_SECURE = True # Только HTTPS +SESSION_COOKIE_SAMESITE = "lax" # CSRF защита +SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 дней +``` + +### CSRF Protection +- **State parameter**: Криптографически стойкий state для каждого запроса +- **PKCE**: Code challenge для дополнительной защиты +- **Redirect URI validation**: Проверка разрешенных доменов + +### TTL и истечение +- **OAuth state**: 10 минут (одноразовое использование) +- **Session tokens**: 30 дней (настраивается) +- **Автоматическая очистка**: Redis удаляет истекшие токены + +## 🔧 API для разработчиков + +### Проверка OAuth токенов ```python from auth.tokens.oauth import OAuthTokenManager oauth = OAuthTokenManager() -# Сохранение токенов +# Сохранение OAuth токенов (для API интеграций) await oauth.store_oauth_tokens( user_id="123", provider="google", @@ -37,584 +288,98 @@ await oauth.store_oauth_tokens( expires_in=3600 ) -# Получение токена -access_data = await oauth.get_token(user_id, "google", "oauth_access") - -# Отзыв токенов -await oauth.revoke_oauth_tokens(user_id, "google") -``` - -## 🔧 OAuth Flow - -### 1. Инициация OAuth (Фронтенд) -```javascript -// Простой вызов без параметров - backend получит redirect_uri из Referer header -const oauth = (provider: string) => { - window.location.href = `https://v3.dscrs.site/oauth/${provider}` -} -``` - -### 2. Backend Endpoints - -#### GET `/oauth/{provider}` - Старт OAuth -```python -# v3.dscrs.site/oauth/github -# 1. Сохраняет redirect_uri из Referer header в Redis state -# 2. Редиректит на провайдера с PKCE challenge -``` - -#### GET `/oauth/{provider}/callback` - Callback -```python -# GitHub → v3.dscrs.site/oauth/github/callback?code=xxx&state=yyy -# 1. Обменивает code на access_token -# 2. Получает профиль пользователя -# 3. Создает/обновляет пользователя -# 4. Создает JWT сессию -# 5. Устанавливает httpOnly cookie (для GraphQL) -# 6. Редиректит на https://testing.discours.io/oauth?redirect_url=... (JWT в httpOnly cookie) -``` - -### 3. Фронтенд финализация -```javascript -// https://testing.discours.io/oauth роут -const urlParams = new URLSearchParams(window.location.search) -const error = urlParams.get('error') -const redirectUrl = urlParams.get('redirect_url') || '/' - -if (error) { - // Обработка ошибок OAuth - console.error('OAuth error:', error) - alert('Authentication failed. Please try again.') - window.location.href = '/' -} else { - // Нет ошибки = успех! JWT уже в httpOnly cookie - // SessionProvider загружает сессию из cookie - await sessionProvider.loadSession() - - // Редиректим на исходную страницу - window.location.href = redirectUrl -} -``` - -### 4. Единая аутентификация через httpOnly cookie -```javascript -// GraphQL клиент использует httpOnly cookie -const client = new ApolloClient({ - uri: 'https://v3.dscrs.site/graphql', - credentials: 'include', // ✅ Отправляет httpOnly cookie -}) - -// Все API вызовы также используют httpOnly cookie -fetch('/api/endpoint', { - credentials: 'include' // ✅ Отправляет httpOnly cookie -}) -``` - -### 4. Настройки провайдеров (админки) -- **GitHub**: `https://v3.dscrs.site/oauth/github/callback` -- **Google**: `https://v3.dscrs.site/oauth/google/callback` -- **Twitter**: `https://v3.dscrs.site/oauth/twitter/callback` -async def oauth_redirect( - provider: str, - state: str, - redirect_uri: str, - request: Request -): - # Валидация провайдера - if provider not in SUPPORTED_PROVIDERS: - raise HTTPException(status_code=400, detail="Unsupported OAuth provider") - - # Сохранение state в Redis - await store_oauth_state(state, redirect_uri) - - # Генерация URL провайдера - oauth_url = generate_provider_url(provider, state, redirect_uri) - - return RedirectResponse(url=oauth_url) -``` - -#### GET `/oauth/{provider}/callback` -```python -@router.get("/oauth/{provider}/callback") -async def oauth_callback( - provider: str, - code: str, - state: str, - request: Request -): - # Проверка state - stored_data = await get_oauth_state(state) - if not stored_data: - raise HTTPException(status_code=400, detail="Invalid or expired state") - - # Обмен code на access_token - try: - user_data = await exchange_code_for_user_data(provider, code) - except OAuthException as e: - logger.error(f"OAuth error for {provider}: {e}") - return RedirectResponse(url=f"{stored_data['redirect_uri']}?error=oauth_failed") - - # Поиск/создание пользователя - user = await get_or_create_user_from_oauth(provider, user_data) - - # Генерация JWT токена - access_token = generate_jwt_token(user.id) - - # Редирект обратно на фронтенд - redirect_url = f"{stored_data['redirect_uri']}?state={state}&access_token={access_token}" - return RedirectResponse(url=redirect_url) -``` - -### 3. OAuth State Management -```python -import redis -from datetime import timedelta - -redis_client = redis.Redis() - -async def store_oauth_state( - state: str, - redirect_uri: str, - ttl: timedelta = timedelta(minutes=10) -): - """Сохранение OAuth state с TTL""" - key = f"oauth_state:{state}" - data = { - "redirect_uri": redirect_uri, - "created_at": datetime.utcnow().isoformat() - } - await redis_client.setex(key, ttl, json.dumps(data)) - -async def get_oauth_state(state: str) -> Optional[dict]: - """Получение и удаление OAuth state""" - key = f"oauth_state:{state}" - data = await redis_client.get(key) - if data: - await redis_client.delete(key) # One-time use - return json.loads(data) - return None -``` - -## 🔐 Провайдеры - -### Google OAuth -```python -GOOGLE_OAUTH_CONFIG = { - "client_id": os.getenv("GOOGLE_CLIENT_ID"), - "client_secret": os.getenv("GOOGLE_CLIENT_SECRET"), - "server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration", - "scope": "openid email profile" -} -``` - -**✅ Преимущества OpenID Connect:** -- Автоматическое обнаружение endpoints через `.well-known/openid-configuration` -- Поддержка актуальных стандартов безопасности -- Автоматические обновления при изменениях Google API - -### GitHub OAuth -```python -GITHUB_OAUTH_CONFIG = { - "client_id": os.getenv("GITHUB_CLIENT_ID"), - "client_secret": os.getenv("GITHUB_CLIENT_SECRET"), - "auth_url": "https://github.com/login/oauth/authorize", - "token_url": "https://github.com/login/oauth/access_token", - "user_info_url": "https://api.github.com/user", - "scope": "read:user user:email" -} -``` - -**⚠️ Важные требования GitHub:** -- Scope `user:email` **обязателен** для получения email адреса -- Проверяйте rate limits (5000 запросов/час для авторизованных пользователей) -- Используйте `User-Agent` header во всех запросах к API - -### Facebook OAuth -```python -FACEBOOK_OAUTH_CONFIG = { - "client_id": os.getenv("FACEBOOK_APP_ID"), - "client_secret": os.getenv("FACEBOOK_APP_SECRET"), - "auth_url": "https://www.facebook.com/v18.0/dialog/oauth", - "token_url": "https://graph.facebook.com/v18.0/oauth/access_token", - "user_info_url": "https://graph.facebook.com/v18.0/me", - "scope": "email public_profile", - "token_endpoint_auth_method": "client_secret_post" # Требование Facebook -} -``` - -**⚠️ Важные требования Facebook:** -- Используйте **минимум API v18.0** -- Обязательно настройте **точные Redirect URIs** в Facebook App -- Приложение должно быть в режиме **"Live"** для работы с реальными пользователями -- **HTTPS обязателен** для production окружения - -### VK OAuth -```python -VK_OAUTH_CONFIG = { - "client_id": os.getenv("VK_APP_ID"), - "client_secret": os.getenv("VK_APP_SECRET"), - "auth_url": "https://oauth.vk.com/authorize", - "token_url": "https://oauth.vk.com/access_token", - "user_info_url": "https://api.vk.com/method/users.get", - "scope": "email", - "api_version": "5.199" # Актуальная версия API -} -``` - -**⚠️ Важные требования VK:** -- Используйте **API версию 5.199+** (5.131 устарела) -- Scope `email` необходим для получения email адреса -- Redirect URI должен **точно совпадать** с настройками в приложении VK -- Поддерживаются только HTTPS redirect URI в production - -### X (Twitter) OAuth -```python -X_OAUTH_CONFIG = { - "client_id": os.getenv("X_CLIENT_ID"), - "client_secret": os.getenv("X_CLIENT_SECRET"), - "auth_url": "https://twitter.com/i/oauth2/authorize", - "token_url": "https://api.twitter.com/2/oauth2/token", - "user_info_url": "https://api.twitter.com/2/users/me", - "scope": "tweet.read users.read" -} -``` - -**⚠️ Важные требования X:** -- Используйте **API v2** endpoints -- Scope `users.read` обязателен для получения профиля -- Email недоступен через публичное API -- Требуется верификация приложения для production - -### Yandex OAuth -```python -YANDEX_OAUTH_CONFIG = { - "client_id": os.getenv("YANDEX_CLIENT_ID"), - "client_secret": os.getenv("YANDEX_CLIENT_SECRET"), - "auth_url": "https://oauth.yandex.ru/authorize", - "token_url": "https://oauth.yandex.ru/token", - "user_info_url": "https://login.yandex.ru/info", - "scope": "login:email login:info login:avatar" -} -``` - -**⚠️ Важные требования Yandex:** -- Scope `login:email` для получения email -- Scope `login:info` для базовой информации профиля -- Scope `login:avatar` для получения аватара -- Поддержка только HTTPS redirect URI - -### Telegram OAuth -```python -TELEGRAM_OAUTH_CONFIG = { - "client_id": os.getenv("TELEGRAM_CLIENT_ID"), - "client_secret": os.getenv("TELEGRAM_CLIENT_SECRET"), - "auth_url": "https://oauth.telegram.org/auth", - "token_url": "https://oauth.telegram.org/auth/request", - "scope": "read" -} -``` - -**⚠️ Важные требования Telegram:** -- Специальная настройка через @BotFather -- Email недоступен - используется временный email -- Получение номера телефона требует дополнительных разрешений - -## 🔒 Безопасность - -### TTL и истечение токенов -- **Access tokens**: 1 час (настраивается) -- **Refresh tokens**: 30 дней -- **OAuth state**: 10 минут -- **Автоматическая очистка**: Redis удаляет истекшие токены -- **Изоляция провайдеров**: Токены разных провайдеров хранятся отдельно - -### CSRF Protection -```python -def validate_oauth_state(stored_state: str, received_state: str) -> bool: - """Проверка OAuth state для защиты от CSRF""" - return stored_state == received_state - -def validate_redirect_uri(uri: str) -> bool: - """Валидация redirect_uri для предотвращения открытых редиректов""" - allowed_domains = [ - "localhost:3000", - "discours.io", - "new.discours.io" - ] - - parsed = urlparse(uri) - return any(domain in parsed.netloc for domain in allowed_domains) -``` - -## 💡 Практические примеры - -### OAuth Login Flow -```python -from auth.oauth import oauth_login, oauth_callback, _create_or_update_user -from auth.oauth import oauth_login_http, oauth_callback_http -from auth.oauth import store_oauth_state, get_oauth_state - -# GraphQL resolver для OAuth login -async def handle_oauth_login(provider: str, callback_data: dict): - """Инициация OAuth авторизации""" - return await oauth_login(None, info, provider, callback_data) - -# HTTP handler для OAuth login -async def handle_oauth_login_http(request): - """HTTP инициация OAuth авторизации""" - return await oauth_login_http(request) - -# HTTP handler для OAuth callback -async def handle_oauth_callback_http(request): - """HTTP обработка OAuth callback""" - return await oauth_callback_http(request) - -# Создание/обновление пользователя -async def create_user_from_oauth(provider: str, profile: dict): - """Создание пользователя из OAuth профиля""" - return await _create_or_update_user(provider, profile) - -# Управление OAuth состоянием -await store_oauth_state(state, oauth_data) -state_data = await get_oauth_state(state) -``` - -### API Integration -```python -async def make_oauth_request(user_id: int, provider: str, endpoint: str): - """Запрос к API провайдера""" - oauth = OAuthTokenManager() - - # Получаем access token - token_data = await oauth.get_token(str(user_id), provider, "oauth_access") - if not token_data: - raise OAuthTokenMissing() - - # Делаем запрос +# Получение токена для API вызовов +token_data = await oauth.get_token("123", "google", "oauth_access") +if token_data: + # Используем токен для вызовов Google API headers = {"Authorization": f"Bearer {token_data['token']}"} - response = await httpx.get(endpoint, headers=headers) - - if response.status_code == 401: - # Токен истек, требуется повторная авторизация - raise OAuthTokenExpired() - - return response.json() ``` -### Мониторинг токенов -```python -async def check_oauth_health(): - """Проверка здоровья OAuth системы""" - from auth.tokens.monitoring import TokenMonitoring - - monitoring = TokenMonitoring() - stats = await monitoring.get_token_statistics() - - return { - "oauth_tokens": stats["oauth_access_tokens"] + stats["oauth_refresh_tokens"], - "memory_usage": stats["memory_usage"] - } -``` - -## 🔧 Настройка и деплой - -### Environment Variables +### Redis структура ```bash -# Google OAuth -GOOGLE_CLIENT_ID=your_google_client_id -GOOGLE_CLIENT_SECRET=your_google_client_secret +# OAuth токены для API интеграций +oauth_access:{user_id}:{provider} # Access токен +oauth_refresh:{user_id}:{provider} # Refresh токен -# GitHub OAuth -GITHUB_CLIENT_ID=your_github_client_id -GITHUB_CLIENT_SECRET=your_github_client_secret +# OAuth state (временный) +oauth_state:{state} # Данные авторизации (TTL: 10 мин) -# Facebook OAuth -FACEBOOK_APP_ID=your_facebook_app_id -FACEBOOK_APP_SECRET=your_facebook_app_secret - -# VK OAuth -VK_APP_ID=your_vk_app_id -VK_APP_SECRET=your_vk_app_secret - -# X (Twitter) OAuth -X_CLIENT_ID=your_x_client_id -X_CLIENT_SECRET=your_x_client_secret - -# Yandex OAuth -YANDEX_CLIENT_ID=your_yandex_client_id -YANDEX_CLIENT_SECRET=your_yandex_client_secret - -# Telegram OAuth -TELEGRAM_CLIENT_ID=your_telegram_client_id -TELEGRAM_CLIENT_SECRET=your_telegram_client_secret - -# HTTPS настройки -HTTPS_ENABLED=true # false для разработки - -# Redis для state management -REDIS_URL=redis://localhost:6379/0 - -# JWT -JWT_SECRET=your_jwt_secret_key -JWT_EXPIRATION_HOURS=24 -``` - -### Настройка провайдеров - -#### Google OAuth -1. Перейти в [Google Cloud Console](https://console.cloud.google.com/) -2. Создать новый проект или выбрать существующий -3. Включить Google+ API -4. Настроить OAuth consent screen -5. Создать OAuth 2.0 credentials -6. Добавить redirect URIs: - - `https://your-domain.com/auth/oauth/google/callback` - - `http://localhost:3000/auth/oauth/google/callback` (для разработки) - -#### GitHub OAuth -1. Перейти в [GitHub Settings](https://github.com/settings/applications/new) -2. Создать новое OAuth App -3. Настроить Authorization callback URL: - - `https://your-domain.com/auth/oauth/github/callback` - -#### Facebook OAuth -1. Перейти в [Facebook Developers](https://developers.facebook.com/) -2. Создать новое приложение -3. Добавить продукт "Facebook Login" -4. Настроить Valid OAuth Redirect URIs: - - `https://your-domain.com/oauth/facebook/callback` -5. Переключить приложение в режим "Live" - -#### X (Twitter) OAuth -1. Перейти в [Twitter Developer Portal](https://developer.twitter.com/en/portal/dashboard) -2. Создать новое приложение -3. Настроить OAuth 2.0 settings -4. Добавить Callback URLs: - - `https://your-domain.com/oauth/x/callback` -5. Получить Client ID и Client Secret - -#### VK OAuth -1. Перейти в [VK Developers](https://vk.com/dev) -2. Создать новое приложение типа "Веб-сайт" -3. Настроить "Доверенный redirect URI": - - `https://your-domain.com/oauth/vk/callback` -4. Получить ID приложения и Защищённый ключ - -#### Yandex OAuth -1. Перейти в [Yandex OAuth](https://oauth.yandex.ru/) -2. Создать новое приложение -3. Настроить Callback URL: - - `https://your-domain.com/oauth/yandex/callback` -4. Выбрать необходимые права доступа -5. Получить ID и пароль приложения - -#### Telegram OAuth -1. Создать бота через @BotFather -2. Получить Bot Token -3. Настроить OAuth через Telegram API -4. **Внимание**: Telegram OAuth имеет специфическую реализацию - -### Redis команды для отладки -```bash -# Поиск OAuth токенов пользователя -redis-cli --scan --pattern "oauth_access:123:*" -redis-cli --scan --pattern "oauth_refresh:123:*" - -# Получение данных токена -redis-cli GET "oauth_access:123:google" - -# Проверка TTL -redis-cli TTL "oauth_access:123:google" - -# Поиск OAuth state -redis-cli --scan --pattern "oauth_state:*" +# Сессии пользователей (основные) +session:{user_id}:{token} # JWT сессия (TTL: 30 дней) ``` ## 🧪 Тестирование -### Unit Tests -```python -def test_oauth_redirect(): - response = client.get("/auth/oauth/google?state=test&redirect_uri=http://localhost:3000") - assert response.status_code == 307 - assert "accounts.google.com" in response.headers["location"] - -def test_oauth_callback(): - # Mock provider response - with mock.patch('oauth.exchange_code_for_user_data') as mock_exchange: - mock_exchange.return_value = OAuthUser( - provider="google", - provider_id="123456", - email="test@example.com", - name="Test User" - ) - - response = client.get("/auth/oauth/google/callback?code=test_code&state=test_state") - assert response.status_code == 307 - assert "access_token=" in response.headers["location"] -``` - -### E2E Tests +### E2E Test ```typescript -// tests/oauth.spec.ts -test('OAuth flow with Google', async ({ page }) => { - await page.goto('/login') +test('OAuth flow with httpOnly cookies', async ({ page }) => { + // 1. Инициация OAuth + await page.goto('/login'); + await page.click('[data-testid="google-login"]'); - // Click Google OAuth button - await page.click('[data-testid="oauth-google"]') + // 2. Проверяем редирект на Google + await expect(page).toHaveURL(/accounts\.google\.com/); - // Should redirect to Google - await page.waitForURL(/accounts\.google\.com/) + // 3. Симулируем успешный callback (в тестовой среде) + await page.goto('/oauth/callback'); - // Mock successful OAuth (in test environment) - await page.goto('/?state=test&access_token=mock_token') + // 4. Проверяем что cookie установлен + const cookies = await page.context().cookies(); + const authCookie = cookies.find(c => c.name === 'session_token'); + expect(authCookie).toBeTruthy(); + expect(authCookie?.httpOnly).toBe(true); - // Should be logged in - await expect(page.locator('[data-testid="user-menu"]')).toBeVisible() -}) + // 5. Проверяем что пользователь авторизован + await expect(page.locator('[data-testid="user-menu"]')).toBeVisible(); +}); ``` -## 🔧 Troubleshooting - -### Частые ошибки - -1. **"OAuth state mismatch"** - - Проверьте TTL Redis - - Убедитесь, что state генерируется правильно - -2. **"Provider authentication failed"** - - Проверьте client_id и client_secret - - Убедитесь, что redirect_uri совпадает с настройками провайдера - -3. **"Invalid redirect URI"** - - Добавьте все возможные redirect URIs в настройки приложения - - Проверьте HTTPS/HTTP в production/development - -### Логи для отладки +### Отладка ```bash -# Backend логи -tail -f /var/log/app/oauth.log | grep "oauth" +# Проверка OAuth провайдеров +curl -v "https://your-domain.com/oauth/google/login" -# Frontend логи (browser console) -# Фильтр: "[oauth]" или "[SessionProvider]" +# Проверка callback +curl -v "https://your-domain.com/oauth/google/callback?code=test&state=test" + +# Проверка сессии с cookie +curl -b "session_token=your_token" "https://your-domain.com/graphql" \ + -d '{"query":"query { getSession { success author { id } } }"}' ``` ## 📊 Мониторинг ```python -# Добавить метрики для мониторинга -from prometheus_client import Counter, Histogram +from auth.tokens.monitoring import TokenMonitoring -oauth_requests = Counter('oauth_requests_total', 'OAuth requests', ['provider', 'status']) -oauth_duration = Histogram('oauth_duration_seconds', 'OAuth request duration') +monitoring = TokenMonitoring() -@router.get("/{provider}") -async def oauth_redirect(provider: str, state: str, redirect_uri: str): - with oauth_duration.time(): - try: - # OAuth logic - oauth_requests.labels(provider=provider, status='success').inc() - except Exception as e: - oauth_requests.labels(provider=provider, status='error').inc() - raise +# Статистика OAuth +stats = await monitoring.get_token_statistics() +oauth_tokens = stats.get("oauth_access_tokens", 0) + stats.get("oauth_refresh_tokens", 0) +print(f"OAuth tokens: {oauth_tokens}") + +# Health check +health = await monitoring.health_check() +if health["status"] == "healthy": + print("✅ OAuth system is healthy") ``` + +## 🎯 Преимущества новой архитектуры + +### 🛡️ Максимальная безопасность: +- **🚫 Защита от XSS**: Токены недоступны JavaScript +- **🔒 Защита от CSRF**: SameSite cookies +- **🛡️ Единообразие**: Все провайдеры используют один механизм + +### 🚀 Простота использования: +- **📱 Автоматическая отправка**: Браузер сам включает cookies +- **🧹 Чистый код**: Нет управления токенами в JavaScript +- **🔄 Единый API**: Один GraphQL клиент для всех случаев + +### ⚡ Производительность: +- **🚀 Быстрее**: Нет localStorage операций +- **📦 Меньше кода**: Упрощенная логика фронтенда +- **🔄 Автоматическое управление**: Браузер оптимизирует отправку cookies + +**Результат: Самая безопасная и простая OAuth интеграция!** 🔐✨ \ No newline at end of file diff --git a/docs/auth/setup.md b/docs/auth/setup.md new file mode 100644 index 00000000..fd2d7e77 --- /dev/null +++ b/docs/auth/setup.md @@ -0,0 +1,267 @@ +# 🔧 Настройка системы аутентификации + +## 🎯 Быстрая настройка + +### 1. Environment Variables + +```bash +# JWT настройки +JWT_SECRET=your_super_secret_key_minimum_256_bits +JWT_ALGORITHM=HS256 +JWT_EXPIRATION_HOURS=720 # 30 дней + +# Cookie настройки (httpOnly для безопасности) +SESSION_COOKIE_NAME=session_token +SESSION_COOKIE_HTTPONLY=true +SESSION_COOKIE_SECURE=true # Только HTTPS в продакшене +SESSION_COOKIE_SAMESITE=lax # CSRF защита +SESSION_COOKIE_MAX_AGE=2592000 # 30 дней + +# Redis +REDIS_URL=redis://localhost:6379/0 +REDIS_SOCKET_KEEPALIVE=true +REDIS_HEALTH_CHECK_INTERVAL=30 + +# OAuth провайдеры +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret +YANDEX_CLIENT_ID=your_yandex_client_id +YANDEX_CLIENT_SECRET=your_yandex_client_secret +VK_CLIENT_ID=your_vk_app_id +VK_CLIENT_SECRET=your_vk_secure_key + +# Безопасность +RATE_LIMIT_ENABLED=true +MAX_LOGIN_ATTEMPTS=5 +LOCKOUT_DURATION=1800 # 30 минут +``` + +### 2. OAuth Провайдеры + +#### Google OAuth +1. [Google Cloud Console](https://console.cloud.google.com/) +2. **APIs & Services** → **Credentials** → **Create OAuth 2.0 Client ID** +3. **Authorized redirect URIs**: + - `https://your-domain.com/oauth/google/callback` (продакшн) + - `http://localhost:8000/oauth/google/callback` (разработка) + +#### GitHub OAuth +1. [GitHub Developer Settings](https://github.com/settings/developers) +2. **New OAuth App** +3. **Authorization callback URL**: `https://your-domain.com/oauth/github/callback` + +#### Yandex OAuth +1. [Yandex OAuth](https://oauth.yandex.ru/) +2. **Создать новое приложение** +3. **Callback URI**: `https://your-domain.com/oauth/yandex/callback` +4. **Права**: `login:info`, `login:email`, `login:avatar` + +#### VK OAuth +1. [VK Developers](https://dev.vk.com/apps) +2. **Создать приложение** → **Веб-сайт** +3. **Redirect URI**: `https://your-domain.com/oauth/vk/callback` + +### 3. Проверка настройки + +```bash +# Проверка переменных окружения +python -c " +import os +required = ['JWT_SECRET', 'REDIS_URL', 'GOOGLE_CLIENT_ID'] +for var in required: + print(f'{var}: {\"✅\" if os.getenv(var) else \"❌\"}')" + +# Проверка Redis подключения +python -c " +import asyncio +from storage.redis import redis +async def test(): + result = await redis.ping() + print(f'Redis: {\"✅\" if result else \"❌\"}') +asyncio.run(test())" + +# Проверка OAuth провайдеров +curl -v "https://your-domain.com/oauth/google/login" +``` + +## 🔒 Безопасность в продакшене + +### SSL/HTTPS настройки +```bash +# Принудительное HTTPS +FORCE_HTTPS=true +HSTS_MAX_AGE=31536000 + +# Secure cookies только для HTTPS +SESSION_COOKIE_SECURE=true +``` + +### Rate Limiting +```bash +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_WINDOW=3600 # 1 час +``` + +### Account Lockout +```bash +MAX_LOGIN_ATTEMPTS=5 +LOCKOUT_DURATION=1800 # 30 минут +``` + +## 🐛 Диагностика проблем + +### Частые ошибки + +#### "Provider not configured" +```bash +# Проверить переменные окружения +echo $GOOGLE_CLIENT_ID +echo $GOOGLE_CLIENT_SECRET + +# Перезапустить приложение после установки переменных +``` + +#### "redirect_uri_mismatch" +- Проверить точное соответствие URL в настройках провайдера +- Убедиться что протокол (http/https) совпадает +- Callback URL должен указывать на backend, НЕ на frontend + +#### "Cookies не работают" +```bash +# Проверить настройки cookie +curl -v -b "session_token=test" "https://your-domain.com/graphql" + +# Проверить что фронтенд отправляет credentials +# В коде должно быть: credentials: 'include' +``` + +#### "CORS ошибки" +```python +# В настройках CORS должно быть: +allow_credentials=True +allow_origins=["https://your-frontend-domain.com"] +``` + +### Логи для отладки +```bash +# Поиск ошибок аутентификации +grep -i "auth\|oauth\|cookie" /var/log/app/app.log + +# Мониторинг Redis операций +redis-cli monitor | grep "session\|oauth" +``` + +## 📊 Мониторинг + +### Health Check +```python +from auth.tokens.monitoring import TokenMonitoring + +async def auth_health(): + monitoring = TokenMonitoring() + health = await monitoring.health_check() + stats = await monitoring.get_token_statistics() + + return { + "status": health["status"], + "redis_connected": health["redis_connected"], + "active_sessions": stats["session_tokens"], + "memory_usage_mb": stats["memory_usage"] / 1024 / 1024 + } +``` + +### Метрики для мониторинга +- Количество активных сессий +- Успешность OAuth авторизаций +- Rate limit нарушения +- Заблокированные аккаунты +- Использование памяти Redis + +## 🧪 Тестирование + +### Unit тесты +```bash +# Запуск auth тестов +pytest tests/auth/ -v + +# Проверка типов +mypy auth/ +``` + +### E2E тесты +```bash +# Тестирование OAuth flow +playwright test tests/oauth.spec.ts + +# Тестирование cookie аутентификации +playwright test tests/auth-cookies.spec.ts +``` + +### Нагрузочное тестирование +```bash +# Тестирование login endpoint +ab -n 1000 -c 10 -p login.json -T application/json http://localhost:8000/graphql + +# Содержимое login.json: +# {"query":"mutation{login(email:\"test@example.com\",password:\"password\"){success}}"} +``` + +## 🚀 Развертывание + +### Docker +```dockerfile +# Dockerfile +ENV JWT_SECRET=your_secret_here +ENV REDIS_URL=redis://redis:6379/0 +ENV SESSION_COOKIE_SECURE=true +``` + +### Dokku/Heroku +```bash +# Установка переменных окружения +dokku config:set myapp JWT_SECRET=xxx REDIS_URL=yyy +heroku config:set JWT_SECRET=xxx REDIS_URL=yyy +``` + +### Nginx настройки +```nginx +# Поддержка cookies +proxy_set_header Cookie $http_cookie; +proxy_cookie_path / "/; Secure; HttpOnly; SameSite=lax"; + +# CORS для credentials +add_header Access-Control-Allow-Credentials true; +add_header Access-Control-Allow-Origin https://your-frontend.com; +``` + +## ✅ Checklist для продакшена + +### Безопасность +- [ ] JWT secret минимум 256 бит +- [ ] HTTPS принудительно включен +- [ ] httpOnly cookies настроены +- [ ] SameSite cookies включены +- [ ] Rate limiting активен +- [ ] Account lockout настроен + +### OAuth +- [ ] Все провайдеры настроены +- [ ] Redirect URIs правильные +- [ ] Client secrets безопасно хранятся +- [ ] PKCE включен для поддерживающих провайдеров + +### Мониторинг +- [ ] Health checks настроены +- [ ] Логирование работает +- [ ] Метрики собираются +- [ ] Алерты настроены + +### Производительность +- [ ] Redis connection pooling +- [ ] TTL для всех ключей +- [ ] Batch операции для массовых действий +- [ ] Memory optimization включена + +**Готово к продакшену!** 🚀✅ diff --git a/docs/auth/sse-httponly-integration.md b/docs/auth/sse-httponly-integration.md new file mode 100644 index 00000000..0bd7a12f --- /dev/null +++ b/docs/auth/sse-httponly-integration.md @@ -0,0 +1,414 @@ +# 📡 SSE + httpOnly Cookies Integration + +## 🎯 Обзор + +Server-Sent Events (SSE) **отлично работают** с httpOnly cookies! Браузер автоматически отправляет cookies при установке SSE соединения. + +## 🔄 Как это работает + +### 1. 🚀 Установка SSE соединения + +```typescript +// Фронтенд - SSE с cross-origin поддоменом +const eventSource = new EventSource('https://connect.discours.io/notifications', { + withCredentials: true // ✅ КРИТИЧНО: отправляет httpOnly cookies cross-origin +}); + +// Для продакшена +const SSE_URL = process.env.NODE_ENV === 'production' + ? 'https://connect.discours.io/' + : 'https://connect.discours.io/'; + +const eventSource = new EventSource(SSE_URL, { + withCredentials: true // ✅ Обязательно для cross-origin cookies +}); +``` + +### 2. 🔧 Backend SSE endpoint с аутентификацией + +```python +# main.py - добавляем SSE endpoint +from starlette.responses import StreamingResponse +from auth.middleware import auth_middleware + +@app.route("/sse/notifications") +async def sse_notifications(request: Request): + """SSE endpoint для real-time уведомлений""" + + # ✅ Аутентификация через httpOnly cookie + user_data = await auth_middleware.authenticate_user(request) + if not user_data: + return Response("Unauthorized", status_code=401) + + user_id = user_data.get("user_id") + + async def event_stream(): + """Генератор SSE событий""" + try: + # Подписываемся на Redis каналы пользователя + channels = [ + f"notifications:{user_id}", + f"follower:{user_id}", + f"shout:{user_id}" + ] + + pubsub = redis.pubsub() + await pubsub.subscribe(*channels) + + # Отправляем initial heartbeat + yield f"data: {json.dumps({'type': 'connected', 'user_id': user_id})}\n\n" + + async for message in pubsub.listen(): + if message['type'] == 'message': + # Форматируем SSE событие + data = message['data'].decode('utf-8') + yield f"data: {data}\n\n" + + except asyncio.CancelledError: + await pubsub.unsubscribe() + await pubsub.close() + except Exception as e: + logger.error(f"SSE error for user {user_id}: {e}") + yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Credentials": "true", # Для CORS + } + ) +``` + +### 3. 🌐 Фронтенд SSE клиент + +```typescript +// SSE клиент с автоматической аутентификацией через cookies +class SSEClient { + private eventSource: EventSource | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + + connect() { + try { + // ✅ Cross-origin SSE с cookies + const SSE_URL = process.env.NODE_ENV === 'production' + ? 'https://connect.discours.io/sse/notifications' + : 'https://connect.discours.io/sse/notifications'; + + this.eventSource = new EventSource(SSE_URL, { + withCredentials: true // ✅ КРИТИЧНО для cross-origin cookies + }); + + this.eventSource.onopen = () => { + console.log('✅ SSE connected'); + this.reconnectAttempts = 0; + }; + + this.eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + this.handleNotification(data); + } catch (error) { + console.error('SSE message parse error:', error); + } + }; + + this.eventSource.onerror = (error) => { + console.error('SSE error:', error); + + // Если получили 401 - cookie недействителен + if (this.eventSource?.readyState === EventSource.CLOSED) { + this.handleAuthError(); + } else { + this.handleReconnect(); + } + }; + + } catch (error) { + console.error('SSE connection error:', error); + this.handleReconnect(); + } + } + + private handleNotification(data: any) { + switch (data.type) { + case 'connected': + console.log(`SSE connected for user: ${data.user_id}`); + break; + + case 'follower': + this.handleFollowerNotification(data); + break; + + case 'shout': + this.handleShoutNotification(data); + break; + + case 'error': + console.error('SSE server error:', data.message); + break; + } + } + + private handleAuthError() { + console.warn('SSE authentication failed - redirecting to login'); + // Cookie недействителен - редиректим на login + window.location.href = '/login?error=session_expired'; + } + + private handleReconnect() { + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + const delay = Math.pow(2, this.reconnectAttempts) * 1000; // Exponential backoff + + console.log(`Reconnecting SSE in ${delay}ms (attempt ${this.reconnectAttempts})`); + + setTimeout(() => { + this.disconnect(); + this.connect(); + }, delay); + } else { + console.error('Max SSE reconnect attempts reached'); + } + } + + disconnect() { + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + } + + private handleFollowerNotification(data: any) { + // Обновляем UI при новом подписчике + if (data.action === 'create') { + showNotification(`${data.payload.follower_name} подписался на вас!`); + updateFollowersCount(+1); + } + } + + private handleShoutNotification(data: any) { + // Обновляем UI при новых публикациях + if (data.action === 'create') { + showNotification(`Новая публикация: ${data.payload.title}`); + refreshFeed(); + } + } +} + +// Использование в приложении +const sseClient = new SSEClient(); + +// Подключаемся после успешной аутентификации +const auth = useAuth(); +if (auth.isAuthenticated()) { + sseClient.connect(); +} + +// Отключаемся при logout +auth.onLogout(() => { + sseClient.disconnect(); +}); +``` + +## 🔧 Интеграция с существующей системой + +### SSE сервер на connect.discours.io + +```python +# connect.discours.io / connect.discours.io - отдельный SSE сервер +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware +from starlette.routing import Route + +# SSE приложение +sse_app = Starlette( + routes=[ + # ✅ Единственный endpoint - SSE notifications + Route("/sse/notifications", sse_notifications, methods=["GET"]), + Route("/health", health_check, methods=["GET"]), + ], + middleware=[ + # ✅ CORS для cross-origin cookies + Middleware( + CORSMiddleware, + allow_origins=[ + "https://testing.discours.io", + "https://discours.io", + "https://new.discours.io", + "http://localhost:3000", # dev + ], + allow_credentials=True, # ✅ Разрешаем cookies + allow_methods=["GET", "OPTIONS"], + allow_headers=["*"], + ), + ], +) + +# Основной сервер остается без изменений +# main.py - БЕЗ SSE routes +app = Starlette( + routes=[ + Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]), + Route("/oauth/{provider}/callback", oauth_callback_http, methods=["GET"]), + Route("/oauth/{provider}", oauth_login_http, methods=["GET"]), + # SSE НЕ здесь - он на отдельном поддомене! + ], + middleware=middleware, + lifespan=lifespan, +) +``` + +### Используем существующую notify систему + +```python +# services/notify.py - уже готова! +# Ваша система уже отправляет уведомления в Redis каналы: + +async def notify_follower(follower, author_id, action="follow"): + channel_name = f"follower:{author_id}" + data = { + "type": "follower", + "action": "create" if action == "follow" else "delete", + "entity": "follower", + "payload": { + "follower_id": follower["id"], + "follower_name": follower["name"], + "following_id": author_id, + } + } + + # ✅ Отправляем в Redis - SSE endpoint получит автоматически + await redis.publish(channel_name, orjson.dumps(data)) +``` + +## 🛡️ Безопасность SSE + httpOnly cookies + +### Преимущества: +- **🚫 Защита от XSS**: Токены недоступны JavaScript +- **🔒 Автоматическая аутентификация**: Браузер сам отправляет cookies +- **🛡️ CSRF защита**: SameSite cookies +- **📱 Простота**: Нет управления токенами в JavaScript + +### CORS настройки для cross-origin SSE: + +```python +# connect.discours.io / connect.discours.io - CORS для SSE +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "https://testing.discours.io", + "https://discours.io", + "https://new.discours.io", + # Для разработки + "http://localhost:3000", + "http://localhost:3001", + ], + allow_credentials=True, # ✅ КРИТИЧНО: разрешает отправку cookies cross-origin + allow_methods=["GET", "OPTIONS"], # SSE использует GET + preflight OPTIONS + allow_headers=["*"], +) +``` + +### Cookie Domain настройки: + +```python +# settings.py - Cookie должен работать для всех поддоменов +SESSION_COOKIE_DOMAIN = ".discours.io" # ✅ Работает для всех поддоменов +SESSION_COOKIE_SECURE = True # ✅ Только HTTPS +SESSION_COOKIE_SAMESITE = "none" # ✅ Для cross-origin (но secure!) + +# Для продакшена +if PRODUCTION: + SESSION_COOKIE_DOMAIN = ".discours.io" +``` + +## 🧪 Тестирование SSE + cookies + +```typescript +// Тест SSE соединения +test('SSE connects with httpOnly cookies', async ({ page }) => { + // 1. Авторизуемся (cookie устанавливается) + await page.goto('/login'); + await loginWithEmail(page, 'test@example.com', 'password'); + + // 2. Проверяем что cookie установлен + const cookies = await page.context().cookies(); + const authCookie = cookies.find(c => c.name === 'session_token'); + expect(authCookie).toBeTruthy(); + + // 3. Тестируем cross-origin SSE соединение + const sseConnected = await page.evaluate(() => { + return new Promise((resolve) => { + const eventSource = new EventSource('https://connect.discours.io/', { + withCredentials: true // ✅ Отправляем cookies cross-origin + }); + + eventSource.onopen = () => { + resolve(true); + eventSource.close(); + }; + + eventSource.onerror = () => { + resolve(false); + eventSource.close(); + }; + + // Timeout после 5 секунд + setTimeout(() => { + resolve(false); + eventSource.close(); + }, 5000); + }); + }); + + expect(sseConnected).toBe(true); +}); +``` + +## 📊 Мониторинг SSE соединений + +```python +# Добавляем метрики SSE +from collections import defaultdict + +sse_connections = defaultdict(int) + +async def sse_notifications(request: Request): + user_data = await auth_middleware.authenticate_user(request) + if not user_data: + return Response("Unauthorized", status_code=401) + + user_id = user_data.get("user_id") + + # Увеличиваем счетчик соединений + sse_connections[user_id] += 1 + logger.info(f"SSE connected: user_id={user_id}, total_connections={sse_connections[user_id]}") + + try: + async def event_stream(): + # ... SSE логика ... + pass + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + finally: + # Уменьшаем счетчик при отключении + sse_connections[user_id] -= 1 + logger.info(f"SSE disconnected: user_id={user_id}, remaining_connections={sse_connections[user_id]}") +``` + +## 🎯 Результат + +**SSE + httpOnly cookies = Идеальное сочетание для real-time уведомлений:** + +- ✅ **Безопасность**: Максимальная защита от XSS/CSRF +- ✅ **Простота**: Автоматическая аутентификация +- ✅ **Производительность**: Нет дополнительных HTTP запросов для аутентификации +- ✅ **Надежность**: Браузер сам управляет отправкой cookies +- ✅ **Совместимость**: Работает со всеми современными браузерами + +**Ваша существующая notify система готова к работе с SSE!** 📡🍪✨ diff --git a/docs/oauth-debug-checklist.md b/docs/oauth-debug-checklist.md deleted file mode 100644 index 769b557d..00000000 --- a/docs/oauth-debug-checklist.md +++ /dev/null @@ -1,72 +0,0 @@ -# 🔍 OAuth Debug Checklist - -## 🚨 Проблема: Google callback получает параметры, но фронтенд получает auth_failed - -### ✅ Диагностика выполнена: - -1. **✅ OAuth Endpoint существует**: `/oauth/google/callback` - роут настроен в `main.py` -2. **⚠️ OAuth провайдеры**: Настроены на продакшн сервере (Dokku), локально отсутствуют -3. **✅ Логирование добавлено**: Детальные логи на каждом этапе OAuth flow - -### 🎯 Правильный подход к диагностике продакшн проблем: - -### 🔧 Исправления внесены: - -1. **Улучшена обработка ошибок**: Правильный redirect на testing.discours.io с error параметрами -2. **Добавлена диагностика**: Проверка конфигурации OAuth провайдеров -3. **Создан скрипт проверки**: `scripts/check_oauth_config.py` - -### 🎯 Диагностика продакшн OAuth проблем: - -#### 1. Анализ продакшн логов (приоритет): -```bash -# Смотрим логи OAuth callback на продакшн сервере -dokku logs discours --tail 100 | grep -E "(OAuth|callback|google)" - -# Ищем конкретные ошибки в логах -dokku logs discours --tail 500 | grep -E "(❌|ERROR|Exception)" -``` - -#### 2. Проверка переменных окружения на сервере: -```bash -# Проверяем настройки OAuth на продакшн -dokku config:show discours | grep -E "(GOOGLE|GITHUB|OAUTH)" -``` - -#### 3. Проверка redirect URI в Google Console: -- Должен быть: `https://v3.dscrs.site/oauth/google/callback` -- Проверить точное совпадение URL -- Убедиться что HTTPS включен - -#### 4. Тестирование с детальными логами: -- Логи уже добавлены в код -- Смотреть продакшн логи во время OAuth попытки -- Анализировать каждый этап: token exchange, profile fetch, user creation - -### 🔍 Логи для мониторинга: - -После настройки OAuth провайдеров, логи должны показывать: -``` -✅ Got access token for google: True -✅ Got user profile for google: id=..., email=..., name=... -✅ User created/updated for google: user_id=..., email=... -✅ Session token created for google: token_length=... -🔗 OAuth redirect URL: https://testing.discours.io/oauth?redirect_url=... -OAuth успешно завершен для google, user_id=... -``` - -### 🚨 Критические проверки: - -1. **Redirect URI в Google Console** должен точно совпадать с `https://v3.dscrs.site/oauth/google/callback` -2. **HTTPS обязателен** для продакшена -3. **Переменные окружения** должны быть установлены на сервере -4. **Перезапуск сервера** после установки переменных окружения - -### 📊 Ожидаемый результат: - -После настройки OAuth: -- ✅ Google callback обрабатывается успешно -- ✅ Пользователь создается/обновляется -- ✅ Session token устанавливается в httpOnly cookie -- ✅ Редирект на `https://testing.discours.io/oauth?redirect_url=...` -- ✅ Фронтенд получает успешную аутентификацию diff --git a/docs/oauth-frontend-integration.md b/docs/oauth-frontend-integration.md deleted file mode 100644 index 5d388498..00000000 --- a/docs/oauth-frontend-integration.md +++ /dev/null @@ -1,325 +0,0 @@ -# OAuth Frontend Integration для testing.discours.io - -## 🎯 Схема: JWT в URL + httpOnly Cookie - -### 📋 Полный flow: -1. **OAuth success** → бэкенд генерирует JWT -2. **Редирект**: `/oauth?access_token=JWT&redirect_url=...` + httpOnly cookie -3. **Фронт роут**: `localStorage.setItem('auth_token', token)` -4. **SessionProvider**: `loadSession()` → использует localStorage токен -5. **GraphQL клиент**: `credentials: 'include'` → использует httpOnly cookie - -## 🔧 Frontend Implementation - -### 1. OAuth Route Handler (`/oauth`) -```typescript -// routes/oauth.tsx -import { useEffect } from 'solid-js' -import { useNavigate, useSearchParams } from '@solidjs/router' -import { useSession } from '../context/SessionProvider' - -export default function OAuthCallback() { - const navigate = useNavigate() - const [searchParams] = useSearchParams() - const { loadSession } = useSession() - - useEffect(async () => { - const error = searchParams.error - const accessToken = searchParams.access_token - const redirectUrl = searchParams.redirect_url || '/' - - if (error) { - // Обработка ошибок OAuth - console.error('OAuth error:', error) - - // Показываем пользователю ошибку - if (error === 'oauth_state_expired') { - alert('OAuth session expired. Please try logging in again.') - } else if (error === 'access_denied') { - alert('Access denied by provider.') - } else { - alert('Authentication failed. Please try again.') - } - - navigate('/') - return - } - - if (accessToken) { - try { - // 1. Сохраняем JWT в localStorage для быстрого доступа - localStorage.setItem('auth_token', accessToken) - - // 2. SessionProvider загружает сессию (использует localStorage токен) - await loadSession() - - // 3. Очищаем URL от токена (безопасность) - window.history.replaceState({}, document.title, '/oauth-success') - - // 4. Редиректим на исходную страницу через 1 секунду - setTimeout(() => { - navigate(decodeURIComponent(redirectUrl)) - }, 1000) - - } catch (error) { - console.error('Failed to load session:', error) - localStorage.removeItem('auth_token') - navigate('/') - } - } else { - // Неожиданный случай - navigate('/') - } - }) - - return ( -
-
-

Completing authentication...

-
-
-
- ) -} -``` - -### 2. OAuth Initiation -```typescript -// utils/auth.ts -export const oauth = (provider: string) => { - // Простой редирект - backend получит redirect_uri из Referer header - window.location.href = `https://v3.dscrs.site/oauth/${provider}` -} - -// Использование в компонентах -import { oauth } from '../utils/auth' - -const LoginButton = () => ( - -) -``` - -### 3. Session Provider -```typescript -// context/SessionProvider.tsx -import { createContext, useContext, createSignal, onMount } from 'solid-js' - -interface SessionContextType { - user: () => User | null - isAuthenticated: () => boolean - loadSession: () => Promise - logout: () => void -} - -const SessionContext = createContext() - -export function SessionProvider(props: { children: any }) { - const [user, setUser] = createSignal(null) - - const isAuthenticated = () => !!user() - - const loadSession = async () => { - try { - // Проверяем localStorage токен - const token = localStorage.getItem('auth_token') - if (!token) { - setUser(null) - return - } - - // Загружаем профиль пользователя через GraphQL (использует httpOnly cookie) - const response = await client.query({ - query: GET_CURRENT_USER, - fetchPolicy: 'network-only' // Всегда свежие данные - }) - - if (response.data?.currentUser) { - setUser(response.data.currentUser) - } else { - // Токен невалидный, очищаем - localStorage.removeItem('auth_token') - setUser(null) - } - } catch (error) { - console.error('Failed to load session:', error) - localStorage.removeItem('auth_token') - setUser(null) - } - } - - const logout = () => { - localStorage.removeItem('auth_token') - setUser(null) - - // Опционально: вызов logout endpoint для очистки httpOnly cookie - fetch('https://v3.dscrs.site/auth/logout', { - method: 'POST', - credentials: 'include' - }) - } - - // Загружаем сессию при инициализации - onMount(() => { - loadSession() - }) - - return ( - - {props.children} - - ) -} - -export const useSession = () => { - const context = useContext(SessionContext) - if (!context) { - throw new Error('useSession must be used within SessionProvider') - } - return context -} -``` - -### 4. GraphQL Client Setup -```typescript -// graphql/client.ts -import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client' - -const httpLink = createHttpLink({ - uri: 'https://v3.dscrs.site/graphql', - credentials: 'include', // ✅ КРИТИЧНО: отправляет httpOnly cookie -}) - -export const client = new ApolloClient({ - link: httpLink, - cache: new InMemoryCache(), - defaultOptions: { - watchQuery: { - errorPolicy: 'all' - }, - query: { - errorPolicy: 'all' - } - } -}) -``` - -### 5. API Client для прямых вызовов -```typescript -// utils/api.ts -class ApiClient { - private baseUrl = 'https://v3.dscrs.site' - - private getAuthHeaders() { - const token = localStorage.getItem('auth_token') - return token ? { 'Authorization': `Bearer ${token}` } : {} - } - - async request(endpoint: string, options: RequestInit = {}) { - const response = await fetch(`${this.baseUrl}${endpoint}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...this.getAuthHeaders(), - ...options.headers - }, - credentials: 'include' // Для httpOnly cookie - }) - - if (!response.ok) { - if (response.status === 401) { - // Токен истек, очищаем localStorage - localStorage.removeItem('auth_token') - window.location.href = '/login' - } - throw new Error(`API Error: ${response.status}`) - } - - return response.json() - } - - // Методы для различных API calls - async uploadFile(file: File) { - const formData = new FormData() - formData.append('file', file) - - return this.request('/upload', { - method: 'POST', - body: formData, - headers: this.getAuthHeaders() // Только Authorization header, без Content-Type - }) - } -} - -export const apiClient = new ApiClient() -``` - -## 🔒 Безопасность - -### Преимущества двойной схемы: -1. **httpOnly Cookie** - защита от XSS для GraphQL -2. **localStorage JWT** - быстрый доступ для API calls -3. **Automatic cleanup** - токен удаляется при ошибках 401 -4. **URL cleanup** - токен не остается в истории браузера - -### Обработка ошибок: -```typescript -// utils/errorHandler.ts -export const handleAuthError = (error: any) => { - if (error.networkError?.statusCode === 401) { - // Токен истек - localStorage.removeItem('auth_token') - window.location.href = '/login' - } -} - -// В Apollo Client -import { onError } from '@apollo/client/link/error' - -const errorLink = onError(({ graphQLErrors, networkError }) => { - if (networkError?.statusCode === 401) { - handleAuthError(networkError) - } -}) -``` - -## 🧪 Testing - -### E2E Test -```typescript -// tests/oauth.spec.ts -import { test, expect } from '@playwright/test' - -test('OAuth flow works correctly', async ({ page }) => { - // 1. Инициация OAuth - await page.goto('https://testing.discours.io') - await page.click('[data-testid="github-login"]') - - // 2. Проверяем редирект на GitHub - await expect(page).toHaveURL(/github\.com\/login\/oauth\/authorize/) - - // 3. Симулируем успешный callback - await page.goto('https://testing.discours.io/oauth?access_token=test_jwt&redirect_url=%2Fdashboard') - - // 4. Проверяем что токен сохранился - const token = await page.evaluate(() => localStorage.getItem('auth_token')) - expect(token).toBe('test_jwt') - - // 5. Проверяем редирект на dashboard - await expect(page).toHaveURL('https://testing.discours.io/dashboard') -}) -``` - -## 📊 Monitoring - -### Метрики для отслеживания: -- OAuth success rate -- Token validation errors -- Session load time -- Cookie/localStorage sync issues diff --git a/docs/oauth-glitchtip-integration.md b/docs/oauth-glitchtip-integration.md deleted file mode 100644 index 01fa4495..00000000 --- a/docs/oauth-glitchtip-integration.md +++ /dev/null @@ -1,172 +0,0 @@ -# GlitchTip Security Alerts Integration - -## 🚨 Автоматические алерты безопасности OAuth - -Система OAuth теперь автоматически отправляет алерты в GlitchTip при обнаружении подозрительной активности. - -## 🎯 Типы алертов - -### 🔴 Критические события (ERROR level) -- **`open_redirect_attempt`** - Попытка open redirect атаки -- **`rate_limit_exceeded`** - Превышение лимита запросов (брутфорс) -- **`invalid_provider`** - Попытка использования несуществующего провайдера -- **`suspicious_redirect_uri`** - Подозрительный redirect URI -- **`brute_force_detected`** - Обнаружена брутфорс атака - -### 🟡 Обычные события (WARNING level) -- **`oauth_login_attempt`** - Обычная попытка входа -- **`provider_validation`** - Валидация провайдера -- **`redirect_uri_validation`** - Валидация redirect URI - -## 🏷️ Теги для фильтрации в GlitchTip - -Каждый алерт содержит теги для удобной фильтрации: - -```python -{ - "security_event": "rate_limit_exceeded", - "component": "oauth", - "client_ip": "192.168.1.100", - "oauth_provider": "github", - "has_redirect_uri": "true" -} -``` - -## 📊 Контекст события - -Детальная информация в контексте `security_details`: - -```python -{ - "ip": "192.168.1.100", - "provider": "github", - "attempts": 15, - "limit": 10, - "window_seconds": 300, - "severity": "high", - "malicious_uri": "https://evil.com/steal", - "attack_type": "open_redirect" -} -``` - -## 🔧 Интеграция в коде - -### Автоматические алерты - -```python -# При превышении rate limit -if len(requests) >= OAUTH_RATE_LIMIT: - send_rate_limit_alert(client_ip, len(requests)) - return False - -# При попытке open redirect -if not is_allowed: - send_open_redirect_alert(redirect_uri) - return False -``` - -### Ручные алерты - -```python -from auth.oauth_security import log_oauth_security_event - -# Отправка кастомного алерта -log_oauth_security_event("suspicious_activity", { - "ip": client_ip, - "details": "Custom security event", - "severity": "medium" -}) -``` - -## 🛡️ Обработка ошибок - -Система устойчива к сбоям GlitchTip: - -```python -try: - # Отправка алерта в GlitchTip - sentry_sdk.capture_message(message, level=level) -except Exception as e: - # Не ломаем основную логику - logger.error(f"Failed to send alert to GlitchTip: {e}") -``` - -## 📈 Мониторинг в GlitchTip - -### Фильтры для критических событий: -``` -tag:security_event AND level:error -``` - -### Фильтры по компонентам: -``` -tag:component:oauth -``` - -### Фильтры по IP адресам: -``` -tag:client_ip:192.168.1.100 -``` - -## 🚨 Алерты по типам атак - -### Open Redirect атаки: -``` -tag:security_event:open_redirect_attempt -``` - -### Брутфорс атаки: -``` -tag:security_event:rate_limit_exceeded -``` - -### Невалидные провайдеры: -``` -tag:security_event:invalid_provider -``` - -## 📊 Статистика безопасности - -GlitchTip позволяет отслеживать: -- Количество атак по времени -- Топ атакующих IP адресов -- Самые частые типы атак -- Географическое распределение атак - -## 🔄 Настройка алертов - -В GlitchTip можно настроить: -- Email уведомления при критических событиях -- Slack/Discord интеграции -- Webhook для автоматической блокировки IP -- Дашборды для мониторинга безопасности - -## ✅ Тестирование - -Система покрыта тестами: - -```bash -# Запуск тестов GlitchTip интеграции -uv run python -m pytest tests/test_oauth_glitchtip_alerts.py -v - -# Результат: 8/8 тестов прошли -✅ Critical events sent as ERROR -✅ Normal events sent as WARNING -✅ Open redirect alert integration -✅ Rate limit alert integration -✅ Failure handling (graceful degradation) -✅ Security context tags -✅ Event logging integration -✅ Critical events list validation -``` - -## 🎯 Преимущества - -1. **Реальное время** - мгновенные алерты при атаках -2. **Контекст** - полная информация о событии -3. **Фильтрация** - удобные теги для поиска -4. **Устойчивость** - не ломает основную логику при сбоях -5. **Тестируемость** - полное покрытие тестами -6. **Масштабируемость** - готово для высоких нагрузок - -**Система безопасности OAuth теперь имеет полноценный мониторинг!** 🔒✨ diff --git a/docs/oauth-minimal-flow.md b/docs/oauth-minimal-flow.md deleted file mode 100644 index 715635b7..00000000 --- a/docs/oauth-minimal-flow.md +++ /dev/null @@ -1,287 +0,0 @@ -# Минимальный OAuth Flow для testing.discours.io - -## 🎯 Философия: Максимальная простота - -### ✨ **Принцип: "Нет ошибки = успех"** - -Никаких лишних параметров, флагов или токенов в URL. Только самое необходимое. - -## 🔧 Backend Implementation - -### OAuth Callback Handler -```python -@app.route('/oauth//callback') -def oauth_callback(provider): - try: - # 1. Валидация state (CSRF защита) - state = request.args.get('state') - oauth_data = get_oauth_state(state) - if not oauth_data: - raise ValueError('Invalid or expired state') - - # 2. Обмен code на access_token - code = request.args.get('code') - access_token = exchange_code_for_token(provider, code) - - # 3. Получение профиля пользователя - user_data = get_user_profile(provider, access_token) - - # 4. Создание/обновление пользователя - user = create_or_update_user(user_data, provider) - - # 5. Генерация JWT - jwt_token = create_jwt_token(user.id) - - # 6. Простой редирект без лишних параметров - redirect_url = oauth_data.get('redirect_uri', '/') - response = make_response(redirect( - f'https://testing.discours.io/oauth?redirect_url={quote(redirect_url)}' - )) - - # 7. JWT только в httpOnly cookie - response.set_cookie( - 'auth_token', - jwt_token, - httponly=True, # ✅ Защита от XSS - secure=True, # ✅ Только HTTPS - samesite='Lax', # ✅ CSRF защита - max_age=7*24*60*60, # 7 дней - domain='.discours.io' # ✅ Поддомены - ) - - return response - - except Exception as e: - # При ошибке - добавляем error параметр - logger.error(f'OAuth error: {e}') - redirect_url = oauth_data.get('redirect_uri', '/') if 'oauth_data' in locals() else '/' - return redirect( - f'https://testing.discours.io/oauth?error=auth_failed&redirect_url={quote(redirect_url)}' - ) -``` - -## 🌐 Frontend Implementation - -### OAuth Route Handler -```typescript -// routes/oauth.tsx -import { useEffect } from 'solid-js' -import { useNavigate, useSearchParams } from '@solidjs/router' -import { useSession } from '../context/SessionProvider' - -export default function OAuthCallback() { - const navigate = useNavigate() - const [searchParams] = useSearchParams() - const { loadSession } = useSession() - - useEffect(async () => { - const error = searchParams.error - const redirectUrl = searchParams.redirect_url || '/' - - if (error) { - // Есть ошибка = неудача - console.error('OAuth error:', error) - - if (error === 'oauth_state_expired') { - alert('OAuth session expired. Please try logging in again.') - } else if (error === 'access_denied') { - alert('Access denied by provider.') - } else { - alert('Authentication failed. Please try again.') - } - - navigate('/') - } else { - // Нет ошибки = успех! JWT уже в httpOnly cookie - try { - await loadSession() // Загружает из httpOnly cookie - navigate(decodeURIComponent(redirectUrl)) - } catch (error) { - console.error('Failed to load session:', error) - navigate('/') - } - } - }) - - return ( -
-
-

Completing authentication...

-
-
-
- ) -} -``` - -### Session Provider (httpOnly only) -```typescript -// context/SessionProvider.tsx -export function SessionProvider(props: { children: any }) { - const [user, setUser] = createSignal(null) - - const loadSession = async () => { - try { - // Загружаем профиль через GraphQL (httpOnly cookie автоматически) - const response = await client.query({ - query: GET_CURRENT_USER, - fetchPolicy: 'network-only' - }) - - if (response.data?.currentUser) { - setUser(response.data.currentUser) - } else { - setUser(null) - } - } catch (error) { - console.error('Failed to load session:', error) - setUser(null) - } - } - - const logout = async () => { - setUser(null) - - // Очистка httpOnly cookie через logout endpoint - await fetch('https://v3.dscrs.site/auth/logout', { - method: 'POST', - credentials: 'include' - }) - } - - // ... остальная логика -} -``` - -## 🔒 Unified Authentication - -### Все запросы используют httpOnly cookie -```typescript -// GraphQL Client -const client = new ApolloClient({ - uri: 'https://v3.dscrs.site/graphql', - credentials: 'include', // ✅ httpOnly cookie -}) - -// REST API calls -const apiCall = async (endpoint: string, options: RequestInit = {}) => { - return fetch(`https://v3.dscrs.site${endpoint}`, { - ...options, - credentials: 'include', // ✅ httpOnly cookie - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }) -} - -// File uploads -const uploadFile = async (file: File) => { - const formData = new FormData() - formData.append('file', file) - - return fetch('https://v3.dscrs.site/upload', { - method: 'POST', - body: formData, - credentials: 'include' // ✅ httpOnly cookie - }) -} -``` - -## 🎯 URL Examples - -### ✅ Успешная авторизация -``` -https://testing.discours.io/oauth?redirect_url=%2Fdashboard -``` -- Нет `error` параметра = успех -- JWT в httpOnly cookie -- Редирект на `/dashboard` - -### ❌ Ошибка авторизации -``` -https://testing.discours.io/oauth?error=auth_failed&redirect_url=%2F -``` -- Есть `error` параметр = неудача -- Показать ошибку пользователю -- Редирект на главную - -### 🔒 Истекший state -``` -https://testing.discours.io/oauth?error=oauth_state_expired&redirect_url=%2F -``` -- CSRF защита сработала -- Предложить повторить авторизацию - -## 🚀 Преимущества минимального подхода - -### 🔒 Максимальная безопасность -- **Никаких JWT в URL** - нет токенов в истории браузера -- **httpOnly cookie** - защита от XSS атак -- **SameSite=Lax** - защита от CSRF -- **Secure flag** - только HTTPS - -### 🧹 Чистота и простота -- **Минимум параметров** - только необходимые -- **Логичная схема** - отсутствие ошибки = успех -- **Единый источник истины** - httpOnly cookie для всего -- **Простой код** - меньше условий и проверок - -### ⚡ Производительность -- **Меньше парсинга** - меньше URL параметров -- **Автоматические cookie** - браузер сам отправляет -- **Меньше localStorage операций** - нет дублирования -- **Простая логика** - быстрее выполнение - -## 🧪 Testing - -### E2E Test -```typescript -test('Minimal OAuth flow', async ({ page }) => { - // 1. Инициация - await page.goto('https://testing.discours.io') - await page.click('[data-testid="github-login"]') - - // 2. Симуляция успешного callback - await page.goto('https://testing.discours.io/oauth?redirect_url=%2Fdashboard') - - // 3. Проверяем что попали на dashboard (успех) - await expect(page).toHaveURL('https://testing.discours.io/dashboard') - - // 4. Проверяем что cookie установлен - const cookies = await page.context().cookies() - const authCookie = cookies.find(c => c.name === 'auth_token') - expect(authCookie).toBeTruthy() - expect(authCookie?.httpOnly).toBe(true) -}) - -test('OAuth error handling', async ({ page }) => { - // Симуляция ошибки - await page.goto('https://testing.discours.io/oauth?error=auth_failed&redirect_url=%2F') - - // Проверяем что показалась ошибка и редирект на главную - await expect(page).toHaveURL('https://testing.discours.io/') -}) -``` - -## 📊 Comparison - -| Параметр | Старый подход | Новый подход | -|----------|---------------|--------------| -| URL параметры | `success=true&access_token=JWT&redirect_url=...` | `redirect_url=...` | -| Токен в URL | ✅ Да | ❌ Нет | -| localStorage | ✅ Используется | ❌ Не нужен | -| httpOnly cookie | ✅ Да | ✅ Да | -| Логика успеха | Проверка `success=true` | Отсутствие `error` | -| Безопасность | Средняя | Максимальная | -| Простота | Средняя | Максимальная | - -## 🎉 Результат - -**Самый простой и безопасный OAuth flow:** -1. Нет ошибки = успех -2. Один источник аутентификации = httpOnly cookie -3. Минимум параметров = максимум простоты -4. Максимальная безопасность = никаких токенов в URL - -**Элегантно. Просто. Безопасно.** ✨ diff --git a/docs/oauth-setup.md b/docs/oauth-setup.md deleted file mode 100644 index d330c1f0..00000000 --- a/docs/oauth-setup.md +++ /dev/null @@ -1,144 +0,0 @@ -# 🔐 Настройка OAuth Провайдеров - -## 🎯 Архитектура OAuth - -**Важно понимать разделение:** -- **Frontend**: `testing.discours.io` - где пользователь нажимает кнопку входа -- **Backend**: `v3.dscrs.site` - где обрабатывается OAuth логика -- **Callback URL**: Всегда должен указывать на **backend** (`v3.dscrs.site`) - -## 🔄 OAuth Flow (пошагово): - -1. Пользователь на `testing.discours.io` нажимает "Войти через GitHub" -2. Фронтенд редиректит на `v3.dscrs.site/oauth/github` -3. Backend редиректит на `github.com` с callback_uri=`v3.dscrs.site/oauth/github/callback` -4. GitHub после авторизации редиректит на `v3.dscrs.site/oauth/github/callback` -5. Backend обрабатывает callback и редиректит обратно на `testing.discours.io` - -## 🎯 Быстрая настройка - -### 1. Google OAuth - -1. Перейти в [Google Cloud Console](https://console.cloud.google.com/) -2. Создать проект или выбрать существующий -3. **APIs & Services** → **Credentials** → **Create Credentials** → **OAuth 2.0 Client ID** -4. **Application type**: Web application -5. **Authorized redirect URIs**: - - `https://v3.dscrs.site/oauth/google/callback` ⚠️ **ОБЯЗАТЕЛЬНО HTTPS** - - `http://localhost:8000/oauth/google/callback` (для разработки) -6. Скопировать **Client ID** и **Client Secret** - -**Переменные окружения:** -```bash -GOOGLE_CLIENT_ID=your_google_client_id -GOOGLE_CLIENT_SECRET=your_google_client_secret -``` - -### 2. GitHub OAuth - -1. Перейти в [GitHub Developer Settings](https://github.com/settings/developers) -2. **New OAuth App** -3. **Authorization callback URL**: `https://v3.dscrs.site/oauth/github/callback` ⚠️ **ОБЯЗАТЕЛЬНО HTTPS** -4. Скопировать **Client ID** и **Client Secret** - -**Переменные окружения:** -```bash -GITHUB_CLIENT_ID=your_github_client_id -GITHUB_CLIENT_SECRET=your_github_client_secret -``` - -### 3. VK OAuth - -1. Перейти в [VK Developers](https://dev.vk.com/apps) -2. **Создать приложение** → **Веб-сайт** -3. **Настройки** → **Redirect URI**: `https://v3.dscrs.site/oauth/vk/callback` ⚠️ **ОБЯЗАТЕЛЬНО HTTPS** -4. Скопировать **ID приложения** и **Защищённый ключ** - -**Переменные окружения:** -```bash -VK_CLIENT_ID=your_vk_app_id -VK_CLIENT_SECRET=your_vk_secure_key -``` - -### 4. Facebook OAuth - -1. Перейти в [Facebook Developers](https://developers.facebook.com/) -2. **My Apps** → **Create App** → **Consumer** -3. **Facebook Login** → **Settings** -4. **Valid OAuth Redirect URIs**: `https://v3.dscrs.site/oauth/facebook/callback` ⚠️ **ОБЯЗАТЕЛЬНО HTTPS** -5. Скопировать **App ID** и **App Secret** - -**Переменные окружения:** -```bash -FACEBOOK_CLIENT_ID=your_facebook_app_id -FACEBOOK_CLIENT_SECRET=your_facebook_app_secret -``` - -### 5. Yandex OAuth - -1. Перейти в [Yandex OAuth](https://oauth.yandex.ru/) -2. **Создать новое приложение** -3. **Callback URI**: `https://v3.dscrs.site/oauth/yandex/callback` ⚠️ **ОБЯЗАТЕЛЬНО HTTPS** -4. **Права**: `login:info`, `login:email`, `login:avatar` -5. Скопировать **ID** и **Пароль** - -**Переменные окружения:** -```bash -YANDEX_CLIENT_ID=your_yandex_client_id -YANDEX_CLIENT_SECRET=your_yandex_client_secret -``` - -## 🚀 Развертывание - -### Локальная разработка -```bash -# .env файл -GOOGLE_CLIENT_ID=... -GOOGLE_CLIENT_SECRET=... -GITHUB_CLIENT_ID=... -GITHUB_CLIENT_SECRET=... -# и т.д. -``` - -### Продакшн (Dokku/Heroku) -```bash -# Установка переменных окружения -dokku config:set myapp GOOGLE_CLIENT_ID=xxx GOOGLE_CLIENT_SECRET=yyy -# или -heroku config:set GOOGLE_CLIENT_ID=xxx GOOGLE_CLIENT_SECRET=yyy -``` - -## ✅ Проверка настройки - -1. Перезапустить приложение -2. Проверить логи: `OAuth provider google: id=SET, key=SET` -3. Тестовый вход: `https://v3.dscrs.site/oauth/google` - -## 🔍 Диагностика проблем - -**Ошибка "Provider not configured":** -- Проверить переменные окружения -- Убедиться что значения не пустые -- Перезапустить приложение - -**Ошибка redirect_uri_mismatch:** -- Проверить точное соответствие URL в настройках провайдера -- Убедиться что протокол (http/https) совпадает -- **ВАЖНО**: Callback URL должен указывать на backend (`v3.dscrs.site`), НЕ на frontend (`testing.discours.io`) - -**Ошибка "redirect_uri is not associated with this application":** -- Callback URL в настройках провайдера должен быть `https://v3.dscrs.site/oauth/{provider}/callback` -- **ОБЯЗАТЕЛЬНО HTTPS** (не HTTP) для продакшна -- НЕ указывать frontend URL в настройках провайдера -- Проверить что URL в настройках GitHub **точно совпадает** с тем что отправляет код - -**VK ошибка "Code challenge method is unsupported":** -- Это нормально, VK не поддерживает PKCE -- Система автоматически обрабатывает это - -## 📞 Поддержка - -При проблемах проверить: -1. Логи приложения при запуске -2. Настройки redirect URI у провайдера -3. Корректность переменных окружения diff --git a/docs/oauth-test-scenarios.md b/docs/oauth-test-scenarios.md deleted file mode 100644 index 37ad0491..00000000 --- a/docs/oauth-test-scenarios.md +++ /dev/null @@ -1,174 +0,0 @@ -# OAuth Test Scenarios для testing.discours.io - -## 🧪 Тестовые сценарии для проверки OAuth flow - -### 1. ✅ Успешная авторизация GitHub -```bash -# Шаг 1: Инициация OAuth -curl -v "https://v3.dscrs.site/oauth/github" \ - -H "Referer: https://testing.discours.io/some-page" \ - -H "User-Agent: Mozilla/5.0" - -# Ожидаемый результат: -# - Редирект 302 на GitHub с правильными параметрами -# - state сохранен в Redis с TTL 10 минут -# - redirect_uri взят из Referer header - -# Шаг 2: Callback от GitHub (симуляция) -curl -v "https://v3.dscrs.site/oauth/github/callback?code=test_code&state=valid_state" \ - -H "User-Agent: Mozilla/5.0" - -# Ожидаемый результат: -# - Обмен code на access_token -# - Получение профиля пользователя -# - Создание JWT токена -# - Установка httpOnly cookie с domain=".discours.io" -# - Редирект на https://testing.discours.io/oauth?success=true -``` - -### 2. 🚨 Обработка ошибок провайдера -```bash -# GitHub отклонил доступ -curl -v "https://v3.dscrs.site/oauth/github/callback?error=access_denied&state=valid_state" - -# Ожидаемый результат: -# - Редирект на https://testing.discours.io/oauth?error=access_denied -``` - -### 3. 🛡️ CSRF защита (state validation) -```bash -# Неправильный state -curl -v "https://v3.dscrs.site/oauth/github/callback?code=test_code&state=invalid_state" - -# Ожидаемый результат: -# - Редирект на https://testing.discours.io/oauth?error=oauth_state_expired -``` - -### 4. 🔍 Валидация провайдера -```bash -# Несуществующий провайдер -curl -v "https://v3.dscrs.site/oauth/invalid_provider" - -# Ожидаемый результат: -# - JSON ответ с ошибкой {"error": "Invalid provider"} -``` - -### 5. 🍪 Проверка cookie установки -```bash -# Проверка что cookie устанавливается правильно -curl -v "https://v3.dscrs.site/oauth/github/callback?code=valid_code&state=valid_state" \ - -c cookies.txt - -# Проверить в cookies.txt: -# - session_token cookie -# - HttpOnly=true -# - Secure=true -# - SameSite=Lax -# - Domain=.discours.io -``` - -### 6. 🌐 CORS проверка -```bash -# Preflight запрос -curl -v "https://v3.dscrs.site/oauth/github" \ - -X OPTIONS \ - -H "Origin: https://testing.discours.io" \ - -H "Access-Control-Request-Method: GET" - -# Ожидаемый результат: -# - Access-Control-Allow-Origin: https://testing.discours.io -# - Access-Control-Allow-Credentials: true -``` - -### 7. 🔄 Полный E2E тест -```bash -#!/bin/bash -# Полный тест OAuth flow - -echo "🔄 Тестируем полный OAuth flow..." - -# 1. Инициация -INIT_RESPONSE=$(curl -s -D headers1.txt "https://v3.dscrs.site/oauth/github" \ - -H "Referer: https://testing.discours.io/test-page") - -# Извлекаем Location header для получения state -GITHUB_URL=$(grep -i "location:" headers1.txt | cut -d' ' -f2 | tr -d '\r') -STATE=$(echo "$GITHUB_URL" | grep -o 'state=[^&]*' | cut -d'=' -f2) - -echo "✅ State получен: $STATE" - -# 2. Симуляция callback -CALLBACK_RESPONSE=$(curl -s -D headers2.txt \ - "https://v3.dscrs.site/oauth/github/callback?code=test_code&state=$STATE") - -# Проверяем редирект -REDIRECT_URL=$(grep -i "location:" headers2.txt | cut -d' ' -f2 | tr -d '\r') -echo "✅ Redirect URL: $REDIRECT_URL" - -# Проверяем cookie -COOKIE=$(grep -i "set-cookie:" headers2.txt | grep "session_token") -echo "✅ Cookie установлен: $COOKIE" - -if [[ "$REDIRECT_URL" == *"testing.discours.io/oauth?success=true"* ]]; then - echo "🎉 OAuth flow работает корректно!" -else - echo "❌ OAuth flow не работает" - exit 1 -fi -``` - -## 🔧 Настройки провайдеров для тестирования - -### GitHub OAuth App -``` -Application name: Discours Testing -Homepage URL: https://testing.discours.io -Authorization callback URL: https://v3.dscrs.site/oauth/github/callback -``` - -### Google OAuth Client -``` -Authorized JavaScript origins: https://testing.discours.io -Authorized redirect URIs: https://v3.dscrs.site/oauth/google/callback -``` - -### Environment Variables -```bash -# Для тестирования нужны эти переменные: -GITHUB_CLIENT_ID=your_github_client_id -GITHUB_CLIENT_SECRET=your_github_client_secret -GOOGLE_CLIENT_ID=your_google_client_id -GOOGLE_CLIENT_SECRET=your_google_client_secret - -# Redis для state storage -REDIS_URL=redis://localhost:6379 - -# Frontend URL -FRONTEND_URL=https://testing.discours.io -``` - -## 🐛 Возможные проблемы и решения - -### 1. Cookie не устанавливается -**Проблема**: Domain mismatch между v3.dscrs.site и testing.discours.io -**Решение**: Используется domain=".discours.io" для поддержки поддоменов - -### 2. CORS ошибки -**Проблема**: Браузер блокирует запросы между доменами -**Решение**: allow_credentials=True в CORS настройках - -### 3. State expired -**Проблема**: Redis state истекает через 10 минут -**Решение**: Увеличить TTL или оптимизировать flow - -### 4. Provider not configured -**Проблема**: Отсутствуют CLIENT_ID/CLIENT_SECRET -**Решение**: Проверить environment variables - -## 📊 Метрики успешности - -- ✅ Успешная авторизация: > 95% -- ✅ CSRF защита: 100% блокировка invalid state -- ✅ Cookie безопасность: HttpOnly + Secure + SameSite -- ✅ Error handling: Все ошибки редиректят на фронт -- ✅ Performance: < 2 секунд на полный flow diff --git a/main.py b/main.py index de099c0c..0981050e 100644 --- a/main.py +++ b/main.py @@ -50,7 +50,7 @@ middleware = [ allow_origins=[ "https://testing.discours.io", "https://testing3.discours.io", - "https://v3.dscrs.site", + "https://v3.discours.io", "https://session-daily.vercel.app", "https://coretest.discours.io", "https://new.discours.io", diff --git a/package-lock.json b/package-lock.json index 199a2f26..fe0814d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "publy-panel", - "version": "0.9.29", + "version": "0.9.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "publy-panel", - "version": "0.9.29", + "version": "0.9.28", "devDependencies": { "@biomejs/biome": "^2.2.4", "@graphql-codegen/cli": "^6.0.0", @@ -2387,9 +2387,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.2.tgz", - "integrity": "sha512-o3pcKzJgSGt4d74lSZ+OCnHwkKBeAbFDmbEm5gg70eA8VkyCuC/zV9TwBnmw6VjDlRdF4Pshfb+WE9E6XY1PoQ==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", + "integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==", "cpu": [ "arm" ], @@ -2401,9 +2401,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.2.tgz", - "integrity": "sha512-cqFSWO5tX2vhC9hJTK8WAiPIm4Q8q/cU8j2HQA0L3E1uXvBYbOZMhE2oFL8n2pKB5sOCHY6bBuHaRwG7TkfJyw==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz", + "integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==", "cpu": [ "arm64" ], @@ -2415,9 +2415,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.2.tgz", - "integrity": "sha512-vngduywkkv8Fkh3wIZf5nFPXzWsNsVu1kvtLETWxTFf/5opZmflgVSeLgdHR56RQh71xhPhWoOkEBvbehwTlVA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz", + "integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==", "cpu": [ "arm64" ], @@ -2429,9 +2429,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.2.tgz", - "integrity": "sha512-h11KikYrUCYTrDj6h939hhMNlqU2fo/X4NB0OZcys3fya49o1hmFaczAiJWVAFgrM1NCP6RrO7lQKeVYSKBPSQ==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz", + "integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==", "cpu": [ "x64" ], @@ -2443,9 +2443,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.2.tgz", - "integrity": "sha512-/eg4CI61ZUkLXxMHyVlmlGrSQZ34xqWlZNW43IAU4RmdzWEx0mQJ2mN/Cx4IHLVZFL6UBGAh+/GXhgvGb+nVxw==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz", + "integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==", "cpu": [ "arm64" ], @@ -2457,9 +2457,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.2.tgz", - "integrity": "sha512-QOWgFH5X9+p+S1NAfOqc0z8qEpJIoUHf7OWjNUGOeW18Mx22lAUOiA9b6r2/vpzLdfxi/f+VWsYjUOMCcYh0Ng==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz", + "integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==", "cpu": [ "x64" ], @@ -2471,9 +2471,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.2.tgz", - "integrity": "sha512-kDWSPafToDd8LcBYd1t5jw7bD5Ojcu12S3uT372e5HKPzQt532vW+rGFFOaiR0opxePyUkHrwz8iWYEyH1IIQA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz", + "integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==", "cpu": [ "arm" ], @@ -2485,9 +2485,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.2.tgz", - "integrity": "sha512-gKm7Mk9wCv6/rkzwCiUC4KnevYhlf8ztBrDRT9g/u//1fZLapSRc+eDZj2Eu2wpJ+0RzUKgtNijnVIB4ZxyL+w==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz", + "integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==", "cpu": [ "arm" ], @@ -2499,9 +2499,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.2.tgz", - "integrity": "sha512-66lA8vnj5mB/rtDNwPgrrKUOtCLVQypkyDa2gMfOefXK6rcZAxKLO9Fy3GkW8VkPnENv9hBkNOFfGLf6rNKGUg==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz", + "integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==", "cpu": [ "arm64" ], @@ -2513,9 +2513,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.2.tgz", - "integrity": "sha512-s+OPucLNdJHvuZHuIz2WwncJ+SfWHFEmlC5nKMUgAelUeBUnlB4wt7rXWiyG4Zn07uY2Dd+SGyVa9oyLkVGOjA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz", + "integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==", "cpu": [ "arm64" ], @@ -2527,9 +2527,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.2.tgz", - "integrity": "sha512-8wTRM3+gVMDLLDdaT6tKmOE3lJyRy9NpJUS/ZRWmLCmOPIJhVyXwjBo+XbrrwtV33Em1/eCTd5TuGJm4+DmYjw==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz", + "integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==", "cpu": [ "loong64" ], @@ -2541,9 +2541,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.2.tgz", - "integrity": "sha512-6yqEfgJ1anIeuP2P/zhtfBlDpXUb80t8DpbYwXQ3bQd95JMvUaqiX+fKqYqUwZXqdJDd8xdilNtsHM2N0cFm6A==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz", + "integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==", "cpu": [ "ppc64" ], @@ -2555,9 +2555,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.2.tgz", - "integrity": "sha512-sshYUiYVSEI2B6dp4jMncwxbrUqRdNApF2c3bhtLAU0qA8Lrri0p0NauOsTWh3yCCCDyBOjESHMExonp7Nzc0w==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz", + "integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==", "cpu": [ "riscv64" ], @@ -2569,9 +2569,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.2.tgz", - "integrity": "sha512-duBLgd+3pqC4MMwBrKkFxaZerUxZcYApQVC5SdbF5/e/589GwVvlRUnyqMFbM8iUSb1BaoX/3fRL7hB9m2Pj8Q==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz", + "integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==", "cpu": [ "riscv64" ], @@ -2583,9 +2583,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.2.tgz", - "integrity": "sha512-tzhYJJidDUVGMgVyE+PmxENPHlvvqm1KILjjZhB8/xHYqAGeizh3GBGf9u6WdJpZrz1aCpIIHG0LgJgH9rVjHQ==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz", + "integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==", "cpu": [ "s390x" ], @@ -2597,9 +2597,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.2.tgz", - "integrity": "sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz", + "integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==", "cpu": [ "x64" ], @@ -2611,9 +2611,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.2.tgz", - "integrity": "sha512-LSeBHnGli1pPKVJ79ZVJgeZWWZXkEe/5o8kcn23M8eMKCUANejchJbF/JqzM4RRjOJfNRhKJk8FuqL1GKjF5oQ==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz", + "integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==", "cpu": [ "x64" ], @@ -2625,9 +2625,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.2.tgz", - "integrity": "sha512-uPj7MQ6/s+/GOpolavm6BPo+6CbhbKYyZHUDvZ/SmJM7pfDBgdGisFX3bY/CBDMg2ZO4utfhlApkSfZ92yXw7Q==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz", + "integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==", "cpu": [ "arm64" ], @@ -2639,9 +2639,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.2.tgz", - "integrity": "sha512-Z9MUCrSgIaUeeHAiNkm3cQyst2UhzjPraR3gYYfOjAuZI7tcFRTOD+4cHLPoS/3qinchth+V56vtqz1Tv+6KPA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz", + "integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==", "cpu": [ "arm64" ], @@ -2653,9 +2653,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.2.tgz", - "integrity": "sha512-+GnYBmpjldD3XQd+HMejo+0gJGwYIOfFeoBQv32xF/RUIvccUz20/V6Otdv+57NE70D5pa8W/jVGDoGq0oON4A==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz", + "integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==", "cpu": [ "ia32" ], @@ -2667,9 +2667,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.2.tgz", - "integrity": "sha512-ApXFKluSB6kDQkAqZOKXBjiaqdF1BlKi+/eqnYe9Ee7U2K3pUDKsIyr8EYm/QDHTJIM+4X+lI0gJc3TTRhd+dA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz", + "integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==", "cpu": [ "x64" ], @@ -2681,9 +2681,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.2.tgz", - "integrity": "sha512-ARz+Bs8kY6FtitYM96PqPEVvPXqEZmPZsSkXvyX19YzDqkCaIlhCieLLMI5hxO9SRZ2XtCtm8wxhy0iJ2jxNfw==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz", + "integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==", "cpu": [ "x64" ], @@ -3030,9 +3030,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", - "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.8.tgz", + "integrity": "sha512-be0PUaPsQX/gPWWgFsdD+GFzaoig5PXaUC1xLkQiYdDnANU8sMnHoQd8JhbJQuvTWrWLyeFN9Imb5Qtfvr4RrQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3588,9 +3588,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.223", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.223.tgz", - "integrity": "sha512-qKm55ic6nbEmagFlTFczML33rF90aU+WtrJ9MdTCThrcvDNdUHN4p6QfVN78U06ZmguqXIyMPyYhw2TrbDUwPQ==", + "version": "1.5.227", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.227.tgz", + "integrity": "sha512-ITxuoPfJu3lsNWUi2lBM2PaBPYgH3uqmxut5vmBxgYvyI4AlJ6P3Cai1O76mOrkJCBzq0IxWg/NtqOrpu/0gKA==", "dev": true, "license": "ISC" }, @@ -5408,9 +5408,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.52.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz", - "integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", + "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", "dev": true, "license": "MIT", "dependencies": { @@ -5424,28 +5424,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.2", - "@rollup/rollup-android-arm64": "4.52.2", - "@rollup/rollup-darwin-arm64": "4.52.2", - "@rollup/rollup-darwin-x64": "4.52.2", - "@rollup/rollup-freebsd-arm64": "4.52.2", - "@rollup/rollup-freebsd-x64": "4.52.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.2", - "@rollup/rollup-linux-arm-musleabihf": "4.52.2", - "@rollup/rollup-linux-arm64-gnu": "4.52.2", - "@rollup/rollup-linux-arm64-musl": "4.52.2", - "@rollup/rollup-linux-loong64-gnu": "4.52.2", - "@rollup/rollup-linux-ppc64-gnu": "4.52.2", - "@rollup/rollup-linux-riscv64-gnu": "4.52.2", - "@rollup/rollup-linux-riscv64-musl": "4.52.2", - "@rollup/rollup-linux-s390x-gnu": "4.52.2", - "@rollup/rollup-linux-x64-gnu": "4.52.2", - "@rollup/rollup-linux-x64-musl": "4.52.2", - "@rollup/rollup-openharmony-arm64": "4.52.2", - "@rollup/rollup-win32-arm64-msvc": "4.52.2", - "@rollup/rollup-win32-ia32-msvc": "4.52.2", - "@rollup/rollup-win32-x64-gnu": "4.52.2", - "@rollup/rollup-win32-x64-msvc": "4.52.2", + "@rollup/rollup-android-arm-eabi": "4.52.3", + "@rollup/rollup-android-arm64": "4.52.3", + "@rollup/rollup-darwin-arm64": "4.52.3", + "@rollup/rollup-darwin-x64": "4.52.3", + "@rollup/rollup-freebsd-arm64": "4.52.3", + "@rollup/rollup-freebsd-x64": "4.52.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", + "@rollup/rollup-linux-arm-musleabihf": "4.52.3", + "@rollup/rollup-linux-arm64-gnu": "4.52.3", + "@rollup/rollup-linux-arm64-musl": "4.52.3", + "@rollup/rollup-linux-loong64-gnu": "4.52.3", + "@rollup/rollup-linux-ppc64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-musl": "4.52.3", + "@rollup/rollup-linux-s390x-gnu": "4.52.3", + "@rollup/rollup-linux-x64-gnu": "4.52.3", + "@rollup/rollup-linux-x64-musl": "4.52.3", + "@rollup/rollup-openharmony-arm64": "4.52.3", + "@rollup/rollup-win32-arm64-msvc": "4.52.3", + "@rollup/rollup-win32-ia32-msvc": "4.52.3", + "@rollup/rollup-win32-x64-gnu": "4.52.3", + "@rollup/rollup-win32-x64-msvc": "4.52.3", "fsevents": "~2.3.2" } }, diff --git a/package.json b/package.json index c0be2db9..2d2be029 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "publy-panel", - "version": "0.9.29", + "version": "0.9.28", "type": "module", "description": "Publy, a modern platform for collaborative text creation, offers a user-friendly interface for authors, editors, and readers, supporting real-time collaboration and structured feedback.", "scripts": { diff --git a/panel/graphql/index.ts b/panel/graphql/index.ts index 00c4255d..eb94364f 100644 --- a/panel/graphql/index.ts +++ b/panel/graphql/index.ts @@ -3,12 +3,7 @@ * @module api */ -import { - AUTH_TOKEN_KEY, - clearAuthTokens, - getAuthTokenFromCookie, - getCsrfTokenFromCookie -} from '../utils/auth' +import { AUTH_TOKEN_KEY, clearAuthTokens, getCsrfTokenFromCookie } from '../utils/auth' /** * Тип для произвольных данных GraphQL @@ -27,7 +22,7 @@ function getRequestHeaders(): Record { // Проверяем наличие токена в localStorage const localToken = localStorage.getItem(AUTH_TOKEN_KEY) - + // Используем только токен из localStorage (если есть) const token = localToken diff --git a/panel/utils/auth.ts b/panel/utils/auth.ts index eb2a4119..203b06eb 100644 --- a/panel/utils/auth.ts +++ b/panel/utils/auth.ts @@ -84,7 +84,7 @@ export function checkAuthStatus(): boolean { // 💋 НЕ проверяем httpOnly cookie через JavaScript - он недоступен! // httpOnly cookie автоматически отправляется браузером, но недоступен для чтения - + // Проверяем наличие токена в localStorage const localToken = localStorage.getItem(AUTH_TOKEN_KEY) const hasLocalToken = !!localToken && localToken.length > 10 @@ -93,9 +93,11 @@ export function checkAuthStatus(): boolean { // Если нет токена в 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)'}`) + console.log( + `[Auth] Authentication status: ${isAuth ? 'authenticated via localStorage' : 'unknown (may be authenticated via httpOnly cookie)'}` + ) // Дополнительное логирование для диагностики if (localToken) { diff --git a/pyproject.toml b/pyproject.toml index cefbbe05..3fb842b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "discours-core" -version = "0.9.25" +version = "0.9.28" description = "Core backend for Discours.io platform" authors = [ {name = "Tony Rewin", email = "tonyrewin@yandex.ru"} diff --git a/resolvers/auth.py b/resolvers/auth.py index cc6bd1b6..4f20ae02 100644 --- a/resolvers/auth.py +++ b/resolvers/auth.py @@ -9,7 +9,14 @@ from starlette.responses import JSONResponse from auth.utils import extract_token_from_request, get_auth_token_from_context, get_user_data_by_token from services.auth import auth_service -from settings import SESSION_COOKIE_NAME +from settings import ( + SESSION_COOKIE_DOMAIN, + SESSION_COOKIE_HTTPONLY, + SESSION_COOKIE_MAX_AGE, + SESSION_COOKIE_NAME, + SESSION_COOKIE_SAMESITE, + SESSION_COOKIE_SECURE, +) from storage.schema import mutation, query, type_author from utils.logger import root_logger as logger @@ -84,20 +91,36 @@ async def login(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, A result = await auth_service.login(email, password, request) - # Устанавливаем cookie если есть токен - if result.get("success") and result.get("token") and request: + # Устанавливаем httpOnly cookie если есть токен + if result.get("success") and result.get("token"): try: - if not hasattr(info.context, "response"): + response = info.context.get("response") + if not response: response = JSONResponse({}) - response.set_cookie( - key=SESSION_COOKIE_NAME, - value=result["token"], - httponly=True, - secure=True, - samesite="strict", - max_age=86400 * 30, - ) info.context["response"] = response + + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=result["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"✅ Email/Password: httpOnly cookie установлен для пользователя {result.get('author', {}).get('id')}" + ) + + # 💋 НЕ возвращаем токен клиенту - он в httpOnly cookie + result_without_token = result.copy() + result_without_token["token"] = None # Скрываем токен от JavaScript + return result_without_token + except Exception as cookie_error: logger.warning(f"Не удалось установить cookie: {cookie_error}") @@ -129,7 +152,11 @@ async def logout(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, # Удаляем cookie if request and hasattr(info.context, "response"): try: - info.context["response"].delete_cookie(SESSION_COOKIE_NAME) + info.context["response"].delete_cookie( + key=SESSION_COOKIE_NAME, + path="/", + domain=SESSION_COOKIE_DOMAIN, # ✅ КРИТИЧНО: тот же domain что при установке + ) except Exception as e: logger.warning(f"Не удалось удалить cookie: {e}") @@ -174,10 +201,14 @@ async def refresh_token(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dic info.context["response"].set_cookie( key=SESSION_COOKIE_NAME, value=result["token"], - httponly=True, - secure=True, - samesite="strict", - max_age=86400 * 30, + 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, # ✅ КРИТИЧНО для поддоменов ) except Exception as e: logger.warning(f"Не удалось обновить cookie: {e}") diff --git a/settings.py b/settings.py index ef0224df..0a675c13 100644 --- a/settings.py +++ b/settings.py @@ -4,7 +4,7 @@ import datetime import os from os import environ from pathlib import Path -from typing import Literal +from typing import Literal, cast # Корневая директория проекта ROOT_DIR = Path(__file__).parent.absolute() @@ -85,13 +85,19 @@ SESSION_COOKIE_NAME = "session_token" # 🔒 Автоматически определяем HTTPS на основе окружения SESSION_COOKIE_SECURE = os.getenv("HTTPS_ENABLED", "true").lower() in ["true", "1", "yes"] SESSION_COOKIE_HTTPONLY = True -SESSION_COOKIE_SAMESITE: Literal["lax", "strict", "none"] = "lax" +# 🌐 Для cross-origin SSE на поддоменах +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" +) SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 дней MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY", "") MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN", "discours.io") - # Search service configuration SEARCH_MAX_BATCH_SIZE = int(os.environ.get("SEARCH_MAX_BATCH_SIZE", "25")) SEARCH_CACHE_ENABLED = bool(os.environ.get("SEARCH_CACHE_ENABLED", "true").lower() in ["true", "1", "yes"]) diff --git a/tests/test_oauth_minimal.py b/tests/test_oauth_minimal.py index dab834fc..220444ad 100644 --- a/tests/test_oauth_minimal.py +++ b/tests/test_oauth_minimal.py @@ -22,7 +22,7 @@ class MinimalOAuthRequest: self.query_params = query_params or {} self.path_params = path_params or {} self.headers = headers or {"user-agent": "test-agent"} - self.url = "https://v3.dscrs.site/oauth/github/callback" + self.url = "https://v3.discours.io/oauth/github/callback" self.method = "GET" # ✅ Добавляем method для логирования self.client = MagicMock() self.client.host = "127.0.0.1" diff --git a/uv.lock b/uv.lock index a6e5ba88..d9621001 100644 --- a/uv.lock +++ b/uv.lock @@ -425,7 +425,7 @@ wheels = [ [[package]] name = "discours-core" -version = "0.9.25" +version = "0.9.28" source = { editable = "." } dependencies = [ { name = "ariadne" },