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