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!** 📡🍪✨
|