[0.9.28] - 2025-09-28
All checks were successful
Deploy on push / deploy (push) Successful in 2m46s

### 🍪 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:
2025-09-28 13:06:03 +03:00
parent fb98a1c6c8
commit 752e2dcbdc
9 changed files with 255 additions and 223 deletions

View File

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

View File

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

View File

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

View File

@@ -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 })
});

View File

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

View File

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

View File

@@ -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 проверку
}

View File

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

View File

@@ -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 дней