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