Browse Source

Bypass portal authentication, implement full search parameter queries

master
NGnius (Graham) 1 year ago
parent
commit
8bd6e2995f
7 changed files with 303 additions and 51 deletions
  1. +5
    -1
      Cargo.toml
  2. +1
    -1
      src/lib.rs
  3. +57
    -12
      src/robocraft2/factory.rs
  4. +1
    -1
      src/robocraft2/mod.rs
  5. +216
    -29
      src/robocraft2/portal.rs
  6. +20
    -4
      tests/robocraft_auth.rs
  7. +3
    -3
      tests/robocraft_factory.rs

+ 5
- 1
Cargo.toml View File

@@ -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"]

+ 1
- 1
src/lib.rs View File

@@ -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;

+ 57
- 12
src/robocraft2/factory.rs View File

@@ -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<dyn ITokenProvider>,
token: Mutex<Box<dyn ITokenProvider>>,
}

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<dyn ITokenProvider>) -> 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<SearchResponse, Error> {
let url = Url::parse(FACTORY_DOMAIN)
self.search(SearchPayload::default()).await
}

pub async fn search(&self, params: SearchPayload) -> Result<SearchResponse, Error> {
let mut url = Url::parse(FACTORY_DOMAIN)
.unwrap()
.join("/v1/foundry/search")
.unwrap();
if let Some(text) = &params.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) = &params.base_maximum_cpu {
url.query_pairs_mut().append_pair("baseCpuMaximum", &base_maximum_cpu.to_string());
}
if let Some(x) = &params.weapon_minimum_cpu {
url.query_pairs_mut().append_pair("weaponCpuMinimum", &x.to_string());
}
if let Some(x) = &params.weapon_maximum_cpu {
url.query_pairs_mut().append_pair("weaponCpuMaximum", &x.to_string());
}
if let Some(x) = &params.cosmetic_minimum_cpu {
url.query_pairs_mut().append_pair("cosmeticCpuMinimum", &x.to_string());
}
if let Some(x) = &params.cosmetic_maximum_cpu {
url.query_pairs_mut().append_pair("cosmeticCpuMaximum", &x.to_string());
}
if let Some(x) = &params.cluster_minimum {
url.query_pairs_mut().append_pair("clusterMinimum", &x.to_string());
}
if let Some(x) = &params.cluster_maximum {
url.query_pairs_mut().append_pair("clusterMaximum", &x.to_string());
}
if let Some(x) = &params.date_minimum {
url.query_pairs_mut().append_pair("dateMinimum", x);
}
if let Some(x) = &params.date_maximum {
url.query_pairs_mut().append_pair("dateMaximum", x);
}
if let Some(x) = &params.creator_id {
url.query_pairs_mut().append_pair("creatorId", x);
}
if let Some(x) = &params.page {
url.query_pairs_mut().append_pair("page", &x.to_string());
}
if let Some(x) = &params.count {
url.query_pairs_mut().append_pair("count", &x.to_string());
}
url.query_pairs_mut().append_pair("sortBy", &params.sort_by);
url.query_pairs_mut().append_pair("orderBy", &params.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::<SearchResponse>().await
}

async fn search(&self, params: SearchPayload) -> Result<SearchResponse, Error> {
todo!()
}
}

+ 1
- 1
src/robocraft2/mod.rs View File

@@ -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};

+ 216
- 29
src/robocraft2/portal.rs View File

@@ -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<String, ()>;
}

/// 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<ProgressionLoginResponse>,
token: ProgressionLoginResponse,
/// User info token
jwt: AuthenticationResponseInfo,
jwt: PortalCheckResponse,
/// Ureq HTTP client
client: Agent,
client: Client,
}

impl PortalTokenProvider {
pub fn portal() -> Result<Self, Error> {
Self::target("Techblox".to_owned())
/// Login through the web browser portal
pub async fn portal() -> Result<Self, Error> {
Self::target("Techblox".to_owned()).await
}

pub fn target(value: String) -> Result<Self, Error> {
let client = Agent::new();
/// Login through the portal with a custom target value
pub async fn target(value: String) -> Result<Self, Error> {
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::<PortalStartResponse>()?;
.header("Content-Type", "application/json")
.json(&payload)
.send().await?;
let start_res = start_response.json::<PortalStartResponse>().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::<AuthenticationResponseInfo>()?;
let check_res = check_response.json::<PortalCheckResponse>().await?;

// login with token we just got
Self::login_internal(check_res, client).await
}

pub async fn with_email(email: &str, password: &str) -> Result<Self, Error> {
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::<AuthenticationResponseInfo>().await?;
Self::auto_portal(client, "Techblox".to_owned(), json_res.token).await
}

pub async fn with_username(username: &str, password: &str) -> Result<Self, Error> {
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::<AuthenticationResponseInfo>().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<Self, Error> {
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::<PortalStartResponse>().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::<PortalCheckResponse>().await?;

// login with token we just got
Self::login_internal(check_res, client).await
}

async fn login_internal(token_data: PortalCheckResponse, client: Client) -> Result<Self, Error> {
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::<ProgressionLoginResponse>()?;
.header("Content-Type", "application/json")
.json(&payload)
.send().await?;
let progress_res = progress_response.json::<ProgressionLoginResponse>().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, Error> {
Self::login_internal(token_data, Client::new()).await
}

pub fn get_account_info(&self) -> Result<AccountInfo, Error> {
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<String, ()> {
async fn token(&mut self) -> Result<String, ()> {
// 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::<Vec<&str>>()[1];
let data_vec = base64::decode(data).unwrap();
from_slice::<AccountInfo>(&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<String>,
}

/// 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<String>,
/// Account flags
#[serde(rename = "Flags")]
pub flags: Vec<String>,
/// 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<String>,
/// 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,
}

+ 20
- 4
tests/robocraft_auth.rs View File

@@ -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(())
}

+ 3
- 3
tests/robocraft_factory.rs View File

@@ -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 {


Loading…
Cancel
Save