Files
core/docs/auth/sse-httponly-integration.md
Untone fb98a1c6c8
All checks were successful
Deploy on push / deploy (push) Successful in 4m32s
[0.9.28] - OAuth/Auth with httpOnly cookie
2025-09-28 12:22:37 +03:00

14 KiB
Raw Permalink Blame History

📡 SSE + httpOnly Cookies Integration

🎯 Обзор

Server-Sent Events (SSE) отлично работают с httpOnly cookies! Браузер автоматически отправляет cookies при установке SSE соединения.

🔄 Как это работает

1. 🚀 Установка SSE соединения

// Фронтенд - 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 с аутентификацией

# 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 клиент

// 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

# 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 систему

# 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:

# 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=["*"],
)
# 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

// Тест 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 соединений

# Добавляем метрики 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! 📡🍪