[0.9.28] - OAuth/Auth with httpOnly cookie
All checks were successful
Deploy on push / deploy (push) Successful in 4m32s
All checks were successful
Deploy on push / deploy (push) Successful in 4m32s
This commit is contained in:
@@ -90,7 +90,7 @@ jobs:
|
|||||||
echo "📝 Запускаем GraphQL codegen..."
|
echo "📝 Запускаем GraphQL codegen..."
|
||||||
npm run codegen 2>&1 | tee codegen_output.log
|
npm run codegen 2>&1 | tee codegen_output.log
|
||||||
if [ ${PIPESTATUS[0]} -ne 0 ]; then
|
if [ ${PIPESTATUS[0]} -ne 0 ]; then
|
||||||
echo "❌ GraphQL codegen упал с v3.dscrs.site!"
|
echo "❌ GraphQL codegen упал с v3.discours.io!"
|
||||||
echo "📋 ПОЛНЫЙ ВЫВОД ОШИБКИ:"
|
echo "📋 ПОЛНЫЙ ВЫВОД ОШИБКИ:"
|
||||||
cat codegen_output.log
|
cat codegen_output.log
|
||||||
echo "📋 КОНЕЦ ВЫВОДА ОШИБКИ"
|
echo "📋 КОНЕЦ ВЫВОДА ОШИБКИ"
|
||||||
@@ -101,8 +101,8 @@ jobs:
|
|||||||
V3_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
V3_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"query":"query{__typename}"}' \
|
-d '{"query":"query{__typename}"}' \
|
||||||
https://v3.dscrs.site/graphql 2>/dev/null || echo "000")
|
https://v3.discours.io/graphql 2>/dev/null || echo "000")
|
||||||
echo "v3.dscrs.site: $V3_STATUS"
|
echo "v3.discours.io: $V3_STATUS"
|
||||||
|
|
||||||
CORETEST_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
CORETEST_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
if [ "$CORETEST_STATUS" = "200" ]; then
|
if [ "$CORETEST_STATUS" = "200" ]; then
|
||||||
echo "🔄 Переключаемся на coretest.discours.io..."
|
echo "🔄 Переключаемся на coretest.discours.io..."
|
||||||
# Временно меняем схему в codegen.ts
|
# Временно меняем схему в 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
|
npm run codegen 2>&1 | tee fallback_output.log
|
||||||
if [ ${PIPESTATUS[0]} -ne 0 ]; then
|
if [ ${PIPESTATUS[0]} -ne 0 ]; then
|
||||||
echo "❌ Fallback тоже не сработал!"
|
echo "❌ Fallback тоже не сработал!"
|
||||||
@@ -122,11 +122,11 @@ jobs:
|
|||||||
cat fallback_output.log
|
cat fallback_output.log
|
||||||
echo "📋 КОНЕЦ ВЫВОДА ОШИБКИ FALLBACK"
|
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
|
exit 1
|
||||||
fi
|
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
|
else
|
||||||
echo "❌ Оба endpoint недоступны!"
|
echo "❌ Оба endpoint недоступны!"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -210,22 +210,22 @@ jobs:
|
|||||||
echo "${{ secrets.STAGING_PRIVATE_KEY }}" > ~/.ssh/id_rsa
|
echo "${{ secrets.STAGING_PRIVATE_KEY }}" > ~/.ssh/id_rsa
|
||||||
chmod 600 ~/.ssh/id_rsa
|
chmod 600 ~/.ssh/id_rsa
|
||||||
|
|
||||||
# Добавляем v3.dscrs.site в known_hosts
|
# Добавляем v3.discours.io в known_hosts
|
||||||
ssh-keyscan -H v3.dscrs.site >> ~/.ssh/known_hosts
|
ssh-keyscan -H v3.discours.io >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
# Запускаем ssh-agent
|
# Запускаем ssh-agent
|
||||||
eval $(ssh-agent -s)
|
eval $(ssh-agent -s)
|
||||||
ssh-add ~/.ssh/id_rsa
|
ssh-add ~/.ssh/id_rsa
|
||||||
|
|
||||||
echo "✅ SSH настроен для v3.dscrs.site"
|
echo "✅ SSH настроен для v3.discours.io"
|
||||||
|
|
||||||
- name: Push to dokku for dev branch
|
- name: Push to dokku for dev branch
|
||||||
if: github.ref == 'refs/heads/dev'
|
if: github.ref == 'refs/heads/dev'
|
||||||
run: |
|
run: |
|
||||||
echo "🚀 Деплоим на v3.dscrs.site..."
|
echo "🚀 Деплоим на v3.discours.io..."
|
||||||
|
|
||||||
# Добавляем dokku remote
|
# Добавляем 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
|
# Проверяем remote
|
||||||
git remote -v
|
git remote -v
|
||||||
|
|||||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -1,43 +1,6 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [0.9.29] - 2025-09-26
|
## [0.9.28] - OAuth/Auth with httpOnly cookie
|
||||||
|
|
||||||
### 🚨 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.27] - 2025-09-25
|
## [0.9.27] - 2025-09-25
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ async def logout(request: Request) -> Response:
|
|||||||
key=SESSION_COOKIE_NAME,
|
key=SESSION_COOKIE_NAME,
|
||||||
secure=SESSION_COOKIE_SECURE,
|
secure=SESSION_COOKIE_SECURE,
|
||||||
httponly=SESSION_COOKIE_HTTPONLY,
|
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 успешно удалена")
|
logger.info("[auth] logout: Cookie успешно удалена")
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ async def refresh_token(request: Request) -> JSONResponse:
|
|||||||
value=new_token,
|
value=new_token,
|
||||||
httponly=SESSION_COOKIE_HTTPONLY,
|
httponly=SESSION_COOKIE_HTTPONLY,
|
||||||
secure=SESSION_COOKIE_SECURE,
|
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,
|
max_age=SESSION_COOKIE_MAX_AGE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
Единый middleware для обработки авторизации в GraphQL запросах
|
Единый middleware для обработки авторизации в GraphQL запросах
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, MutableMapping
|
from collections.abc import Awaitable, MutableMapping
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
@@ -21,8 +20,8 @@ from settings import (
|
|||||||
ADMIN_EMAILS as ADMIN_EMAILS_LIST,
|
ADMIN_EMAILS as ADMIN_EMAILS_LIST,
|
||||||
)
|
)
|
||||||
from settings import (
|
from settings import (
|
||||||
|
SESSION_COOKIE_DOMAIN,
|
||||||
SESSION_COOKIE_HTTPONLY,
|
SESSION_COOKIE_HTTPONLY,
|
||||||
SESSION_COOKIE_MAX_AGE,
|
|
||||||
SESSION_COOKIE_NAME,
|
SESSION_COOKIE_NAME,
|
||||||
SESSION_COOKIE_SAMESITE,
|
SESSION_COOKIE_SAMESITE,
|
||||||
SESSION_COOKIE_SECURE,
|
SESSION_COOKIE_SECURE,
|
||||||
@@ -294,34 +293,12 @@ class AuthMiddleware:
|
|||||||
logger.debug(f"[middleware] Проверяем cookies: {cookies[:100]}...")
|
logger.debug(f"[middleware] Проверяем cookies: {cookies[:100]}...")
|
||||||
logger.debug(f"[middleware] Ищем cookie с именем: '{SESSION_COOKIE_NAME}'")
|
logger.debug(f"[middleware] Ищем cookie с именем: '{SESSION_COOKIE_NAME}'")
|
||||||
|
|
||||||
# 🔍 Дополнительная диагностика для отладки
|
# 🔍 Диагностика cookies (только для debug уровня)
|
||||||
if not cookies:
|
if not cookies:
|
||||||
logger.warning("[middleware] 🚨 ПРОБЛЕМА: Cookie заголовок полностью отсутствует!")
|
logger.debug("[middleware] Cookie заголовок отсутствует")
|
||||||
logger.warning(f"[middleware] 🔍 Все заголовки: {list(headers.keys())}")
|
logger.debug(f"[middleware] Доступные заголовки: {list(headers.keys())}")
|
||||||
# Проверяем, есть ли активные сессии для этого пользователя
|
# 💋 OAuth не использует cookies - это нормальное поведение
|
||||||
try:
|
logger.debug("[middleware] OAuth система работает без cookies - токены передаются через заголовки")
|
||||||
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}")
|
|
||||||
|
|
||||||
cookie_items = cookies.split(";")
|
cookie_items = cookies.split(";")
|
||||||
found_cookies = []
|
found_cookies = []
|
||||||
@@ -472,23 +449,7 @@ class AuthMiddleware:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Проверяем, является ли result уже объектом Response
|
# Проверяем, является ли result уже объектом Response
|
||||||
if isinstance(result, Response):
|
response = result if isinstance(result, Response) else JSONResponse(result)
|
||||||
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
|
|
||||||
|
|
||||||
# Проверяем, был ли токен в запросе или ответе
|
# Проверяем, был ли токен в запросе или ответе
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -496,55 +457,13 @@ class AuthMiddleware:
|
|||||||
data = await request.json()
|
data = await request.json()
|
||||||
op_name = data.get("operationName", "").lower()
|
op_name = data.get("operationName", "").lower()
|
||||||
|
|
||||||
# Если это операция логина или обновления токена, и в ответе есть токен
|
# 💋 OAuth НЕ использует cookies - токены передаются только через заголовки/localStorage
|
||||||
if op_name in ["login", "refreshtoken"]:
|
# Убираем автоматическую установку cookies для login/refreshtoken/getSession
|
||||||
token = None
|
if op_name in ["login", "refreshtoken", "getsession"]:
|
||||||
# Пытаемся извлечь токен из данных ответа
|
logger.debug(f"[graphql_handler] Операция {op_name}: токены передаются БЕЗ cookies")
|
||||||
if result_data and isinstance(result_data, dict):
|
logger.debug(
|
||||||
data_obj = result_data.get("data", {})
|
"[graphql_handler] Фронтенд должен извлечь токен из ответа и управлять им самостоятельно"
|
||||||
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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Если это операция logout, удаляем cookie
|
# Если это операция logout, удаляем cookie
|
||||||
elif op_name == "logout":
|
elif op_name == "logout":
|
||||||
@@ -552,7 +471,10 @@ class AuthMiddleware:
|
|||||||
key=SESSION_COOKIE_NAME,
|
key=SESSION_COOKIE_NAME,
|
||||||
secure=SESSION_COOKIE_SECURE,
|
secure=SESSION_COOKIE_SECURE,
|
||||||
httponly=SESSION_COOKIE_HTTPONLY,
|
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}")
|
logger.debug(f"[graphql_handler] Удалена cookie {SESSION_COOKIE_NAME} для операции {op_name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from orm.community import Community, CommunityAuthor, CommunityFollower
|
|||||||
from settings import (
|
from settings import (
|
||||||
FRONTEND_URL,
|
FRONTEND_URL,
|
||||||
OAUTH_CLIENTS,
|
OAUTH_CLIENTS,
|
||||||
|
SESSION_COOKIE_DOMAIN,
|
||||||
SESSION_COOKIE_HTTPONLY,
|
SESSION_COOKIE_HTTPONLY,
|
||||||
SESSION_COOKIE_MAX_AGE,
|
SESSION_COOKIE_MAX_AGE,
|
||||||
SESSION_COOKIE_NAME,
|
SESSION_COOKIE_NAME,
|
||||||
@@ -526,48 +527,25 @@ async def oauth_callback(request: Any) -> JSONResponse | RedirectResponse:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"🔗 OAuth redirect URL: {final_redirect_url}")
|
# 🍪 Устанавливаем httpOnly cookie вместо токена в URL
|
||||||
|
response = RedirectResponse(url=redirect_uri, status_code=307)
|
||||||
# Создаем ответ с редиректом
|
|
||||||
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
|
|
||||||
|
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
SESSION_COOKIE_NAME,
|
key=SESSION_COOKIE_NAME,
|
||||||
session_token,
|
value=session_token,
|
||||||
httponly=SESSION_COOKIE_HTTPONLY,
|
httponly=SESSION_COOKIE_HTTPONLY,
|
||||||
secure=cookie_secure,
|
secure=SESSION_COOKIE_SECURE,
|
||||||
samesite=cookie_samesite,
|
samesite=SESSION_COOKIE_SAMESITE if SESSION_COOKIE_SAMESITE in ["strict", "lax", "none"] else "none",
|
||||||
max_age=SESSION_COOKIE_MAX_AGE,
|
max_age=SESSION_COOKIE_MAX_AGE,
|
||||||
path="/", # Важно: устанавливаем path="/" для доступности cookie во всех путях
|
path="/",
|
||||||
domain=cookie_domain, # Поддержка поддоменов только для основного домена
|
domain=SESSION_COOKIE_DOMAIN, # ✅ Для работы с поддоменами
|
||||||
)
|
)
|
||||||
|
|
||||||
# 🔍 Дополнительная диагностика cookie
|
logger.info(f"✅ OAuth: httpOnly cookie установлен для user_id={author.id}")
|
||||||
logger.warning("🚨 ВАЖНО: Cookie должен быть установлен в браузере и отправляться в последующих запросах!")
|
logger.info(f"🔗 Redirect на фронтенд БЕЗ токена в URL: {redirect_uri}")
|
||||||
logger.warning("🚨 Если cookie не передается, проверьте:")
|
logger.info(
|
||||||
logger.warning(f" - Браузер принимает cookie с domain={cookie_domain}")
|
f"🍪 Cookie: {SESSION_COOKIE_NAME}, secure={SESSION_COOKIE_SECURE}, samesite={SESSION_COOKIE_SAMESITE}"
|
||||||
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}")
|
logger.info(f"OAuth успешно завершен для {provider}, user_id={author.id}")
|
||||||
return response
|
return response
|
||||||
@@ -883,55 +861,30 @@ async def oauth_callback_http(request: Request) -> JSONResponse | RedirectRespon
|
|||||||
logger.info(f" - Provider: {provider}")
|
logger.info(f" - Provider: {provider}")
|
||||||
logger.info(f" - User ID: {author.id}")
|
logger.info(f" - User ID: {author.id}")
|
||||||
|
|
||||||
# Возвращаем redirect с токеном в URL
|
# 🍪 Устанавливаем httpOnly cookie вместо токена в URL
|
||||||
response = RedirectResponse(url=final_redirect_url, status_code=307)
|
response = RedirectResponse(url=redirect_uri, 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
|
|
||||||
|
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
SESSION_COOKIE_NAME,
|
key=SESSION_COOKIE_NAME,
|
||||||
session_token,
|
value=session_token,
|
||||||
httponly=SESSION_COOKIE_HTTPONLY,
|
httponly=SESSION_COOKIE_HTTPONLY,
|
||||||
secure=cookie_secure,
|
secure=SESSION_COOKIE_SECURE,
|
||||||
samesite=cookie_samesite,
|
samesite=SESSION_COOKIE_SAMESITE,
|
||||||
max_age=SESSION_COOKIE_MAX_AGE,
|
max_age=SESSION_COOKIE_MAX_AGE,
|
||||||
path="/", # Важно: устанавливаем path="/" для доступности cookie во всех путях
|
path="/",
|
||||||
domain=cookie_domain, # Поддержка поддоменов только для основного домена
|
domain=SESSION_COOKIE_DOMAIN, # ✅ Для работы с поддоменами
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ OAuth: httpOnly cookie установлен для user_id={author.id}")
|
||||||
|
logger.info(f"🔗 Redirect на фронтенд БЕЗ токена в URL: {redirect_uri}")
|
||||||
logger.info(
|
logger.info(
|
||||||
f"🍪 Cookie установлен: name={SESSION_COOKIE_NAME}, domain={cookie_domain}, secure={cookie_secure}, samesite={cookie_samesite}"
|
f"🍪 Cookie: {SESSION_COOKIE_NAME}, secure={SESSION_COOKIE_SECURE}, samesite={SESSION_COOKIE_SAMESITE}"
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"🔍 Cookie debug: redirect_netloc={parsed_redirect.netloc}, is_testing={('testing.discours.io' in parsed_redirect.netloc)}"
|
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"🔍 Session token preview: {session_token[:30]}..."
|
f"🔍 Session token preview: {session_token[:30]}..."
|
||||||
if len(session_token) > 30
|
if len(session_token) > 30
|
||||||
else f"🔍 Session token: {session_token}"
|
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}")
|
logger.info(f"✅ OAuth успешно завершен для {provider}, user_id={author.id}")
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { CodegenConfig } from '@graphql-codegen/cli'
|
|||||||
const config: CodegenConfig = {
|
const config: CodegenConfig = {
|
||||||
overwrite: true,
|
overwrite: true,
|
||||||
// Используем основной endpoint с fallback логикой
|
// Используем основной 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/**'],
|
documents: ['panel/graphql/queries/**/*.ts', 'panel/**/*.{ts,tsx}', '!panel/graphql/generated/**'],
|
||||||
generates: {
|
generates: {
|
||||||
'./panel/graphql/generated/introspection.json': {
|
'./panel/graphql/generated/introspection.json': {
|
||||||
|
|||||||
@@ -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
|
```python
|
||||||
from auth.tokens.sessions import SessionTokenManager
|
from auth.tokens.sessions import SessionTokenManager
|
||||||
from auth.utils import extract_token_from_request
|
from auth.utils import extract_token_from_request
|
||||||
|
|
||||||
# Проверка токена
|
# Проверка токена (автоматически из cookie или Bearer заголовка)
|
||||||
sessions = SessionTokenManager()
|
sessions = SessionTokenManager()
|
||||||
token = await extract_token_from_request(request)
|
token = await extract_token_from_request(request)
|
||||||
payload = await sessions.verify_session(token)
|
payload = await sessions.verify_session(token)
|
||||||
@@ -22,6 +29,18 @@ if payload:
|
|||||||
print(f"Пользователь авторизован: {user_id}")
|
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 ключи для поиска
|
### Redis ключи для поиска
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -29,7 +48,7 @@ if payload:
|
|||||||
session:{user_id}:{token} # Данные сессии (hash)
|
session:{user_id}:{token} # Данные сессии (hash)
|
||||||
user_sessions:{user_id} # Список активных токенов (set)
|
user_sessions:{user_id} # Список активных токенов (set)
|
||||||
|
|
||||||
# OAuth токены
|
# OAuth токены (для API интеграций)
|
||||||
oauth_access:{user_id}:{provider} # Access токен
|
oauth_access:{user_id}:{provider} # Access токен
|
||||||
oauth_refresh:{user_id}:{provider} # Refresh токен
|
oauth_refresh:{user_id}:{provider} # Refresh токен
|
||||||
```
|
```
|
||||||
@@ -43,7 +62,7 @@ oauth_refresh:{user_id}:{provider} # Refresh токен
|
|||||||
|
|
||||||
### 🔑 Аутентификация
|
### 🔑 Аутентификация
|
||||||
- **[Управление сессиями](sessions.md)** - JWT токены и Redis хранение
|
- **[Управление сессиями](sessions.md)** - JWT токены и Redis хранение
|
||||||
- **[OAuth интеграция](oauth.md)** - Социальные провайдеры
|
- **[OAuth интеграция](oauth.md)** - Социальные провайдеры с httpOnly cookies
|
||||||
- **[Микросервисы](microservices.md)** - 🎯 **Интеграция с другими сервисами**
|
- **[Микросервисы](microservices.md)** - 🎯 **Интеграция с другими сервисами**
|
||||||
|
|
||||||
### 🛠️ Разработка
|
### 🛠️ Разработка
|
||||||
@@ -56,6 +75,40 @@ oauth_refresh:{user_id}:{provider} # Refresh токен
|
|||||||
- **[Security System](../security.md)** - Управление паролями и email
|
- **[Security System](../security.md)** - Управление паролями и email
|
||||||
- **[Redis Schema](../redis-schema.md)** - Схема данных и кеширование
|
- **[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
|
### Подключение к Redis
|
||||||
@@ -78,23 +131,22 @@ results = await batch.batch_validate_tokens(token_list)
|
|||||||
### HTTP заголовки
|
### HTTP заголовки
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Извлечение токена из запроса
|
# Извлечение токена из запроса (cookie или Bearer)
|
||||||
from auth.utils import extract_token_from_request, get_safe_headers
|
from auth.utils import extract_token_from_request
|
||||||
|
|
||||||
token = await extract_token_from_request(request)
|
token = await extract_token_from_request(request)
|
||||||
|
# Автоматически проверяет:
|
||||||
# Или вручную
|
# 1. Authorization: Bearer <token>
|
||||||
headers = get_safe_headers(request)
|
# 2. Cookie: session_token=<token>
|
||||||
token = headers.get("authorization", "").replace("Bearer ", "")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎯 Основные компоненты
|
## 🎯 Основные компоненты
|
||||||
|
|
||||||
- **SessionTokenManager** - JWT сессии с Redis хранением
|
- **SessionTokenManager** - JWT сессии с Redis хранением + httpOnly cookies
|
||||||
- **OAuthTokenManager** - OAuth access/refresh токены
|
- **OAuthTokenManager** - OAuth access/refresh токены для API интеграций
|
||||||
- **BatchTokenOperations** - Массовые операции с токенами
|
- **BatchTokenOperations** - Массовые операции с токенами
|
||||||
- **TokenMonitoring** - Мониторинг и статистика
|
- **TokenMonitoring** - Мониторинг и статистика
|
||||||
- **AuthMiddleware** - HTTP middleware для автоматической обработки
|
- **AuthMiddleware** - HTTP middleware с поддержкой cookies
|
||||||
|
|
||||||
## ⚡ Производительность
|
## ⚡ Производительность
|
||||||
|
|
||||||
@@ -103,3 +155,79 @@ token = headers.get("authorization", "").replace("Bearer ", "")
|
|||||||
- **Pipeline использование** для атомарности
|
- **Pipeline использование** для атомарности
|
||||||
- **SCAN** вместо KEYS для безопасности
|
- **SCAN** вместо KEYS для безопасности
|
||||||
- **TTL** автоматическая очистка истекших токенов
|
- **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 для всех типов авторизации
|
||||||
|
- ✅ **Простота**: Браузер автоматически управляет токенами
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
# Архитектура системы авторизации Discours Core
|
# 🏗️ Архитектура системы авторизации Discours Core
|
||||||
|
|
||||||
## 🎯 Обзор архитектуры
|
## 🎯 Обзор архитектуры 2025
|
||||||
|
|
||||||
Модульная система авторизации с разделением ответственности между компонентами.
|
Модульная система авторизации с **httpOnly cookies** для максимальной безопасности и единообразия.
|
||||||
|
|
||||||
|
**Ключевые принципы:**
|
||||||
|
- **🍪 httpOnly cookies** для ВСЕХ типов авторизации (OAuth + Email/Password)
|
||||||
|
- **🛡️ Максимальная безопасность** - защита от XSS и CSRF
|
||||||
|
- **🔄 Единообразие** - один механизм для всех провайдеров
|
||||||
|
- **📱 Автоматическое управление** - браузер сам отправляет cookies
|
||||||
|
|
||||||
**Хранение данных:**
|
**Хранение данных:**
|
||||||
- **Токены** → Redis (сессии, OAuth, verification)
|
- **Сессии** → Redis (JWT токены) + httpOnly cookies (передача)
|
||||||
- **Пользователи** → PostgreSQL (основные данные + OAuth в JSON поле)
|
- **OAuth токены** → Redis (для API интеграций)
|
||||||
|
- **Пользователи** → PostgreSQL (основные данные + OAuth связи)
|
||||||
|
|
||||||
## 📊 Схема потоков данных
|
## 📊 Схема потоков данных
|
||||||
|
|
||||||
@@ -121,67 +128,90 @@ graph TB
|
|||||||
OTM --> RESP
|
OTM --> RESP
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔐 OAuth Flow
|
## 🔐 OAuth Flow (httpOnly cookies)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant U as User
|
participant U as User
|
||||||
participant F as Frontend
|
participant F as Frontend
|
||||||
participant A as Auth Service
|
participant B as Backend
|
||||||
participant R as Redis
|
participant R as Redis
|
||||||
participant P as OAuth Provider
|
participant P as OAuth Provider
|
||||||
|
|
||||||
U->>F: Click "Login with Provider"
|
U->>F: Click "Login with Provider"
|
||||||
F->>A: GET /oauth/{provider}?state={csrf}
|
F->>B: GET /oauth/{provider}/login
|
||||||
A->>R: Store OAuth state (TTL: 10 min)
|
B->>R: Store OAuth state (TTL: 10 min)
|
||||||
A->>P: Redirect to Provider
|
B->>P: Redirect to Provider
|
||||||
P->>U: Show authorization page
|
P->>U: Show authorization page
|
||||||
U->>P: Grant permission
|
U->>P: Grant permission
|
||||||
P->>A: GET /oauth/{provider}/callback?code={code}&state={state}
|
P->>B: GET /oauth/{provider}/callback?code={code}&state={state}
|
||||||
A->>R: Verify state
|
B->>R: Verify state
|
||||||
A->>P: Exchange code for token
|
B->>P: Exchange code for token
|
||||||
P->>A: Return access token + user data
|
P->>B: Return access token + user data
|
||||||
A->>R: Store OAuth tokens
|
B->>B: Create/update user
|
||||||
A->>A: Generate JWT session token
|
B->>B: Generate JWT session token
|
||||||
A->>R: Store session in Redis
|
B->>R: Store session in Redis
|
||||||
A->>F: Redirect with JWT token
|
B->>F: Redirect + Set httpOnly cookie
|
||||||
F->>U: User logged in
|
Note over B,F: Cookie: session_token=JWT<br/>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
|
```mermaid
|
||||||
stateDiagram-v2
|
stateDiagram-v2
|
||||||
[*] --> Anonymous
|
[*] --> Anonymous
|
||||||
Anonymous --> Authenticating: Login attempt
|
Anonymous --> Authenticating: Login attempt (OAuth/Email)
|
||||||
Authenticating --> Authenticated: Valid JWT + Redis session
|
Authenticating --> Authenticated: Valid JWT + httpOnly cookie set
|
||||||
Authenticating --> Anonymous: Invalid credentials
|
Authenticating --> Anonymous: Invalid credentials
|
||||||
Authenticated --> Refreshing: Token near expiry
|
Authenticated --> Refreshing: Token near expiry
|
||||||
Refreshing --> Authenticated: Successful refresh
|
Refreshing --> Authenticated: New httpOnly cookie set
|
||||||
Refreshing --> Anonymous: Refresh failed
|
Refreshing --> Anonymous: Refresh failed
|
||||||
Authenticated --> Anonymous: Logout/Revoke
|
Authenticated --> Anonymous: Logout (cookie deleted)
|
||||||
Authenticated --> Anonymous: Token expired
|
Authenticated --> Anonymous: Token expired (cookie invalid)
|
||||||
|
|
||||||
|
note right of Authenticated
|
||||||
|
All requests include
|
||||||
|
httpOnly cookie automatically
|
||||||
|
via credentials: 'include'
|
||||||
|
end note
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🗄️ Redis структура данных
|
## 🗄️ Redis структура данных
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# JWT Sessions
|
# JWT Sessions (основные - передаются через httpOnly cookies)
|
||||||
session:{user_id}:{token} # Hash: {user_id, username, device_info, last_activity}
|
session:{user_id}:{token} # Hash: {user_id, username, device_info, last_activity}
|
||||||
user_sessions:{user_id} # Set: {token1, token2, ...}
|
user_sessions:{user_id} # Set: {token1, token2, ...}
|
||||||
|
|
||||||
# Verification Tokens
|
# OAuth Tokens (для API интеграций - НЕ для аутентификации)
|
||||||
verification_token:{token} # JSON: {user_id, type, data, created_at}
|
|
||||||
|
|
||||||
# OAuth Tokens
|
|
||||||
oauth_access:{user_id}:{provider} # JSON: {token, expires_in, scope}
|
oauth_access:{user_id}:{provider} # JSON: {token, expires_in, scope}
|
||||||
oauth_refresh:{user_id}:{provider} # JSON: {token, provider_data}
|
oauth_refresh:{user_id}:{provider} # JSON: {token, provider_data}
|
||||||
oauth_state:{state} # JSON: {provider, redirect_uri, code_verifier}
|
|
||||||
|
|
||||||
# Legacy (для совместимости)
|
# OAuth State (временные - для CSRF защиты)
|
||||||
{user_id}-{username}-{token} # Hash: legacy format
|
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 команд
|
### Примеры Redis команд
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,34 +1,285 @@
|
|||||||
# OAuth Integration Guide
|
# 🔐 OAuth Integration Guide
|
||||||
|
|
||||||
## 🎯 Обзор
|
## 🎯 Обзор
|
||||||
|
|
||||||
Система OAuth интеграции с поддержкой популярных провайдеров. Токены хранятся в Redis с автоматическим TTL и поддержкой refresh.
|
Система OAuth интеграции с **httpOnly cookies** для максимальной безопасности. Поддержка популярных провайдеров с единым подходом к аутентификации.
|
||||||
|
|
||||||
## 🚀 Быстрый старт
|
### 🔄 **Архитектура 2025: httpOnly cookies для всех**
|
||||||
|
|
||||||
### Поддерживаемые провайдеры
|
```mermaid
|
||||||
- **Google** ✅ - OpenID Connect (актуальные endpoints)
|
sequenceDiagram
|
||||||
- **GitHub** ✅ - OAuth 2.0 (scope: read:user user:email)
|
participant U as User
|
||||||
- **Facebook** ✅ - Facebook Login API v18.0+ (scope: email public_profile)
|
participant F as Frontend
|
||||||
- **VK** ✅ - VK OAuth API v5.199+ (scope: email)
|
participant B as Backend
|
||||||
- **X (Twitter)** ✅ - OAuth 2.0 API v2 (scope: tweet.read users.read)
|
participant P as OAuth Provider
|
||||||
- **Yandex** ✅ - Yandex OAuth (scope: login:email login:info login:avatar)
|
|
||||||
- **Telegram** ⚠️ - Telegram Login (специфическая реализация)
|
|
||||||
|
|
||||||
### Redis структура
|
U->>F: Click "Login with Google"
|
||||||
```bash
|
F->>B: GET /oauth/google/login
|
||||||
oauth_access:{user_id}:{provider} # Access токены
|
B->>P: Redirect to Provider
|
||||||
oauth_refresh:{user_id}:{provider} # Refresh токены
|
P->>U: Show authorization page
|
||||||
oauth_state:{state} # OAuth state с TTL 10 минут
|
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`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Использование
|
||||||
|
<button onClick={() => handleOAuthLogin('google')}>
|
||||||
|
🔐 Войти через Google
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 (
|
||||||
|
<div class="oauth-callback">
|
||||||
|
<h2>Завершение авторизации...</h2>
|
||||||
|
<p>Пожалуйста, подождите...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<User | null>(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 (
|
||||||
|
<AuthContext.Provider value={{
|
||||||
|
user,
|
||||||
|
isAuthenticated: () => !!user(),
|
||||||
|
checkSession,
|
||||||
|
logout,
|
||||||
|
}}>
|
||||||
|
{props.children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Настройка провайдеров
|
||||||
|
|
||||||
|
### 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
|
```python
|
||||||
from auth.tokens.oauth import OAuthTokenManager
|
from auth.tokens.oauth import OAuthTokenManager
|
||||||
|
|
||||||
oauth = OAuthTokenManager()
|
oauth = OAuthTokenManager()
|
||||||
|
|
||||||
# Сохранение токенов
|
# Сохранение OAuth токенов (для API интеграций)
|
||||||
await oauth.store_oauth_tokens(
|
await oauth.store_oauth_tokens(
|
||||||
user_id="123",
|
user_id="123",
|
||||||
provider="google",
|
provider="google",
|
||||||
@@ -37,584 +288,98 @@ await oauth.store_oauth_tokens(
|
|||||||
expires_in=3600
|
expires_in=3600
|
||||||
)
|
)
|
||||||
|
|
||||||
# Получение токена
|
# Получение токена для API вызовов
|
||||||
access_data = await oauth.get_token(user_id, "google", "oauth_access")
|
token_data = await oauth.get_token("123", "google", "oauth_access")
|
||||||
|
if token_data:
|
||||||
# Отзыв токенов
|
# Используем токен для вызовов Google API
|
||||||
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()
|
|
||||||
|
|
||||||
# Делаем запрос
|
|
||||||
headers = {"Authorization": f"Bearer {token_data['token']}"}
|
headers = {"Authorization": f"Bearer {token_data['token']}"}
|
||||||
response = await httpx.get(endpoint, headers=headers)
|
|
||||||
|
|
||||||
if response.status_code == 401:
|
|
||||||
# Токен истек, требуется повторная авторизация
|
|
||||||
raise OAuthTokenExpired()
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Мониторинг токенов
|
### Redis структура
|
||||||
```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
|
|
||||||
```bash
|
```bash
|
||||||
# Google OAuth
|
# OAuth токены для API интеграций
|
||||||
GOOGLE_CLIENT_ID=your_google_client_id
|
oauth_access:{user_id}:{provider} # Access токен
|
||||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
oauth_refresh:{user_id}:{provider} # Refresh токен
|
||||||
|
|
||||||
# GitHub OAuth
|
# OAuth state (временный)
|
||||||
GITHUB_CLIENT_ID=your_github_client_id
|
oauth_state:{state} # Данные авторизации (TTL: 10 мин)
|
||||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
|
||||||
|
|
||||||
# Facebook OAuth
|
# Сессии пользователей (основные)
|
||||||
FACEBOOK_APP_ID=your_facebook_app_id
|
session:{user_id}:{token} # JWT сессия (TTL: 30 дней)
|
||||||
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:*"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🧪 Тестирование
|
## 🧪 Тестирование
|
||||||
|
|
||||||
### Unit Tests
|
### E2E Test
|
||||||
```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
|
|
||||||
```typescript
|
```typescript
|
||||||
// tests/oauth.spec.ts
|
test('OAuth flow with httpOnly cookies', async ({ page }) => {
|
||||||
test('OAuth flow with Google', async ({ page }) => {
|
// 1. Инициация OAuth
|
||||||
await page.goto('/login')
|
await page.goto('/login');
|
||||||
|
await page.click('[data-testid="google-login"]');
|
||||||
|
|
||||||
// Click Google OAuth button
|
// 2. Проверяем редирект на Google
|
||||||
await page.click('[data-testid="oauth-google"]')
|
await expect(page).toHaveURL(/accounts\.google\.com/);
|
||||||
|
|
||||||
// Should redirect to Google
|
// 3. Симулируем успешный callback (в тестовой среде)
|
||||||
await page.waitForURL(/accounts\.google\.com/)
|
await page.goto('/oauth/callback');
|
||||||
|
|
||||||
// Mock successful OAuth (in test environment)
|
// 4. Проверяем что cookie установлен
|
||||||
await page.goto('/?state=test&access_token=mock_token')
|
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
|
// 5. Проверяем что пользователь авторизован
|
||||||
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible()
|
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
|
```bash
|
||||||
# Backend логи
|
# Проверка OAuth провайдеров
|
||||||
tail -f /var/log/app/oauth.log | grep "oauth"
|
curl -v "https://your-domain.com/oauth/google/login"
|
||||||
|
|
||||||
# Frontend логи (browser console)
|
# Проверка callback
|
||||||
# Фильтр: "[oauth]" или "[SessionProvider]"
|
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
|
```python
|
||||||
# Добавить метрики для мониторинга
|
from auth.tokens.monitoring import TokenMonitoring
|
||||||
from prometheus_client import Counter, Histogram
|
|
||||||
|
|
||||||
oauth_requests = Counter('oauth_requests_total', 'OAuth requests', ['provider', 'status'])
|
monitoring = TokenMonitoring()
|
||||||
oauth_duration = Histogram('oauth_duration_seconds', 'OAuth request duration')
|
|
||||||
|
|
||||||
@router.get("/{provider}")
|
# Статистика OAuth
|
||||||
async def oauth_redirect(provider: str, state: str, redirect_uri: str):
|
stats = await monitoring.get_token_statistics()
|
||||||
with oauth_duration.time():
|
oauth_tokens = stats.get("oauth_access_tokens", 0) + stats.get("oauth_refresh_tokens", 0)
|
||||||
try:
|
print(f"OAuth tokens: {oauth_tokens}")
|
||||||
# OAuth logic
|
|
||||||
oauth_requests.labels(provider=provider, status='success').inc()
|
# Health check
|
||||||
except Exception as e:
|
health = await monitoring.health_check()
|
||||||
oauth_requests.labels(provider=provider, status='error').inc()
|
if health["status"] == "healthy":
|
||||||
raise
|
print("✅ OAuth system is healthy")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 🎯 Преимущества новой архитектуры
|
||||||
|
|
||||||
|
### 🛡️ Максимальная безопасность:
|
||||||
|
- **🚫 Защита от XSS**: Токены недоступны JavaScript
|
||||||
|
- **🔒 Защита от CSRF**: SameSite cookies
|
||||||
|
- **🛡️ Единообразие**: Все провайдеры используют один механизм
|
||||||
|
|
||||||
|
### 🚀 Простота использования:
|
||||||
|
- **📱 Автоматическая отправка**: Браузер сам включает cookies
|
||||||
|
- **🧹 Чистый код**: Нет управления токенами в JavaScript
|
||||||
|
- **🔄 Единый API**: Один GraphQL клиент для всех случаев
|
||||||
|
|
||||||
|
### ⚡ Производительность:
|
||||||
|
- **🚀 Быстрее**: Нет localStorage операций
|
||||||
|
- **📦 Меньше кода**: Упрощенная логика фронтенда
|
||||||
|
- **🔄 Автоматическое управление**: Браузер оптимизирует отправку cookies
|
||||||
|
|
||||||
|
**Результат: Самая безопасная и простая OAuth интеграция!** 🔐✨
|
||||||
267
docs/auth/setup.md
Normal file
267
docs/auth/setup.md
Normal file
@@ -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 включена
|
||||||
|
|
||||||
|
**Готово к продакшену!** 🚀✅
|
||||||
414
docs/auth/sse-httponly-integration.md
Normal file
414
docs/auth/sse-httponly-integration.md
Normal file
@@ -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!** 📡🍪✨
|
||||||
@@ -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=...`
|
|
||||||
- ✅ Фронтенд получает успешную аутентификацию
|
|
||||||
@@ -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 (
|
|
||||||
<div class="oauth-callback">
|
|
||||||
<div class="loading">
|
|
||||||
<h2>Completing authentication...</h2>
|
|
||||||
<div class="spinner"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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 = () => (
|
|
||||||
<button onClick={() => oauth('github')}>
|
|
||||||
Login with GitHub
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Session Provider
|
|
||||||
```typescript
|
|
||||||
// context/SessionProvider.tsx
|
|
||||||
import { createContext, useContext, createSignal, onMount } from 'solid-js'
|
|
||||||
|
|
||||||
interface SessionContextType {
|
|
||||||
user: () => User | null
|
|
||||||
isAuthenticated: () => boolean
|
|
||||||
loadSession: () => Promise<void>
|
|
||||||
logout: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const SessionContext = createContext<SessionContextType>()
|
|
||||||
|
|
||||||
export function SessionProvider(props: { children: any }) {
|
|
||||||
const [user, setUser] = createSignal<User | null>(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 (
|
|
||||||
<SessionContext.Provider value={{
|
|
||||||
user,
|
|
||||||
isAuthenticated,
|
|
||||||
loadSession,
|
|
||||||
logout
|
|
||||||
}}>
|
|
||||||
{props.children}
|
|
||||||
</SessionContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -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 теперь имеет полноценный мониторинг!** 🔒✨
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
# Минимальный OAuth Flow для testing.discours.io
|
|
||||||
|
|
||||||
## 🎯 Философия: Максимальная простота
|
|
||||||
|
|
||||||
### ✨ **Принцип: "Нет ошибки = успех"**
|
|
||||||
|
|
||||||
Никаких лишних параметров, флагов или токенов в URL. Только самое необходимое.
|
|
||||||
|
|
||||||
## 🔧 Backend Implementation
|
|
||||||
|
|
||||||
### OAuth Callback Handler
|
|
||||||
```python
|
|
||||||
@app.route('/oauth/<provider>/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 (
|
|
||||||
<div class="oauth-callback">
|
|
||||||
<div class="loading">
|
|
||||||
<h2>Completing authentication...</h2>
|
|
||||||
<div class="spinner"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Session Provider (httpOnly only)
|
|
||||||
```typescript
|
|
||||||
// context/SessionProvider.tsx
|
|
||||||
export function SessionProvider(props: { children: any }) {
|
|
||||||
const [user, setUser] = createSignal<User | null>(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
|
|
||||||
|
|
||||||
**Элегантно. Просто. Безопасно.** ✨
|
|
||||||
@@ -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. Корректность переменных окружения
|
|
||||||
@@ -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
|
|
||||||
2
main.py
2
main.py
@@ -50,7 +50,7 @@ middleware = [
|
|||||||
allow_origins=[
|
allow_origins=[
|
||||||
"https://testing.discours.io",
|
"https://testing.discours.io",
|
||||||
"https://testing3.discours.io",
|
"https://testing3.discours.io",
|
||||||
"https://v3.dscrs.site",
|
"https://v3.discours.io",
|
||||||
"https://session-daily.vercel.app",
|
"https://session-daily.vercel.app",
|
||||||
"https://coretest.discours.io",
|
"https://coretest.discours.io",
|
||||||
"https://new.discours.io",
|
"https://new.discours.io",
|
||||||
|
|||||||
198
package-lock.json
generated
198
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "publy-panel",
|
"name": "publy-panel",
|
||||||
"version": "0.9.29",
|
"version": "0.9.28",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "publy-panel",
|
"name": "publy-panel",
|
||||||
"version": "0.9.29",
|
"version": "0.9.28",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.2.4",
|
"@biomejs/biome": "^2.2.4",
|
||||||
"@graphql-codegen/cli": "^6.0.0",
|
"@graphql-codegen/cli": "^6.0.0",
|
||||||
@@ -2387,9 +2387,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz",
|
||||||
"integrity": "sha512-o3pcKzJgSGt4d74lSZ+OCnHwkKBeAbFDmbEm5gg70eA8VkyCuC/zV9TwBnmw6VjDlRdF4Pshfb+WE9E6XY1PoQ==",
|
"integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2401,9 +2401,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz",
|
||||||
"integrity": "sha512-cqFSWO5tX2vhC9hJTK8WAiPIm4Q8q/cU8j2HQA0L3E1uXvBYbOZMhE2oFL8n2pKB5sOCHY6bBuHaRwG7TkfJyw==",
|
"integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2415,9 +2415,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz",
|
||||||
"integrity": "sha512-vngduywkkv8Fkh3wIZf5nFPXzWsNsVu1kvtLETWxTFf/5opZmflgVSeLgdHR56RQh71xhPhWoOkEBvbehwTlVA==",
|
"integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2429,9 +2429,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz",
|
||||||
"integrity": "sha512-h11KikYrUCYTrDj6h939hhMNlqU2fo/X4NB0OZcys3fya49o1hmFaczAiJWVAFgrM1NCP6RrO7lQKeVYSKBPSQ==",
|
"integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2443,9 +2443,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz",
|
||||||
"integrity": "sha512-/eg4CI61ZUkLXxMHyVlmlGrSQZ34xqWlZNW43IAU4RmdzWEx0mQJ2mN/Cx4IHLVZFL6UBGAh+/GXhgvGb+nVxw==",
|
"integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2457,9 +2457,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz",
|
||||||
"integrity": "sha512-QOWgFH5X9+p+S1NAfOqc0z8qEpJIoUHf7OWjNUGOeW18Mx22lAUOiA9b6r2/vpzLdfxi/f+VWsYjUOMCcYh0Ng==",
|
"integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2471,9 +2471,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz",
|
||||||
"integrity": "sha512-kDWSPafToDd8LcBYd1t5jw7bD5Ojcu12S3uT372e5HKPzQt532vW+rGFFOaiR0opxePyUkHrwz8iWYEyH1IIQA==",
|
"integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2485,9 +2485,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz",
|
||||||
"integrity": "sha512-gKm7Mk9wCv6/rkzwCiUC4KnevYhlf8ztBrDRT9g/u//1fZLapSRc+eDZj2Eu2wpJ+0RzUKgtNijnVIB4ZxyL+w==",
|
"integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2499,9 +2499,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz",
|
||||||
"integrity": "sha512-66lA8vnj5mB/rtDNwPgrrKUOtCLVQypkyDa2gMfOefXK6rcZAxKLO9Fy3GkW8VkPnENv9hBkNOFfGLf6rNKGUg==",
|
"integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2513,9 +2513,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz",
|
||||||
"integrity": "sha512-s+OPucLNdJHvuZHuIz2WwncJ+SfWHFEmlC5nKMUgAelUeBUnlB4wt7rXWiyG4Zn07uY2Dd+SGyVa9oyLkVGOjA==",
|
"integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2527,9 +2527,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz",
|
||||||
"integrity": "sha512-8wTRM3+gVMDLLDdaT6tKmOE3lJyRy9NpJUS/ZRWmLCmOPIJhVyXwjBo+XbrrwtV33Em1/eCTd5TuGJm4+DmYjw==",
|
"integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -2541,9 +2541,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz",
|
||||||
"integrity": "sha512-6yqEfgJ1anIeuP2P/zhtfBlDpXUb80t8DpbYwXQ3bQd95JMvUaqiX+fKqYqUwZXqdJDd8xdilNtsHM2N0cFm6A==",
|
"integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -2555,9 +2555,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz",
|
||||||
"integrity": "sha512-sshYUiYVSEI2B6dp4jMncwxbrUqRdNApF2c3bhtLAU0qA8Lrri0p0NauOsTWh3yCCCDyBOjESHMExonp7Nzc0w==",
|
"integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2569,9 +2569,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz",
|
||||||
"integrity": "sha512-duBLgd+3pqC4MMwBrKkFxaZerUxZcYApQVC5SdbF5/e/589GwVvlRUnyqMFbM8iUSb1BaoX/3fRL7hB9m2Pj8Q==",
|
"integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2583,9 +2583,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz",
|
||||||
"integrity": "sha512-tzhYJJidDUVGMgVyE+PmxENPHlvvqm1KILjjZhB8/xHYqAGeizh3GBGf9u6WdJpZrz1aCpIIHG0LgJgH9rVjHQ==",
|
"integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -2597,9 +2597,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz",
|
||||||
"integrity": "sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA==",
|
"integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2611,9 +2611,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz",
|
||||||
"integrity": "sha512-LSeBHnGli1pPKVJ79ZVJgeZWWZXkEe/5o8kcn23M8eMKCUANejchJbF/JqzM4RRjOJfNRhKJk8FuqL1GKjF5oQ==",
|
"integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2625,9 +2625,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz",
|
||||||
"integrity": "sha512-uPj7MQ6/s+/GOpolavm6BPo+6CbhbKYyZHUDvZ/SmJM7pfDBgdGisFX3bY/CBDMg2ZO4utfhlApkSfZ92yXw7Q==",
|
"integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2639,9 +2639,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz",
|
||||||
"integrity": "sha512-Z9MUCrSgIaUeeHAiNkm3cQyst2UhzjPraR3gYYfOjAuZI7tcFRTOD+4cHLPoS/3qinchth+V56vtqz1Tv+6KPA==",
|
"integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2653,9 +2653,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz",
|
||||||
"integrity": "sha512-+GnYBmpjldD3XQd+HMejo+0gJGwYIOfFeoBQv32xF/RUIvccUz20/V6Otdv+57NE70D5pa8W/jVGDoGq0oON4A==",
|
"integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -2667,9 +2667,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz",
|
||||||
"integrity": "sha512-ApXFKluSB6kDQkAqZOKXBjiaqdF1BlKi+/eqnYe9Ee7U2K3pUDKsIyr8EYm/QDHTJIM+4X+lI0gJc3TTRhd+dA==",
|
"integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2681,9 +2681,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz",
|
||||||
"integrity": "sha512-ARz+Bs8kY6FtitYM96PqPEVvPXqEZmPZsSkXvyX19YzDqkCaIlhCieLLMI5hxO9SRZ2XtCtm8wxhy0iJ2jxNfw==",
|
"integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3030,9 +3030,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.6",
|
"version": "2.8.8",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.8.tgz",
|
||||||
"integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==",
|
"integrity": "sha512-be0PUaPsQX/gPWWgFsdD+GFzaoig5PXaUC1xLkQiYdDnANU8sMnHoQd8JhbJQuvTWrWLyeFN9Imb5Qtfvr4RrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3588,9 +3588,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.223",
|
"version": "1.5.227",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.223.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.227.tgz",
|
||||||
"integrity": "sha512-qKm55ic6nbEmagFlTFczML33rF90aU+WtrJ9MdTCThrcvDNdUHN4p6QfVN78U06ZmguqXIyMPyYhw2TrbDUwPQ==",
|
"integrity": "sha512-ITxuoPfJu3lsNWUi2lBM2PaBPYgH3uqmxut5vmBxgYvyI4AlJ6P3Cai1O76mOrkJCBzq0IxWg/NtqOrpu/0gKA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -5408,9 +5408,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.52.2",
|
"version": "4.52.3",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz",
|
||||||
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
|
"integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5424,28 +5424,28 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.52.2",
|
"@rollup/rollup-android-arm-eabi": "4.52.3",
|
||||||
"@rollup/rollup-android-arm64": "4.52.2",
|
"@rollup/rollup-android-arm64": "4.52.3",
|
||||||
"@rollup/rollup-darwin-arm64": "4.52.2",
|
"@rollup/rollup-darwin-arm64": "4.52.3",
|
||||||
"@rollup/rollup-darwin-x64": "4.52.2",
|
"@rollup/rollup-darwin-x64": "4.52.3",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.52.2",
|
"@rollup/rollup-freebsd-arm64": "4.52.3",
|
||||||
"@rollup/rollup-freebsd-x64": "4.52.2",
|
"@rollup/rollup-freebsd-x64": "4.52.3",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.52.2",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.52.3",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.52.2",
|
"@rollup/rollup-linux-arm-musleabihf": "4.52.3",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.52.2",
|
"@rollup/rollup-linux-arm64-gnu": "4.52.3",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.52.2",
|
"@rollup/rollup-linux-arm64-musl": "4.52.3",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.52.2",
|
"@rollup/rollup-linux-loong64-gnu": "4.52.3",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.52.2",
|
"@rollup/rollup-linux-ppc64-gnu": "4.52.3",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.52.2",
|
"@rollup/rollup-linux-riscv64-gnu": "4.52.3",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.52.2",
|
"@rollup/rollup-linux-riscv64-musl": "4.52.3",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.52.2",
|
"@rollup/rollup-linux-s390x-gnu": "4.52.3",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.52.2",
|
"@rollup/rollup-linux-x64-gnu": "4.52.3",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.52.2",
|
"@rollup/rollup-linux-x64-musl": "4.52.3",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.52.2",
|
"@rollup/rollup-openharmony-arm64": "4.52.3",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.52.2",
|
"@rollup/rollup-win32-arm64-msvc": "4.52.3",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.52.2",
|
"@rollup/rollup-win32-ia32-msvc": "4.52.3",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.52.2",
|
"@rollup/rollup-win32-x64-gnu": "4.52.3",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.52.2",
|
"@rollup/rollup-win32-x64-msvc": "4.52.3",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "publy-panel",
|
"name": "publy-panel",
|
||||||
"version": "0.9.29",
|
"version": "0.9.28",
|
||||||
"type": "module",
|
"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.",
|
"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": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,12 +3,7 @@
|
|||||||
* @module api
|
* @module api
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { AUTH_TOKEN_KEY, clearAuthTokens, getCsrfTokenFromCookie } from '../utils/auth'
|
||||||
AUTH_TOKEN_KEY,
|
|
||||||
clearAuthTokens,
|
|
||||||
getAuthTokenFromCookie,
|
|
||||||
getCsrfTokenFromCookie
|
|
||||||
} from '../utils/auth'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тип для произвольных данных GraphQL
|
* Тип для произвольных данных GraphQL
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ export function checkAuthStatus(): boolean {
|
|||||||
const isAuth = hasLocalToken
|
const isAuth = hasLocalToken
|
||||||
|
|
||||||
console.log(`[Auth] Local token: ${hasLocalToken ? 'present' : 'missing'}`)
|
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) {
|
if (localToken) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "discours-core"
|
name = "discours-core"
|
||||||
version = "0.9.25"
|
version = "0.9.28"
|
||||||
description = "Core backend for Discours.io platform"
|
description = "Core backend for Discours.io platform"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Tony Rewin", email = "tonyrewin@yandex.ru"}
|
{name = "Tony Rewin", email = "tonyrewin@yandex.ru"}
|
||||||
|
|||||||
@@ -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 auth.utils import extract_token_from_request, get_auth_token_from_context, get_user_data_by_token
|
||||||
from services.auth import auth_service
|
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 storage.schema import mutation, query, type_author
|
||||||
from utils.logger import root_logger as logger
|
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)
|
result = await auth_service.login(email, password, request)
|
||||||
|
|
||||||
# Устанавливаем cookie если есть токен
|
# Устанавливаем httpOnly cookie если есть токен
|
||||||
if result.get("success") and result.get("token") and request:
|
if result.get("success") and result.get("token"):
|
||||||
try:
|
try:
|
||||||
if not hasattr(info.context, "response"):
|
response = info.context.get("response")
|
||||||
|
if not response:
|
||||||
response = JSONResponse({})
|
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
|
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:
|
except Exception as cookie_error:
|
||||||
logger.warning(f"Не удалось установить cookie: {cookie_error}")
|
logger.warning(f"Не удалось установить cookie: {cookie_error}")
|
||||||
|
|
||||||
@@ -129,7 +152,11 @@ async def logout(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str,
|
|||||||
# Удаляем cookie
|
# Удаляем cookie
|
||||||
if request and hasattr(info.context, "response"):
|
if request and hasattr(info.context, "response"):
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Не удалось удалить cookie: {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(
|
info.context["response"].set_cookie(
|
||||||
key=SESSION_COOKIE_NAME,
|
key=SESSION_COOKIE_NAME,
|
||||||
value=result["token"],
|
value=result["token"],
|
||||||
httponly=True,
|
httponly=SESSION_COOKIE_HTTPONLY,
|
||||||
secure=True,
|
secure=SESSION_COOKIE_SECURE,
|
||||||
samesite="strict",
|
samesite=SESSION_COOKIE_SAMESITE
|
||||||
max_age=86400 * 30,
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Не удалось обновить cookie: {e}")
|
logger.warning(f"Не удалось обновить cookie: {e}")
|
||||||
|
|||||||
12
settings.py
12
settings.py
@@ -4,7 +4,7 @@ import datetime
|
|||||||
import os
|
import os
|
||||||
from os import environ
|
from os import environ
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal, cast
|
||||||
|
|
||||||
# Корневая директория проекта
|
# Корневая директория проекта
|
||||||
ROOT_DIR = Path(__file__).parent.absolute()
|
ROOT_DIR = Path(__file__).parent.absolute()
|
||||||
@@ -85,13 +85,19 @@ SESSION_COOKIE_NAME = "session_token"
|
|||||||
# 🔒 Автоматически определяем HTTPS на основе окружения
|
# 🔒 Автоматически определяем HTTPS на основе окружения
|
||||||
SESSION_COOKIE_SECURE = os.getenv("HTTPS_ENABLED", "true").lower() in ["true", "1", "yes"]
|
SESSION_COOKIE_SECURE = os.getenv("HTTPS_ENABLED", "true").lower() in ["true", "1", "yes"]
|
||||||
SESSION_COOKIE_HTTPONLY = True
|
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 дней
|
SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 дней
|
||||||
|
|
||||||
MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY", "")
|
MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY", "")
|
||||||
MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN", "discours.io")
|
MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN", "discours.io")
|
||||||
|
|
||||||
|
|
||||||
# Search service configuration
|
# Search service configuration
|
||||||
SEARCH_MAX_BATCH_SIZE = int(os.environ.get("SEARCH_MAX_BATCH_SIZE", "25"))
|
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"])
|
SEARCH_CACHE_ENABLED = bool(os.environ.get("SEARCH_CACHE_ENABLED", "true").lower() in ["true", "1", "yes"])
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class MinimalOAuthRequest:
|
|||||||
self.query_params = query_params or {}
|
self.query_params = query_params or {}
|
||||||
self.path_params = path_params or {}
|
self.path_params = path_params or {}
|
||||||
self.headers = headers or {"user-agent": "test-agent"}
|
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.method = "GET" # ✅ Добавляем method для логирования
|
||||||
self.client = MagicMock()
|
self.client = MagicMock()
|
||||||
self.client.host = "127.0.0.1"
|
self.client.host = "127.0.0.1"
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -425,7 +425,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "discours-core"
|
name = "discours-core"
|
||||||
version = "0.9.25"
|
version = "0.9.28"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "ariadne" },
|
{ name = "ariadne" },
|
||||||
|
|||||||
Reference in New Issue
Block a user