415 lines
14 KiB
Markdown
415 lines
14 KiB
Markdown
|
|
# 📡 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!** 📡🍪✨
|