1
2
Fork 0
mirror of https://github.com/mat-1/azalea.git synced 2025-08-02 23:44:38 +00:00

add auth in azalea-protocol/client

This commit is contained in:
mat 2022-10-15 16:36:55 -05:00
commit cb001fa341
10 changed files with 463 additions and 25 deletions

1
Cargo.lock generated
View file

@ -124,6 +124,7 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"thiserror",
"tokio", "tokio",
"uuid", "uuid",
] ]

View file

@ -18,6 +18,7 @@ serde_json = "1.0.86"
tokio = "1.21.2" tokio = "1.21.2"
uuid = "^1.1.2" uuid = "^1.1.2"
azalea-crypto = { path = "../azalea-crypto", version = "^0.1.0" } azalea-crypto = { path = "../azalea-crypto", version = "^0.1.0" }
thiserror = "1.0.37"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.21.2", features = ["full"] } tokio = { version = "1.21.2", features = ["full"] }

View file

@ -1,5 +1,7 @@
#[tokio::main] #[tokio::main]
async fn 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); println!("{:?}", auth_result);
} }

13
azalea-auth/src/cache.rs Normal file
View file

@ -0,0 +1,13 @@
//! Cache auth information
// pub fn get_auth_token() -> Option<AccessTokenResponse> {
// 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())
// }

View file

