Files
core/docs/auth/sse-httponly-integration.md

415 lines
14 KiB
Markdown
Raw Normal View History

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