[0.9.28] - OAuth/Auth with httpOnly cookie
All checks were successful
Deploy on push / deploy (push) Successful in 4m32s
All checks were successful
Deploy on push / deploy (push) Successful in 4m32s
This commit is contained in:
414
docs/auth/sse-httponly-integration.md
Normal file
414
docs/auth/sse-httponly-integration.md
Normal file
@@ -0,0 +1,414 @@
|
||||
# 📡 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!** 📡🍪✨
|
||||
Reference in New Issue
Block a user