From fe1fb2dbfda8f45e0aa55ae9217bdc769e09cd1c Mon Sep 17 00:00:00 2001 From: mat Date: Sun, 16 Oct 2022 19:34:56 -0500 Subject: [PATCH] caching* refreshing microsoft auth tokens isn't implemented yet, also i haven't tested it --- Cargo.lock | 11 +-- azalea-auth/Cargo.toml | 13 +-- azalea-auth/src/auth.rs | 165 +++++++++++++++++++++++++++++---------- azalea-auth/src/cache.rs | 118 +++++++++++++++++++++++++--- azalea-auth/src/lib.rs | 41 +++++++--- 5 files changed, 273 insertions(+), 75 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 55c57c38..8fa12fd7 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,7 @@ dependencies = [ "anyhow", "azalea-buf", "azalea-crypto", + "chrono", "log", "num-bigint", "reqwest", @@ -429,9 +430,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.19" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1" dependencies = [ "num-integer", "num-traits", @@ -818,7 +819,7 @@ checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad" dependencies = [ "cfg-if", "libc", - "wasi 0.10.2+wasi-snapshot-preview1", + "wasi 0.10.0+wasi-snapshot-preview1", ] [[package]] @@ -2096,9 +2097,9 @@ dependencies = [ [[package]] name = "wasi" -version = "0.10.2+wasi-snapshot-preview1" +version = "0.10.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" [[package]] name = "wasi" diff --git a/azalea-auth/Cargo.toml b/azalea-auth/Cargo.toml index 5121d257..3a93fe62 100755 --- a/azalea-auth/Cargo.toml +++ b/azalea-auth/Cargo.toml @@ -9,16 +9,17 @@ version = "0.1.0" [dependencies] anyhow = "1.0.65" -azalea-buf = { path = "../azalea-buf", version = "^0.1.0" } +azalea-buf = {path = "../azalea-buf", version = "^0.1.0"} +azalea-crypto = {path = "../azalea-crypto", version = "^0.1.0"} +chrono = {version = "0.4.22", default-features = false} log = "0.4.17" num-bigint = "0.4.3" -reqwest = { version = "0.11.12", features = ["json"] } -serde = { version = "1.0.145", features = ["derive"] } +reqwest = {version = "0.11.12", features = ["json"]} +serde = {version = "1.0.145", features = ["derive"]} serde_json = "1.0.86" +thiserror = "1.0.37" tokio = "1.21.2" uuid = "^1.1.2" -azalea-crypto = { path = "../azalea-crypto", version = "^0.1.0" } -thiserror = "1.0.37" [dev-dependencies] -tokio = { version = "1.21.2", features = ["full"] } +tokio = {version = "1.21.2", features = ["full"]} diff --git a/azalea-auth/src/auth.rs b/azalea-auth/src/auth.rs index 4b3b7f52..f224bcc9 100644 --- a/azalea-auth/src/auth.rs +++ b/azalea-auth/src/auth.rs @@ -1,10 +1,15 @@ //! Handle Minecraft (Xbox) authentication. - +use crate::cache::{self, CachedAccount, ExpiringValue}; use anyhow::anyhow; -use serde::Deserialize; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use serde_json::json; -use std::{collections::HashMap, time::Instant}; +use std::{ + collections::HashMap, + path::PathBuf, + time::{Instant, SystemTime, UNIX_EPOCH}, +}; #[derive(Default)] pub struct AuthOpts { @@ -15,38 +20,94 @@ pub struct AuthOpts { // /// Whether we should get the Minecraft profile data (i.e. username, uuid, // /// skin, etc) for the player. // pub get_profile: bool, + /// The directory to store the cache in. If this is not set, caching is not + /// done. + pub cache_file: Option, } /// Authenticate with authenticate with Microsoft. If the data isn't cached, /// they'll be asked to go to log into Microsoft in a web page. -pub async fn auth(opts: Option) -> anyhow::Result { +/// +/// The email is technically only used as a cache key, so it *could* be +/// anything. You should just have it be the actual email so it's not confusing +/// though, and in case the Microsoft API does start providing the real email. +pub async fn auth(email: &str, opts: Option) -> anyhow::Result { let opts = opts.unwrap_or_default(); - let client = reqwest::Client::new(); + let cached_account = if let Some(cache_file) = &opts.cache_file && let Some(account) = cache::get_account_in_cache(&cache_file, email).await { + Some(account) + } else { None }; - let auth_token_res = interactive_get_auth_token(&client).await?; - // TODO: cache this - println!("Got access token: {}", auth_token_res.access_token); + // these two MUST be set by the end, since we return them in AuthResult + let profile: ProfileResponse; + let minecraft_access_token: String; - let xbl_auth = auth_with_xbox_live(&client, &auth_token_res.access_token).await?; + if let Some(account) = &cached_account && !account.mca.is_expired() { + // the minecraft auth data is cached and not expired, so we can just + // use that instead of doing auth all over again :) + profile = account.profile.clone(); + minecraft_access_token = account.mca.data.access_token.clone(); + } else { + let client = reqwest::Client::new(); + let mut msa = if let Some(account) = cached_account { + account.msa + } else { + interactive_get_auth_token(&client).await? + }; + if msa.is_expired() { + todo!("refresh msa token"); + } + let ms_access_token = &msa.data.access_token; + println!("Got access token: {}", ms_access_token); - let xsts_token = obtain_xsts_for_minecraft(&client, &xbl_auth).await?; + let xbl_auth = auth_with_xbox_live(&client, &ms_access_token).await?; - let minecraft_access_token = - auth_with_minecraft(&client, &xbl_auth.user_hash, &xsts_token).await?; + let xsts_token = obtain_xsts_for_minecraft( + &client, + &xbl_auth + .get() + .expect("Xbox Live auth token shouldn't have expired yet") + .token, + ) + .await?; - if opts.check_ownership { - let has_game = check_ownership(&client, &minecraft_access_token).await?; - if !has_game { - panic!( - "The Minecraft API is indicating that you don't own the game. \ - If you're using Xbox Game Pass, set `check_ownership` to false in the auth options." - ); + // Minecraft auth + let mca = auth_with_minecraft(&client, &xbl_auth.data.user_hash, &xsts_token).await?; + + minecraft_access_token = (&mca) + .get() + .expect("Minecraft auth shouldn't have expired yet") + .access_token + .to_string(); + + if opts.check_ownership { + let has_game = check_ownership(&client, &minecraft_access_token).await?; + if !has_game { + panic!( + "The Minecraft API is indicating that you don't own the game. \ + If you're using Xbox Game Pass, set `check_ownership` to false in the auth options." + ); + } + } + + profile = get_profile(&client, &minecraft_access_token).await?; + + if let Some(cache_file) = opts.cache_file { + cache::set_account_in_cache( + &cache_file, + email, + CachedAccount { + email: email.to_string(), + mca, + msa, + xbl: xbl_auth, + profile: profile.clone(), + }, + ) + .await?; } } - let profile = get_profile(&client, &minecraft_access_token).await?; - Ok(AuthResult { access_token: minecraft_access_token, profile, @@ -69,7 +130,7 @@ pub struct DeviceCodeResponse { } #[allow(unused)] -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct AccessTokenResponse { token_type: String, expires_in: u64, @@ -90,13 +151,14 @@ pub struct XboxLiveAuthResponse { } /// Just the important data +#[derive(Serialize, Deserialize)] pub struct XboxLiveAuth { token: String, user_hash: String, } #[allow(unused)] -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct MinecraftAuthResponse { username: String, roles: Vec, @@ -120,7 +182,7 @@ pub struct GameOwnershipItem { signature: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct ProfileResponse { pub id: String, pub name: String, @@ -131,7 +193,7 @@ pub struct ProfileResponse { /// Asks the user to go to a webpage and log in with Microsoft. async fn interactive_get_auth_token( client: &reqwest::Client, -) -> anyhow::Result { +) -> anyhow::Result> { // nintendo switch (real) let client_id = "00000000441cc96b"; @@ -152,15 +214,13 @@ async fn interactive_get_auth_token( res.verification_uri, res.user_code ); - let access_token_response: AccessTokenResponse; + let login_expires_at = Instant::now() + std::time::Duration::from_secs(res.expires_in); - let expire_time = Instant::now() + std::time::Duration::from_secs(res.expires_in); - - while Instant::now() < expire_time { + while Instant::now() < login_expires_at { tokio::time::sleep(std::time::Duration::from_secs(res.interval)).await; println!("trying"); - if let Ok(res) = client + if let Ok(access_token_response) = client .post(format!( "https://login.live.com/oauth20_token.srf?client_id={}", client_id @@ -175,8 +235,15 @@ async fn interactive_get_auth_token( .json::() .await { - access_token_response = res; - return Ok(access_token_response); + let expires_at = SystemTime::now() + + std::time::Duration::from_secs(access_token_response.expires_in); + return Ok(ExpiringValue { + data: access_token_response, + expires_at: expires_at + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(), + }); } } @@ -186,7 +253,7 @@ async fn interactive_get_auth_token( async fn auth_with_xbox_live( client: &reqwest::Client, access_token: &str, -) -> anyhow::Result { +) -> anyhow::Result> { let auth_json = json!({ "Properties": { "AuthMethod": "RPS", @@ -219,15 +286,28 @@ async fn auth_with_xbox_live( .await?; println!("got res: {:?}", res); - Ok(XboxLiveAuth { - token: res.token, - user_hash: res.display_claims["xui"].get(0).unwrap()["uhs"].clone(), + // not_after looks like 2020-12-21T19:52:08.4463796Z + let expires_at = DateTime::parse_from_rfc3339(&res.not_after) + .map_err(|_| { + anyhow!( + "Failed to parse not_after date from Xbox Live: {}", + res.not_after + ) + })? + .with_timezone(&Utc) + .timestamp() as u64; + Ok(ExpiringValue { + data: XboxLiveAuth { + token: res.token, + user_hash: res.display_claims["xui"].get(0).unwrap()["uhs"].clone(), + }, + expires_at, }) } async fn obtain_xsts_for_minecraft( client: &reqwest::Client, - xbl_auth: &XboxLiveAuth, + xbl_auth_token: &str, ) -> anyhow::Result { let res = client .post("https://xsts.auth.xboxlive.com/xsts/authorize") @@ -235,7 +315,7 @@ async fn obtain_xsts_for_minecraft( .json(&json!({ "Properties": { "SandboxId": "RETAIL", - "UserTokens": [xbl_auth.token] + "UserTokens": [xbl_auth_token.to_string()] }, "RelyingParty": "rp://api.minecraftservices.com/", "TokenType": "JWT" @@ -253,7 +333,7 @@ async fn auth_with_minecraft( client: &reqwest::Client, user_hash: &str, xsts_token: &str, -) -> anyhow::Result { +) -> anyhow::Result> { let res = client .post("https://api.minecraftservices.com/authentication/login_with_xbox") .header("Accept", "application/json") @@ -266,7 +346,12 @@ async fn auth_with_minecraft( .await?; println!("{:?}", res); - Ok(res.access_token) + let expires_at = SystemTime::now() + std::time::Duration::from_secs(res.expires_in); + Ok(ExpiringValue { + data: res, + // to seconds since epoch + expires_at: expires_at.duration_since(UNIX_EPOCH).unwrap().as_secs(), + }) } async fn check_ownership( diff --git a/azalea-auth/src/cache.rs b/azalea-auth/src/cache.rs index 59953824..e705fed1 100644 --- a/azalea-auth/src/cache.rs +++ b/azalea-auth/src/cache.rs @@ -1,13 +1,109 @@ //! Cache auth information -// pub fn get_auth_token() -> Option { -// let mut cache = CACHE.lock().unwrap(); -// if cache.auth_token.is_none() { -// return None; -// } -// let auth_token = cache.auth_token.as_ref().unwrap(); -// if auth_token.expires_in < SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() { -// return None; -// } -// Some(auth_token.clone()) -// } +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; +use thiserror::Error; +use tokio::fs::File; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Debug, Error)] +pub enum CacheError { + #[error("Failed to read cache file")] + ReadError(std::io::Error), + #[error("Failed to write cache file")] + WriteError(std::io::Error), + #[error("Failed to parse cache file")] + ParseError(serde_json::Error), +} + +#[derive(Deserialize, Serialize)] +pub struct CachedAccount { + pub email: String, + /// Microsoft auth + pub msa: ExpiringValue, + /// Xbox Live auth + pub xbl: ExpiringValue, + /// Minecraft auth + pub mca: ExpiringValue, + /// The user's Minecraft profile (i.e. username, UUID, skin) + pub profile: crate::auth::ProfileResponse, +} + +#[derive(Deserialize, Serialize)] +pub struct ExpiringValue { + /// Seconds since the UNIX epoch + pub expires_at: u64, + pub data: T, +} + +impl ExpiringValue { + pub fn is_expired(&self) -> bool { + self.expires_at + < SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } + + /// Return the data if it's not expired, otherwise return `None` + pub fn get(&self) -> Option<&T> { + if self.is_expired() { + None + } else { + Some(&self.data) + } + } +} + +async fn get_entire_cache(cache_file: &Path) -> Result, CacheError> { + let mut cache: Vec = Vec::new(); + if cache_file.exists() { + let cache_file = File::open(cache_file) + .await + .map_err(CacheError::ReadError)?; + // read the file into a string + let mut cache_file = tokio::io::BufReader::new(cache_file); + let mut contents = String::new(); + cache_file + .read_to_string(&mut contents) + .await + .map_err(CacheError::ReadError)?; + cache = serde_json::from_str(&contents).map_err(CacheError::ParseError)?; + } + Ok(cache) +} +async fn set_entire_cache(cache_file: &Path, cache: Vec) -> Result<(), CacheError> { + let cache_file = File::create(cache_file) + .await + .map_err(CacheError::WriteError)?; + let mut cache_file = tokio::io::BufWriter::new(cache_file); + let cache = serde_json::to_string(&cache).map_err(CacheError::ParseError)?; + cache_file + .write_all(cache.as_bytes()) + .await + .map_err(CacheError::WriteError)?; + + Ok(()) +} + +/// Gets cached data for the given email. +/// +/// Technically it doesn't actually have to be an email since it's only the +/// cache key. I considered using usernames or UUIDs as the cache key, but +/// usernames change and no one has their UUID memorized. +pub async fn get_account_in_cache(cache_file: &Path, email: &str) -> Option { + let cache = get_entire_cache(cache_file).await.unwrap_or_default(); + cache.into_iter().find(|account| account.email == email) +} + +pub async fn set_account_in_cache( + cache_file: &Path, + email: &str, + account: CachedAccount, +) -> Result<(), CacheError> { + let mut cache = get_entire_cache(cache_file).await.unwrap_or_default(); + cache.retain(|account| account.email != email); + cache.push(account); + set_entire_cache(cache_file, cache).await +} diff --git a/azalea-auth/src/lib.rs b/azalea-auth/src/lib.rs index 50c10d6a..c55810ef 100755 --- a/azalea-auth/src/lib.rs +++ b/azalea-auth/src/lib.rs @@ -1,4 +1,7 @@ +#![feature(let_chains)] + pub mod auth; +mod cache; pub mod game_profile; pub mod sessionserver; @@ -7,17 +10,6 @@ use serde::Deserialize; use serde_json::json; use std::{collections::HashMap, time::Instant}; -#[derive(Default)] -pub struct AuthOpts { - /// Whether we should check if the user actually owns the game. This will - /// fail if the user has Xbox Game Pass! Note that this isn't really - /// necessary, since getting the user profile will check this anyways. - pub check_ownership: bool, - // /// Whether we should get the Minecraft profile data (i.e. username, uuid, - // /// skin, etc) for the player. - // pub get_profile: bool, -} - /// Ask the user to authenticate with Microsoft and return the user's Minecraft /// access token. There's caching, so if the user has already authenticated /// before then they won't have to log in manually again. @@ -50,6 +42,29 @@ pub async fn auth(opts: AuthOpts) -> anyhow::Result { Ok(minecraft_access_token) } +pub struct AuthOpts { + /// Whether we should check if the user actually owns the game. This will + /// fail if the user has Xbox Game Pass! Note that this isn't really + /// necessary, since getting the user profile will check this anyways. + pub check_ownership: bool, + // /// Whether we should get the Minecraft profile data (i.e. username, uuid, + // /// skin, etc) for the player. + // pub get_profile: bool, + /// The file to store cached data in. If this is `None`, then no + /// caching will be done. + pub cache_dir: Option, +} + +impl Default for AuthOpts { + fn default() -> Self { + Self { + check_ownership: true, + // get_profile: true, + cache_dir: None, + } + } +} + #[derive(Debug, Deserialize)] pub struct DeviceCodeResponse { user_code: String, @@ -260,7 +275,7 @@ async fn auth_with_minecraft( Ok(res.access_token) } -async fn check_ownership( +pub async fn check_ownership( client: &reqwest::Client, minecraft_access_token: &str, ) -> anyhow::Result { @@ -281,7 +296,7 @@ async fn check_ownership( Ok(!res.items.is_empty()) } -async fn get_profile( +pub async fn get_profile( client: &reqwest::Client, minecraft_access_token: &str, ) -> anyhow::Result {