[0.9.28] - OAuth/Auth with httpOnly cookie
All checks were successful
Deploy on push / deploy (push) Successful in 4m32s
All checks were successful
Deploy on push / deploy (push) Successful in 4m32s
This commit is contained in:
@@ -2,17 +2,24 @@
|
||||
|
||||
## 📚 Обзор
|
||||
|
||||
Модульная система аутентификации с JWT токенами, Redis-сессиями, OAuth интеграцией и RBAC авторизацией. Поддерживает httpOnly cookies и Bearer токены для веб и API клиентов.
|
||||
Модульная система аутентификации с **httpOnly cookies**, JWT токенами, Redis-сессиями, OAuth интеграцией и RBAC авторизацией.
|
||||
|
||||
### 🎯 **Единый подход с httpOnly cookies для ВСЕХ типов авторизации:**
|
||||
|
||||
- ✅ **OAuth** (Google/GitHub/Yandex/VK) → httpOnly cookie
|
||||
- ✅ **Email/Password** → httpOnly cookie
|
||||
- ✅ **GraphQL запросы** → `credentials: 'include'`
|
||||
- ✅ **Максимальная безопасность** → защита от XSS/CSRF
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Для микросервисов
|
||||
### Для разработчиков
|
||||
|
||||
```python
|
||||
from auth.tokens.sessions import SessionTokenManager
|
||||
from auth.utils import extract_token_from_request
|
||||
|
||||
# Проверка токена
|
||||
# Проверка токена (автоматически из cookie или Bearer заголовка)
|
||||
sessions = SessionTokenManager()
|
||||
token = await extract_token_from_request(request)
|
||||
payload = await sessions.verify_session(token)
|
||||
@@ -22,6 +29,18 @@ if payload:
|
||||
print(f"Пользователь авторизован: {user_id}")
|
||||
```
|
||||
|
||||
### Для фронтенда
|
||||
|
||||
```typescript
|
||||
// Все запросы используют httpOnly cookies
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
credentials: 'include', // ✅ КРИТИЧНО: отправляет httpOnly cookies
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
```
|
||||
|
||||
### Redis ключи для поиска
|
||||
|
||||
```bash
|
||||
@@ -29,7 +48,7 @@ if payload:
|
||||
session:{user_id}:{token} # Данные сессии (hash)
|
||||
user_sessions:{user_id} # Список активных токенов (set)
|
||||
|
||||
# OAuth токены
|
||||
# OAuth токены (для API интеграций)
|
||||
oauth_access:{user_id}:{provider} # Access токен
|
||||
oauth_refresh:{user_id}:{provider} # Refresh токен
|
||||
```
|
||||
@@ -43,7 +62,7 @@ oauth_refresh:{user_id}:{provider} # Refresh токен
|
||||
|
||||
### 🔑 Аутентификация
|
||||
- **[Управление сессиями](sessions.md)** - JWT токены и Redis хранение
|
||||
- **[OAuth интеграция](oauth.md)** - Социальные провайдеры
|
||||
- **[OAuth интеграция](oauth.md)** - Социальные провайдеры с httpOnly cookies
|
||||
- **[Микросервисы](microservices.md)** - 🎯 **Интеграция с другими сервисами**
|
||||
|
||||
### 🛠️ Разработка
|
||||
@@ -56,6 +75,40 @@ oauth_refresh:{user_id}:{provider} # Refresh токен
|
||||
- **[Security System](../security.md)** - Управление паролями и email
|
||||
- **[Redis Schema](../redis-schema.md)** - Схема данных и кеширование
|
||||
|
||||
## 🔄 OAuth Flow (обновленный 2025)
|
||||
|
||||
### 1. 🚀 Инициация OAuth
|
||||
```typescript
|
||||
// Пользователь нажимает "Войти через Google"
|
||||
const handleOAuthLogin = (provider: string) => {
|
||||
window.location.href = `/oauth/${provider}/login`;
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 🔄 OAuth Callback (бэкенд)
|
||||
```python
|
||||
# Google → /oauth/google/callback
|
||||
# 1. Обменивает code на access_token
|
||||
# 2. Получает профиль пользователя
|
||||
# 3. Создает JWT сессию
|
||||
# 4. Устанавливает httpOnly cookie
|
||||
# 5. Редиректит на фронтенд БЕЗ токена в URL
|
||||
```
|
||||
|
||||
### 3. 🌐 Фронтенд финализация
|
||||
```typescript
|
||||
// Проверяем URL на ошибки
|
||||
const error = urlParams.get('error');
|
||||
if (error) {
|
||||
// Обработка ошибок OAuth
|
||||
console.error('OAuth error:', error);
|
||||
} else {
|
||||
// Успех! httpOnly cookie уже установлен
|
||||
await auth.checkSession(); // Загружает из cookie
|
||||
navigate('/dashboard');
|
||||
}
|
||||
```
|
||||
|
||||
## 🔍 Для микросервисов
|
||||
|
||||
### Подключение к Redis
|
||||
@@ -78,23 +131,22 @@ results = await batch.batch_validate_tokens(token_list)
|
||||
### HTTP заголовки
|
||||
|
||||
```python
|
||||
# Извлечение токена из запроса
|
||||
from auth.utils import extract_token_from_request, get_safe_headers
|
||||
# Извлечение токена из запроса (cookie или Bearer)
|
||||
from auth.utils import extract_token_from_request
|
||||
|
||||
token = await extract_token_from_request(request)
|
||||
|
||||
# Или вручную
|
||||
headers = get_safe_headers(request)
|
||||
token = headers.get("authorization", "").replace("Bearer ", "")
|
||||
# Автоматически проверяет:
|
||||
# 1. Authorization: Bearer <token>
|
||||
# 2. Cookie: session_token=<token>
|
||||
```
|
||||
|
||||
## 🎯 Основные компоненты
|
||||
|
||||
- **SessionTokenManager** - JWT сессии с Redis хранением
|
||||
- **OAuthTokenManager** - OAuth access/refresh токены
|
||||
- **SessionTokenManager** - JWT сессии с Redis хранением + httpOnly cookies
|
||||
- **OAuthTokenManager** - OAuth access/refresh токены для API интеграций
|
||||
- **BatchTokenOperations** - Массовые операции с токенами
|
||||
- **TokenMonitoring** - Мониторинг и статистика
|
||||
- **AuthMiddleware** - HTTP middleware для автоматической обработки
|
||||
- **AuthMiddleware** - HTTP middleware с поддержкой cookies
|
||||
|
||||
## ⚡ Производительность
|
||||
|
||||
@@ -103,3 +155,79 @@ token = headers.get("authorization", "").replace("Bearer ", "")
|
||||
- **Pipeline использование** для атомарности
|
||||
- **SCAN** вместо KEYS для безопасности
|
||||
- **TTL** автоматическая очистка истекших токенов
|
||||
- **httpOnly cookies** - автоматическая отправка браузером
|
||||
|
||||
## 🛡️ Безопасность (2025)
|
||||
|
||||
### Максимальная защита:
|
||||
- **🚫 Защита от XSS**: httpOnly cookies недоступны JavaScript
|
||||
- **🔒 Защита от CSRF**: SameSite=lax cookies
|
||||
- **🛡️ Единообразие**: Все типы авторизации через cookies
|
||||
- **📱 Автоматическая отправка**: Браузер сам включает cookies
|
||||
|
||||
### Миграция с Bearer токенов:
|
||||
- ✅ OAuth теперь использует httpOnly cookies (вместо localStorage)
|
||||
- ✅ Email/Password использует httpOnly cookies (вместо Bearer)
|
||||
- ✅ Фронтенд: `credentials: 'include'` во всех запросах
|
||||
- ✅ Middleware поддерживает оба подхода для совместимости
|
||||
|
||||
## 🔧 Настройка
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
# OAuth провайдеры
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
GITHUB_CLIENT_ID=your_github_client_id
|
||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
||||
|
||||
# Cookie настройки
|
||||
SESSION_COOKIE_SECURE=true
|
||||
SESSION_COOKIE_HTTPONLY=true
|
||||
SESSION_COOKIE_SAMESITE=lax
|
||||
SESSION_COOKIE_MAX_AGE=2592000 # 30 дней
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your_jwt_secret_key
|
||||
JWT_EXPIRATION_HOURS=720 # 30 дней
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
```
|
||||
|
||||
### Быстрая проверка
|
||||
```bash
|
||||
# Проверка OAuth провайдеров
|
||||
curl https://your-domain.com/oauth/google
|
||||
|
||||
# Проверка сессии
|
||||
curl -b "session_token=your_token" https://your-domain.com/graphql \
|
||||
-d '{"query":"query { getSession { success author { id } } }"}'
|
||||
```
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
```python
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
monitoring = TokenMonitoring()
|
||||
|
||||
# Статистика токенов
|
||||
stats = await monitoring.get_token_statistics()
|
||||
print(f"Active sessions: {stats['session_tokens']}")
|
||||
print(f"Memory usage: {stats['memory_usage'] / 1024 / 1024:.2f} MB")
|
||||
|
||||
# Health check
|
||||
health = await monitoring.health_check()
|
||||
if health["status"] == "healthy":
|
||||
print("✅ Auth system is healthy")
|
||||
```
|
||||
|
||||
## 🎯 Результат архитектуры 2025
|
||||
|
||||
После внедрения httpOnly cookies:
|
||||
- ✅ **OAuth**: Google/GitHub → httpOnly cookie → GraphQL запросы
|
||||
- ✅ **Email/Password**: Login form → httpOnly cookie → GraphQL запросы
|
||||
- ✅ **Единая архитектура**: Все через cookies + `credentials: 'include'`
|
||||
- ✅ **Максимальная безопасность**: Защита от XSS и CSRF для всех типов авторизации
|
||||
- ✅ **Простота**: Браузер автоматически управляет токенами
|
||||
@@ -1,12 +1,19 @@
|
||||
# Архитектура системы авторизации Discours Core
|
||||
# 🏗️ Архитектура системы авторизации Discours Core
|
||||
|
||||
## 🎯 Обзор архитектуры
|
||||
## 🎯 Обзор архитектуры 2025
|
||||
|
||||
Модульная система авторизации с разделением ответственности между компонентами.
|
||||
Модульная система авторизации с **httpOnly cookies** для максимальной безопасности и единообразия.
|
||||
|
||||
**Ключевые принципы:**
|
||||
- **🍪 httpOnly cookies** для ВСЕХ типов авторизации (OAuth + Email/Password)
|
||||
- **🛡️ Максимальная безопасность** - защита от XSS и CSRF
|
||||
- **🔄 Единообразие** - один механизм для всех провайдеров
|
||||
- **📱 Автоматическое управление** - браузер сам отправляет cookies
|
||||
|
||||
**Хранение данных:**
|
||||
- **Токены** → Redis (сессии, OAuth, verification)
|
||||
- **Пользователи** → PostgreSQL (основные данные + OAuth в JSON поле)
|
||||
- **Сессии** → Redis (JWT токены) + httpOnly cookies (передача)
|
||||
- **OAuth токены** → Redis (для API интеграций)
|
||||
- **Пользователи** → PostgreSQL (основные данные + OAuth связи)
|
||||
|
||||
## 📊 Схема потоков данных
|
||||
|
||||
@@ -121,67 +128,90 @@ graph TB
|
||||
OTM --> RESP
|
||||
```
|
||||
|
||||
## 🔐 OAuth Flow
|
||||
## 🔐 OAuth Flow (httpOnly cookies)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant F as Frontend
|
||||
participant A as Auth Service
|
||||
participant B as Backend
|
||||
participant R as Redis
|
||||
participant P as OAuth Provider
|
||||
|
||||
U->>F: Click "Login with Provider"
|
||||
F->>A: GET /oauth/{provider}?state={csrf}
|
||||
A->>R: Store OAuth state (TTL: 10 min)
|
||||
A->>P: Redirect to Provider
|
||||
F->>B: GET /oauth/{provider}/login
|
||||
B->>R: Store OAuth state (TTL: 10 min)
|
||||
B->>P: Redirect to Provider
|
||||
P->>U: Show authorization page
|
||||
U->>P: Grant permission
|
||||
P->>A: GET /oauth/{provider}/callback?code={code}&state={state}
|
||||
A->>R: Verify state
|
||||
A->>P: Exchange code for token
|
||||
P->>A: Return access token + user data
|
||||
A->>R: Store OAuth tokens
|
||||
A->>A: Generate JWT session token
|
||||
A->>R: Store session in Redis
|
||||
A->>F: Redirect with JWT token
|
||||
F->>U: User logged in
|
||||
P->>B: GET /oauth/{provider}/callback?code={code}&state={state}
|
||||
B->>R: Verify state
|
||||
B->>P: Exchange code for token
|
||||
P->>B: Return access token + user data
|
||||
B->>B: Create/update user
|
||||
B->>B: Generate JWT session token
|
||||
B->>R: Store session in Redis
|
||||
B->>F: Redirect + Set httpOnly cookie
|
||||
Note over B,F: Cookie: session_token=JWT<br/>HttpOnly, Secure, SameSite=lax
|
||||
F->>U: User logged in (cookie automatic)
|
||||
|
||||
Note over F,B: All subsequent requests
|
||||
F->>B: GraphQL with credentials: 'include'
|
||||
Note over F,B: Browser automatically sends cookie
|
||||
```
|
||||
|
||||
## 🔄 Session Management
|
||||
## 🔄 Session Management (httpOnly cookies)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Anonymous
|
||||
Anonymous --> Authenticating: Login attempt
|
||||
Authenticating --> Authenticated: Valid JWT + Redis session
|
||||
Anonymous --> Authenticating: Login attempt (OAuth/Email)
|
||||
Authenticating --> Authenticated: Valid JWT + httpOnly cookie set
|
||||
Authenticating --> Anonymous: Invalid credentials
|
||||
Authenticated --> Refreshing: Token near expiry
|
||||
Refreshing --> Authenticated: Successful refresh
|
||||
Refreshing --> Authenticated: New httpOnly cookie set
|
||||
Refreshing --> Anonymous: Refresh failed
|
||||
Authenticated --> Anonymous: Logout/Revoke
|
||||
Authenticated --> Anonymous: Token expired
|
||||
Authenticated --> Anonymous: Logout (cookie deleted)
|
||||
Authenticated --> Anonymous: Token expired (cookie invalid)
|
||||
|
||||
note right of Authenticated
|
||||
All requests include
|
||||
httpOnly cookie automatically
|
||||
via credentials: 'include'
|
||||
end note
|
||||
```
|
||||
|
||||
## 🗄️ Redis структура данных
|
||||
|
||||
```bash
|
||||
# JWT Sessions
|
||||
# JWT Sessions (основные - передаются через httpOnly cookies)
|
||||
session:{user_id}:{token} # Hash: {user_id, username, device_info, last_activity}
|
||||
user_sessions:{user_id} # Set: {token1, token2, ...}
|
||||
|
||||
# Verification Tokens
|
||||
verification_token:{token} # JSON: {user_id, type, data, created_at}
|
||||
|
||||
# OAuth Tokens
|
||||
# OAuth Tokens (для API интеграций - НЕ для аутентификации)
|
||||
oauth_access:{user_id}:{provider} # JSON: {token, expires_in, scope}
|
||||
oauth_refresh:{user_id}:{provider} # JSON: {token, provider_data}
|
||||
oauth_state:{state} # JSON: {provider, redirect_uri, code_verifier}
|
||||
|
||||
# Legacy (для совместимости)
|
||||
{user_id}-{username}-{token} # Hash: legacy format
|
||||
# OAuth State (временные - для CSRF защиты)
|
||||
oauth_state:{state} # JSON: {provider, redirect_uri, code_verifier} TTL: 10 мин
|
||||
|
||||
# Verification Tokens (email подтверждения и т.д.)
|
||||
verification_token:{token} # JSON: {user_id, type, data, created_at}
|
||||
```
|
||||
|
||||
### 🔄 Изменения в архитектуре 2025:
|
||||
|
||||
**Убрано:**
|
||||
- ❌ Токены в URL параметрах (небезопасно)
|
||||
- ❌ localStorage для основных токенов (уязвимо к XSS)
|
||||
- ❌ Bearer заголовки для веб-приложений (сложнее управлять)
|
||||
|
||||
**Добавлено:**
|
||||
- ✅ httpOnly cookies для всех типов авторизации
|
||||
- ✅ Автоматическая отправка cookies браузером
|
||||
- ✅ SameSite защита от CSRF
|
||||
- ✅ Secure flag для HTTPS
|
||||
|
||||
### Примеры Redis команд
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,34 +1,285 @@
|
||||
# OAuth Integration Guide
|
||||
# 🔐 OAuth Integration Guide
|
||||
|
||||
## 🎯 Обзор
|
||||
|
||||
Система OAuth интеграции с поддержкой популярных провайдеров. Токены хранятся в Redis с автоматическим TTL и поддержкой refresh.
|
||||
Система OAuth интеграции с **httpOnly cookies** для максимальной безопасности. Поддержка популярных провайдеров с единым подходом к аутентификации.
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
### 🔄 **Архитектура 2025: httpOnly cookies для всех**
|
||||
|
||||
### Поддерживаемые провайдеры
|
||||
- **Google** ✅ - OpenID Connect (актуальные endpoints)
|
||||
- **GitHub** ✅ - OAuth 2.0 (scope: read:user user:email)
|
||||
- **Facebook** ✅ - Facebook Login API v18.0+ (scope: email public_profile)
|
||||
- **VK** ✅ - VK OAuth API v5.199+ (scope: email)
|
||||
- **X (Twitter)** ✅ - OAuth 2.0 API v2 (scope: tweet.read users.read)
|
||||
- **Yandex** ✅ - Yandex OAuth (scope: login:email login:info login:avatar)
|
||||
- **Telegram** ⚠️ - Telegram Login (специфическая реализация)
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant F as Frontend
|
||||
participant B as Backend
|
||||
participant P as OAuth Provider
|
||||
|
||||
### Redis структура
|
||||
```bash
|
||||
oauth_access:{user_id}:{provider} # Access токены
|
||||
oauth_refresh:{user_id}:{provider} # Refresh токены
|
||||
oauth_state:{state} # OAuth state с TTL 10 минут
|
||||
U->>F: Click "Login with Google"
|
||||
F->>B: GET /oauth/google/login
|
||||
B->>P: Redirect to Provider
|
||||
P->>U: Show authorization page
|
||||
U->>P: Grant permission
|
||||
P->>B: GET /oauth/google/callback?code=xxx
|
||||
B->>P: Exchange code for token
|
||||
P->>B: Return access token + user data
|
||||
B->>B: Create JWT session
|
||||
B->>F: Redirect + Set httpOnly cookie
|
||||
F->>U: User logged in (cookie automatic)
|
||||
```
|
||||
|
||||
### Основные операции
|
||||
## 🚀 Поддерживаемые провайдеры
|
||||
|
||||
| Провайдер | Статус | Особенности |
|
||||
|-----------|--------|-------------|
|
||||
| **Google** | ✅ | OpenID Connect, актуальные endpoints |
|
||||
| **GitHub** | ✅ | OAuth 2.0, scope: `read:user user:email` |
|
||||
| **Yandex** | ✅ | OAuth, scope: `login:email login:info` |
|
||||
| **VK** | ✅ | OAuth API v5.199+, scope: `email` |
|
||||
| **Facebook** | ✅ | Facebook Login API v18.0+ |
|
||||
| **X (Twitter)** | ✅ | OAuth 2.0 API v2 |
|
||||
|
||||
## 🔧 OAuth Flow
|
||||
|
||||
### 1. 🚀 Инициация OAuth (Фронтенд)
|
||||
|
||||
```typescript
|
||||
// Простой редирект - backend получит redirect_uri из Referer header
|
||||
const handleOAuthLogin = (provider: string) => {
|
||||
// Сохраняем текущую страницу для возврата
|
||||
localStorage.setItem('oauth_return_url', window.location.pathname);
|
||||
|
||||
// Редиректим на OAuth endpoint
|
||||
window.location.href = `/oauth/${provider}/login`;
|
||||
};
|
||||
|
||||
// Использование
|
||||
<button onClick={() => handleOAuthLogin('google')}>
|
||||
🔐 Войти через Google
|
||||
</button>
|
||||
```
|
||||
|
||||
### 2. 🔄 Backend Endpoints
|
||||
|
||||
#### GET `/oauth/{provider}/login` - Старт OAuth
|
||||
```python
|
||||
# /oauth/github/login
|
||||
# 1. Сохраняет redirect_uri из Referer header в Redis state
|
||||
# 2. Генерирует PKCE challenge для безопасности
|
||||
# 3. Редиректит на провайдера с параметрами авторизации
|
||||
```
|
||||
|
||||
#### GET `/oauth/{provider}/callback` - Callback
|
||||
```python
|
||||
# GitHub → /oauth/github/callback?code=xxx&state=yyy
|
||||
# 1. Валидирует state (CSRF защита)
|
||||
# 2. Обменивает code на access_token
|
||||
# 3. Получает профиль пользователя
|
||||
# 4. Создает/обновляет пользователя в БД
|
||||
# 5. Создает JWT сессию
|
||||
# 6. Устанавливает httpOnly cookie
|
||||
# 7. Редиректит на фронтенд БЕЗ токена в URL
|
||||
```
|
||||
|
||||
### 3. 🌐 Фронтенд финализация
|
||||
|
||||
```typescript
|
||||
// OAuth callback route (/oauth/callback или аналогичный)
|
||||
export default function OAuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
|
||||
onMount(async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
// ❌ Ошибка OAuth
|
||||
console.error('OAuth error:', error);
|
||||
|
||||
switch (error) {
|
||||
case 'access_denied':
|
||||
alert('Доступ отклонен провайдером');
|
||||
break;
|
||||
case 'oauth_state_expired':
|
||||
alert('Сессия OAuth истекла. Попробуйте еще раз.');
|
||||
break;
|
||||
default:
|
||||
alert('Ошибка авторизации. Попробуйте еще раз.');
|
||||
}
|
||||
|
||||
navigate('/login');
|
||||
} else {
|
||||
// ✅ Успех! httpOnly cookie уже установлен
|
||||
try {
|
||||
// Проверяем сессию (cookie отправится автоматически)
|
||||
await auth.checkSession();
|
||||
|
||||
if (auth.isAuthenticated()) {
|
||||
// Возвращаемся на сохраненную страницу
|
||||
const returnUrl = localStorage.getItem('oauth_return_url') || '/';
|
||||
localStorage.removeItem('oauth_return_url');
|
||||
navigate(returnUrl);
|
||||
} else {
|
||||
throw new Error('Session validation failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to validate session:', error);
|
||||
navigate('/login?error=session_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="oauth-callback">
|
||||
<h2>Завершение авторизации...</h2>
|
||||
<p>Пожалуйста, подождите...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 🍪 Единая аутентификация через httpOnly cookie
|
||||
|
||||
```typescript
|
||||
// GraphQL клиент использует httpOnly cookie
|
||||
const graphqlRequest = async (query: string, variables?: any) => {
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', // ✅ КРИТИЧНО: отправляет httpOnly cookie
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// Auth Context
|
||||
export const AuthProvider = (props: { children: JSX.Element }) => {
|
||||
const [user, setUser] = createSignal<User | null>(null);
|
||||
|
||||
const checkSession = async () => {
|
||||
try {
|
||||
const response = await graphqlRequest(`
|
||||
query GetSession {
|
||||
getSession {
|
||||
success
|
||||
author { id slug email name }
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
if (response.data?.getSession?.success) {
|
||||
setUser(response.data.getSession.author);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Session check failed:', error);
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
// Удаляем httpOnly cookie на бэкенде
|
||||
await graphqlRequest(`mutation { logout { success } }`);
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
// Проверяем сессию при загрузке
|
||||
onMount(() => checkSession());
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
isAuthenticated: () => !!user(),
|
||||
checkSession,
|
||||
logout,
|
||||
}}>
|
||||
{props.children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 🔐 Настройка провайдеров
|
||||
|
||||
### Google OAuth
|
||||
1. [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. **APIs & Services** → **Credentials** → **OAuth 2.0 Client ID**
|
||||
3. **Authorized redirect URIs**: `https://your-domain.com/oauth/google/callback`
|
||||
|
||||
```bash
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
```
|
||||
|
||||
### GitHub OAuth
|
||||
1. [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. **New OAuth App**
|
||||
3. **Authorization callback URL**: `https://your-domain.com/oauth/github/callback`
|
||||
|
||||
```bash
|
||||
GITHUB_CLIENT_ID=your_github_client_id
|
||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
||||
```
|
||||
|
||||
### Yandex OAuth
|
||||
1. [Yandex OAuth](https://oauth.yandex.ru/)
|
||||
2. **Создать новое приложение**
|
||||
3. **Callback URI**: `https://your-domain.com/oauth/yandex/callback`
|
||||
4. **Права**: `login:info`, `login:email`, `login:avatar`
|
||||
|
||||
```bash
|
||||
YANDEX_CLIENT_ID=your_yandex_client_id
|
||||
YANDEX_CLIENT_SECRET=your_yandex_client_secret
|
||||
```
|
||||
|
||||
### VK OAuth
|
||||
1. [VK Developers](https://dev.vk.com/apps)
|
||||
2. **Создать приложение** → **Веб-сайт**
|
||||
3. **Redirect URI**: `https://your-domain.com/oauth/vk/callback`
|
||||
|
||||
```bash
|
||||
VK_CLIENT_ID=your_vk_app_id
|
||||
VK_CLIENT_SECRET=your_vk_secure_key
|
||||
```
|
||||
|
||||
## 🛡️ Безопасность
|
||||
|
||||
### httpOnly Cookie настройки
|
||||
```python
|
||||
# settings.py
|
||||
SESSION_COOKIE_NAME = "session_token"
|
||||
SESSION_COOKIE_HTTPONLY = True # Защита от XSS
|
||||
SESSION_COOKIE_SECURE = True # Только HTTPS
|
||||
SESSION_COOKIE_SAMESITE = "lax" # CSRF защита
|
||||
SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 дней
|
||||
```
|
||||
|
||||
### CSRF Protection
|
||||
- **State parameter**: Криптографически стойкий state для каждого запроса
|
||||
- **PKCE**: Code challenge для дополнительной защиты
|
||||
- **Redirect URI validation**: Проверка разрешенных доменов
|
||||
|
||||
### TTL и истечение
|
||||
- **OAuth state**: 10 минут (одноразовое использование)
|
||||
- **Session tokens**: 30 дней (настраивается)
|
||||
- **Автоматическая очистка**: Redis удаляет истекшие токены
|
||||
|
||||
## 🔧 API для разработчиков
|
||||
|
||||
### Проверка OAuth токенов
|
||||
```python
|
||||
from auth.tokens.oauth import OAuthTokenManager
|
||||
|
||||
oauth = OAuthTokenManager()
|
||||
|
||||
# Сохранение токенов
|
||||
# Сохранение OAuth токенов (для API интеграций)
|
||||
await oauth.store_oauth_tokens(
|
||||
user_id="123",
|
||||
provider="google",
|
||||
@@ -37,584 +288,98 @@ await oauth.store_oauth_tokens(
|
||||
expires_in=3600
|
||||
)
|
||||
|
||||
# Получение токена
|
||||
access_data = await oauth.get_token(user_id, "google", "oauth_access")
|
||||
|
||||
# Отзыв токенов
|
||||
await oauth.revoke_oauth_tokens(user_id, "google")
|
||||
```
|
||||
|
||||
## 🔧 OAuth Flow
|
||||
|
||||
### 1. Инициация OAuth (Фронтенд)
|
||||
```javascript
|
||||
// Простой вызов без параметров - backend получит redirect_uri из Referer header
|
||||
const oauth = (provider: string) => {
|
||||
window.location.href = `https://v3.dscrs.site/oauth/${provider}`
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Backend Endpoints
|
||||
|
||||
#### GET `/oauth/{provider}` - Старт OAuth
|
||||
```python
|
||||
# v3.dscrs.site/oauth/github
|
||||
# 1. Сохраняет redirect_uri из Referer header в Redis state
|
||||
# 2. Редиректит на провайдера с PKCE challenge
|
||||
```
|
||||
|
||||
#### GET `/oauth/{provider}/callback` - Callback
|
||||
```python
|
||||
# GitHub → v3.dscrs.site/oauth/github/callback?code=xxx&state=yyy
|
||||
# 1. Обменивает code на access_token
|
||||
# 2. Получает профиль пользователя
|
||||
# 3. Создает/обновляет пользователя
|
||||
# 4. Создает JWT сессию
|
||||
# 5. Устанавливает httpOnly cookie (для GraphQL)
|
||||
# 6. Редиректит на https://testing.discours.io/oauth?redirect_url=... (JWT в httpOnly cookie)
|
||||
```
|
||||
|
||||
### 3. Фронтенд финализация
|
||||
```javascript
|
||||
// https://testing.discours.io/oauth роут
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const error = urlParams.get('error')
|
||||
const redirectUrl = urlParams.get('redirect_url') || '/'
|
||||
|
||||
if (error) {
|
||||
// Обработка ошибок OAuth
|
||||
console.error('OAuth error:', error)
|
||||
alert('Authentication failed. Please try again.')
|
||||
window.location.href = '/'
|
||||
} else {
|
||||
// Нет ошибки = успех! JWT уже в httpOnly cookie
|
||||
// SessionProvider загружает сессию из cookie
|
||||
await sessionProvider.loadSession()
|
||||
|
||||
// Редиректим на исходную страницу
|
||||
window.location.href = redirectUrl
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Единая аутентификация через httpOnly cookie
|
||||
```javascript
|
||||
// GraphQL клиент использует httpOnly cookie
|
||||
const client = new ApolloClient({
|
||||
uri: 'https://v3.dscrs.site/graphql',
|
||||
credentials: 'include', // ✅ Отправляет httpOnly cookie
|
||||
})
|
||||
|
||||
// Все API вызовы также используют httpOnly cookie
|
||||
fetch('/api/endpoint', {
|
||||
credentials: 'include' // ✅ Отправляет httpOnly cookie
|
||||
})
|
||||
```
|
||||
|
||||
### 4. Настройки провайдеров (админки)
|
||||
- **GitHub**: `https://v3.dscrs.site/oauth/github/callback`
|
||||
- **Google**: `https://v3.dscrs.site/oauth/google/callback`
|
||||
- **Twitter**: `https://v3.dscrs.site/oauth/twitter/callback`
|
||||
async def oauth_redirect(
|
||||
provider: str,
|
||||
state: str,
|
||||
redirect_uri: str,
|
||||
request: Request
|
||||
):
|
||||
# Валидация провайдера
|
||||
if provider not in SUPPORTED_PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail="Unsupported OAuth provider")
|
||||
|
||||
# Сохранение state в Redis
|
||||
await store_oauth_state(state, redirect_uri)
|
||||
|
||||
# Генерация URL провайдера
|
||||
oauth_url = generate_provider_url(provider, state, redirect_uri)
|
||||
|
||||
return RedirectResponse(url=oauth_url)
|
||||
```
|
||||
|
||||
#### GET `/oauth/{provider}/callback`
|
||||
```python
|
||||
@router.get("/oauth/{provider}/callback")
|
||||
async def oauth_callback(
|
||||
provider: str,
|
||||
code: str,
|
||||
state: str,
|
||||
request: Request
|
||||
):
|
||||
# Проверка state
|
||||
stored_data = await get_oauth_state(state)
|
||||
if not stored_data:
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired state")
|
||||
|
||||
# Обмен code на access_token
|
||||
try:
|
||||
user_data = await exchange_code_for_user_data(provider, code)
|
||||
except OAuthException as e:
|
||||
logger.error(f"OAuth error for {provider}: {e}")
|
||||
return RedirectResponse(url=f"{stored_data['redirect_uri']}?error=oauth_failed")
|
||||
|
||||
# Поиск/создание пользователя
|
||||
user = await get_or_create_user_from_oauth(provider, user_data)
|
||||
|
||||
# Генерация JWT токена
|
||||
access_token = generate_jwt_token(user.id)
|
||||
|
||||
# Редирект обратно на фронтенд
|
||||
redirect_url = f"{stored_data['redirect_uri']}?state={state}&access_token={access_token}"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
```
|
||||
|
||||
### 3. OAuth State Management
|
||||
```python
|
||||
import redis
|
||||
from datetime import timedelta
|
||||
|
||||
redis_client = redis.Redis()
|
||||
|
||||
async def store_oauth_state(
|
||||
state: str,
|
||||
redirect_uri: str,
|
||||
ttl: timedelta = timedelta(minutes=10)
|
||||
):
|
||||
"""Сохранение OAuth state с TTL"""
|
||||
key = f"oauth_state:{state}"
|
||||
data = {
|
||||
"redirect_uri": redirect_uri,
|
||||
"created_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
await redis_client.setex(key, ttl, json.dumps(data))
|
||||
|
||||
async def get_oauth_state(state: str) -> Optional[dict]:
|
||||
"""Получение и удаление OAuth state"""
|
||||
key = f"oauth_state:{state}"
|
||||
data = await redis_client.get(key)
|
||||
if data:
|
||||
await redis_client.delete(key) # One-time use
|
||||
return json.loads(data)
|
||||
return None
|
||||
```
|
||||
|
||||
## 🔐 Провайдеры
|
||||
|
||||
### Google OAuth
|
||||
```python
|
||||
GOOGLE_OAUTH_CONFIG = {
|
||||
"client_id": os.getenv("GOOGLE_CLIENT_ID"),
|
||||
"client_secret": os.getenv("GOOGLE_CLIENT_SECRET"),
|
||||
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
|
||||
"scope": "openid email profile"
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Преимущества OpenID Connect:**
|
||||
- Автоматическое обнаружение endpoints через `.well-known/openid-configuration`
|
||||
- Поддержка актуальных стандартов безопасности
|
||||
- Автоматические обновления при изменениях Google API
|
||||
|
||||
### GitHub OAuth
|
||||
```python
|
||||
GITHUB_OAUTH_CONFIG = {
|
||||
"client_id": os.getenv("GITHUB_CLIENT_ID"),
|
||||
"client_secret": os.getenv("GITHUB_CLIENT_SECRET"),
|
||||
"auth_url": "https://github.com/login/oauth/authorize",
|
||||
"token_url": "https://github.com/login/oauth/access_token",
|
||||
"user_info_url": "https://api.github.com/user",
|
||||
"scope": "read:user user:email"
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ Важные требования GitHub:**
|
||||
- Scope `user:email` **обязателен** для получения email адреса
|
||||
- Проверяйте rate limits (5000 запросов/час для авторизованных пользователей)
|
||||
- Используйте `User-Agent` header во всех запросах к API
|
||||
|
||||
### Facebook OAuth
|
||||
```python
|
||||
FACEBOOK_OAUTH_CONFIG = {
|
||||
"client_id": os.getenv("FACEBOOK_APP_ID"),
|
||||
"client_secret": os.getenv("FACEBOOK_APP_SECRET"),
|
||||
"auth_url": "https://www.facebook.com/v18.0/dialog/oauth",
|
||||
"token_url": "https://graph.facebook.com/v18.0/oauth/access_token",
|
||||
"user_info_url": "https://graph.facebook.com/v18.0/me",
|
||||
"scope": "email public_profile",
|
||||
"token_endpoint_auth_method": "client_secret_post" # Требование Facebook
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ Важные требования Facebook:**
|
||||
- Используйте **минимум API v18.0**
|
||||
- Обязательно настройте **точные Redirect URIs** в Facebook App
|
||||
- Приложение должно быть в режиме **"Live"** для работы с реальными пользователями
|
||||
- **HTTPS обязателен** для production окружения
|
||||
|
||||
### VK OAuth
|
||||
```python
|
||||
VK_OAUTH_CONFIG = {
|
||||
"client_id": os.getenv("VK_APP_ID"),
|
||||
"client_secret": os.getenv("VK_APP_SECRET"),
|
||||
"auth_url": "https://oauth.vk.com/authorize",
|
||||
"token_url": "https://oauth.vk.com/access_token",
|
||||
"user_info_url": "https://api.vk.com/method/users.get",
|
||||
"scope": "email",
|
||||
"api_version": "5.199" # Актуальная версия API
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ Важные требования VK:**
|
||||
- Используйте **API версию 5.199+** (5.131 устарела)
|
||||
- Scope `email` необходим для получения email адреса
|
||||
- Redirect URI должен **точно совпадать** с настройками в приложении VK
|
||||
- Поддерживаются только HTTPS redirect URI в production
|
||||
|
||||
### X (Twitter) OAuth
|
||||
```python
|
||||
X_OAUTH_CONFIG = {
|
||||
"client_id": os.getenv("X_CLIENT_ID"),
|
||||
"client_secret": os.getenv("X_CLIENT_SECRET"),
|
||||
"auth_url": "https://twitter.com/i/oauth2/authorize",
|
||||
"token_url": "https://api.twitter.com/2/oauth2/token",
|
||||
"user_info_url": "https://api.twitter.com/2/users/me",
|
||||
"scope": "tweet.read users.read"
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ Важные требования X:**
|
||||
- Используйте **API v2** endpoints
|
||||
- Scope `users.read` обязателен для получения профиля
|
||||
- Email недоступен через публичное API
|
||||
- Требуется верификация приложения для production
|
||||
|
||||
### Yandex OAuth
|
||||
```python
|
||||
YANDEX_OAUTH_CONFIG = {
|
||||
"client_id": os.getenv("YANDEX_CLIENT_ID"),
|
||||
"client_secret": os.getenv("YANDEX_CLIENT_SECRET"),
|
||||
"auth_url": "https://oauth.yandex.ru/authorize",
|
||||
"token_url": "https://oauth.yandex.ru/token",
|
||||
"user_info_url": "https://login.yandex.ru/info",
|
||||
"scope": "login:email login:info login:avatar"
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ Важные требования Yandex:**
|
||||
- Scope `login:email` для получения email
|
||||
- Scope `login:info` для базовой информации профиля
|
||||
- Scope `login:avatar` для получения аватара
|
||||
- Поддержка только HTTPS redirect URI
|
||||
|
||||
### Telegram OAuth
|
||||
```python
|
||||
TELEGRAM_OAUTH_CONFIG = {
|
||||
"client_id": os.getenv("TELEGRAM_CLIENT_ID"),
|
||||
"client_secret": os.getenv("TELEGRAM_CLIENT_SECRET"),
|
||||
"auth_url": "https://oauth.telegram.org/auth",
|
||||
"token_url": "https://oauth.telegram.org/auth/request",
|
||||
"scope": "read"
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ Важные требования Telegram:**
|
||||
- Специальная настройка через @BotFather
|
||||
- Email недоступен - используется временный email
|
||||
- Получение номера телефона требует дополнительных разрешений
|
||||
|
||||
## 🔒 Безопасность
|
||||
|
||||
### TTL и истечение токенов
|
||||
- **Access tokens**: 1 час (настраивается)
|
||||
- **Refresh tokens**: 30 дней
|
||||
- **OAuth state**: 10 минут
|
||||
- **Автоматическая очистка**: Redis удаляет истекшие токены
|
||||
- **Изоляция провайдеров**: Токены разных провайдеров хранятся отдельно
|
||||
|
||||
### CSRF Protection
|
||||
```python
|
||||
def validate_oauth_state(stored_state: str, received_state: str) -> bool:
|
||||
"""Проверка OAuth state для защиты от CSRF"""
|
||||
return stored_state == received_state
|
||||
|
||||
def validate_redirect_uri(uri: str) -> bool:
|
||||
"""Валидация redirect_uri для предотвращения открытых редиректов"""
|
||||
allowed_domains = [
|
||||
"localhost:3000",
|
||||
"discours.io",
|
||||
"new.discours.io"
|
||||
]
|
||||
|
||||
parsed = urlparse(uri)
|
||||
return any(domain in parsed.netloc for domain in allowed_domains)
|
||||
```
|
||||
|
||||
## 💡 Практические примеры
|
||||
|
||||
### OAuth Login Flow
|
||||
```python
|
||||
from auth.oauth import oauth_login, oauth_callback, _create_or_update_user
|
||||
from auth.oauth import oauth_login_http, oauth_callback_http
|
||||
from auth.oauth import store_oauth_state, get_oauth_state
|
||||
|
||||
# GraphQL resolver для OAuth login
|
||||
async def handle_oauth_login(provider: str, callback_data: dict):
|
||||
"""Инициация OAuth авторизации"""
|
||||
return await oauth_login(None, info, provider, callback_data)
|
||||
|
||||
# HTTP handler для OAuth login
|
||||
async def handle_oauth_login_http(request):
|
||||
"""HTTP инициация OAuth авторизации"""
|
||||
return await oauth_login_http(request)
|
||||
|
||||
# HTTP handler для OAuth callback
|
||||
async def handle_oauth_callback_http(request):
|
||||
"""HTTP обработка OAuth callback"""
|
||||
return await oauth_callback_http(request)
|
||||
|
||||
# Создание/обновление пользователя
|
||||
async def create_user_from_oauth(provider: str, profile: dict):
|
||||
"""Создание пользователя из OAuth профиля"""
|
||||
return await _create_or_update_user(provider, profile)
|
||||
|
||||
# Управление OAuth состоянием
|
||||
await store_oauth_state(state, oauth_data)
|
||||
state_data = await get_oauth_state(state)
|
||||
```
|
||||
|
||||
### API Integration
|
||||
```python
|
||||
async def make_oauth_request(user_id: int, provider: str, endpoint: str):
|
||||
"""Запрос к API провайдера"""
|
||||
oauth = OAuthTokenManager()
|
||||
|
||||
# Получаем access token
|
||||
token_data = await oauth.get_token(str(user_id), provider, "oauth_access")
|
||||
if not token_data:
|
||||
raise OAuthTokenMissing()
|
||||
|
||||
# Делаем запрос
|
||||
# Получение токена для API вызовов
|
||||
token_data = await oauth.get_token("123", "google", "oauth_access")
|
||||
if token_data:
|
||||
# Используем токен для вызовов Google API
|
||||
headers = {"Authorization": f"Bearer {token_data['token']}"}
|
||||
response = await httpx.get(endpoint, headers=headers)
|
||||
|
||||
if response.status_code == 401:
|
||||
# Токен истек, требуется повторная авторизация
|
||||
raise OAuthTokenExpired()
|
||||
|
||||
return response.json()
|
||||
```
|
||||
|
||||
### Мониторинг токенов
|
||||
```python
|
||||
async def check_oauth_health():
|
||||
"""Проверка здоровья OAuth системы"""
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
monitoring = TokenMonitoring()
|
||||
stats = await monitoring.get_token_statistics()
|
||||
|
||||
return {
|
||||
"oauth_tokens": stats["oauth_access_tokens"] + stats["oauth_refresh_tokens"],
|
||||
"memory_usage": stats["memory_usage"]
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Настройка и деплой
|
||||
|
||||
### Environment Variables
|
||||
### Redis структура
|
||||
```bash
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
# OAuth токены для API интеграций
|
||||
oauth_access:{user_id}:{provider} # Access токен
|
||||
oauth_refresh:{user_id}:{provider} # Refresh токен
|
||||
|
||||
# GitHub OAuth
|
||||
GITHUB_CLIENT_ID=your_github_client_id
|
||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
||||
# OAuth state (временный)
|
||||
oauth_state:{state} # Данные авторизации (TTL: 10 мин)
|
||||
|
||||
# Facebook OAuth
|
||||
FACEBOOK_APP_ID=your_facebook_app_id
|
||||
FACEBOOK_APP_SECRET=your_facebook_app_secret
|
||||
|
||||
# VK OAuth
|
||||
VK_APP_ID=your_vk_app_id
|
||||
VK_APP_SECRET=your_vk_app_secret
|
||||
|
||||
# X (Twitter) OAuth
|
||||
X_CLIENT_ID=your_x_client_id
|
||||
X_CLIENT_SECRET=your_x_client_secret
|
||||
|
||||
# Yandex OAuth
|
||||
YANDEX_CLIENT_ID=your_yandex_client_id
|
||||
YANDEX_CLIENT_SECRET=your_yandex_client_secret
|
||||
|
||||
# Telegram OAuth
|
||||
TELEGRAM_CLIENT_ID=your_telegram_client_id
|
||||
TELEGRAM_CLIENT_SECRET=your_telegram_client_secret
|
||||
|
||||
# HTTPS настройки
|
||||
HTTPS_ENABLED=true # false для разработки
|
||||
|
||||
# Redis для state management
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your_jwt_secret_key
|
||||
JWT_EXPIRATION_HOURS=24
|
||||
```
|
||||
|
||||
### Настройка провайдеров
|
||||
|
||||
#### Google OAuth
|
||||
1. Перейти в [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Создать новый проект или выбрать существующий
|
||||
3. Включить Google+ API
|
||||
4. Настроить OAuth consent screen
|
||||
5. Создать OAuth 2.0 credentials
|
||||
6. Добавить redirect URIs:
|
||||
- `https://your-domain.com/auth/oauth/google/callback`
|
||||
- `http://localhost:3000/auth/oauth/google/callback` (для разработки)
|
||||
|
||||
#### GitHub OAuth
|
||||
1. Перейти в [GitHub Settings](https://github.com/settings/applications/new)
|
||||
2. Создать новое OAuth App
|
||||
3. Настроить Authorization callback URL:
|
||||
- `https://your-domain.com/auth/oauth/github/callback`
|
||||
|
||||
#### Facebook OAuth
|
||||
1. Перейти в [Facebook Developers](https://developers.facebook.com/)
|
||||
2. Создать новое приложение
|
||||
3. Добавить продукт "Facebook Login"
|
||||
4. Настроить Valid OAuth Redirect URIs:
|
||||
- `https://your-domain.com/oauth/facebook/callback`
|
||||
5. Переключить приложение в режим "Live"
|
||||
|
||||
#### X (Twitter) OAuth
|
||||
1. Перейти в [Twitter Developer Portal](https://developer.twitter.com/en/portal/dashboard)
|
||||
2. Создать новое приложение
|
||||
3. Настроить OAuth 2.0 settings
|
||||
4. Добавить Callback URLs:
|
||||
- `https://your-domain.com/oauth/x/callback`
|
||||
5. Получить Client ID и Client Secret
|
||||
|
||||
#### VK OAuth
|
||||
1. Перейти в [VK Developers](https://vk.com/dev)
|
||||
2. Создать новое приложение типа "Веб-сайт"
|
||||
3. Настроить "Доверенный redirect URI":
|
||||
- `https://your-domain.com/oauth/vk/callback`
|
||||
4. Получить ID приложения и Защищённый ключ
|
||||
|
||||
#### Yandex OAuth
|
||||
1. Перейти в [Yandex OAuth](https://oauth.yandex.ru/)
|
||||
2. Создать новое приложение
|
||||
3. Настроить Callback URL:
|
||||
- `https://your-domain.com/oauth/yandex/callback`
|
||||
4. Выбрать необходимые права доступа
|
||||
5. Получить ID и пароль приложения
|
||||
|
||||
#### Telegram OAuth
|
||||
1. Создать бота через @BotFather
|
||||
2. Получить Bot Token
|
||||
3. Настроить OAuth через Telegram API
|
||||
4. **Внимание**: Telegram OAuth имеет специфическую реализацию
|
||||
|
||||
### Redis команды для отладки
|
||||
```bash
|
||||
# Поиск OAuth токенов пользователя
|
||||
redis-cli --scan --pattern "oauth_access:123:*"
|
||||
redis-cli --scan --pattern "oauth_refresh:123:*"
|
||||
|
||||
# Получение данных токена
|
||||
redis-cli GET "oauth_access:123:google"
|
||||
|
||||
# Проверка TTL
|
||||
redis-cli TTL "oauth_access:123:google"
|
||||
|
||||
# Поиск OAuth state
|
||||
redis-cli --scan --pattern "oauth_state:*"
|
||||
# Сессии пользователей (основные)
|
||||
session:{user_id}:{token} # JWT сессия (TTL: 30 дней)
|
||||
```
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
### Unit Tests
|
||||
```python
|
||||
def test_oauth_redirect():
|
||||
response = client.get("/auth/oauth/google?state=test&redirect_uri=http://localhost:3000")
|
||||
assert response.status_code == 307
|
||||
assert "accounts.google.com" in response.headers["location"]
|
||||
|
||||
def test_oauth_callback():
|
||||
# Mock provider response
|
||||
with mock.patch('oauth.exchange_code_for_user_data') as mock_exchange:
|
||||
mock_exchange.return_value = OAuthUser(
|
||||
provider="google",
|
||||
provider_id="123456",
|
||||
email="test@example.com",
|
||||
name="Test User"
|
||||
)
|
||||
|
||||
response = client.get("/auth/oauth/google/callback?code=test_code&state=test_state")
|
||||
assert response.status_code == 307
|
||||
assert "access_token=" in response.headers["location"]
|
||||
```
|
||||
|
||||
### E2E Tests
|
||||
### E2E Test
|
||||
```typescript
|
||||
// tests/oauth.spec.ts
|
||||
test('OAuth flow with Google', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
test('OAuth flow with httpOnly cookies', async ({ page }) => {
|
||||
// 1. Инициация OAuth
|
||||
await page.goto('/login');
|
||||
await page.click('[data-testid="google-login"]');
|
||||
|
||||
// Click Google OAuth button
|
||||
await page.click('[data-testid="oauth-google"]')
|
||||
// 2. Проверяем редирект на Google
|
||||
await expect(page).toHaveURL(/accounts\.google\.com/);
|
||||
|
||||
// Should redirect to Google
|
||||
await page.waitForURL(/accounts\.google\.com/)
|
||||
// 3. Симулируем успешный callback (в тестовой среде)
|
||||
await page.goto('/oauth/callback');
|
||||
|
||||
// Mock successful OAuth (in test environment)
|
||||
await page.goto('/?state=test&access_token=mock_token')
|
||||
// 4. Проверяем что cookie установлен
|
||||
const cookies = await page.context().cookies();
|
||||
const authCookie = cookies.find(c => c.name === 'session_token');
|
||||
expect(authCookie).toBeTruthy();
|
||||
expect(authCookie?.httpOnly).toBe(true);
|
||||
|
||||
// Should be logged in
|
||||
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible()
|
||||
})
|
||||
// 5. Проверяем что пользователь авторизован
|
||||
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Частые ошибки
|
||||
|
||||
1. **"OAuth state mismatch"**
|
||||
- Проверьте TTL Redis
|
||||
- Убедитесь, что state генерируется правильно
|
||||
|
||||
2. **"Provider authentication failed"**
|
||||
- Проверьте client_id и client_secret
|
||||
- Убедитесь, что redirect_uri совпадает с настройками провайдера
|
||||
|
||||
3. **"Invalid redirect URI"**
|
||||
- Добавьте все возможные redirect URIs в настройки приложения
|
||||
- Проверьте HTTPS/HTTP в production/development
|
||||
|
||||
### Логи для отладки
|
||||
### Отладка
|
||||
```bash
|
||||
# Backend логи
|
||||
tail -f /var/log/app/oauth.log | grep "oauth"
|
||||
# Проверка OAuth провайдеров
|
||||
curl -v "https://your-domain.com/oauth/google/login"
|
||||
|
||||
# Frontend логи (browser console)
|
||||
# Фильтр: "[oauth]" или "[SessionProvider]"
|
||||
# Проверка callback
|
||||
curl -v "https://your-domain.com/oauth/google/callback?code=test&state=test"
|
||||
|
||||
# Проверка сессии с cookie
|
||||
curl -b "session_token=your_token" "https://your-domain.com/graphql" \
|
||||
-d '{"query":"query { getSession { success author { id } } }"}'
|
||||
```
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
```python
|
||||
# Добавить метрики для мониторинга
|
||||
from prometheus_client import Counter, Histogram
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
oauth_requests = Counter('oauth_requests_total', 'OAuth requests', ['provider', 'status'])
|
||||
oauth_duration = Histogram('oauth_duration_seconds', 'OAuth request duration')
|
||||
monitoring = TokenMonitoring()
|
||||
|
||||
@router.get("/{provider}")
|
||||
async def oauth_redirect(provider: str, state: str, redirect_uri: str):
|
||||
with oauth_duration.time():
|
||||
try:
|
||||
# OAuth logic
|
||||
oauth_requests.labels(provider=provider, status='success').inc()
|
||||
except Exception as e:
|
||||
oauth_requests.labels(provider=provider, status='error').inc()
|
||||
raise
|
||||
# Статистика OAuth
|
||||
stats = await monitoring.get_token_statistics()
|
||||
oauth_tokens = stats.get("oauth_access_tokens", 0) + stats.get("oauth_refresh_tokens", 0)
|
||||
print(f"OAuth tokens: {oauth_tokens}")
|
||||
|
||||
# Health check
|
||||
health = await monitoring.health_check()
|
||||
if health["status"] == "healthy":
|
||||
print("✅ OAuth system is healthy")
|
||||
```
|
||||
|
||||
## 🎯 Преимущества новой архитектуры
|
||||
|
||||
### 🛡️ Максимальная безопасность:
|
||||
- **🚫 Защита от XSS**: Токены недоступны JavaScript
|
||||
- **🔒 Защита от CSRF**: SameSite cookies
|
||||
- **🛡️ Единообразие**: Все провайдеры используют один механизм
|
||||
|
||||
### 🚀 Простота использования:
|
||||
- **📱 Автоматическая отправка**: Браузер сам включает cookies
|
||||
- **🧹 Чистый код**: Нет управления токенами в JavaScript
|
||||
- **🔄 Единый API**: Один GraphQL клиент для всех случаев
|
||||
|
||||
### ⚡ Производительность:
|
||||
- **🚀 Быстрее**: Нет localStorage операций
|
||||
- **📦 Меньше кода**: Упрощенная логика фронтенда
|
||||
- **🔄 Автоматическое управление**: Браузер оптимизирует отправку cookies
|
||||
|
||||
**Результат: Самая безопасная и простая OAuth интеграция!** 🔐✨
|
||||
267
docs/auth/setup.md
Normal file
267
docs/auth/setup.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# 🔧 Настройка системы аутентификации
|
||||
|
||||
## 🎯 Быстрая настройка
|
||||
|
||||
### 1. Environment Variables
|
||||
|
||||
```bash
|
||||
# JWT настройки
|
||||
JWT_SECRET=your_super_secret_key_minimum_256_bits
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRATION_HOURS=720 # 30 дней
|
||||
|
||||
# Cookie настройки (httpOnly для безопасности)
|
||||
SESSION_COOKIE_NAME=session_token
|
||||
SESSION_COOKIE_HTTPONLY=true
|
||||
SESSION_COOKIE_SECURE=true # Только HTTPS в продакшене
|
||||
SESSION_COOKIE_SAMESITE=lax # CSRF защита
|
||||
SESSION_COOKIE_MAX_AGE=2592000 # 30 дней
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
REDIS_SOCKET_KEEPALIVE=true
|
||||
REDIS_HEALTH_CHECK_INTERVAL=30
|
||||
|
||||
# OAuth провайдеры
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
GITHUB_CLIENT_ID=your_github_client_id
|
||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
||||
YANDEX_CLIENT_ID=your_yandex_client_id
|
||||
YANDEX_CLIENT_SECRET=your_yandex_client_secret
|
||||
VK_CLIENT_ID=your_vk_app_id
|
||||
VK_CLIENT_SECRET=your_vk_secure_key
|
||||
|
||||
# Безопасность
|
||||
RATE_LIMIT_ENABLED=true
|
||||
MAX_LOGIN_ATTEMPTS=5
|
||||
LOCKOUT_DURATION=1800 # 30 минут
|
||||
```
|
||||
|
||||
### 2. OAuth Провайдеры
|
||||
|
||||
#### Google OAuth
|
||||
1. [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. **APIs & Services** → **Credentials** → **Create OAuth 2.0 Client ID**
|
||||
3. **Authorized redirect URIs**:
|
||||
- `https://your-domain.com/oauth/google/callback` (продакшн)
|
||||
- `http://localhost:8000/oauth/google/callback` (разработка)
|
||||
|
||||
#### GitHub OAuth
|
||||
1. [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. **New OAuth App**
|
||||
3. **Authorization callback URL**: `https://your-domain.com/oauth/github/callback`
|
||||
|
||||
#### Yandex OAuth
|
||||
1. [Yandex OAuth](https://oauth.yandex.ru/)
|
||||
2. **Создать новое приложение**
|
||||
3. **Callback URI**: `https://your-domain.com/oauth/yandex/callback`
|
||||
4. **Права**: `login:info`, `login:email`, `login:avatar`
|
||||
|
||||
#### VK OAuth
|
||||
1. [VK Developers](https://dev.vk.com/apps)
|
||||
2. **Создать приложение** → **Веб-сайт**
|
||||
3. **Redirect URI**: `https://your-domain.com/oauth/vk/callback`
|
||||
|
||||
### 3. Проверка настройки
|
||||
|
||||
```bash
|
||||
# Проверка переменных окружения
|
||||
python -c "
|
||||
import os
|
||||
required = ['JWT_SECRET', 'REDIS_URL', 'GOOGLE_CLIENT_ID']
|
||||
for var in required:
|
||||
print(f'{var}: {\"✅\" if os.getenv(var) else \"❌\"}')"
|
||||
|
||||
# Проверка Redis подключения
|
||||
python -c "
|
||||
import asyncio
|
||||
from storage.redis import redis
|
||||
async def test():
|
||||
result = await redis.ping()
|
||||
print(f'Redis: {\"✅\" if result else \"❌\"}')
|
||||
asyncio.run(test())"
|
||||
|
||||
# Проверка OAuth провайдеров
|
||||
curl -v "https://your-domain.com/oauth/google/login"
|
||||
```
|
||||
|
||||
## 🔒 Безопасность в продакшене
|
||||
|
||||
### SSL/HTTPS настройки
|
||||
```bash
|
||||
# Принудительное HTTPS
|
||||
FORCE_HTTPS=true
|
||||
HSTS_MAX_AGE=31536000
|
||||
|
||||
# Secure cookies только для HTTPS
|
||||
SESSION_COOKIE_SECURE=true
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
```bash
|
||||
RATE_LIMIT_REQUESTS=100
|
||||
RATE_LIMIT_WINDOW=3600 # 1 час
|
||||
```
|
||||
|
||||
### Account Lockout
|
||||
```bash
|
||||
MAX_LOGIN_ATTEMPTS=5
|
||||
LOCKOUT_DURATION=1800 # 30 минут
|
||||
```
|
||||
|
||||
## 🐛 Диагностика проблем
|
||||
|
||||
### Частые ошибки
|
||||
|
||||
#### "Provider not configured"
|
||||
```bash
|
||||
# Проверить переменные окружения
|
||||
echo $GOOGLE_CLIENT_ID
|
||||
echo $GOOGLE_CLIENT_SECRET
|
||||
|
||||
# Перезапустить приложение после установки переменных
|
||||
```
|
||||
|
||||
#### "redirect_uri_mismatch"
|
||||
- Проверить точное соответствие URL в настройках провайдера
|
||||
- Убедиться что протокол (http/https) совпадает
|
||||
- Callback URL должен указывать на backend, НЕ на frontend
|
||||
|
||||
#### "Cookies не работают"
|
||||
```bash
|
||||
# Проверить настройки cookie
|
||||
curl -v -b "session_token=test" "https://your-domain.com/graphql"
|
||||
|
||||
# Проверить что фронтенд отправляет credentials
|
||||
# В коде должно быть: credentials: 'include'
|
||||
```
|
||||
|
||||
#### "CORS ошибки"
|
||||
```python
|
||||
# В настройках CORS должно быть:
|
||||
allow_credentials=True
|
||||
allow_origins=["https://your-frontend-domain.com"]
|
||||
```
|
||||
|
||||
### Логи для отладки
|
||||
```bash
|
||||
# Поиск ошибок аутентификации
|
||||
grep -i "auth\|oauth\|cookie" /var/log/app/app.log
|
||||
|
||||
# Мониторинг Redis операций
|
||||
redis-cli monitor | grep "session\|oauth"
|
||||
```
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
### Health Check
|
||||
```python
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
async def auth_health():
|
||||
monitoring = TokenMonitoring()
|
||||
health = await monitoring.health_check()
|
||||
stats = await monitoring.get_token_statistics()
|
||||
|
||||
return {
|
||||
"status": health["status"],
|
||||
"redis_connected": health["redis_connected"],
|
||||
"active_sessions": stats["session_tokens"],
|
||||
"memory_usage_mb": stats["memory_usage"] / 1024 / 1024
|
||||
}
|
||||
```
|
||||
|
||||
### Метрики для мониторинга
|
||||
- Количество активных сессий
|
||||
- Успешность OAuth авторизаций
|
||||
- Rate limit нарушения
|
||||
- Заблокированные аккаунты
|
||||
- Использование памяти Redis
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
### Unit тесты
|
||||
```bash
|
||||
# Запуск auth тестов
|
||||
pytest tests/auth/ -v
|
||||
|
||||
# Проверка типов
|
||||
mypy auth/
|
||||
```
|
||||
|
||||
### E2E тесты
|
||||
```bash
|
||||
# Тестирование OAuth flow
|
||||
playwright test tests/oauth.spec.ts
|
||||
|
||||
# Тестирование cookie аутентификации
|
||||
playwright test tests/auth-cookies.spec.ts
|
||||
```
|
||||
|
||||
### Нагрузочное тестирование
|
||||
```bash
|
||||
# Тестирование login endpoint
|
||||
ab -n 1000 -c 10 -p login.json -T application/json http://localhost:8000/graphql
|
||||
|
||||
# Содержимое login.json:
|
||||
# {"query":"mutation{login(email:\"test@example.com\",password:\"password\"){success}}"}
|
||||
```
|
||||
|
||||
## 🚀 Развертывание
|
||||
|
||||
### Docker
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
ENV JWT_SECRET=your_secret_here
|
||||
ENV REDIS_URL=redis://redis:6379/0
|
||||
ENV SESSION_COOKIE_SECURE=true
|
||||
```
|
||||
|
||||
### Dokku/Heroku
|
||||
```bash
|
||||
# Установка переменных окружения
|
||||
dokku config:set myapp JWT_SECRET=xxx REDIS_URL=yyy
|
||||
heroku config:set JWT_SECRET=xxx REDIS_URL=yyy
|
||||
```
|
||||
|
||||
### Nginx настройки
|
||||
```nginx
|
||||
# Поддержка cookies
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_cookie_path / "/; Secure; HttpOnly; SameSite=lax";
|
||||
|
||||
# CORS для credentials
|
||||
add_header Access-Control-Allow-Credentials true;
|
||||
add_header Access-Control-Allow-Origin https://your-frontend.com;
|
||||
```
|
||||
|
||||
## ✅ Checklist для продакшена
|
||||
|
||||
### Безопасность
|
||||
- [ ] JWT secret минимум 256 бит
|
||||
- [ ] HTTPS принудительно включен
|
||||
- [ ] httpOnly cookies настроены
|
||||
- [ ] SameSite cookies включены
|
||||
- [ ] Rate limiting активен
|
||||
- [ ] Account lockout настроен
|
||||
|
||||
### OAuth
|
||||
- [ ] Все провайдеры настроены
|
||||
- [ ] Redirect URIs правильные
|
||||
- [ ] Client secrets безопасно хранятся
|
||||
- [ ] PKCE включен для поддерживающих провайдеров
|
||||
|
||||
### Мониторинг
|
||||
- [ ] Health checks настроены
|
||||
- [ ] Логирование работает
|
||||
- [ ] Метрики собираются
|
||||
- [ ] Алерты настроены
|
||||
|
||||
### Производительность
|
||||
- [ ] Redis connection pooling
|
||||
- [ ] TTL для всех ключей
|
||||
- [ ] Batch операции для массовых действий
|
||||
- [ ] Memory optimization включена
|
||||
|
||||
**Готово к продакшену!** 🚀✅
|
||||
414
docs/auth/sse-httponly-integration.md
Normal file
414
docs/auth/sse-httponly-integration.md
Normal file
@@ -0,0 +1,414 @@
|
||||
# 📡 SSE + httpOnly Cookies Integration
|
||||
|
||||
## 🎯 Обзор
|
||||
|
||||
Server-Sent Events (SSE) **отлично работают** с httpOnly cookies! Браузер автоматически отправляет cookies при установке SSE соединения.
|
||||
|
||||
## 🔄 Как это работает
|
||||
|
||||
### 1. 🚀 Установка SSE соединения
|
||||
|
||||
```typescript
|
||||
// Фронтенд - SSE с cross-origin поддоменом
|
||||
const eventSource = new EventSource('https://connect.discours.io/notifications', {
|
||||
withCredentials: true // ✅ КРИТИЧНО: отправляет httpOnly cookies cross-origin
|
||||
});
|
||||
|
||||
// Для продакшена
|
||||
const SSE_URL = process.env.NODE_ENV === 'production'
|
||||
? 'https://connect.discours.io/'
|
||||
: 'https://connect.discours.io/';
|
||||
|
||||
const eventSource = new EventSource(SSE_URL, {
|
||||
withCredentials: true // ✅ Обязательно для cross-origin cookies
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 🔧 Backend SSE endpoint с аутентификацией
|
||||
|
||||
```python
|
||||
# main.py - добавляем SSE endpoint
|
||||
from starlette.responses import StreamingResponse
|
||||
from auth.middleware import auth_middleware
|
||||
|
||||
@app.route("/sse/notifications")
|
||||
async def sse_notifications(request: Request):
|
||||
"""SSE endpoint для real-time уведомлений"""
|
||||
|
||||
# ✅ Аутентификация через httpOnly cookie
|
||||
user_data = await auth_middleware.authenticate_user(request)
|
||||
if not user_data:
|
||||
return Response("Unauthorized", status_code=401)
|
||||
|
||||
user_id = user_data.get("user_id")
|
||||
|
||||
async def event_stream():
|
||||
"""Генератор SSE событий"""
|
||||
try:
|
||||
# Подписываемся на Redis каналы пользователя
|
||||
channels = [
|
||||
f"notifications:{user_id}",
|
||||
f"follower:{user_id}",
|
||||
f"shout:{user_id}"
|
||||
]
|
||||
|
||||
pubsub = redis.pubsub()
|
||||
await pubsub.subscribe(*channels)
|
||||
|
||||
# Отправляем initial heartbeat
|
||||
yield f"data: {json.dumps({'type': 'connected', 'user_id': user_id})}\n\n"
|
||||
|
||||
async for message in pubsub.listen():
|
||||
if message['type'] == 'message':
|
||||
# Форматируем SSE событие
|
||||
data = message['data'].decode('utf-8')
|
||||
yield f"data: {data}\n\n"
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await pubsub.unsubscribe()
|
||||
await pubsub.close()
|
||||
except Exception as e:
|
||||
logger.error(f"SSE error for user {user_id}: {e}")
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Access-Control-Allow-Credentials": "true", # Для CORS
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 3. 🌐 Фронтенд SSE клиент
|
||||
|
||||
```typescript
|
||||
// SSE клиент с автоматической аутентификацией через cookies
|
||||
class SSEClient {
|
||||
private eventSource: EventSource | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
|
||||
connect() {
|
||||
try {
|
||||
// ✅ Cross-origin SSE с cookies
|
||||
const SSE_URL = process.env.NODE_ENV === 'production'
|
||||
? 'https://connect.discours.io/sse/notifications'
|
||||
: 'https://connect.discours.io/sse/notifications';
|
||||
|
||||
this.eventSource = new EventSource(SSE_URL, {
|
||||
withCredentials: true // ✅ КРИТИЧНО для cross-origin cookies
|
||||
});
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
console.log('✅ SSE connected');
|
||||
this.reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
this.eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this.handleNotification(data);
|
||||
} catch (error) {
|
||||
console.error('SSE message parse error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
|
||||
// Если получили 401 - cookie недействителен
|
||||
if (this.eventSource?.readyState === EventSource.CLOSED) {
|
||||
this.handleAuthError();
|
||||
} else {
|
||||
this.handleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('SSE connection error:', error);
|
||||
this.handleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private handleNotification(data: any) {
|
||||
switch (data.type) {
|
||||
case 'connected':
|
||||
console.log(`SSE connected for user: ${data.user_id}`);
|
||||
break;
|
||||
|
||||
case 'follower':
|
||||
this.handleFollowerNotification(data);
|
||||
break;
|
||||
|
||||
case 'shout':
|
||||
this.handleShoutNotification(data);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('SSE server error:', data.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleAuthError() {
|
||||
console.warn('SSE authentication failed - redirecting to login');
|
||||
// Cookie недействителен - редиректим на login
|
||||
window.location.href = '/login?error=session_expired';
|
||||
}
|
||||
|
||||
private handleReconnect() {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
const delay = Math.pow(2, this.reconnectAttempts) * 1000; // Exponential backoff
|
||||
|
||||
console.log(`Reconnecting SSE in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.disconnect();
|
||||
this.connect();
|
||||
}, delay);
|
||||
} else {
|
||||
console.error('Max SSE reconnect attempts reached');
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleFollowerNotification(data: any) {
|
||||
// Обновляем UI при новом подписчике
|
||||
if (data.action === 'create') {
|
||||
showNotification(`${data.payload.follower_name} подписался на вас!`);
|
||||
updateFollowersCount(+1);
|
||||
}
|
||||
}
|
||||
|
||||
private handleShoutNotification(data: any) {
|
||||
// Обновляем UI при новых публикациях
|
||||
if (data.action === 'create') {
|
||||
showNotification(`Новая публикация: ${data.payload.title}`);
|
||||
refreshFeed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Использование в приложении
|
||||
const sseClient = new SSEClient();
|
||||
|
||||
// Подключаемся после успешной аутентификации
|
||||
const auth = useAuth();
|
||||
if (auth.isAuthenticated()) {
|
||||
sseClient.connect();
|
||||
}
|
||||
|
||||
// Отключаемся при logout
|
||||
auth.onLogout(() => {
|
||||
sseClient.disconnect();
|
||||
});
|
||||
```
|
||||
|
||||
## 🔧 Интеграция с существующей системой
|
||||
|
||||
### SSE сервер на connect.discours.io
|
||||
|
||||
```python
|
||||
# connect.discours.io / connect.discours.io - отдельный SSE сервер
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.routing import Route
|
||||
|
||||
# SSE приложение
|
||||
sse_app = Starlette(
|
||||
routes=[
|
||||
# ✅ Единственный endpoint - SSE notifications
|
||||
Route("/sse/notifications", sse_notifications, methods=["GET"]),
|
||||
Route("/health", health_check, methods=["GET"]),
|
||||
],
|
||||
middleware=[
|
||||
# ✅ CORS для cross-origin cookies
|
||||
Middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://testing.discours.io",
|
||||
"https://discours.io",
|
||||
"https://new.discours.io",
|
||||
"http://localhost:3000", # dev
|
||||
],
|
||||
allow_credentials=True, # ✅ Разрешаем cookies
|
||||
allow_methods=["GET", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Основной сервер остается без изменений
|
||||
# main.py - БЕЗ SSE routes
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]),
|
||||
Route("/oauth/{provider}/callback", oauth_callback_http, methods=["GET"]),
|
||||
Route("/oauth/{provider}", oauth_login_http, methods=["GET"]),
|
||||
# SSE НЕ здесь - он на отдельном поддомене!
|
||||
],
|
||||
middleware=middleware,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
```
|
||||
|
||||
### Используем существующую notify систему
|
||||
|
||||
```python
|
||||
# services/notify.py - уже готова!
|
||||
# Ваша система уже отправляет уведомления в Redis каналы:
|
||||
|
||||
async def notify_follower(follower, author_id, action="follow"):
|
||||
channel_name = f"follower:{author_id}"
|
||||
data = {
|
||||
"type": "follower",
|
||||
"action": "create" if action == "follow" else "delete",
|
||||
"entity": "follower",
|
||||
"payload": {
|
||||
"follower_id": follower["id"],
|
||||
"follower_name": follower["name"],
|
||||
"following_id": author_id,
|
||||
}
|
||||
}
|
||||
|
||||
# ✅ Отправляем в Redis - SSE endpoint получит автоматически
|
||||
await redis.publish(channel_name, orjson.dumps(data))
|
||||
```
|
||||
|
||||
## 🛡️ Безопасность SSE + httpOnly cookies
|
||||
|
||||
### Преимущества:
|
||||
- **🚫 Защита от XSS**: Токены недоступны JavaScript
|
||||
- **🔒 Автоматическая аутентификация**: Браузер сам отправляет cookies
|
||||
- **🛡️ CSRF защита**: SameSite cookies
|
||||
- **📱 Простота**: Нет управления токенами в JavaScript
|
||||
|
||||
### CORS настройки для cross-origin SSE:
|
||||
|
||||
```python
|
||||
# connect.discours.io / connect.discours.io - CORS для SSE
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://testing.discours.io",
|
||||
"https://discours.io",
|
||||
"https://new.discours.io",
|
||||
# Для разработки
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
],
|
||||
allow_credentials=True, # ✅ КРИТИЧНО: разрешает отправку cookies cross-origin
|
||||
allow_methods=["GET", "OPTIONS"], # SSE использует GET + preflight OPTIONS
|
||||
allow_headers=["*"],
|
||||
)
|
||||
```
|
||||
|
||||
### Cookie Domain настройки:
|
||||
|
||||
```python
|
||||
# settings.py - Cookie должен работать для всех поддоменов
|
||||
SESSION_COOKIE_DOMAIN = ".discours.io" # ✅ Работает для всех поддоменов
|
||||
SESSION_COOKIE_SECURE = True # ✅ Только HTTPS
|
||||
SESSION_COOKIE_SAMESITE = "none" # ✅ Для cross-origin (но secure!)
|
||||
|
||||
# Для продакшена
|
||||
if PRODUCTION:
|
||||
SESSION_COOKIE_DOMAIN = ".discours.io"
|
||||
```
|
||||
|
||||
## 🧪 Тестирование SSE + cookies
|
||||
|
||||
```typescript
|
||||
// Тест SSE соединения
|
||||
test('SSE connects with httpOnly cookies', async ({ page }) => {
|
||||
// 1. Авторизуемся (cookie устанавливается)
|
||||
await page.goto('/login');
|
||||
await loginWithEmail(page, 'test@example.com', 'password');
|
||||
|
||||
// 2. Проверяем что cookie установлен
|
||||
const cookies = await page.context().cookies();
|
||||
const authCookie = cookies.find(c => c.name === 'session_token');
|
||||
expect(authCookie).toBeTruthy();
|
||||
|
||||
// 3. Тестируем cross-origin SSE соединение
|
||||
const sseConnected = await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
const eventSource = new EventSource('https://connect.discours.io/', {
|
||||
withCredentials: true // ✅ Отправляем cookies cross-origin
|
||||
});
|
||||
|
||||
eventSource.onopen = () => {
|
||||
resolve(true);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
resolve(false);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
// Timeout после 5 секунд
|
||||
setTimeout(() => {
|
||||
resolve(false);
|
||||
eventSource.close();
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
|
||||
expect(sseConnected).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
## 📊 Мониторинг SSE соединений
|
||||
|
||||
```python
|
||||
# Добавляем метрики SSE
|
||||
from collections import defaultdict
|
||||
|
||||
sse_connections = defaultdict(int)
|
||||
|
||||
async def sse_notifications(request: Request):
|
||||
user_data = await auth_middleware.authenticate_user(request)
|
||||
if not user_data:
|
||||
return Response("Unauthorized", status_code=401)
|
||||
|
||||
user_id = user_data.get("user_id")
|
||||
|
||||
# Увеличиваем счетчик соединений
|
||||
sse_connections[user_id] += 1
|
||||
logger.info(f"SSE connected: user_id={user_id}, total_connections={sse_connections[user_id]}")
|
||||
|
||||
try:
|
||||
async def event_stream():
|
||||
# ... SSE логика ...
|
||||
pass
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
finally:
|
||||
# Уменьшаем счетчик при отключении
|
||||
sse_connections[user_id] -= 1
|
||||
logger.info(f"SSE disconnected: user_id={user_id}, remaining_connections={sse_connections[user_id]}")
|
||||
```
|
||||
|
||||
## 🎯 Результат
|
||||
|
||||
**SSE + httpOnly cookies = Идеальное сочетание для real-time уведомлений:**
|
||||
|
||||
- ✅ **Безопасность**: Максимальная защита от XSS/CSRF
|
||||
- ✅ **Простота**: Автоматическая аутентификация
|
||||
- ✅ **Производительность**: Нет дополнительных HTTP запросов для аутентификации
|
||||
- ✅ **Надежность**: Браузер сам управляет отправкой cookies
|
||||
- ✅ **Совместимость**: Работает со всеми современными браузерами
|
||||
|
||||
**Ваша существующая notify система готова к работе с SSE!** 📡🍪✨
|
||||
Reference in New Issue
Block a user