From 8bd6e2995f31348bae6306d4754942d72a7416f5 Mon Sep 17 00:00:00 2001 From: "NGnius (Graham)" Date: Wed, 19 Oct 2022 20:10:15 -0400 Subject: [PATCH] Bypass portal authentication, implement full search parameter queries --- Cargo.toml | 6 +- src/lib.rs | 2 +- src/robocraft2/factory.rs | 69 +++++++++-- src/robocraft2/mod.rs | 2 +- src/robocraft2/portal.rs | 245 ++++++++++++++++++++++++++++++++----- tests/robocraft_auth.rs | 24 +++- tests/robocraft_factory.rs | 6 +- 7 files changed, 303 insertions(+), 51 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dd47078..b3592e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,9 @@ serde_json = "^1" reqwest = { version = "^0.11", features = ["json"], optional = true} url = "^2.2" ureq = { version = "^2", features = ["json"], optional = true} +cookie_store = { version = "0.16", optional = true} +cookie = { version = "0.16", optional = true} +async-trait = { version = "0.1", optional = true } base64 = "^0.13" num_enum = "^0.5" chrono = {version = "^0.4", optional = true} @@ -36,10 +39,11 @@ cgmath = {version = "^0.18", optional = true} tokio = { version = "1.4.0", features = ["macros"]} [features] -all = ["simple", "robocraft", "cardlife", "techblox", "convert"] +all = ["simple", "robocraft", "cardlife", "techblox", "convert", "robocraft2"] default = ["all"] simple = ["ureq"] robocraft = ["reqwest", "ureq"] cardlife = ["reqwest"] techblox = ["chrono", "highhash", "half", "libfj_parsable_macro_derive"] convert = ["obj", "genmesh", "cgmath"] +robocraft2 = ["reqwest", "reqwest/cookies", "async-trait"] diff --git a/src/lib.rs b/src/lib.rs index bc12a86..add98af 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,5 +13,5 @@ pub mod robocraft_simple; pub mod techblox; #[cfg(feature = "convert")] pub mod convert; -#[cfg(feature = "robocraft")] +#[cfg(feature = "robocraft2")] pub mod robocraft2; diff --git a/src/robocraft2/factory.rs b/src/robocraft2/factory.rs index 0447b2e..0609fd3 100644 --- a/src/robocraft2/factory.rs +++ b/src/robocraft2/factory.rs @@ -1,8 +1,9 @@ +use std::sync::Mutex; + use reqwest::{Client, Error}; use url::{Url}; -use crate::robocraft::{ITokenProvider, DefaultTokenProvider}; -use crate::robocraft2::{SearchPayload, SearchResponse}; +use crate::robocraft2::{SearchPayload, SearchResponse, ITokenProvider}; /// Community Factory Robot 2 root URL pub const FACTORY_DOMAIN: &str = "https://factory.production.robocraft2.com"; @@ -10,23 +11,23 @@ pub const FACTORY_DOMAIN: &str = "https://factory.production.robocraft2.com"; /// CRF API implementation pub struct FactoryAPI { client: Client, - token: Box, + token: Mutex>, } impl FactoryAPI { - /// Create a new instance, using `DefaultTokenProvider`. + /*/// Create a new instance, using `DefaultTokenProvider`. pub fn new() -> FactoryAPI { FactoryAPI { client: Client::new(), token: Box::new(DefaultTokenProvider{}), } - } + }*/ /// Create a new instance using the provided token provider. pub fn with_auth(token_provider: Box) -> FactoryAPI { FactoryAPI { client: Client::new(), - token: token_provider, + token: Mutex::new(token_provider), } } @@ -34,19 +35,63 @@ impl FactoryAPI { /// /// For searching, use `list_builder()` instead. pub async fn list(&self) -> Result { - let url = Url::parse(FACTORY_DOMAIN) + self.search(SearchPayload::default()).await + } + + pub async fn search(&self, params: SearchPayload) -> Result { + let mut url = Url::parse(FACTORY_DOMAIN) .unwrap() .join("/v1/foundry/search") .unwrap(); + if let Some(text) = ¶ms.text { + url.query_pairs_mut().append_pair("text", text); + } + if let Some(base_minimum_cpu) = params.base_minimum_cpu { + url.query_pairs_mut().append_pair("baseCpuMinimum", &base_minimum_cpu.to_string()); + } + if let Some(base_maximum_cpu) = ¶ms.base_maximum_cpu { + url.query_pairs_mut().append_pair("baseCpuMaximum", &base_maximum_cpu.to_string()); + } + if let Some(x) = ¶ms.weapon_minimum_cpu { + url.query_pairs_mut().append_pair("weaponCpuMinimum", &x.to_string()); + } + if let Some(x) = ¶ms.weapon_maximum_cpu { + url.query_pairs_mut().append_pair("weaponCpuMaximum", &x.to_string()); + } + if let Some(x) = ¶ms.cosmetic_minimum_cpu { + url.query_pairs_mut().append_pair("cosmeticCpuMinimum", &x.to_string()); + } + if let Some(x) = ¶ms.cosmetic_maximum_cpu { + url.query_pairs_mut().append_pair("cosmeticCpuMaximum", &x.to_string()); + } + if let Some(x) = ¶ms.cluster_minimum { + url.query_pairs_mut().append_pair("clusterMinimum", &x.to_string()); + } + if let Some(x) = ¶ms.cluster_maximum { + url.query_pairs_mut().append_pair("clusterMaximum", &x.to_string()); + } + if let Some(x) = ¶ms.date_minimum { + url.query_pairs_mut().append_pair("dateMinimum", x); + } + if let Some(x) = ¶ms.date_maximum { + url.query_pairs_mut().append_pair("dateMaximum", x); + } + if let Some(x) = ¶ms.creator_id { + url.query_pairs_mut().append_pair("creatorId", x); + } + if let Some(x) = ¶ms.page { + url.query_pairs_mut().append_pair("page", &x.to_string()); + } + if let Some(x) = ¶ms.count { + url.query_pairs_mut().append_pair("count", &x.to_string()); + } + url.query_pairs_mut().append_pair("sortBy", ¶ms.sort_by); + url.query_pairs_mut().append_pair("orderBy", ¶ms.order_by); let mut request_builder = self.client.get(url); - if let Ok(token) = self.token.token() { + if let Ok(token) = self.token.lock().unwrap().token().await { request_builder = request_builder.header("Authorization", "Bearer ".to_owned() + &token); } let result = request_builder.send().await?; result.json::().await } - - async fn search(&self, params: SearchPayload) -> Result { - todo!() - } } diff --git a/src/robocraft2/mod.rs b/src/robocraft2/mod.rs index c46b5a6..9b12e67 100644 --- a/src/robocraft2/mod.rs +++ b/src/robocraft2/mod.rs @@ -8,4 +8,4 @@ mod factory_json; pub use factory_json::{SearchPayload, SearchResponse, SearchResponseItem, RobotInfo, RobotPrice}; mod portal; -pub use self::portal::PortalTokenProvider; +pub use self::portal::{PortalTokenProvider, AccountInfo, PortalCheckResponse, ITokenProvider}; diff --git a/src/robocraft2/portal.rs b/src/robocraft2/portal.rs index 84dd801..555cec2 100644 --- a/src/robocraft2/portal.rs +++ b/src/robocraft2/portal.rs @@ -1,37 +1,46 @@ -use std::sync::RwLock; - use serde::{Deserialize, Serialize}; -use ureq::{Agent, Error}; -use serde_json::to_string; +//use ureq::{Agent, Error, AgentBuilder}; +use reqwest::{Client, Error}; +//use cookie_store::CookieStore; +//use url::{Url}; +use serde_json::from_slice; -use crate::robocraft::{ITokenProvider, account::AuthenticationResponseInfo, AccountInfo}; +/// Token generator for authenticated API endpoints +#[async_trait::async_trait] +pub trait ITokenProvider { + /// Retrieve the token to use + async fn token(&mut self) -> Result; +} /// Token provider for an existing Freejam account, authenticated through the web browser portal. /// /// Steam and Epic accounts are not supported. pub struct PortalTokenProvider { /// Login token - token: RwLock, + token: ProgressionLoginResponse, /// User info token - jwt: AuthenticationResponseInfo, + jwt: PortalCheckResponse, /// Ureq HTTP client - client: Agent, + client: Client, } impl PortalTokenProvider { - pub fn portal() -> Result { - Self::target("Techblox".to_owned()) + /// Login through the web browser portal + pub async fn portal() -> Result { + Self::target("Techblox".to_owned()).await } - pub fn target(value: String) -> Result { - let client = Agent::new(); + /// Login through the portal with a custom target value + pub async fn target(value: String) -> Result { + let client = Client::new(); let payload = PortalStartPayload { target: value, }; let start_response = client.post("https://account.freejamgames.com/api/authenticate/portal/start") - .set("Content-Type", "application/json") - .send_string(&to_string(&payload).unwrap())?; - let start_res = start_response.into_json::()?; + .header("Content-Type", "application/json") + .json(&payload) + .send().await?; + let start_res = start_response.json::().await?; println!("GO TO https://account.freejamgames.com/login?theme=rc2&redirect_url=portal?theme=rc2%26portalToken={}", start_res.token); @@ -39,42 +48,117 @@ impl PortalTokenProvider { token: start_res.token, }; let mut check_response = client.post("https://account.freejamgames.com/api/authenticate/portal/check") - .set("Content-Type", "application/json") - .send_json(&payload)?; - //.send_string(&to_string(&payload).unwrap())?; + .header("Content-Type", "application/json") + .json(&payload) + .send().await?; let mut auth_complete = check_response.status() == 200; while !auth_complete { check_response = client.post("https://account.freejamgames.com/api/authenticate/portal/check") - .set("Content-Type", "application/json") - .send_json(&payload)?; + .header("Content-Type", "application/json") + .json(&payload) + .send().await?; auth_complete = check_response.status() == 200; } - let check_res = check_response.into_json::()?; + let check_res = check_response.json::().await?; + + // login with token we just got + Self::login_internal(check_res, client).await + } + + pub async fn with_email(email: &str, password: &str) -> Result { + let client = Client::new(); + let payload = AuthenticationEmailPayload { + email_address: email.to_string(), + password: password.to_string(), + }; + let response = client.post("https://account.freejamgames.com/api/authenticate/email/web") + .header("Content-Type", "application/json") + .json(&payload) + .send().await?; + let json_res = response.json::().await?; + Self::auto_portal(client, "Techblox".to_owned(), json_res.token).await + } + + pub async fn with_username(username: &str, password: &str) -> Result { + let client = Client::new(); + let payload = AuthenticationUsernamePayload { + username: username.to_string(), + password: password.to_string(), + }; + let response = client.post("https://account.freejamgames.com/api/authenticate/displayname/web") + .header("Content-Type", "application/json") + .json(&payload) + .send().await?; + let json_res = response.json::().await?; + Self::auto_portal(client, "Techblox".to_owned(), json_res.token).await + } + + /// Automatically validate portal + async fn auto_portal(client: Client, value: String, token: String) -> Result { + let payload = PortalStartPayload { + target: value, + }; + let start_response = client.post("https://account.freejamgames.com/api/authenticate/portal/start") + .header("Content-Type", "application/json") + .json(&payload) + .send().await?; + let start_res = start_response.json::().await?; + + let payload = PortalCheckPayload { + token: start_res.token, + }; + + let _assign_response = client.post("https://account.freejamgames.com/api/authenticate/portal/assign") + .header("Content-Type", "application/json") + .header("Authorization", "Web ".to_owned() + &token) + .json(&payload) + .send().await?; + + let check_response = client.post("https://account.freejamgames.com/api/authenticate/portal/check") + .header("Content-Type", "application/json") + .json(&payload) + .send().await?; + let check_res = check_response.json::().await?; // login with token we just got + Self::login_internal(check_res, client).await + } + + async fn login_internal(token_data: PortalCheckResponse, client: Client) -> Result { let payload = ProgressionLoginPayload { - token: check_res.token.clone(), + token: token_data.token.clone(), }; let progress_response = client.post("https://progression.production.robocraft2.com/login/fj") - .set("Content-Type", "application/json") - .send_json(&payload)?; - let progress_res = progress_response.into_json::()?; + .header("Content-Type", "application/json") + .json(&payload) + .send().await?; + let progress_res = progress_response.json::().await?; Ok(Self { - token: RwLock::new(progress_res), - jwt: check_res, + token: progress_res, + jwt: token_data, client: client, }) } + /// Login using the portal token data from a previous portal authentication + pub async fn login(token_data: PortalCheckResponse) -> Result { + Self::login_internal(token_data, Client::new()).await + } + pub fn get_account_info(&self) -> Result { Ok(self.jwt.decode_jwt_data()) } + + pub fn token_data(&self) -> &'_ PortalCheckResponse { + &self.jwt + } } +#[async_trait::async_trait] impl ITokenProvider for PortalTokenProvider { - fn token(&self) -> Result { + async fn token(&mut self) -> Result { // TODO re-authenticate when expired - if let Some(token) = self.token.read().map_err(|_| ())?.token.clone() { + if let Some(token) = self.token.token.clone() { Ok(token) } else { Err(()) @@ -82,6 +166,32 @@ impl ITokenProvider for PortalTokenProvider { } } +#[derive(Deserialize, Serialize, Clone)] +pub(crate) struct AuthenticationEmailPayload { + #[serde(rename = "EmailAddress")] + pub email_address: String, + #[serde(rename = "Password")] + pub password: String, +} + +#[derive(Deserialize, Serialize, Clone)] +pub(crate) struct AuthenticationUsernamePayload { + #[serde(rename = "DisplayName")] + pub username: String, + #[serde(rename = "Password")] + pub password: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub(crate) struct AuthenticationResponseInfo { + #[serde(rename = "Token")] + pub token: String, + #[serde(rename = "RefreshToken")] + pub refresh_token: String, + #[serde(rename = "RefreshTokenExpiry")] + pub refresh_token_expiry: String, +} + #[derive(Deserialize, Serialize, Clone)] pub(crate) struct PortalStartPayload { #[serde(rename = "Target")] @@ -100,6 +210,27 @@ pub(crate) struct PortalCheckPayload { pub token: String, } +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct PortalCheckResponse { + #[serde(rename = "Token")] + pub token: String, + #[serde(rename = "RefreshToken")] + pub refresh_token: String, + #[serde(rename = "RefreshTokenExpiry")] + pub refresh_token_expiry: String, +} + +impl PortalCheckResponse { + pub fn decode_jwt_data(&self) -> AccountInfo { + // Refer to https://jwt.io/ + // header is before dot, signature is after dot. + // data is sandwiched in the middle, and it's all we care about + let data = self.token.split(".").collect::>()[1]; + let data_vec = base64::decode(data).unwrap(); + from_slice::(&data_vec).unwrap() + } +} + #[derive(Deserialize, Serialize, Clone)] pub(crate) struct ProgressionLoginPayload { #[serde(rename = "token")] @@ -117,3 +248,59 @@ pub(crate) struct ProgressionLoginResponse { #[serde(rename = "serverToken")] pub server_token: Option, } + +/// Robocraft2 account information. +#[derive(Deserialize, Serialize, Clone)] +pub struct AccountInfo { + /// User's public ID + #[serde(rename = "PublicId")] + pub public_id: String, + /// Account display name + #[serde(rename = "DisplayName")] + pub display_name: String, + /// Account GUID, or display name for older accounts + #[serde(rename = "RobocraftName")] + pub robocraft_name: String, + /// ??? is confirmed? + #[serde(rename = "Confirmed")] + pub confirmed: bool, + /// Freejam support code + #[serde(rename = "SupportCode")] + pub support_code: String, + /// User's email address + #[serde(rename = "EmailAddress")] + pub email_address: String, + /// Email address is verified? + #[serde(rename = "EmailVerified")] + pub email_verified: bool, + /// Account creation date + #[serde(rename = "CreatedDate")] + pub created_date: String, + /// Owned products (?) + #[serde(rename = "Products")] + pub products: Vec, + /// Account flags + #[serde(rename = "Flags")] + pub flags: Vec, + /// Account has a password? + #[serde(rename = "HasPassword")] + pub has_password: bool, + /// Mailing lists that the account is signed up for + #[serde(rename = "MailingLists")] + pub mailing_lists: Vec, + /// Is Steam account? (always false) + #[serde(rename = "HasSteam")] + pub has_steam: bool, + /// iss (?) + #[serde(rename = "iss")] + pub iss: String, + /// sub (?) + #[serde(rename = "sub")] + pub sub: String, + /// Token created at (unix time) (?) + #[serde(rename = "iat")] + pub iat: u64, + /// Token expiry (unix time) (?) + #[serde(rename = "exp")] + pub exp: u64, +} diff --git a/tests/robocraft_auth.rs b/tests/robocraft_auth.rs index 7b0fa91..fd7d80a 100644 --- a/tests/robocraft_auth.rs +++ b/tests/robocraft_auth.rs @@ -34,11 +34,11 @@ fn robocraft_account() -> Result<(), ()> { } // this requires human-interaction so it's disabled by default -#[cfg(feature = "robocraft")] +#[cfg(feature = "robocraft2")] #[allow(dead_code)] -//#[test] -fn robocraft2_account() -> Result<(), ()> { - let token_maybe = robocraft2::PortalTokenProvider::portal(); +//#[tokio::test] +async fn robocraft2_account() -> Result<(), ()> { + let token_maybe = robocraft2::PortalTokenProvider::portal().await; assert!(token_maybe.is_ok()); let token_provider = token_maybe.unwrap(); let account_maybe = token_provider.get_account_info(); @@ -48,3 +48,19 @@ fn robocraft2_account() -> Result<(), ()> { assert_eq!(account.created_date, "2014-09-17T21:02:46"); Ok(()) } + +// this requires human-interaction so it's disabled by default +#[cfg(feature = "robocraft2")] +#[allow(dead_code)] +#[tokio::test] +async fn robocraft2_simple_account() -> Result<(), ()> { + let token_maybe = robocraft2::PortalTokenProvider::with_username("FJAPIC00L", "P4$$w0rd").await; + assert!(token_maybe.is_ok()); + let token_provider = token_maybe.unwrap(); + let account_maybe = token_provider.get_account_info(); + assert!(account_maybe.is_ok()); + let account = account_maybe.unwrap(); + assert_eq!(account.display_name, "FJAPIC00L"); + assert_eq!(account.created_date, "2019-01-18T14:48:09"); + Ok(()) +} diff --git a/tests/robocraft_factory.rs b/tests/robocraft_factory.rs index b34065d..df9aefc 100644 --- a/tests/robocraft_factory.rs +++ b/tests/robocraft_factory.rs @@ -31,12 +31,12 @@ async fn robocraft_factory_default_query() -> Result<(), ()> { Ok(()) } -#[cfg(feature = "robocraft")] +#[cfg(feature = "robocraft2")] #[tokio::test] async fn robocraft2_factory_default_query() -> Result<(), ()> { - let api = robocraft2::FactoryAPI::with_auth(Box::new(robocraft2::PortalTokenProvider::portal().unwrap())); + let api = robocraft2::FactoryAPI::with_auth(Box::new(robocraft2::PortalTokenProvider::with_username("FJAPIC00L", "P4$$w0rd").await.unwrap())); let result = api.list().await; - //assert!(result.is_ok()); + assert!(result.is_ok()); let robo_info = result.unwrap(); assert_ne!(robo_info.results.len(), 0); for robot in &robo_info.results {