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