From cb001fa341636d1fa6cc186bfd372f092088270c Mon Sep 17 00:00:00 2001 From: mat Date: Sat, 15 Oct 2022 16:36:55 -0500 Subject: [PATCH] add auth in azalea-protocol/client --- Cargo.lock | 1 + azalea-auth/Cargo.toml | 1 + azalea-auth/examples/auth.rs | 4 +- azalea-auth/src/cache.rs | 13 ++ azalea-auth/src/lib.rs | 298 +++++++++++++++++++++++++++++++ azalea-auth/src/sessionserver.rs | 76 ++++++-- azalea-client/src/account.rs | 7 + azalea-client/src/client.rs | 16 +- azalea-protocol/README.md | 2 +- azalea-protocol/src/connect.rs | 70 +++++++- 10 files changed, 463 insertions(+), 25 deletions(-) create mode 100644 azalea-auth/src/cache.rs diff --git a/Cargo.lock b/Cargo.lock index 0da88af5..55c57c38 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "thiserror", "tokio", "uuid", ] diff --git a/azalea-auth/Cargo.toml b/azalea-auth/Cargo.toml index 1e5a7f8d..5121d257 100755 --- a/azalea-auth/Cargo.toml +++ b/azalea-auth/Cargo.toml @@ -18,6 +18,7 @@ serde_json = "1.0.86" 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"] } diff --git a/azalea-auth/examples/auth.rs b/azalea-auth/examples/auth.rs index 0e53e5e9..f9e6ad79 100644 --- a/azalea-auth/examples/auth.rs +++ b/azalea-auth/examples/auth.rs @@ -1,5 +1,7 @@ #[tokio::main] async fn main() { - let auth_result = azalea_auth::auth(None).await.unwrap(); + let auth_result = azalea_auth::auth(azalea_auth::AuthOpts::default()) + .await + .unwrap(); println!("{:?}", auth_result); } diff --git a/azalea-auth/src/cache.rs b/azalea-auth/src/cache.rs new file mode 100644 index 00000000..59953824 --- /dev/null +++ b/azalea-auth/src/cache.rs @@ -0,0 +1,13 @@ +//! 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()) +// } diff --git a/azalea-auth/src/lib.rs b/azalea-auth/src/lib.rs index 8dc4d1c9..50c10d6a 100755 --- a/azalea-auth/src/lib.rs +++ b/azalea-auth/src/lib.rs @@ -1,3 +1,301 @@ pub mod auth; pub mod game_profile; pub mod sessionserver; + +use anyhow::anyhow; +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. +pub async fn auth(opts: AuthOpts) -> anyhow::Result { + let client = reqwest::Client::new(); + + let auth_token_res = interactive_get_ms_auth_token(&client).await?; + // TODO: cache this + println!("Got access token: {}", auth_token_res.access_token); + + let xbl_auth = auth_with_xbox_live(&client, &auth_token_res.access_token).await?; + + let xsts_token = obtain_xsts_for_minecraft(&client, &xbl_auth).await?; + + let minecraft_access_token = + auth_with_minecraft(&client, &xbl_auth.user_hash, &xsts_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." + ); + } + } + + // let profile = get_profile(&client, &minecraft_access_token).await?; + + Ok(minecraft_access_token) +} + +#[derive(Debug, Deserialize)] +pub struct DeviceCodeResponse { + user_code: String, + device_code: String, + verification_uri: String, + expires_in: u64, + interval: u64, +} + +#[allow(unused)] +#[derive(Debug, Deserialize)] +pub struct AccessTokenResponse { + token_type: String, + expires_in: u64, + scope: String, + access_token: String, + refresh_token: String, + user_id: String, +} + +#[allow(unused)] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct XboxLiveAuthResponse { + issue_instant: String, + not_after: String, + token: String, + display_claims: HashMap>>, +} + +/// Just the important data +pub struct XboxLiveAuth { + token: String, + user_hash: String, +} + +#[allow(unused)] +#[derive(Debug, Deserialize)] +pub struct MinecraftAuthResponse { + username: String, + roles: Vec, + access_token: String, + token_type: String, + expires_in: u64, +} + +#[allow(unused)] +#[derive(Debug, Deserialize)] +pub struct GameOwnershipResponse { + items: Vec, + signature: String, + key_id: String, +} + +#[allow(unused)] +#[derive(Debug, Deserialize)] +pub struct GameOwnershipItem { + name: String, + signature: String, +} + +#[derive(Debug, Deserialize)] +pub struct ProfileResponse { + pub id: String, + pub name: String, + pub skins: Vec, + pub capes: Vec, +} + +/// Asks the user to go to a webpage and log in with Microsoft. +async fn interactive_get_ms_auth_token( + client: &reqwest::Client, +) -> anyhow::Result { + // nintendo switch (real) + let client_id = "00000000441cc96b"; + + let res = client + .post("https://login.live.com/oauth20_connect.srf") + .form(&vec![ + ("scope", "service::user.auth.xboxlive.com::MBI_SSL"), + ("client_id", client_id), + ("response_type", "device_code"), + ]) + .send() + .await? + .json::() + .await?; + println!("{:?}", res); + println!( + "Go to {} and enter the code {}", + res.verification_uri, res.user_code + ); + + let access_token_response: AccessTokenResponse; + + let expire_time = Instant::now() + std::time::Duration::from_secs(res.expires_in); + + while Instant::now() < expire_time { + tokio::time::sleep(std::time::Duration::from_secs(res.interval)).await; + + println!("trying"); + if let Ok(res) = client + .post(format!( + "https://login.live.com/oauth20_token.srf?client_id={}", + client_id + )) + .form(&vec![ + ("client_id", client_id), + ("device_code", &res.device_code), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]) + .send() + .await? + .json::() + .await + { + access_token_response = res; + return Ok(access_token_response); + } + } + + Err(anyhow!("Authentication timed out")) +} + +async fn auth_with_xbox_live( + client: &reqwest::Client, + access_token: &str, +) -> anyhow::Result { + let auth_json = json!({ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + // i thought this was supposed to be d={} but it doesn't work for + // me when i add it ?????? + "RpsTicket": format!("{}", access_token) + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" + }); + let payload = auth_json.to_string(); + // let signature = sign( + // "https://user.auth.xboxlive.com/user/authenticate", + // "", + // &payload, + // )?; + println!("auth_json: {:#?}", auth_json); + let res = client + .post("https://user.auth.xboxlive.com/user/authenticate") + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("x-xbl-contract-version", "1") + // .header("Cache-Control", "no-store, must-revalidate, no-cache") + // .header("Signature", base64::encode(signature)) + .body(payload) + .send() + .await? + .json::() + .await?; + println!("got res: {:?}", res); + + Ok(XboxLiveAuth { + token: res.token, + user_hash: res.display_claims["xui"].get(0).unwrap()["uhs"].clone(), + }) +} + +async fn obtain_xsts_for_minecraft( + client: &reqwest::Client, + xbl_auth: &XboxLiveAuth, +) -> anyhow::Result { + let res = client + .post("https://xsts.auth.xboxlive.com/xsts/authorize") + .header("Accept", "application/json") + .json(&json!({ + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [xbl_auth.token] + }, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT" + })) + .send() + .await? + .json::() + .await?; + println!("{:?}", res); + + Ok(res.token) +} + +async fn auth_with_minecraft( + client: &reqwest::Client, + user_hash: &str, + xsts_token: &str, +) -> anyhow::Result { + let res = client + .post("https://api.minecraftservices.com/authentication/login_with_xbox") + .header("Accept", "application/json") + .json(&json!({ + "identityToken": format!("XBL3.0 x={};{}", user_hash, xsts_token) + })) + .send() + .await? + .json::() + .await?; + println!("{:?}", res); + + Ok(res.access_token) +} + +async fn check_ownership( + client: &reqwest::Client, + minecraft_access_token: &str, +) -> anyhow::Result { + let res = client + .get("https://api.minecraftservices.com/entitlements/mcstore") + .header( + "Authorization", + format!("Bearer {}", minecraft_access_token), + ) + .send() + .await? + .json::() + .await?; + println!("{:?}", res); + + // TODO: we *should* check with mojang's public key that the signatures are right + + Ok(!res.items.is_empty()) +} + +async fn get_profile( + client: &reqwest::Client, + minecraft_access_token: &str, +) -> anyhow::Result { + let res = client + .get("https://api.minecraftservices.com/minecraft/profile") + .header( + "Authorization", + format!("Bearer {}", minecraft_access_token), + ) + .send() + .await? + .json::() + .await?; + println!("{:?}", res); + + Ok(res) +} diff --git a/azalea-auth/src/sessionserver.rs b/azalea-auth/src/sessionserver.rs index 64aa85a8..0272bdd3 100644 --- a/azalea-auth/src/sessionserver.rs +++ b/azalea-auth/src/sessionserver.rs @@ -1,29 +1,77 @@ +//! Tell Mojang you're joining a multiplayer server. +//! +use serde::Deserialize; use serde_json::json; +use thiserror::Error; +use uuid::Uuid; -/// Tell Mojang's servers that you are going to join a multiplayer server. -/// The server ID is an empty string. +#[derive(Debug, Error)] +pub enum SessionServerError { + #[error("Error sending HTTP request to sessionserver: {0}")] + HttpError(#[from] reqwest::Error), + #[error("Multiplayer is not enabled for this account")] + MultiplayerDisabled, + #[error("This account has been banned from multiplayer")] + Banned, + #[error("Unknown sessionserver error: {0}")] + Unknown(String), + #[error("Unexpected response from sessionserver (status code {status_code}): {body}")] + UnexpectedResponse { status_code: u16, body: String }, +} + +#[derive(Deserialize)] +pub struct ForbiddenError { + pub error: String, + pub path: String, +} + +/// Tell Mojang's servers that you are going to join a multiplayer server, +/// which is required to join online-mode servers. The server ID is an empty +/// string. pub async fn join( access_token: &str, - public_key: &str, - private_key: &str, - undashed_uuid: &str, + public_key: &[u8], + private_key: &[u8], + uuid: &Uuid, server_id: &str, -) { +) -> Result<(), SessionServerError> { let client = reqwest::Client::new(); let server_hash = azalea_crypto::hex_digest(&azalea_crypto::digest_data( - server_id, + server_id.as_bytes(), public_key, private_key, )); - client + let mut encode_buffer = Uuid::encode_buffer(); + let undashed_uuid = uuid.simple().encode_lower(&mut encode_buffer); + + let res = client .post("https://sessionserver.mojang.com/session/minecraft/join") - .json(json! { - accessToken: access_token, - selectedProfile: undashed_uuid, - serverId: server_hash - }) + .json(&json!({ + "accessToken": access_token, + "selectedProfile": undashed_uuid, + "serverId": server_hash + })) .send() - .await; + .await?; + + match res.status() { + reqwest::StatusCode::NO_CONTENT => Ok(()), + reqwest::StatusCode::FORBIDDEN => { + let forbidden = res.json::().await?; + match forbidden.error.as_str() { + "InsufficientPrivilegesException" => Err(SessionServerError::MultiplayerDisabled), + "UserBannedException" => Err(SessionServerError::Banned), + _ => Err(SessionServerError::Unknown(forbidden.error)), + } + } + status_code => { + let body = res.text().await?; + Err(SessionServerError::UnexpectedResponse { + status_code: status_code.as_u16(), + body, + }) + } + } } diff --git a/azalea-client/src/account.rs b/azalea-client/src/account.rs index c554908f..17389955 100644 --- a/azalea-client/src/account.rs +++ b/azalea-client/src/account.rs @@ -7,11 +7,18 @@ use tokio::sync::mpsc::UnboundedReceiver; /// Something that can join Minecraft servers. pub struct Account { pub username: String, + /// The access token for authentication. You can obtain one of these + /// manually from azalea-auth. + pub access_token: Option, + /// Only required for online-mode accounts. + pub uuid: Option, } impl Account { pub fn offline(username: &str) -> Self { Self { username: username.to_string(), + access_token: None, + uuid: None, } } diff --git a/azalea-client/src/client.rs b/azalea-client/src/client.rs index 09f68c4a..fa5699dc 100644 --- a/azalea-client/src/client.rs +++ b/azalea-client/src/client.rs @@ -38,7 +38,6 @@ use std::{ }; use thiserror::Error; use tokio::{ - io::AsyncWriteExt, sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, task::JoinHandle, time::{self}, @@ -105,6 +104,8 @@ pub enum JoinError { ReadPacket(#[from] azalea_protocol::read::ReadPacketError), #[error("{0}")] Io(#[from] io::Error), + #[error("{0}")] + SessionServer(#[from] azalea_auth::sessionserver::SessionServerError), } #[derive(Error, Debug)] @@ -159,6 +160,17 @@ impl Client { debug!("Got encryption request"); let e = azalea_crypto::encrypt(&p.public_key, &p.nonce).unwrap(); + if let Some(access_token) = &account.access_token { + conn.authenticate( + &access_token, + &account + .uuid + .expect("Uuid must be present if access token is present."), + p, + ) + .await?; + } + // TODO: authenticate with the server here (authenticateServer) conn.write( @@ -237,7 +249,7 @@ impl Client { /// Disconnect from the server, ending all tasks. pub async fn shutdown(self) -> Result<(), std::io::Error> { - self.write_conn.lock().await.write_stream.shutdown().await?; + self.write_conn.lock().await.shutdown().await?; let tasks = self.tasks.lock(); for task in tasks.iter() { task.abort(); diff --git a/azalea-protocol/README.md b/azalea-protocol/README.md index a210e4a6..7bc1f4c0 100644 --- a/azalea-protocol/README.md +++ b/azalea-protocol/README.md @@ -1,6 +1,6 @@ # Azalea Protocol -Send and receive Minecraft packets. You should probably use `azalea` or `azalea-client` instead. +A low-level crate to send and receive Minecraft packets. You should probably use `azalea` or `azalea-client` instead. The goal is to only support the latest Minecraft version in order to ease development. diff --git a/azalea-protocol/src/connect.rs b/azalea-protocol/src/connect.rs index d7b9bd1d..aafeddad 100644 --- a/azalea-protocol/src/connect.rs +++ b/azalea-protocol/src/connect.rs @@ -2,32 +2,38 @@ use crate::packets::game::{ClientboundGamePacket, ServerboundGamePacket}; use crate::packets::handshake::{ClientboundHandshakePacket, ServerboundHandshakePacket}; +use crate::packets::login::clientbound_hello_packet::ClientboundHelloPacket; use crate::packets::login::{ClientboundLoginPacket, ServerboundLoginPacket}; use crate::packets::status::{ClientboundStatusPacket, ServerboundStatusPacket}; use crate::packets::ProtocolPacket; use crate::read::{read_packet, ReadPacketError}; use crate::write::write_packet; use crate::ServerIpAddress; +use azalea_auth::sessionserver::SessionServerError; use azalea_crypto::{Aes128CfbDec, Aes128CfbEnc}; use bytes::BytesMut; use std::fmt::Debug; use std::marker::PhantomData; use thiserror::Error; +use tokio::io::AsyncWriteExt; use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; use tokio::net::TcpStream; +use uuid::Uuid; pub struct ReadConnection { - pub read_stream: OwnedReadHalf, + read_stream: OwnedReadHalf, buffer: BytesMut, - pub compression_threshold: Option, - pub dec_cipher: Option, + compression_threshold: Option, + dec_cipher: Option, + private_key: Option<[u8; 16]>, _reading: PhantomData, } pub struct WriteConnection { - pub write_stream: OwnedWriteHalf, - pub compression_threshold: Option, - pub enc_cipher: Option, + write_stream: OwnedWriteHalf, + compression_threshold: Option, + enc_cipher: Option, + private_key: Option<[u8; 16]>, _writing: PhantomData, } @@ -64,6 +70,10 @@ where ) .await } + + pub async fn shutdown(&mut self) -> std::io::Result<()> { + self.write_stream.shutdown().await + } } impl Connection @@ -110,12 +120,14 @@ impl Connection { buffer: BytesMut::new(), compression_threshold: None, dec_cipher: None, + private_key: None, _reading: PhantomData, }, writer: WriteConnection { write_stream, compression_threshold: None, enc_cipher: None, + private_key: None, _writing: PhantomData, }, }) @@ -145,13 +157,55 @@ impl Connection { pub fn set_encryption_key(&mut self, key: [u8; 16]) { // minecraft has a cipher decoder and encoder, i don't think it matters though? let (enc_cipher, dec_cipher) = azalea_crypto::create_cipher(&key); - self.writer.enc_cipher = Some(enc_cipher); self.reader.dec_cipher = Some(dec_cipher); + self.writer.enc_cipher = Some(enc_cipher); + self.reader.private_key = Some(key); + self.writer.private_key = Some(key); } pub fn game(self) -> Connection { Connection::from(self) } + + /// Authenticate with Minecraft's servers, which is required to join + /// online-mode servers. This must happen when you get a + /// `ClientboundLoginPacket::Hello` packet. + /// + /// ```no_run + /// let token = azalea_auth::auth(azalea_auth::AuthOpts { + /// ..Default::default() + /// }) + /// .await; + /// let player_data = azalea_auth::get_profile(token).await; + /// + /// let mut connection = azalea::Connection::new(&server_address).await?; + /// + /// // transition to the login state, in a real program we would have done a handshake first + /// connection.login(); + /// + /// match connection.read().await? { + /// ClientboundLoginPacket::Hello(p) => { + /// // tell Mojang we're joining the server + /// connection.authenticate(&token, player_data.uuid, p).await?; + /// } + /// _ => {} + /// } + /// ``` + pub async fn authenticate( + &self, + access_token: &str, + uuid: &Uuid, + packet: ClientboundHelloPacket, + ) -> Result<(), SessionServerError> { + azalea_auth::sessionserver::join( + access_token, + &packet.public_key, + &self.writer.private_key.expect("encryption key not set"), + uuid, + &packet.server_id, + ) + .await + } } // rust doesn't let us implement From because allegedly it conflicts with @@ -172,12 +226,14 @@ where buffer: connection.reader.buffer, compression_threshold: connection.reader.compression_threshold, dec_cipher: connection.reader.dec_cipher, + private_key: connection.reader.private_key, _reading: PhantomData, }, writer: WriteConnection { compression_threshold: connection.writer.compression_threshold, write_stream: connection.writer.write_stream, enc_cipher: connection.writer.enc_cipher, + private_key: connection.writer.private_key, _writing: PhantomData, }, }