fix(chat): ensure WebSocket auth token from query or cookie

- Chat server: accept token from ?token= or access_token cookie (httpOnly)
- Frontend: append token to WS URL when available (TokenStorage)
This commit is contained in:
senke 2026-02-18 12:42:48 +01:00
parent 98f6db3a1d
commit a53dc358e6
4 changed files with 42 additions and 12 deletions

View file

@ -29,6 +29,7 @@ interface WebSocketService {
} }
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { TokenStorage } from '@/services/tokenStorage';
class WebSocketServiceImpl implements WebSocketService { class WebSocketServiceImpl implements WebSocketService {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
@ -45,26 +46,26 @@ class WebSocketServiceImpl implements WebSocketService {
return; return;
} }
// CORRECTION: Le serveur Rust expose le WebSocket sur /ws // CORRECTION: Le serveur Rust expose le WebSocket sur /ws et requiert token en query (v0.101)
const wsUrl = (() => { const baseUrl = (() => {
const url = import.meta.env.VITE_WS_URL; const url = import.meta.env.VITE_WS_URL;
if (!url) { if (!url) {
if (import.meta.env.PROD) { if (import.meta.env.PROD) {
throw new Error('VITE_WS_URL must be defined in production'); throw new Error('VITE_WS_URL must be defined in production');
} }
// Fallback uniquement en développement - utiliser chemin relatif par défaut
// SECURITY: Ne jamais utiliser localhost hardcodé, utiliser chemin relatif
// Convertir chemin relatif en URL WebSocket
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/ws`; return `${protocol}//${window.location.host}/ws`;
} }
// Convertir URL relative en URL absolue pour WebSocket
if (url.startsWith('/')) { if (url.startsWith('/')) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}${url}`; return `${protocol}//${window.location.host}${url}`;
} }
return url; return url;
})(); })();
const token = TokenStorage.getAccessToken();
const wsUrl = token
? `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`
: baseUrl;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {

View file

@ -43,6 +43,13 @@ REDIS_ENABLE=true
RABBITMQ_URL=amqp://veza:password@localhost:5672/%2f RABBITMQ_URL=amqp://veza:password@localhost:5672/%2f
RABBITMQ_ENABLE=true RABBITMQ_ENABLE=true
# ClamAV (v0.101 - virus scanning for uploads)
# Dev (Docker): ENABLE_CLAMAV=true, CLAMAV_REQUIRED=false (accepts uploads if ClamAV slow/down)
# Prod: CLAMAV_REQUIRED=true (startup fails if ClamAV unavailable)
ENABLE_CLAMAV=true
CLAMAV_REQUIRED=false
CLAMAV_ADDRESS=clamav:3310
# Security # Security
JWT_SECRET=your-secret-key-here-change-in-production JWT_SECRET=your-secret-key-here-change-in-production
CORS_ALLOWED_ORIGINS=http://localhost:3000 CORS_ALLOWED_ORIGINS=http://localhost:3000

View file

@ -322,9 +322,10 @@ async fn main() -> Result<(), ChatError> {
let ws_state_clone = ws_state.clone(); let ws_state_clone = ws_state.clone();
move |ws: WebSocketUpgrade, move |ws: WebSocketUpgrade,
query: Query<HashMap<String, String>>, query: Query<HashMap<String, String>>,
headers: axum::http::HeaderMap,
request_id: Option<Extension<Uuid>>| request_id: Option<Extension<Uuid>>|
async move { async move {
websocket_handler(ws, query, State(ws_state_clone), request_id).await websocket_handler(ws, query, headers, State(ws_state_clone), request_id).await
} }
}), }),
) )

View file

@ -5,6 +5,7 @@
use axum::extract::ws::{Message, WebSocket}; use axum::extract::ws::{Message, WebSocket};
use axum::extract::{Query, State, WebSocketUpgrade}; use axum::extract::{Query, State, WebSocketUpgrade};
use axum::http::HeaderMap;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use futures_util::StreamExt; use futures_util::StreamExt;
@ -45,6 +46,27 @@ pub struct WebSocketState {
pub rate_limiter: Arc<crate::security::RateLimiter>, pub rate_limiter: Arc<crate::security::RateLimiter>,
} }
/// Extract access token from query param (?token=) or Cookie header (access_token)
fn extract_token(params: &HashMap<String, String>, headers: &HeaderMap) -> Option<String> {
if let Some(t) = params.get("token") {
return Some(t.clone());
}
if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) {
if let Ok(cookie_str) = cookie_header.to_str() {
for part in cookie_str.split(';') {
let part = part.trim();
if part.starts_with("access_token=") {
let value = part.trim_start_matches("access_token=").trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
}
}
None
}
/// Handler principal pour les connexions WebSocket /// Handler principal pour les connexions WebSocket
/// ///
/// Cette fonction gère la mise à niveau de la connexion HTTP vers WebSocket /// Cette fonction gère la mise à niveau de la connexion HTTP vers WebSocket
@ -53,8 +75,8 @@ pub struct WebSocketState {
pub async fn websocket_handler( pub async fn websocket_handler(
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
State(state): State<WebSocketState>, State(state): State<WebSocketState>,
// FIX #13: Extraire le request_id depuis les extensions (ajouté par request_id_middleware)
request_id: Option<axum::extract::Extension<Uuid>>, request_id: Option<axum::extract::Extension<Uuid>>,
) -> Response { ) -> Response {
// FIX #13: Extraire le request_id pour la corrélation // FIX #13: Extraire le request_id pour la corrélation
@ -69,16 +91,15 @@ pub async fn websocket_handler(
async move { async move {
info!(request_id = %request_id, "🔌 Nouvelle connexion WebSocket demandée"); info!(request_id = %request_id, "🔌 Nouvelle connexion WebSocket demandée");
let token = match params.get("token") { let token = match extract_token(&params, &headers) {
Some(t) => t, Some(t) => t,
None => { None => {
error!(request_id = %request_id, "❌ Token manquant"); error!(request_id = %request_id, "❌ Token manquant (query ?token= ou cookie access_token)");
return (StatusCode::UNAUTHORIZED, "Missing token").into_response(); return (StatusCode::UNAUTHORIZED, "Missing token").into_response();
} }
}; };
// Validate token match state.jwt_manager.validate_access_token(&token).await {
match state.jwt_manager.validate_access_token(token).await {
Ok(claims) => { Ok(claims) => {
info!( info!(
request_id = %request_id, request_id = %request_id,