This commit is contained in:
parent
d61f8c931f
commit
33e11088b2
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user