use super::{ Credentials, MinecraftAccountType, MinecraftCharacterExpressionState, MinecraftProfile, MinecraftSkin, MinecraftSkinVariant, }; use crate::ErrorKind; use crate::util::fetch::INSECURE_REQWEST_CLIENT; use base64::Engine; use base64::prelude::{BASE64_STANDARD, BASE64_STANDARD_NO_PAD}; use chrono::{Duration, Utc}; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::Sqlite; use std::collections::HashMap; use std::sync::{Arc, LazyLock}; use std::time::{Duration as StdDuration, Instant}; use tokio::sync::Mutex; use url::Url; use uuid::Uuid; const PENDING_LOGIN_LIFETIME: StdDuration = StdDuration::from_secs(600); static PENDING_LOGINS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); #[derive(Serialize, Deserialize, Debug, Clone)] pub struct YggdrasilAccount { pub api_root: String, pub server_name: String, pub login: String, #[serde(skip_serializing)] pub client_token: String, } #[derive(Serialize, Debug)] #[serde(tag = "status", rename_all = "snake_case")] pub enum YggdrasilLoginResult { Complete { credentials: Credentials, }, SelectProfile { flow_id: Uuid, profiles: Vec, }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct YggdrasilProfile { #[serde(with = "simple_uuid")] pub id: Uuid, pub name: String, } mod simple_uuid { use serde::{Deserialize, Deserializer, Serializer}; use uuid::Uuid; pub fn serialize( value: &Uuid, serializer: S, ) -> Result { serializer.serialize_str(&value.simple().to_string()) } pub fn deserialize<'de, D: Deserializer<'de>>( deserializer: D, ) -> Result { let value = String::deserialize(deserializer)?; Uuid::parse_str(&value).map_err(serde::de::Error::custom) } } #[derive(Debug, Clone)] pub struct YggdrasilMetadata { pub api_root: String, pub server_name: String, pub raw: String, } #[derive(Deserialize)] struct MetadataDocument { #[serde(default)] meta: Metadata, } #[derive(Deserialize, Default)] struct Metadata { #[serde(rename = "serverName")] server_name: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AuthenticateResponse { access_token: String, client_token: String, #[serde(default)] available_profiles: Vec, selected_profile: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RefreshResponse { access_token: String, client_token: String, selected_profile: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ErrorResponse { error: Option, error_message: Option, cause: Option, } #[derive(Deserialize)] struct SessionProfile { #[serde(with = "simple_uuid")] id: Uuid, name: String, #[serde(default)] properties: Vec, } #[derive(Deserialize)] struct SessionProperty { name: String, value: String, } #[derive(Deserialize)] struct TexturePayload { #[serde(default)] textures: SessionTextures, } #[derive(Deserialize, Default)] struct SessionTextures { #[serde(rename = "SKIN")] skin: Option, } #[derive(Deserialize)] struct SessionTexture { url: String, #[serde(default)] metadata: SessionTextureMetadata, } #[derive(Deserialize, Default)] struct SessionTextureMetadata { model: Option, } struct PendingYggdrasilLogin { created: Instant, api_root: String, server_name: String, login: String, access_token: String, client_token: String, profiles: Vec, } pub async fn begin_yggdrasil_login( api_root: &str, login: &str, password: &str, exec: impl sqlx::Executor<'_, Database = Sqlite> + Copy, ) -> crate::Result { let login = login.trim(); if login.is_empty() { return Err(ErrorKind::InputError( "The Yggdrasil account name cannot be empty".to_string(), ) .as_error()); } if password.is_empty() { return Err(ErrorKind::InputError( "The Yggdrasil account password cannot be empty".to_string(), ) .as_error()); } let metadata = fetch_yggdrasil_metadata(api_root).await?; let client_token = Uuid::new_v4().simple().to_string(); let response = post_json::( &metadata.api_root, "authenticate", json!({ "agent": { "name": "Minecraft", "version": 1 }, "username": login, "password": password, "clientToken": client_token, "requestUser": true, }), ) .await?; // 收集本次登录可用的所有角色;优先使用 availableProfiles,回退到 selectedProfile。 let mut profiles: Vec = response.available_profiles.clone(); if profiles.is_empty() { if let Some(selected) = response.selected_profile.clone() { profiles.push(selected); } } if profiles.is_empty() { return Err(ErrorKind::OtherError( "The Yggdrasil account does not have a Minecraft profile" .to_string(), ) .as_error()); } // 不在此处激活任何角色:authenticate 只拿到一条 clientToken 链, // 若循环 refresh 多个角色,同一 clientToken 下后一次会顶掉前一次的 // accessToken,导致只有最后一个角色有效。因此改为返回 // `SelectProfile`,由前端弹出选择框,老板选中后用 // `finish_yggdrasil_login` 单独 refresh 该角色。 // 需要多个角色时重复登录、各选一个,每个角色持有独立的 clientToken。 let _ = exec; let flow_id = Uuid::new_v4(); PENDING_LOGINS.lock().await.insert( flow_id, PendingYggdrasilLogin { created: Instant::now(), api_root: metadata.api_root, server_name: metadata.server_name, login: login.to_string(), access_token: response.access_token, client_token: response.client_token, profiles: profiles.clone(), }, ); Ok(YggdrasilLoginResult::SelectProfile { flow_id, profiles }) } pub async fn finish_yggdrasil_login( flow_id: Uuid, profile_id: Uuid, exec: impl sqlx::Executor<'_, Database = Sqlite> + Copy, ) -> crate::Result { let login = PENDING_LOGINS .lock() .await .remove(&flow_id) .ok_or_else(|| { ErrorKind::InputError( "The Yggdrasil profile selection has expired".to_string(), ) .as_error() })?; if login.created.elapsed() >= PENDING_LOGIN_LIFETIME { return Err(ErrorKind::InputError( "The Yggdrasil profile selection has expired".to_string(), ) .as_error()); } let profile = login .profiles .into_iter() .find(|profile| profile.id == profile_id) .ok_or_else(|| { ErrorKind::InputError( "The selected Yggdrasil profile is not available".to_string(), ) .as_error() })?; let refreshed = refresh_selected_profile( &login.api_root, &login.access_token, &login.client_token, &profile, ) .await?; let selected_profile = refreshed.selected_profile.ok_or_else(|| { ErrorKind::OtherError( "The Yggdrasil service did not select the requested profile" .to_string(), ) .as_error() })?; let credentials = create_credentials( selected_profile, refreshed.access_token, refreshed.client_token, YggdrasilMetadata { api_root: login.api_root, server_name: login.server_name, raw: String::new(), }, &login.login, ); credentials.upsert(exec).await?; Ok(credentials) } pub async fn refresh_yggdrasil_credentials( credentials: &mut Credentials, exec: impl sqlx::Executor<'_, Database = Sqlite> + Copy, ) -> crate::Result<()> { if credentials.expires > Utc::now() { return Ok(()); } let account = credentials.yggdrasil.clone().ok_or_else(|| { ErrorKind::OtherError( "Yggdrasil credentials are missing provider information" .to_string(), ) .as_error() })?; let validate_url = endpoint(&account.api_root, "validate")?; let response = INSECURE_REQWEST_CLIENT .post(validate_url) .json(&json!({ "accessToken": credentials.access_token, "clientToken": account.client_token, })) .send() .await?; if response.status().is_success() { credentials.expires = Utc::now() + Duration::minutes(5); credentials.upsert(exec).await?; return Ok(()); } if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { tracing::warn!( "Yggdrasil token validation for {} was rate limited", account.server_name ); credentials.expires = Utc::now() + Duration::minutes(1); credentials.upsert(exec).await?; return Ok(()); } if response.status().as_u16() != 403 && response.status().as_u16() != 401 { return Err(response_error(response).await); } let refreshed = refresh_selected_profile( &account.api_root, &credentials.access_token, &account.client_token, &YggdrasilProfile { id: credentials.offline_profile.id, name: credentials.offline_profile.name.clone(), }, ) .await?; let profile = refreshed.selected_profile.ok_or_else(|| { ErrorKind::OtherError( "The Yggdrasil service rejected the selected profile".to_string(), ) .as_error() })?; credentials.access_token = refreshed.access_token; credentials.offline_profile.id = profile.id; credentials.offline_profile.name = profile.name; if let Some(account) = credentials.yggdrasil.as_mut() { account.client_token = refreshed.client_token; } credentials.expires = Utc::now() + Duration::minutes(5); credentials.upsert(exec).await?; Ok(()) } pub async fn fetch_yggdrasil_profile( account: &YggdrasilAccount, profile_id: Uuid, ) -> crate::Result> { let mut url = Url::parse(&format!( "{}/sessionserver/session/minecraft/profile/{}", account.api_root, profile_id.simple() ))?; url.query_pairs_mut().append_pair("unsigned", "false"); let response = INSECURE_REQWEST_CLIENT.get(url).send().await?; if response.status() == reqwest::StatusCode::NO_CONTENT { return Ok(None); } if !response.status().is_success() { return Err(response_error(response).await); } let raw = response.text().await?; let profile: SessionProfile = serde_json::from_str(&raw).map_err(|error| { ErrorKind::OtherError(format!( "The Yggdrasil session profile is not valid JSON: {error}" )) .as_error() })?; let skin = profile .properties .iter() .find(|property| property.name == "textures") .and_then(|property| match decode_session_skin(profile.id, &property.value) { Ok(skin) => skin, Err(error) => { tracing::warn!( "Unable to decode the Yggdrasil skin for profile {}: {error}", profile.id ); None } }); Ok(Some(MinecraftProfile { id: profile.id, name: profile.name, skins: skin.into_iter().collect(), capes: Vec::new(), fetch_time: Some(Instant::now()), })) } fn decode_session_skin( profile_id: Uuid, encoded_textures: &str, ) -> Result, String> { let decoded = BASE64_STANDARD .decode(encoded_textures) .or_else(|_| BASE64_STANDARD_NO_PAD.decode(encoded_textures)) .map_err(|error| { format!("invalid Base64 textures property: {error}") })?; let payload: TexturePayload = serde_json::from_slice(&decoded) .map_err(|error| format!("invalid textures JSON: {error}"))?; let Some(skin) = payload.textures.skin else { return Ok(None); }; let url = Url::parse(&skin.url) .map_err(|error| format!("invalid skin texture URL: {error}"))?; if !matches!(url.scheme(), "http" | "https") { return Err("skin texture URL must use HTTP or HTTPS".to_string()); } let variant = if skin.metadata.model.as_deref() == Some("slim") { MinecraftSkinVariant::Slim } else { MinecraftSkinVariant::Classic }; Ok(Some(MinecraftSkin { id: profile_id, state: MinecraftCharacterExpressionState::Active, url: Arc::new(url), texture_key: None, variant, name: None, })) } pub async fn fetch_yggdrasil_metadata( api_root: &str, ) -> crate::Result { let api_root = normalize_api_root(api_root)?; let response = INSECURE_REQWEST_CLIENT.get(&api_root).send().await?; if !response.status().is_success() { return Err(response_error(response).await); } let raw = response.text().await?; let document: MetadataDocument = serde_json::from_str(&raw).map_err(|error| { ErrorKind::InputError(format!( "The Yggdrasil service metadata is not valid JSON: {error}" )) .as_error() })?; let fallback_name = Url::parse(&api_root) .ok() .and_then(|url| url.host_str().map(str::to_string)) .unwrap_or_else(|| "Yggdrasil".to_string()); let server_name = document .meta .server_name .filter(|name| !name.trim().is_empty()) .unwrap_or(fallback_name); Ok(YggdrasilMetadata { api_root, server_name, raw, }) } pub fn normalize_api_root(api_root: &str) -> crate::Result { let mut url = Url::parse(api_root.trim()).map_err(|error| { ErrorKind::InputError(format!("Invalid Yggdrasil API URL: {error}")) .as_error() })?; if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { return Err(ErrorKind::InputError( "The Yggdrasil API URL must be an HTTP or HTTPS URL".to_string(), ) .as_error()); } if url.username() != "" || url.password().is_some() { return Err(ErrorKind::InputError( "The Yggdrasil API URL cannot contain credentials".to_string(), ) .as_error()); } url.set_query(None); url.set_fragment(None); let path = url.path().trim_end_matches('/').to_string(); url.set_path(&path); Ok(url.to_string().trim_end_matches('/').to_string()) } fn endpoint(api_root: &str, action: &str) -> crate::Result { Url::parse(&format!("{api_root}/authserver/{action}")).map_err(|error| { ErrorKind::InputError(format!("Invalid Yggdrasil endpoint: {error}")) .as_error() }) } async fn refresh_selected_profile( api_root: &str, access_token: &str, client_token: &str, profile: &YggdrasilProfile, ) -> crate::Result { post_json( api_root, "refresh", json!({ "accessToken": access_token, "clientToken": client_token, "selectedProfile": profile, "requestUser": true, }), ) .await } async fn post_json Deserialize<'de>>( api_root: &str, action: &str, body: serde_json::Value, ) -> crate::Result { let response = INSECURE_REQWEST_CLIENT .post(endpoint(api_root, action)?) .header(reqwest::header::ACCEPT_LANGUAGE, "zh-CN") .json(&body) .send() .await?; if !response.status().is_success() { return Err(response_error(response).await); } Ok(response.json().await?) } async fn response_error(response: reqwest::Response) -> crate::Error { let status = response.status(); let body = response.text().await.unwrap_or_default(); let message = serde_json::from_str::(&body) .ok() .and_then(|error| { error .error_message .or(error.cause) .or(error.error) .filter(|message| !message.trim().is_empty()) }) .unwrap_or_else(|| { if body.trim().is_empty() { format!("The Yggdrasil service returned HTTP {status}") } else { format!("The Yggdrasil service returned HTTP {status}: {body}") } }); ErrorKind::OtherError(message).as_error() } fn create_credentials( profile: YggdrasilProfile, access_token: String, client_token: String, metadata: YggdrasilMetadata, login: &str, ) -> Credentials { Credentials { offline_profile: MinecraftProfile { id: profile.id, name: profile.name, ..MinecraftProfile::default() }, account_type: MinecraftAccountType::Yggdrasil, access_token, refresh_token: String::new(), expires: Utc::now() + Duration::minutes(5), active: true, yggdrasil: Some(YggdrasilAccount { api_root: metadata.api_root, server_name: metadata.server_name, login: login.to_string(), client_token, }), } } pub async fn login_skin_site_player( token: &str, player_id: Uuid, user_id: &str, exec: impl sqlx::Executor<'_, Database = Sqlite> + Copy, ) -> crate::Result { #[derive(Deserialize)] struct Envelope { payload: SkinSiteLogin, } let client_token = Uuid::new_v4().to_string(); let response = reqwest::Client::builder() .timeout(StdDuration::from_secs(30)) .redirect(reqwest::redirect::Policy::none()) .build()? .post("https://skin.starlight.cool/starlight/launcher/login") .bearer_auth(token) .json(&json!({ "playerId": player_id, "clientToken": client_token })) .send() .await?; if !response.status().is_success() { let status = response.status().as_u16(); // 皮肤站的错误响应体形如 {"error": "...", "errorMessage": "..."}, // 优先透出服务端给出的具体原因(例如正版玩家需用正版账号登录)。 let detail = response .json::() .await .ok() .and_then(|value| { value .get("errorMessage") .and_then(|msg| msg.as_str()) .map(str::to_string) }) .filter(|msg| !msg.trim().is_empty()); return Err(ErrorKind::InputError(match detail { Some(msg) => format!("皮肤站玩家登录失败:{msg}(HTTP {status})"), None => format!( "皮肤站玩家登录失败(HTTP {status}),请检查登录状态后重试" ), }) .as_error()); } let login = response.json::().await?.payload; let credential = skin_site_credentials(login, player_id, user_id, &client_token)?; credential.upsert_preserving_selection(exec).await?; Ok(credential) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SkinSiteLogin { access_token: String, client_token: String, selected_profile: YggdrasilProfile, user_id: String, } fn skin_site_credentials( login: SkinSiteLogin, player_id: Uuid, user_id: &str, client_token: &str, ) -> crate::Result { if login.selected_profile.id != player_id || login.user_id != user_id || login.client_token != client_token || login.access_token.is_empty() { return Err(ErrorKind::InputError( "皮肤站返回的玩家身份不匹配,请重试".into(), ) .as_error()); } let mut credential = create_credentials( login.selected_profile, login.access_token, login.client_token, YggdrasilMetadata { api_root: "https://skin.starlight.cool/yggdrasil".into(), server_name: "StarLight".into(), raw: String::new(), }, user_id, ); credential.active = false; Ok(credential) } #[cfg(test)] mod tests { use super::*; #[test] fn skin_site_login_validates_identity_and_keeps_global_selection() { let id = Uuid::new_v4(); let response = || SkinSiteLogin { access_token: "game-token".into(), client_token: "client".into(), selected_profile: YggdrasilProfile { id, name: "Player".into(), }, user_id: "owner".into(), }; let credentials = skin_site_credentials(response(), id, "owner", "client").unwrap(); assert!(!credentials.active); assert_eq!(credentials.account_type, MinecraftAccountType::Yggdrasil); assert_eq!( credentials.yggdrasil.unwrap().api_root, "https://skin.starlight.cool/yggdrasil" ); assert!( skin_site_credentials( response(), Uuid::new_v4(), "owner", "client" ) .is_err() ); assert!( skin_site_credentials(response(), id, "other", "client").is_err() ); assert!( skin_site_credentials(response(), id, "owner", "other-client") .is_err() ); } #[test] fn normalizes_api_roots() { assert_eq!( normalize_api_root(" https://littleskin.cn/api/yggdrasil/ ") .unwrap(), "https://littleskin.cn/api/yggdrasil" ); assert!(normalize_api_root("file:///tmp/yggdrasil").is_err()); assert!( normalize_api_root("https://user:pass@example.com/api").is_err() ); } #[test] fn serializes_profile_ids_without_hyphens() { let profile = YggdrasilProfile { id: Uuid::parse_str("01234567-89ab-cdef-0123-456789abcdef") .unwrap(), name: "Player".to_string(), }; let serialized = serde_json::to_value(&profile).unwrap(); assert_eq!(serialized["id"], "0123456789abcdef0123456789abcdef"); assert_eq!( serde_json::from_value::(serialized) .unwrap() .id, profile.id ); } #[test] fn decodes_classic_and_slim_session_skins() { let profile_id = Uuid::new_v4(); let classic = BASE64_STANDARD.encode( br#"{"textures":{"SKIN":{"url":"https://textures.example/skin.png"}}}"#, ); let slim = BASE64_STANDARD.encode( br#"{"textures":{"SKIN":{"url":"https://textures.example/slim.png","metadata":{"model":"slim"}}}}"#, ); let classic = decode_session_skin(profile_id, &classic).unwrap().unwrap(); let slim = decode_session_skin(profile_id, &slim).unwrap().unwrap(); assert_eq!(classic.variant, MinecraftSkinVariant::Classic); assert_eq!(slim.variant, MinecraftSkinVariant::Slim); assert_eq!(classic.id, profile_id); assert_eq!(classic.url.as_str(), "https://textures.example/skin.png"); } #[test] fn safely_handles_missing_or_invalid_session_skins() { let profile_id = Uuid::new_v4(); let missing = BASE64_STANDARD.encode(br#"{"textures":{}}"#); let invalid_url = BASE64_STANDARD .encode(br#"{"textures":{"SKIN":{"url":"file:///tmp/skin.png"}}}"#); assert!(decode_session_skin(profile_id, &missing).unwrap().is_none()); assert!(decode_session_skin(profile_id, "not-base64").is_err()); assert!(decode_session_skin(profile_id, &invalid_url).is_err()); } #[tokio::test] async fn persists_yggdrasil_account_metadata() { let pool = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::migrate!().run(&pool).await.unwrap(); let profile_id = Uuid::new_v4(); let credentials = create_credentials( YggdrasilProfile { id: profile_id, name: "Player".to_string(), }, "access-token".to_string(), "client-token".to_string(), YggdrasilMetadata { api_root: "https://littleskin.cn/api/yggdrasil".to_string(), server_name: "LittleSkin".to_string(), raw: "{}".to_string(), }, "player@example.com", ); credentials.upsert(&pool).await.unwrap(); let stored = Credentials::get_active_without_refresh(&pool) .await .unwrap() .unwrap(); assert!(stored.is_yggdrasil()); assert_eq!(stored.offline_profile.id, profile_id); let account = stored.yggdrasil.unwrap(); assert_eq!(account.server_name, "LittleSkin"); assert_eq!(account.login, "player@example.com"); assert_eq!(account.client_token, "client-token"); } }