fix(security): implement JWT auth on stream-server WebSocket

- Validate JWT token via AuthManager before accepting WebSocket connections
- Extract user_id from validated token claims instead of trusting query params
- Reject unauthenticated connections with 401 Unauthorized
- Add `authenticated` field to WebSocketConnection struct
- Update websocket_handler_wrapper to handle auth error responses

Previously, the WebSocket handler accepted all connections without
validating the token (comment: "pour l'instant, on accepte la connexion").
Now requires a valid JWT token via ?token= query param or Authorization header.

Addresses audit finding: A01 (Broken Access Control) — CRITICAL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-11 22:41:35 +01:00
parent af4893e684
commit db47f203f6
2 changed files with 53 additions and 23 deletions

View file

@ -310,20 +310,17 @@ async fn stream_audio(
})
}
// Handler WebSocket wrapper pour utiliser avec AppState
// Handler WebSocket wrapper — delegates to websocket_handler with full AppState for JWT auth
async fn websocket_handler_wrapper(
ws: axum::extract::ws::WebSocketUpgrade,
query: Query<WebSocketQuery>,
headers: HeaderMap,
State(state): State<AppState>,
) -> Response {
websocket_handler(
ws,
query,
headers,
State(state.websocket_manager.clone()),
)
.await
match websocket_handler(ws, query, headers, State(state)).await {
Ok(response) => response,
Err((status, json)) => (status, json).into_response(),
}
}
// Handler pour la master playlist HLS

View file

@ -3,9 +3,11 @@ use axum::{
ws::{Message, WebSocket, WebSocketUpgrade},
Query, State,
},
http::HeaderMap,
response::Response,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use crate::AppState;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
@ -214,6 +216,7 @@ pub enum WebSocketCommand {
pub struct WebSocketConnection {
pub id: Uuid,
pub user_id: Option<String>,
pub authenticated: bool,
pub ip_address: String,
pub connected_at: SystemTime,
pub last_activity: SystemTime,
@ -275,6 +278,7 @@ impl WebSocketManager {
let connection = WebSocketConnection {
id: connection_id,
user_id: user_id.clone(),
authenticated: user_id.is_some(),
ip_address: ip_address.clone(),
connected_at: SystemTime::now(),
last_activity: SystemTime::now(),
@ -788,13 +792,16 @@ pub struct WebSocketQuery {
}
/// Handler pour les connexions WebSocket avec authentification JWT
///
/// Requires a valid JWT token either via `?token=` query param or
/// `Authorization: Bearer <token>` header. Rejects unauthenticated connections.
pub async fn websocket_handler(
ws: WebSocketUpgrade,
Query(params): Query<WebSocketQuery>,
headers: HeaderMap,
State(ws_manager): State<Arc<WebSocketManager>>,
) -> Response {
// Extraire le token JWT depuis les query params ou headers
State(state): State<AppState>,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
// Extract JWT token from query params or Authorization header
let token = params.token.or_else(|| {
headers
.get("authorization")
@ -803,7 +810,7 @@ pub async fn websocket_handler(
.map(|s| s.to_string())
});
// Extraire l'adresse IP réelle
// Extract real IP address
let ip_address = headers
.get("x-forwarded-for")
.or_else(|| headers.get("x-real-ip"))
@ -811,21 +818,47 @@ pub async fn websocket_handler(
.unwrap_or("127.0.0.1")
.to_string();
// Si un token est fourni, on le valide (pour l'instant, on accepte la connexion)
// En production, on validerait le token avec AuthManager
let user_id = params.user_id.or_else(|| {
// Si un token est fourni, on pourrait extraire user_id du token
// Pour l'instant, on utilise le user_id fourni dans les params
None
});
// Require a token — reject unauthenticated connections
let token = token.ok_or_else(|| {
tracing::warn!("WebSocket connection rejected: no token provided from {}", ip_address);
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "Authentication token required"})),
)
})?;
// Validate the JWT token via AuthManager
let validation_result = state.auth_manager.validate_token(&token).await;
if !validation_result.valid {
let reason = validation_result.error.unwrap_or_else(|| "Invalid token".to_string());
tracing::warn!("WebSocket auth failed from {}: {}", ip_address, reason);
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": reason})),
));
}
// Extract user_id from validated token claims (not from query params)
let claims = validation_result.claims.ok_or_else(|| {
tracing::error!("Token valid but claims missing — this should not happen");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Internal authentication error"})),
)
})?;
let user_id = Some(claims.sub.clone());
tracing::info!(
"Nouvelle connexion WebSocket demandée pour utilisateur: {:?} depuis {}",
"WebSocket connection authenticated for user: {:?} from {}",
user_id,
ip_address
);
ws_manager.handle_websocket(ws, user_id, ip_address).await
Ok(state
.websocket_manager
.handle_websocket(ws, user_id, ip_address)
.await)
}
#[cfg(test)]