@ -1,3 +1,301 @@
pub mod auth; pub mod auth;
pub mod game_profile; pub mod game_profile;
pub mod sessionserver; 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<String> {
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<String, Vec<HashMap<String, String>>>,
}
/// Just the important data
pub struct XboxLiveAuth {
token: String,
user_hash: String,
}
#[allow(unused)]
#[derive(Debug, Deserialize)]
pub struct MinecraftAuthResponse {
username: String,
roles: Vec<String>,
access_token: String,
token_type: String,
expires_in: u64,
}
#[allow(unused)]
#[derive(Debug, Deserialize)]
pub struct GameOwnershipResponse {
items: Vec<GameOwnershipItem>,
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<serde_json::Value>,
pub capes: Vec<serde_json::Value>,
}
/// 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<AccessTokenResponse> {
// 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::<DeviceCodeResponse>()
.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::<AccessTokenResponse>()
.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<XboxLiveAuth> {
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::<XboxLiveAuthResponse>()
.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<String> {
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::<XboxLiveAuthResponse>()
.await?;
println!("{:?}", res);
Ok(res.token)
}
async fn auth_with_minecraft(
client: &reqwest::Client,
user_hash: &str,
xsts_token: &str,
) -> anyhow::Result<String> {
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::<MinecraftAuthResponse>()
.await?;
println!("{:?}", res);
Ok(res.access_token)
}
async fn check_ownership(
client: &reqwest::Client,
minecraft_access_token: &str,
) -> anyhow::Result<bool> {
let res = client
.get("https://api.minecraftservices.com/entitlements/mcstore")
.header(
"Authorization",
format!("Bearer {}", minecraft_access_token),
)
.send()
.await?
.json::<GameOwnershipResponse>()
.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<ProfileResponse> {
let res = client
.get("https://api.minecraftservices.com/minecraft/profile")
.header(
"Authorization",
format!("Bearer {}", minecraft_access_token),
)
.send()
.await?
.json::<ProfileResponse>()
.await?;
println!("{:?}", res);
Ok(res)
}

View file

@ -1,29 +1,77 @@
//! Tell Mojang you're joining a multiplayer server.
//!
use serde::Deserialize;
use serde_json::json; use serde_json::json;
use thiserror::Error;
use uuid::Uuid;
/// Tell Mojang's servers that you are going to join a multiplayer server. #[derive(Debug, Error)]
/// The server ID is an empty string. 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( pub async fn join(
access_token: &str, access_token: &str,
public_key: &str, public_key: &[u8],
private_key: &str, private_key: &[u8],
undashed_uuid: &str, uuid: &Uuid,
server_id: &str, server_id: &str,
) { ) -> Result<(), SessionServerError> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let server_hash = azalea_crypto::hex_digest(&azalea_crypto::digest_data( let server_hash = azalea_crypto::hex_digest(&azalea_crypto::digest_data(
server_id, server_id.as_bytes(),
public_key, public_key,
private_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") .post("https://sessionserver.mojang.com/session/minecraft/join")
.json(json! { .json(&json!({
accessToken: access_token, "accessToken": access_token,
selectedProfile: undashed_uuid, "selectedProfile": undashed_uuid,
serverId: server_hash "serverId": server_hash
}) }))
.send() .send()
.await; .await?;
match res.status() {
reqwest::StatusCode::NO_CONTENT => Ok(()),
reqwest::StatusCode::FORBIDDEN => {
let forbidden = res.json::<ForbiddenError>().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,
})
}
}
} }

View file

@ -7,11 +7,18 @@ use tokio::sync::mpsc::UnboundedReceiver;
/// Something that can join Minecraft servers. /// Something that can join Minecraft servers.
pub struct Account { pub struct Account {
pub username: String, pub username: String,
/// The access token for authentication. You can obtain one of these
/// manually from azalea-auth.
pub access_token: Option<String>,
/// Only required for online-mode accounts.
pub uuid: Option<uuid::Uuid>,
} }
impl Account { impl Account {
pub fn offline(username: &str) -> Self { pub fn offline(username: &str) -> Self {
Self { Self {
username: username.to_string(), username: username.to_string(),
access_token: None,
uuid: None,
} }
} }

View file

@ -38,7 +38,6 @@ use std::{
}; };
use thiserror::Error; use thiserror::Error;
use tokio::{ use tokio::{
io::AsyncWriteExt,
sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
task::JoinHandle, task::JoinHandle,
time::{self}, time::{self},
@ -105,6 +104,8 @@ pub enum JoinError {
ReadPacket(#[from] azalea_protocol::read::ReadPacketError), ReadPacket(#[from] azalea_protocol::read::ReadPacketError),
#[error("{0}")] #[error("{0}")]
Io(#[from] io::Error), Io(#[from] io::Error),
#[error("{0}")]
SessionServer(#[from] azalea_auth::sessionserver::SessionServerError),
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]
@ -159,6 +160,17 @@ impl Client {
debug!("Got encryption request"); debug!("Got encryption request");
let e = azalea_crypto::encrypt(&p.public_key, &p.nonce).unwrap(); 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) // TODO: authenticate with the server here (authenticateServer)
conn.write( conn.write(
@ -237,7 +249,7 @@ impl Client {
/// Disconnect from the server, ending all tasks. /// Disconnect from the server, ending all tasks.
pub async fn shutdown(self) -> Result<(), std::io::Error> { 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(); let tasks = self.tasks.lock();
for task in tasks.iter() { for task in tasks.iter() {
task.abort(); task.abort();

View file

@ -1,6 +1,6 @@
# Azalea Protocol # 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. The goal is to only support the latest Minecraft version in order to ease development.

View file

@ -2,32 +2,38 @@
use crate::packets::game::{ClientboundGamePacket, ServerboundGamePacket}; use crate::packets::game::{ClientboundGamePacket, ServerboundGamePacket};
use crate::packets::handshake::{ClientboundHandshakePacket, ServerboundHandshakePacket}; use crate::packets::handshake::{ClientboundHandshakePacket, ServerboundHandshakePacket};
use crate::packets::login::clientbound_hello_packet::ClientboundHelloPacket;
use crate::packets::login::{ClientboundLoginPacket, ServerboundLoginPacket}; use crate::packets::login::{ClientboundLoginPacket, ServerboundLoginPacket};
use crate::packets::status::{ClientboundStatusPacket, ServerboundStatusPacket}; use crate::packets::status::{ClientboundStatusPacket, ServerboundStatusPacket};
use crate::packets::ProtocolPacket; use crate::packets::ProtocolPacket;
use crate::read::{read_packet, ReadPacketError}; use crate::read::{read_packet, ReadPacketError};
use crate::write::write_packet; use crate::write::write_packet;
use crate::ServerIpAddress; use crate::ServerIpAddress;
use azalea_auth::sessionserver::SessionServerError;
use azalea_crypto::{Aes128CfbDec, Aes128CfbEnc}; use azalea_crypto::{Aes128CfbDec, Aes128CfbEnc};
use bytes::BytesMut; use bytes::BytesMut;
use std::fmt::Debug; use std::fmt::Debug;
use std::marker::PhantomData; use std::marker::PhantomData;
use thiserror::Error; use thiserror::Error;
use tokio::io::AsyncWriteExt;
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use uuid::Uuid;
pub struct ReadConnection<R: ProtocolPacket> { pub struct ReadConnection<R: ProtocolPacket> {
pub read_stream: OwnedReadHalf, read_stream: OwnedReadHalf,
buffer: BytesMut, buffer: BytesMut,
pub compression_threshold: Option<u32>, compression_threshold: Option<u32>,
pub dec_cipher: Option<Aes128CfbDec>, dec_cipher: Option<Aes128CfbDec>,
private_key: Option<[u8; 16]>,
_reading: PhantomData<R>, _reading: PhantomData<R>,
} }
pub struct WriteConnection<W: ProtocolPacket> { pub struct WriteConnection<W: ProtocolPacket> {
pub write_stream: OwnedWriteHalf, write_stream: OwnedWriteHalf,
pub compression_threshold: Option<u32>, compression_threshold: Option<u32>,
pub enc_cipher: Option<Aes128CfbEnc>, enc_cipher: Option<Aes128CfbEnc>,
private_key: Option<[u8; 16]>,
_writing: PhantomData<W>, _writing: PhantomData<W>,
} }
@ -64,6 +70,10 @@ where
) )
.await .await
} }
pub async fn shutdown(&mut self) -> std::io::Result<()> {
self.write_stream.shutdown().await
}
} }
impl<R, W> Connection<R, W> impl<R, W> Connection<R, W>
@ -110,12 +120,14 @@ impl Connection<ClientboundHandshakePacket, ServerboundHandshakePacket> {
buffer: BytesMut::new(), buffer: BytesMut::new(),
compression_threshold: None, compression_threshold: None,
dec_cipher: None, dec_cipher: None,
private_key: None,
_reading: PhantomData, _reading: PhantomData,
}, },
writer: WriteConnection { writer: WriteConnection {
write_stream, write_stream,
compression_threshold: None, compression_threshold: None,
enc_cipher: None, enc_cipher: None,
private_key: None,
_writing: PhantomData, _writing: PhantomData,
}, },
}) })
@ -145,13 +157,55 @@ impl Connection<ClientboundLoginPacket, ServerboundLoginPacket> {
pub fn set_encryption_key(&mut self, key: [u8; 16]) { pub fn set_encryption_key(&mut self, key: [u8; 16]) {
// minecraft has a cipher decoder and encoder, i don't think it matters though? // minecraft has a cipher decoder and encoder, i don't think it matters though?
let (enc_cipher, dec_cipher) = azalea_crypto::create_cipher(&key); 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.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<ClientboundGamePacket, ServerboundGamePacket> { pub fn game(self) -> Connection<ClientboundGamePacket, ServerboundGamePacket> {
Connection::from(self) 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 // rust doesn't let us implement From because allegedly it conflicts with
@ -172,12 +226,14 @@ where
buffer: connection.reader.buffer, buffer: connection.reader.buffer,
compression_threshold: connection.reader.compression_threshold, compression_threshold: connection.reader.compression_threshold,
dec_cipher: connection.reader.dec_cipher, dec_cipher: connection.reader.dec_cipher,
private_key: connection.reader.private_key,
_reading: PhantomData, _reading: PhantomData,
}, },
writer: WriteConnection { writer: WriteConnection {
compression_threshold: connection.writer.compression_threshold, compression_threshold: connection.writer.compression_threshold,
write_stream: connection.writer.write_stream, write_stream: connection.writer.write_stream,
enc_cipher: connection.writer.enc_cipher, enc_cipher: connection.writer.enc_cipher,
private_key: connection.writer.private_key,
_writing: PhantomData, _writing: PhantomData,
}, },
} }