Alexander Medvedev 2026-08-09 17:06:40 +02:00
parent d61f8c931f
commit 33e11088b2
2 changed files with 354 additions and 66 deletions

View File

@ -11,6 +11,19 @@ pub struct AuthenticationConfig {
pub enabled: bool,
/// Optional custom authentication URL.
pub url: Option<String>,
/// Fallback authentication server URLs to use if the primary/official server is down.
#[serde(alias = "fallback_urls")]
pub fallbacks: Vec<String>,
/// Optional custom profile lookup by username URL (template parameter `{username}`).
pub profile_by_name_url: Option<String>,
/// Optional fallback profile lookup by username URLs.
#[serde(alias = "profile_by_name_fallback_urls")]
pub profile_by_name_fallbacks: Vec<String>,
/// Optional custom profile lookup by UUID URL (template parameter `{uuid}`).
pub profile_by_uuid_url: Option<String>,
/// Optional fallback profile lookup by UUID URLs.
#[serde(alias = "profile_by_uuid_fallback_urls")]
pub profile_by_uuid_fallbacks: Vec<String>,
/// 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);
}
}

View File

@ -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 <https://pumpkinmc.org/developer/networking/authentication>
pub fn authenticate(
@ -71,40 +93,71 @@ pub fn authenticate(
ip: &IpAddr,
auth_config: &AuthenticationConfig,
) -> Result<GameProfile, AuthError> {
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::<GameProfile>() {
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<Option<(Uuid, String)>, 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::<MojangProfileByNameResponse>()
{
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<Option<GameProfile>, 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::<GameProfile>() {
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}"
);
}
}