diff --git a/crates/pumpkin-config/src/networking/auth.rs b/crates/pumpkin-config/src/networking/auth.rs index 821b25ab4..515c75c07 100644 --- a/crates/pumpkin-config/src/networking/auth.rs +++ b/crates/pumpkin-config/src/networking/auth.rs @@ -11,6 +11,19 @@ pub struct AuthenticationConfig { pub enabled: bool, /// Optional custom authentication URL. pub url: Option, + /// Fallback authentication server URLs to use if the primary/official server is down. + #[serde(alias = "fallback_urls")] + pub fallbacks: Vec, + /// Optional custom profile lookup by username URL (template parameter `{username}`). + pub profile_by_name_url: Option, + /// Optional fallback profile lookup by username URLs. + #[serde(alias = "profile_by_name_fallback_urls")] + pub profile_by_name_fallbacks: Vec, + /// Optional custom profile lookup by UUID URL (template parameter `{uuid}`). + pub profile_by_uuid_url: Option, + /// Optional fallback profile lookup by UUID URLs. + #[serde(alias = "profile_by_uuid_fallback_urls")] + pub profile_by_uuid_fallbacks: Vec, /// Connection timeout in milliseconds. pub connect_timeout: u32, /// Read timeout in milliseconds. @@ -35,6 +48,11 @@ impl Default for AuthenticationConfig { player_profile: PlayerProfileConfig::default(), textures: TextureConfig::default(), url: None, + fallbacks: Vec::new(), + profile_by_name_url: None, + profile_by_name_fallbacks: Vec::new(), + profile_by_uuid_url: None, + profile_by_uuid_fallbacks: Vec::new(), prevent_proxy_connection_auth_url: None, services_url: None, connect_timeout: 5000, @@ -115,3 +133,75 @@ impl Default for TextureTypes { } } } + +#[cfg(test)] +mod tests { + use super::AuthenticationConfig; + + #[test] + fn auth_config_fallbacks_toml_deserialization() { + let toml_str = r#" +enabled = true +url = "https://primary.auth/hasJoined?username={username}&serverId={server_hash}" +fallbacks = [ + "https://fallback1.auth/hasJoined?username={username}&serverId={server_hash}", + "https://fallback2.auth/hasJoined?username={username}&serverId={server_hash}" +] +"#; + let config: AuthenticationConfig = + toml::from_str(toml_str).expect("config should deserialize"); + assert_eq!( + config.url.as_deref(), + Some("https://primary.auth/hasJoined?username={username}&serverId={server_hash}") + ); + assert_eq!(config.fallbacks.len(), 2); + assert_eq!( + config.fallbacks[0], + "https://fallback1.auth/hasJoined?username={username}&serverId={server_hash}" + ); + } + + #[test] + fn auth_config_fallback_urls_alias_toml_deserialization() { + let toml_str = r#" +enabled = true +fallback_urls = [ + "https://fallback1.auth/hasJoined?username={username}&serverId={server_hash}" +] +"#; + let config: AuthenticationConfig = + toml::from_str(toml_str).expect("config with alias should deserialize"); + assert_eq!(config.fallbacks.len(), 1); + assert_eq!( + config.fallbacks[0], + "https://fallback1.auth/hasJoined?username={username}&serverId={server_hash}" + ); + } + + #[test] + fn auth_config_profile_urls_toml_deserialization() { + let toml_str = r#" +enabled = true +profile_by_name_url = "https://custom.auth/users/profiles/minecraft/{username}" +profile_by_name_fallback_urls = [ + "https://fallback.auth/users/profiles/minecraft/{username}" +] +profile_by_uuid_url = "https://custom.auth/session/minecraft/profile/{uuid}?unsigned=false" +profile_by_uuid_fallback_urls = [ + "https://fallback.auth/session/minecraft/profile/{uuid}?unsigned=false" +] +"#; + let config: AuthenticationConfig = + toml::from_str(toml_str).expect("profile config should deserialize"); + assert_eq!( + config.profile_by_name_url.as_deref(), + Some("https://custom.auth/users/profiles/minecraft/{username}") + ); + assert_eq!(config.profile_by_name_fallbacks.len(), 1); + assert_eq!( + config.profile_by_uuid_url.as_deref(), + Some("https://custom.auth/session/minecraft/profile/{uuid}?unsigned=false") + ); + assert_eq!(config.profile_by_uuid_fallbacks.len(), 1); + } +} diff --git a/crates/pumpkin/src/net/authentication.rs b/crates/pumpkin/src/net/authentication.rs index c0bf812b1..d8b65b21b 100644 --- a/crates/pumpkin/src/net/authentication.rs +++ b/crates/pumpkin/src/net/authentication.rs @@ -51,18 +51,40 @@ const MOJANG_PREVENT_PROXY_AUTHENTICATION_URL: &str = "https://sessionserver.moj const MOJANG_SERVICES_URL: &str = "https://api.minecraftservices.com/"; const MOJANG_PROFILE_BY_NAME_URL: &str = "https://api.mojang.com/users/profiles/minecraft/{username}"; +const MOJANG_PROFILE_BY_UUID_URL: &str = + "https://sessionserver.mojang.com/session/minecraft/profile/{uuid}?unsigned=false"; -/// Sends a GET request to Mojang's authentication servers to verify a client's Minecraft account. +fn create_agent(auth_config: &AuthenticationConfig) -> ureq::Agent { + let config = ureq::Agent::config_builder() + .timeout_connect(Some(std::time::Duration::from_millis( + auth_config.connect_timeout as u64, + ))) + .timeout_recv_response(Some(std::time::Duration::from_millis( + auth_config.read_timeout as u64, + ))) + .build(); + config.into() +} + +fn format_auth_url(url_template: &str, username: &str, server_hash: &str, ip: &IpAddr) -> String { + url_template + .replace("{username}", username) + .replace("{server_hash}", server_hash) + .replace("{ip}", &ip.to_string()) +} + +/// Sends a GET request to Mojang's (or custom/fallback) authentication servers to verify a client's Minecraft account. /// /// **Purpose:** /// -/// This function is used to ensure that a client connecting to the server has a valid, premium Minecraft account. It's a crucial step in preventing unauthorized access and maintaining server security. +/// This function is used to ensure that a client connecting to the server has a valid, premium Minecraft account. +/// If the primary authentication server is down or unreachable, it falls back to trying configured fallback servers. /// /// **How it Works:** /// -/// 1. A client with a premium account sends a login request to the Mojang session server. -/// 2. Mojang's servers verify the client's credentials and add the player to the their Servers -/// 3. Now our server will send a Request to the Session servers and check if the Player has joined the Session Server . +/// 1. A client with a premium account sends a login request to the session server. +/// 2. Session servers verify the client's credentials and add the player to their server session list. +/// 3. Pumpkin attempts to authenticate the player against the primary auth server, falling back to secondary auth servers if the primary is down. /// /// See pub fn authenticate( @@ -71,40 +93,71 @@ pub fn authenticate( ip: &IpAddr, auth_config: &AuthenticationConfig, ) -> Result { - let address = if auth_config.prevent_proxy_connections { - let auth_url = auth_config + let primary_url = if auth_config.prevent_proxy_connections { + auth_config .prevent_proxy_connection_auth_url .as_deref() - .unwrap_or(MOJANG_PREVENT_PROXY_AUTHENTICATION_URL); - - auth_url - .replace("{username}", username) - .replace("{server_hash}", server_hash) - .replace("{ip}", &ip.to_string()) + .unwrap_or(MOJANG_PREVENT_PROXY_AUTHENTICATION_URL) } else { - let auth_url = auth_config + auth_config .url .as_deref() - .unwrap_or(MOJANG_AUTHENTICATION_URL); - - auth_url - .replace("{username}", username) - .replace("{server_hash}", server_hash) + .unwrap_or(MOJANG_AUTHENTICATION_URL) }; - let mut response = ureq::get(address) - .call() - .map_err(|_| AuthError::FailedResponse)?; - match response.status() { - StatusCode::OK => {} - StatusCode::NO_CONTENT => Err(AuthError::UnverifiedUsername)?, - other => Err(AuthError::UnknownStatusCode(other))?, + let mut candidate_urls = Vec::with_capacity(1 + auth_config.fallbacks.len()); + candidate_urls.push(primary_url); + for fallback in &auth_config.fallbacks { + candidate_urls.push(fallback.as_str()); + } + + let agent = create_agent(auth_config); + + let mut unverified_count = 0; + let mut last_unknown_status = None; + + for url_template in candidate_urls { + let address = format_auth_url(url_template, username, server_hash, ip); + + let mut response = match agent.get(&address).call() { + Ok(resp) => resp, + Err(err) => { + tracing::warn!( + "Authentication server at '{address}' is down or unreachable: {err}" + ); + continue; + } + }; + + let status = response.status(); + if status.is_server_error() { + tracing::warn!("Authentication server at '{address}' returned server error: {status}"); + continue; + } + + match status { + StatusCode::OK => match response.body_mut().read_json::() { + Ok(profile) => return Ok(profile), + Err(err) => { + tracing::warn!("Failed to parse GameProfile response from '{address}': {err}"); + } + }, + StatusCode::NO_CONTENT => { + unverified_count += 1; + } + other => { + last_unknown_status = Some(other); + } + } + } + + if unverified_count > 0 { + Err(AuthError::UnverifiedUsername) + } else if let Some(status) = last_unknown_status { + Err(AuthError::UnknownStatusCode(status)) + } else { + Err(AuthError::FailedResponse) } - let profile: GameProfile = response - .body_mut() - .read_json() - .map_err(|_| AuthError::FailedParse)?; - Ok(profile) } pub fn validate_textures(property: &Property, config: &TextureConfig) -> Result<(), TextureError> { @@ -158,7 +211,9 @@ pub fn fetch_mojang_public_keys( let url = format!("{services_url}/publickeys"); - let mut response = ureq::get(url) + let agent = create_agent(auth_config); + let mut response = agent + .get(&url) .call() .map_err(|_| AuthError::FailedResponse)?; @@ -195,54 +250,141 @@ struct MojangProfileByNameResponse { pub fn lookup_profile_by_name( name: &str, - _auth_config: &AuthenticationConfig, + auth_config: &AuthenticationConfig, ) -> Result, AuthError> { - let url = MOJANG_PROFILE_BY_NAME_URL.replace("{username}", name); + let primary_url = auth_config + .profile_by_name_url + .as_deref() + .unwrap_or(MOJANG_PROFILE_BY_NAME_URL); - let mut response = ureq::get(url) - .call() - .map_err(|_| AuthError::FailedResponse)?; - - match response.status() { - StatusCode::OK => {} - StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => return Ok(None), - other => Err(AuthError::UnknownStatusCode(other))?, + let mut candidate_urls = Vec::with_capacity(1 + auth_config.profile_by_name_fallbacks.len()); + candidate_urls.push(primary_url); + for fallback in &auth_config.profile_by_name_fallbacks { + candidate_urls.push(fallback.as_str()); } - let profile: MojangProfileByNameResponse = response - .body_mut() - .read_json() - .map_err(|_| AuthError::FailedParse)?; + let agent = create_agent(auth_config); - let parsed_uuid = Uuid::parse_str(&profile.id).map_err(|_| AuthError::FailedParse)?; - Ok(Some((parsed_uuid, profile.name))) + let mut not_found_count = 0; + let mut last_unknown_status = None; + + for url_template in candidate_urls { + let address = url_template.replace("{username}", name); + + let mut response = match agent.get(&address).call() { + Ok(resp) => resp, + Err(err) => { + tracing::warn!( + "Profile lookup server at '{address}' is down or unreachable: {err}" + ); + continue; + } + }; + + let status = response.status(); + if status.is_server_error() { + tracing::warn!("Profile lookup server at '{address}' returned server error: {status}"); + continue; + } + + match status { + StatusCode::OK => match response + .body_mut() + .read_json::() + { + Ok(profile) => { + let parsed_uuid = + Uuid::parse_str(&profile.id).map_err(|_| AuthError::FailedParse)?; + return Ok(Some((parsed_uuid, profile.name))); + } + Err(err) => { + tracing::warn!( + "Failed to parse profile by name response from '{address}': {err}" + ); + } + }, + StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => { + not_found_count += 1; + } + other => { + last_unknown_status = Some(other); + } + } + } + + if not_found_count > 0 { + Ok(None) + } else if let Some(status) = last_unknown_status { + Err(AuthError::UnknownStatusCode(status)) + } else { + Err(AuthError::FailedResponse) + } } pub fn fetch_profile_by_uuid( uuid: Uuid, - _auth_config: &AuthenticationConfig, + auth_config: &AuthenticationConfig, ) -> Result, AuthError> { - let url = format!( - "https://sessionserver.mojang.com/session/minecraft/profile/{}?unsigned=false", - uuid.simple() - ); + let primary_url = auth_config + .profile_by_uuid_url + .as_deref() + .unwrap_or(MOJANG_PROFILE_BY_UUID_URL); - let mut response = ureq::get(&url) - .call() - .map_err(|_| AuthError::FailedResponse)?; - - match response.status() { - StatusCode::OK => {} - StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => return Ok(None), - other => Err(AuthError::UnknownStatusCode(other))?, + let mut candidate_urls = Vec::with_capacity(1 + auth_config.profile_by_uuid_fallbacks.len()); + candidate_urls.push(primary_url); + for fallback in &auth_config.profile_by_uuid_fallbacks { + candidate_urls.push(fallback.as_str()); } - let profile: GameProfile = response - .body_mut() - .read_json() - .map_err(|_| AuthError::FailedParse)?; + let agent = create_agent(auth_config); - Ok(Some(profile)) + let mut not_found_count = 0; + let mut last_unknown_status = None; + + let uuid_simple = uuid.simple().to_string(); + + for url_template in candidate_urls { + let address = url_template + .replace("{uuid}", &uuid_simple) + .replace("{uuid_hyphenated}", &uuid.to_string()); + + let mut response = match agent.get(&address).call() { + Ok(resp) => resp, + Err(err) => { + tracing::warn!("Profile fetch server at '{address}' is down or unreachable: {err}"); + continue; + } + }; + + let status = response.status(); + if status.is_server_error() { + tracing::warn!("Profile fetch server at '{address}' returned server error: {status}"); + continue; + } + + match status { + StatusCode::OK => match response.body_mut().read_json::() { + Ok(profile) => return Ok(Some(profile)), + Err(err) => { + tracing::warn!("Failed to parse GameProfile response from '{address}': {err}"); + } + }, + StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => { + not_found_count += 1; + } + other => { + last_unknown_status = Some(other); + } + } + } + + if not_found_count > 0 { + Ok(None) + } else if let Some(status) = last_unknown_status { + Err(AuthError::UnknownStatusCode(status)) + } else { + Err(AuthError::FailedResponse) + } } #[derive(Error, Debug)] @@ -309,4 +451,60 @@ mod tests { serde_json::from_slice(json.as_bytes()).expect("profile should parse"); assert!(profile.signature_required); } + + #[test] + fn format_auth_url() { + let template = + "https://auth.example.com/hasJoined?username={username}&serverId={server_hash}&ip={ip}"; + let formatted = super::format_auth_url( + template, + "Player1", + "hash123", + &"127.0.0.1".parse().unwrap(), + ); + assert_eq!( + formatted, + "https://auth.example.com/hasJoined?username=Player1&serverId=hash123&ip=127.0.0.1" + ); + } + + #[test] + fn auth_config_fallbacks_deserialization() { + let json_str = r#"{ + "enabled": true, + "url": "https://primary.auth/hasJoined?username={username}&serverId={server_hash}", + "fallbacks": [ + "https://fallback1.auth/hasJoined?username={username}&serverId={server_hash}", + "https://fallback2.auth/hasJoined?username={username}&serverId={server_hash}" + ] + }"#; + let config: pumpkin_config::AuthenticationConfig = + serde_json::from_str(json_str).expect("config should deserialize"); + assert_eq!( + config.url.as_deref(), + Some("https://primary.auth/hasJoined?username={username}&serverId={server_hash}") + ); + assert_eq!(config.fallbacks.len(), 2); + assert_eq!( + config.fallbacks[0], + "https://fallback1.auth/hasJoined?username={username}&serverId={server_hash}" + ); + } + + #[test] + fn auth_config_fallback_urls_alias_deserialization() { + let json_str = r#"{ + "enabled": true, + "fallback_urls": [ + "https://fallback1.auth/hasJoined?username={username}&serverId={server_hash}" + ] + }"#; + let config: pumpkin_config::AuthenticationConfig = + serde_json::from_str(json_str).expect("config with alias should deserialize"); + assert_eq!(config.fallbacks.len(), 1); + assert_eq!( + config.fallbacks[0], + "https://fallback1.auth/hasJoined?username={username}&serverId={server_hash}" + ); + } }