diff --git a/Cargo.toml b/Cargo.toml index 69bbc23..5cbd52f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,4 +20,5 @@ env_logger = "0.11" anyhow = "1.0" toml = "0.8" warp = "0.3" -tokio-stream = "0.1" \ No newline at end of file +tokio-stream = "0.1" +rand = "0.8" \ No newline at end of file diff --git a/src/models.rs b/src/models.rs index 325eaa5..9508bd2 100644 --- a/src/models.rs +++ b/src/models.rs @@ -114,9 +114,9 @@ pub struct CurrentTrack { pub currently_playing_type: String, pub actions: Actions, pub is_playing: bool, - pub device: Device, - pub repeat_state: String, - pub shuffle_state: bool, + pub device: Option, + pub repeat_state: Option, + pub shuffle_state: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/server.rs b/src/server.rs index 06bb783..36ed4ff 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,14 +1,18 @@ use crate::{ConfigManager, Result, SpotifyClient, SpotifyError}; use serde_json::json; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::convert::Infallible; use std::sync::Arc; +use tokio::sync::RwLock; use warp::Filter; pub struct SpotifyServer { client: Arc, config_manager: Arc, port: u16, + // Track used OAuth codes and active states to prevent reuse + used_codes: Arc>>, + active_states: Arc>>, } impl SpotifyServer { @@ -17,6 +21,8 @@ impl SpotifyServer { client: Arc::new(client), config_manager: Arc::new(config_manager), port, + used_codes: Arc::new(RwLock::new(HashSet::new())), + active_states: Arc::new(RwLock::new(HashMap::new())), } } @@ -25,6 +31,8 @@ impl SpotifyServer { let client = self.client.clone(); let config_manager = self.config_manager.clone(); + let used_codes = self.used_codes.clone(); + let active_states = self.active_states.clone(); // Current track endpoint - mimics your existing API let current_track = { @@ -75,28 +83,62 @@ impl SpotifyServer { // OAuth authorization start endpoint let auth_start = { let client = client.clone(); + let active_states = active_states.clone(); warp::path("auth") .and(warp::get()) - .map(move || client.clone()) - .map(|client: Arc| { - let auth_url = client.get_authorization_url(Some("spotify-tracker")); - warp::reply::html(get_auth_page(&auth_url)) + .map(move || (client.clone(), active_states.clone())) + .and_then(|(client, active_states): (Arc, Arc>>)| async move { + // Generate unique state parameter + let state = generate_state(); + + // Store state with timestamp for validation + { + let mut states = active_states.write().await; + states.insert(state.clone(), std::time::SystemTime::now()); + + // Clean up old states (older than 10 minutes) + let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(600); + states.retain(|_, timestamp| *timestamp > cutoff); + } + + let auth_url = client.get_authorization_url(Some(&state)); + Ok::<_, Infallible>(warp::reply::html(get_auth_page(&auth_url))) }) }; - // OAuth callback endpoint + // OAuth callback endpoint - only match when we have OAuth parameters let oauth_callback = { let client = client.clone(); let config_manager = config_manager.clone(); + let used_codes = used_codes.clone(); + let active_states = active_states.clone(); warp::path::end() .and(warp::get()) .and(warp::query::>()) - .map(move |params: HashMap| (client.clone(), config_manager.clone(), params)) - .and_then(|(client, config_manager, params): (Arc, Arc, HashMap)| async move { - handle_oauth_callback(client, config_manager, params).await + .and_then(move |params: HashMap| { + let client = client.clone(); + let config_manager = config_manager.clone(); + let used_codes = used_codes.clone(); + let active_states = active_states.clone(); + async move { + // Only handle this as OAuth callback if we have 'code' or 'error' parameters + if params.contains_key("code") || params.contains_key("error") { + handle_oauth_callback_secure(client, config_manager, used_codes, active_states, params).await.map_err(|_| warp::reject()) + } else { + // This is just a regular root request, reject and let it fall through to other routes + Err(warp::reject()) + } + } }) }; + // Home page showing auth status or links + let home_page = warp::path::end() + .and(warp::get()) + .map(|| { + warp::reply::html(get_home_page()) + }); + // Health check endpoint let health = warp::path("health") .and(warp::get()) @@ -118,6 +160,7 @@ impl SpotifyServer { .or(phantombot) .or(auth_start) .or(oauth_callback) + .or(home_page) .or(health) .with(cors) .with(warp::log("spotify_tracker")); @@ -132,7 +175,7 @@ impl SpotifyServer { println!("Press Ctrl+C to stop"); warp::serve(routes) - .run(([127, 0, 0, 1], self.port)) + .run(([0, 0, 0, 0], self.port)) .await; Ok(()) @@ -173,12 +216,55 @@ async fn get_current_track_text(client: Arc) -> Result { } } -async fn handle_oauth_callback( +fn generate_state() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + let random: u64 = rand::random(); + format!("st_{}_{}_{}", timestamp, random, rand::random::()) +} + +async fn handle_oauth_callback_secure( client: Arc, config_manager: Arc, + used_codes: Arc>>, + active_states: Arc>>, params: HashMap, ) -> std::result::Result { + // Validate state parameter first + if let Some(state) = params.get("state") { + let mut states = active_states.write().await; + if states.remove(state).is_none() { + log::error!("Invalid or reused state parameter: {}", state); + return Ok(warp::reply::html(get_error_page("Invalid or expired authentication session. Please try again."))); + } + } else if params.contains_key("code") { + log::error!("Missing state parameter in OAuth callback"); + return Ok(warp::reply::html(get_error_page("Invalid authentication request. Missing security parameter."))); + } + if let Some(code) = params.get("code") { + // Check if code was already used + { + let mut codes = used_codes.write().await; + if codes.contains(code) { + log::error!("Authorization code reuse attempt detected: {}", code); + return Ok(warp::reply::html(get_error_page("Authorization code has already been used. Please start the authentication process again."))); + } + // Mark code as used immediately + codes.insert(code.clone()); + + // Clean up old codes (keep last 100) + if codes.len() > 100 { + let mut codes_vec: Vec = codes.drain().collect(); + codes_vec.sort(); + codes_vec.truncate(50); // Keep only the first 50 + codes.extend(codes_vec); + } + } + // Exchange code for token match client.exchange_code(code).await { Ok(token_info) => { @@ -186,15 +272,20 @@ async fn handle_oauth_callback( match config_manager.save_token(&token_info) { Ok(()) => { log::info!("OAuth authentication successful"); - Ok(warp::reply::html(get_success_page())) + // Return a success page that redirects to home after 3 seconds + Ok(warp::reply::html(get_success_page_with_redirect())) } Err(e) => { + // Remove the code from used set since token save failed + used_codes.write().await.remove(code); log::error!("Failed to save token: {}", e); Ok(warp::reply::html(get_error_page(&format!("Failed to save token: {}", e)))) } } } Err(e) => { + // Remove the code from used set since exchange failed + used_codes.write().await.remove(code); log::error!("OAuth token exchange failed: {}", e); Ok(warp::reply::html(get_error_page(&format!("Authentication failed: {}", e)))) } @@ -209,6 +300,7 @@ async fn handle_oauth_callback( } } + fn get_auth_page(auth_url: &str) -> String { format!( r#" @@ -240,22 +332,41 @@ fn get_auth_page(auth_url: &str) -> String { ) } -fn get_success_page() -> String { + +fn get_success_page_with_redirect() -> String { r#" Spotify Tracker - Success + +
@@ -270,7 +381,8 @@ fn get_success_page() -> String {
GET https://spotify.tougie.live/health - Health check
-

You can now use the Spotify Tracker API to get your current playing track!

+

Redirecting to home page in 3 seconds...

+

This prevents refresh issues. Click here if not redirected.

"#.to_string() @@ -308,6 +420,44 @@ fn get_error_page(error: &str) -> String { ) } +fn get_home_page() -> String { + r#" + + + + + Spotify Tracker + + + +
+

🎵 Spotify Tracker

+

Track your currently playing Spotify music with a simple API.

+ + Authenticate with Spotify + +
+

API Endpoints:

+
GET /current - Current track (JSON)
+
GET /phantombot - Current track (text)
+
GET /health - Health check
+
+ +

You need to authenticate with Spotify first to use the API endpoints.

+
+ +"#.to_string() +} + #[cfg(test)] mod tests { use super::*;