[0.9.28] - OAuth/Auth with httpOnly cookie
All checks were successful
Deploy on push / deploy (push) Successful in 4m32s

This commit is contained in:
2025-09-28 12:22:37 +03:00
parent 6451ba7de5
commit fb98a1c6c8
27 changed files with 1449 additions and 2147 deletions

View File

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

View File

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

View File

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

View File

@@ -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,54 +457,12 @@ 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):
data_obj = result_data.get("data", {})
if isinstance(data_obj, dict) and op_name in data_obj:
op_result = data_obj.get(op_name, {})
if isinstance(op_result, dict) and "token" in op_result:
token = op_result.get("token")
if token:
# Устанавливаем cookie с токеном
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE,
)
logger.debug( logger.debug(
f"[graphql_handler] Установлена cookie {SESSION_COOKIE_NAME} для операции {op_name}" "[graphql_handler] Фронтенд должен извлечь токен из ответа и управлять им самостоятельно"
)
# Если это операция 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
@@ -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:

View File

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

View File

@@ -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': {

View File

@@ -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 для всех типов авторизации
- ✅ **Простота**: Браузер автоматически управляет токенами

View File

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

View File

@@ -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
View 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 включена
**Готово к продакшену!** 🚀✅

View 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!** 📡🍪✨

View File

@@ -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=...`
- ✅ Фронтенд получает успешную аутентификацию

View File

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

View File

@@ -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 теперь имеет полноценный мониторинг!** 🔒✨

View File

@@ -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
**Элегантно. Просто. Безопасно.**

View File

@@ -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. Корректность переменных окружения

View File

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

View File

@@ -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
View File

@@ -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"
} }
}, },

View File

@@ -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": {

View File

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

View File

@@ -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) {

View File

@@ -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"}

View File

@@ -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({})
info.context["response"] = response
response.set_cookie( 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, # ✅ КРИТИЧНО для поддоменов
) )
info.context["response"] = response
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}")

View File

@@ -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"])

View File

@@ -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
View File

@@ -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" },