Security: Require authentication for all WebSocket and API endpoints
- REST API: All session/code/machine endpoints now require AuthenticatedUser - Viewer WebSocket: Requires JWT token in query params (token=...) - Agent WebSocket: Requires either valid support code OR API key - Dashboard: Passes JWT token when connecting to viewer WS - Native viewer: Passes token in protocol URL and WebSocket connection - Added AGENT_API_KEY env var support for persistent agents - Added get_status() to SupportCodeManager for auth validation This fixes the security vulnerability where unauthenticated agents could connect and appear in the dashboard without any credentials. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -66,12 +66,14 @@ impl MessageReceiver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to the GuruConnect server
|
/// Connect to the GuruConnect server
|
||||||
pub async fn connect(url: &str, api_key: &str) -> Result<(WsSender, MessageReceiver)> {
|
pub async fn connect(url: &str, token: &str) -> Result<(WsSender, MessageReceiver)> {
|
||||||
// Add API key to URL
|
// Add auth token to URL
|
||||||
let full_url = if url.contains('?') {
|
let full_url = if token.is_empty() {
|
||||||
format!("{}&api_key={}", url, api_key)
|
url.to_string()
|
||||||
|
} else if url.contains('?') {
|
||||||
|
format!("{}&token={}", url, urlencoding::encode(token))
|
||||||
} else {
|
} else {
|
||||||
format!("{}?api_key={}", url, api_key)
|
format!("{}?token={}", url, urlencoding::encode(token))
|
||||||
};
|
};
|
||||||
|
|
||||||
debug!("Connecting to {}", full_url);
|
debug!("Connecting to {}", full_url);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ use tracing_subscriber::FmtSubscriber;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use support_codes::{SupportCodeManager, CreateCodeRequest, SupportCode, CodeValidation};
|
use support_codes::{SupportCodeManager, CreateCodeRequest, SupportCode, CodeValidation};
|
||||||
use auth::{JwtConfig, hash_password, generate_random_password};
|
use auth::{JwtConfig, hash_password, generate_random_password, AuthenticatedUser};
|
||||||
|
|
||||||
/// Application state
|
/// Application state
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -43,6 +43,8 @@ pub struct AppState {
|
|||||||
support_codes: SupportCodeManager,
|
support_codes: SupportCodeManager,
|
||||||
db: Option<db::Database>,
|
db: Option<db::Database>,
|
||||||
pub jwt_config: Arc<JwtConfig>,
|
pub jwt_config: Arc<JwtConfig>,
|
||||||
|
/// Optional API key for persistent agents (env: AGENT_API_KEY)
|
||||||
|
pub agent_api_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Middleware to inject JWT config into request extensions
|
/// Middleware to inject JWT config into request extensions
|
||||||
@@ -163,12 +165,21 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agent API key for persistent agents (optional)
|
||||||
|
let agent_api_key = std::env::var("AGENT_API_KEY").ok();
|
||||||
|
if agent_api_key.is_some() {
|
||||||
|
info!("AGENT_API_KEY configured for persistent agents");
|
||||||
|
} else {
|
||||||
|
info!("No AGENT_API_KEY set - persistent agents will need JWT token or support code");
|
||||||
|
}
|
||||||
|
|
||||||
// Create application state
|
// Create application state
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
sessions,
|
sessions,
|
||||||
support_codes: SupportCodeManager::new(),
|
support_codes: SupportCodeManager::new(),
|
||||||
db: database,
|
db: database,
|
||||||
jwt_config,
|
jwt_config,
|
||||||
|
agent_api_key,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build router
|
// Build router
|
||||||
@@ -252,6 +263,7 @@ async fn health() -> &'static str {
|
|||||||
// Support code API handlers
|
// Support code API handlers
|
||||||
|
|
||||||
async fn create_code(
|
async fn create_code(
|
||||||
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(request): Json<CreateCodeRequest>,
|
Json(request): Json<CreateCodeRequest>,
|
||||||
) -> Json<SupportCode> {
|
) -> Json<SupportCode> {
|
||||||
@@ -261,6 +273,7 @@ async fn create_code(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn list_codes(
|
async fn list_codes(
|
||||||
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Json<Vec<SupportCode>> {
|
) -> Json<Vec<SupportCode>> {
|
||||||
Json(state.support_codes.list_active_codes().await)
|
Json(state.support_codes.list_active_codes().await)
|
||||||
@@ -279,6 +292,7 @@ async fn validate_code(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn cancel_code(
|
async fn cancel_code(
|
||||||
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(code): Path<String>,
|
Path(code): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
@@ -292,6 +306,7 @@ async fn cancel_code(
|
|||||||
// Session API handlers (updated to use AppState)
|
// Session API handlers (updated to use AppState)
|
||||||
|
|
||||||
async fn list_sessions(
|
async fn list_sessions(
|
||||||
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Json<Vec<api::SessionInfo>> {
|
) -> Json<Vec<api::SessionInfo>> {
|
||||||
let sessions = state.sessions.list_sessions().await;
|
let sessions = state.sessions.list_sessions().await;
|
||||||
@@ -299,6 +314,7 @@ async fn list_sessions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_session(
|
async fn get_session(
|
||||||
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<api::SessionInfo>, (StatusCode, &'static str)> {
|
) -> Result<Json<api::SessionInfo>, (StatusCode, &'static str)> {
|
||||||
@@ -312,6 +328,7 @@ async fn get_session(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn disconnect_session(
|
async fn disconnect_session(
|
||||||
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
@@ -331,6 +348,7 @@ async fn disconnect_session(
|
|||||||
// Machine API handlers
|
// Machine API handlers
|
||||||
|
|
||||||
async fn list_machines(
|
async fn list_machines(
|
||||||
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<Vec<api::MachineInfo>>, (StatusCode, &'static str)> {
|
) -> Result<Json<Vec<api::MachineInfo>>, (StatusCode, &'static str)> {
|
||||||
let db = state.db.as_ref()
|
let db = state.db.as_ref()
|
||||||
@@ -343,6 +361,7 @@ async fn list_machines(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_machine(
|
async fn get_machine(
|
||||||
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(agent_id): Path<String>,
|
Path(agent_id): Path<String>,
|
||||||
) -> Result<Json<api::MachineInfo>, (StatusCode, &'static str)> {
|
) -> Result<Json<api::MachineInfo>, (StatusCode, &'static str)> {
|
||||||
@@ -357,6 +376,7 @@ async fn get_machine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_machine_history(
|
async fn get_machine_history(
|
||||||
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(agent_id): Path<String>,
|
Path(agent_id): Path<String>,
|
||||||
) -> Result<Json<api::MachineHistory>, (StatusCode, &'static str)> {
|
) -> Result<Json<api::MachineHistory>, (StatusCode, &'static str)> {
|
||||||
@@ -387,6 +407,7 @@ async fn get_machine_history(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_machine(
|
async fn delete_machine(
|
||||||
|
_user: AuthenticatedUser, // Require authentication
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(agent_id): Path<String>,
|
Path(agent_id): Path<String>,
|
||||||
Query(params): Query<api::DeleteMachineParams>,
|
Query(params): Query<api::DeleteMachineParams>,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use axum::{
|
|||||||
Query, State,
|
Query, State,
|
||||||
},
|
},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use prost::Message as ProstMessage;
|
use prost::Message as ProstMessage;
|
||||||
@@ -30,6 +31,9 @@ pub struct AgentParams {
|
|||||||
support_code: Option<String>,
|
support_code: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
hostname: Option<String>,
|
hostname: Option<String>,
|
||||||
|
/// API key for persistent (managed) agents
|
||||||
|
#[serde(default)]
|
||||||
|
api_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -37,6 +41,9 @@ pub struct ViewerParams {
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
#[serde(default = "default_viewer_name")]
|
#[serde(default = "default_viewer_name")]
|
||||||
viewer_name: String,
|
viewer_name: String,
|
||||||
|
/// JWT token for authentication (required)
|
||||||
|
#[serde(default)]
|
||||||
|
token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_viewer_name() -> String {
|
fn default_viewer_name() -> String {
|
||||||
@@ -48,15 +55,71 @@ pub async fn agent_ws_handler(
|
|||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<AgentParams>,
|
Query(params): Query<AgentParams>,
|
||||||
) -> impl IntoResponse {
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
let agent_id = params.agent_id;
|
let agent_id = params.agent_id.clone();
|
||||||
let agent_name = params.hostname.or(params.agent_name).unwrap_or_else(|| agent_id.clone());
|
let agent_name = params.hostname.clone().or(params.agent_name.clone()).unwrap_or_else(|| agent_id.clone());
|
||||||
let support_code = params.support_code;
|
let support_code = params.support_code.clone();
|
||||||
|
let api_key = params.api_key.clone();
|
||||||
|
|
||||||
|
// SECURITY: Agent must provide either a support code OR an API key
|
||||||
|
// Support code = ad-hoc support session (technician generated code)
|
||||||
|
// API key = persistent managed agent
|
||||||
|
|
||||||
|
if support_code.is_none() && api_key.is_none() {
|
||||||
|
warn!("Agent connection rejected: {} - no support code or API key", agent_id);
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate support code if provided
|
||||||
|
if let Some(ref code) = support_code {
|
||||||
|
// Check if it's a valid, pending support code
|
||||||
|
let code_info = state.support_codes.get_status(code).await;
|
||||||
|
if code_info.is_none() {
|
||||||
|
warn!("Agent connection rejected: {} - invalid support code {}", agent_id, code);
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
let status = code_info.unwrap();
|
||||||
|
if status != "pending" && status != "connected" {
|
||||||
|
warn!("Agent connection rejected: {} - support code {} has status {}", agent_id, code, status);
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
info!("Agent {} authenticated via support code {}", agent_id, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate API key if provided (for persistent agents)
|
||||||
|
if let Some(ref key) = api_key {
|
||||||
|
// For now, we'll accept API keys that match the JWT secret or a configured agent key
|
||||||
|
// In production, this should validate against a database of registered agents
|
||||||
|
if !validate_agent_api_key(&state, key).await {
|
||||||
|
warn!("Agent connection rejected: {} - invalid API key", agent_id);
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
info!("Agent {} authenticated via API key", agent_id);
|
||||||
|
}
|
||||||
|
|
||||||
let sessions = state.sessions.clone();
|
let sessions = state.sessions.clone();
|
||||||
let support_codes = state.support_codes.clone();
|
let support_codes = state.support_codes.clone();
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
|
|
||||||
ws.on_upgrade(move |socket| handle_agent_connection(socket, sessions, support_codes, db, agent_id, agent_name, support_code))
|
Ok(ws.on_upgrade(move |socket| handle_agent_connection(socket, sessions, support_codes, db, agent_id, agent_name, support_code)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an agent API key
|
||||||
|
async fn validate_agent_api_key(state: &AppState, api_key: &str) -> bool {
|
||||||
|
// Check if API key is a valid JWT (allows using dashboard token for testing)
|
||||||
|
if state.jwt_config.validate_token(api_key).is_ok() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against configured agent API key if set
|
||||||
|
if let Some(ref configured_key) = state.agent_api_key {
|
||||||
|
if api_key == configured_key {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In future: validate against database of registered agents
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WebSocket handler for viewer connections
|
/// WebSocket handler for viewer connections
|
||||||
@@ -64,13 +127,27 @@ pub async fn viewer_ws_handler(
|
|||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<ViewerParams>,
|
Query(params): Query<ViewerParams>,
|
||||||
) -> impl IntoResponse {
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
|
// Require JWT token for viewers
|
||||||
|
let token = params.token.ok_or_else(|| {
|
||||||
|
warn!("Viewer connection rejected: missing token");
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Validate the token
|
||||||
|
let claims = state.jwt_config.validate_token(&token).map_err(|e| {
|
||||||
|
warn!("Viewer connection rejected: invalid token: {}", e);
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!("Viewer {} authenticated via JWT", claims.username);
|
||||||
|
|
||||||
let session_id = params.session_id;
|
let session_id = params.session_id;
|
||||||
let viewer_name = params.viewer_name;
|
let viewer_name = params.viewer_name;
|
||||||
let sessions = state.sessions.clone();
|
let sessions = state.sessions.clone();
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
|
|
||||||
ws.on_upgrade(move |socket| handle_viewer_connection(socket, sessions, db, session_id, viewer_name))
|
Ok(ws.on_upgrade(move |socket| handle_viewer_connection(socket, sessions, db, session_id, viewer_name)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle an agent WebSocket connection
|
/// Handle an agent WebSocket connection
|
||||||
|
|||||||
@@ -219,10 +219,21 @@ impl SupportCodeManager {
|
|||||||
pub async fn get_by_session(&self, session_id: Uuid) -> Option<SupportCode> {
|
pub async fn get_by_session(&self, session_id: Uuid) -> Option<SupportCode> {
|
||||||
let session_to_code = self.session_to_code.read().await;
|
let session_to_code = self.session_to_code.read().await;
|
||||||
let code = session_to_code.get(&session_id)?;
|
let code = session_to_code.get(&session_id)?;
|
||||||
|
|
||||||
let codes = self.codes.read().await;
|
let codes = self.codes.read().await;
|
||||||
codes.get(code).cloned()
|
codes.get(code).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the status of a code as a string (for auth checks)
|
||||||
|
pub async fn get_status(&self, code: &str) -> Option<String> {
|
||||||
|
let codes = self.codes.read().await;
|
||||||
|
codes.get(code).map(|c| match c.status {
|
||||||
|
CodeStatus::Pending => "pending".to_string(),
|
||||||
|
CodeStatus::Connected => "connected".to_string(),
|
||||||
|
CodeStatus::Completed => "completed".to_string(),
|
||||||
|
CodeStatus::Cancelled => "cancelled".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SupportCodeManager {
|
impl Default for SupportCodeManager {
|
||||||
|
|||||||
@@ -971,7 +971,8 @@
|
|||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const serverUrl = encodeURIComponent(protocol + "//" + window.location.host + "/ws/viewer");
|
const serverUrl = encodeURIComponent(protocol + "//" + window.location.host + "/ws/viewer");
|
||||||
const protocolUrl = `guruconnect://view/${connectSessionId}?server=${serverUrl}`;
|
const token = localStorage.getItem("authToken");
|
||||||
|
const protocolUrl = `guruconnect://view/${connectSessionId}?server=${serverUrl}&token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
// Try to launch the protocol handler
|
// Try to launch the protocol handler
|
||||||
// We use a hidden iframe to avoid navigation issues
|
// We use a hidden iframe to avoid navigation issues
|
||||||
@@ -1109,7 +1110,8 @@
|
|||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const viewerName = user?.name || user?.email || "Technician";
|
const viewerName = user?.name || user?.email || "Technician";
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}&viewer_name=${encodeURIComponent(viewerName)}`;
|
const token = localStorage.getItem("authToken");
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}&viewer_name=${encodeURIComponent(viewerName)}&token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
console.log("Connecting chat to:", wsUrl);
|
console.log("Connecting chat to:", wsUrl);
|
||||||
chatSocket = new WebSocket(wsUrl);
|
chatSocket = new WebSocket(wsUrl);
|
||||||
|
|||||||
@@ -597,7 +597,13 @@
|
|||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}&viewer_name=${encodeURIComponent(viewerName)}`;
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (!token) {
|
||||||
|
updateStatus('error', 'Not authenticated');
|
||||||
|
document.getElementById('overlay-text').textContent = 'Not logged in. Please log in first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}&viewer_name=${encodeURIComponent(viewerName)}&token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
console.log('Connecting to:', wsUrl);
|
console.log('Connecting to:', wsUrl);
|
||||||
updateStatus('connecting', 'Connecting...');
|
updateStatus('connecting', 'Connecting...');
|
||||||
|
|||||||
Reference in New Issue
Block a user