diff --git a/Cargo.lock b/Cargo.lock index ec913bc..4def34c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -698,7 +698,7 @@ dependencies = [ [[package]] name = "lzma-sys" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "cc 1.0.37 (registry+https://github.com/rust-lang/crates.io-index)", @@ -2053,7 +2053,7 @@ name = "xz2" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ - "lzma-sys 0.1.14 (registry+https://github.com/rust-lang/crates.io-index)", + "lzma-sys 0.1.15 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -2145,7 +2145,7 @@ dependencies = [ "checksum lock_api 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "62ebf1391f6acad60e5c8b43706dde4582df75c06698ab44511d15016bc2442c" "checksum log 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)" = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b" "checksum log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)" = "c84ec4b527950aa83a329754b01dbe3f58361d1c5efacd1f6d68c494d08a17c6" -"checksum lzma-sys 0.1.14 (registry+https://github.com/rust-lang/crates.io-index)" = "16b5c59c57cc4d39e7999f50431aa312ea78af7c93b23fbb0c3567bd672e7f35" +"checksum lzma-sys 0.1.15 (registry+https://github.com/rust-lang/crates.io-index)" = "53e48818fd597d46155132bbbb9505d6d1b3d360b4ee25cfa91c406f8a90fe91" "checksum matches 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)" = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" "checksum memchr 2.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "2efc7bc57c883d4a4d6e3246905283d8dae951bb3bd32f49d6ef297f546e1c39" "checksum memoffset 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "0f9dc261e2b62d7a622bf416ea3c5245cdd5d9a7fcc428c0d06804dfce1775b3" diff --git a/config.windows.patreon.toml b/config.windows.patreon.toml index 673a379..2276238 100644 --- a/config.windows.patreon.toml +++ b/config.windows.patreon.toml @@ -28,7 +28,7 @@ default = true [[packages]] name = "yuzu Early Access" -description = "The build for all those epic Chads out there who didn't have to steal 5 dollar from their mom to pay for this." +description = "Bonus preview release for project supporters. Thanks for your support!" # Displayed when the package has no authentication for the user need_authentication_description = "Click here to sign in with your yuzu account for Early Access" # Displayed when the package has an authentication, but the user has not linked their account diff --git a/src/frontend/rest/services/authentication.rs b/src/frontend/rest/services/authentication.rs index cdbb2fa..6ce4b63 100644 --- a/src/frontend/rest/services/authentication.rs +++ b/src/frontend/rest/services/authentication.rs @@ -1,5 +1,5 @@ -use http::build_async_client; +use http::{build_client, build_async_client}; use hyper::header::{ContentLength, ContentType}; use reqwest::header::{USER_AGENT}; @@ -12,49 +12,130 @@ use logging::LoggingErrors; use url::form_urlencoded; use std::collections::HashMap; use std::sync::Arc; +use config::JWTValidation; -/// claims struct, it needs to derive `Serialize` and/or `Deserialize` #[derive(Debug, Serialize, Deserialize)] -struct JWTClaims { - sub: String, - iss: String, - aud: String, - exp: usize, - #[serde(default)] - roles: Vec, - #[serde(rename = "releaseChannels", default)] - channels: Vec, - #[serde(rename = "IsPatreonAccountLinked")] - is_linked: bool, - #[serde(rename = "IsPatreonSubscriptionActive")] - is_subscribed: bool, +struct Auth { + username: String, + token: String, + jwt_token: JWTClaims, } -fn get_text(future: impl Future) -> impl Future { - future.map(|mut response| { - // Get the body of the response +/// claims struct, it needs to derive `Serialize` and/or `Deserialize` +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JWTClaims { + pub sub: String, + pub iss: String, + pub aud: String, + pub exp: usize, + #[serde(default)] + pub roles: Vec, + #[serde(rename = "releaseChannels", default)] + pub channels: Vec, + #[serde(rename = "isPatreonAccountLinked")] + pub is_linked: bool, + #[serde(rename = "isPatreonSubscriptionActive")] + pub is_subscribed: bool, +} + +/// Calls the given server to obtain a JWT token and returns a Future with the response +pub fn authenticate_async(url: String, username: String, token: String) + -> Box> { + + // Build the HTTP client up + let client = match build_async_client() { + Ok(v) => v, + Err(_) => { + return Box::new(futures::future::err("Unable to build async web client".to_string())); + }, + }; + + Box::new(client.post(&url) + .header(USER_AGENT, "liftinstall (j-selby)") + .header("X-USERNAME", username.clone()) + .header("X-TOKEN", token.clone()) + .send() + .map_err(|err| { + format!("stream error {:?}, client: {:?}, http: {:?}, redirect: {:?}, serialization: {:?}, timeout: {:?}, server: {:?}", + err, err.is_client_error(), err.is_http(), err.is_redirect(), + err.is_serialization(), err.is_timeout(), err.is_server_error()) + }) + .map(|mut response| { match response.status() { reqwest::StatusCode::OK => Ok(response.text() .map_err(|e| { - error!("Error while converting the response to text {:?}", e); - Response::new() - .with_status(hyper::StatusCode::InternalServerError) + format!("Error while converting the response to text {:?}", e) })), _ => { - error!("Error wrong response code from server {:?}", response.status()); - Err(Response::new() - .with_status(hyper::StatusCode::InternalServerError)) + Err(format!("Error wrong response code from server {:?}", response.status())) } } }) - .map_err(|err| { - error!("Error cannot get text on errored stream {:?}", err); - Response::new() - .with_status(hyper::StatusCode::InternalServerError) - }) .and_then(|x| x) .flatten() + ) +} + +pub fn authenticate_sync(url: String, username: String, token: String) + -> Result { + + // Build the HTTP client up + let client = build_client()?; + + let mut response = client.post(&url) + .header(USER_AGENT, "liftinstall (j-selby)") + .header("X-USERNAME", username.clone()) + .header("X-TOKEN", token.clone()) + .send() + .map_err(|err| { + format!("stream error {:?}, client: {:?}, http: {:?}, redirect: {:?}, serialization: {:?}, timeout: {:?}, server: {:?}", + err, err.is_client_error(), err.is_http(), err.is_redirect(), + err.is_serialization(), err.is_timeout(), err.is_server_error()) + })?; + + match response.status() { + reqwest::StatusCode::OK => + Ok(response.text() + .map_err(|e| { + format!("Error while converting the response to text {:?}", e) + })?), + _ => { + Err(format!("Error wrong response code from server {:?}", response.status())) + } + } +} + +pub fn validate_token(body: String, pub_key_base64: String, validation: Option) -> Result { + // Get the public key for this authentication url + let pub_key = if pub_key_base64.is_empty() { + vec![] + } else { + match base64::decode(&pub_key_base64) { + Ok(v) => v, + Err(err) => { + return Err(format!("Configured public key was not empty and did not decode as base64 {:?}", err)); + }, + } + }; + + // Configure validation for audience and issuer if the configuration provides it + let validation = match validation { + Some(v) => { + let mut valid = Validation::new(Algorithm::RS256); + valid.iss = v.iss; + if v.aud.is_some() { + valid.set_audience(&v.aud.unwrap()); + } + valid + } + None => Validation::default() + }; + + // Verify the JWT token + decode::(&body, pub_key.as_slice(), &validation) + .map(|tok| tok.claims) + .map_err(|err| format!("Error while decoding the JWT. error: {:?} jwt: {:?}", err, body)) } pub fn handle(service: &WebService, _req: Request) -> InternalFuture { @@ -93,87 +174,53 @@ pub fn handle(service: &WebService, _req: Request) -> InternalFuture { (credentials.username.clone(), credentials.token.clone()) } }; + // second copy of the credentials so we can move them into a different closure + let (username_clone, token_clone) = (username.clone(), token.clone()); let authentication = config.authentication.unwrap(); - - // Get the public key for this authentication url - let pub_key = if authentication.pub_key_base64.is_empty() { - vec![] - } else { - match base64::decode(&authentication.pub_key_base64) { - Ok(v) => v, - Err(err) => { - error!("Configured public key was not empty and did not decode as base64 {:?}", err); - return default_future(Response::new().with_status(hyper::StatusCode::InternalServerError)); - }, - } - }; - - // Build the HTTP client up - let client = match build_async_client() { - Ok(v) => v, - Err(_) => { - return default_future(Response::new().with_status(hyper::StatusCode::InternalServerError)); - }, - }; + let auth_url = authentication.auth_url.clone(); + let pub_key_base64 = authentication.pub_key_base64.clone(); + let validation = authentication.validation.clone(); // call the authentication URL to see if we are authenticated - Box::new(get_text( - client.post(&authentication.auth_url) - .header(USER_AGENT, "liftinstall (j-selby)") - .header("X-USERNAME", username.clone()) - .header("X-TOKEN", token.clone()) - .send() - ).map(move |body| { - // Configure validation for audience and issuer if the configuration provides it - let validation = match authentication.validation { - Some(v) => { - let mut valid = Validation::new(Algorithm::RS256); - valid.iss = v.iss; - if v.aud.is_some() { - valid.set_audience(&v.aud.unwrap()); - } - valid - } - None => Validation::default() + Box::new(authenticate_async(auth_url, username.clone(), token.clone()) + .map(|body| { + validate_token(body, pub_key_base64, validation) + }) + .and_then(|res| res) + .map(move |claims| { + let out = Auth { + username: username_clone, + token: token_clone, + jwt_token: claims.clone(), }; - - // Verify the JWT token - let tok = match decode::(&body, pub_key.as_slice(), &validation) { - Ok(v) => v, - Err(v) => { - error!("Error while decoding the JWT. error: {:?} str: {:?}", v, &body); - return Err(Response::new().with_status(hyper::StatusCode::InternalServerError)); - }, - }; - - { - // Store the validated username and password into the installer database - let mut framework = write_cred_fw.write().log_expect("InstallerFramework has been dirtied"); - framework.database.credentials.username = username.clone(); - framework.database.credentials.token = token.clone(); - // And store the JWT token temporarily in the - framework.authorization_token = Some(body.clone()); - } - // Convert the json to a string and return the json token - match serde_json::to_string(&tok.claims) { + match serde_json::to_string(&out) { Ok(v) => Ok(v), Err(e) => { - error!("Error while converting the claims to JSON string: {:?}", e); - Err(Response::new().with_status(hyper::StatusCode::InternalServerError)) + Err(format!("Error while converting the claims to JSON string: {:?}", e)) } } }) .and_then(|res| res) - .map(|out| { + .map(move |json| { + { + // Store the validated username and password into the installer database + let mut framework = write_cred_fw.write().log_expect("InstallerFramework has been dirtied"); + framework.database.credentials.username = username; + framework.database.credentials.token = token; + } + // Finally return the JSON with the response info!("successfully verified username and token"); Response::new() - .with_header(ContentLength(out.len() as u64)) + .with_header(ContentLength(json.len() as u64)) .with_header(ContentType::json()) .with_status(hyper::StatusCode::Ok) - .with_body(out) + .with_body(json) + }) + .map_err(|err| { + Response::new().with_status(hyper::StatusCode::InternalServerError) }) .or_else(|err| { // Convert the Err value into an Ok value since the error code from this HTTP request is an Ok(response) diff --git a/src/frontend/rest/services/mod.rs b/src/frontend/rest/services/mod.rs index 1b7473e..f5d984d 100644 --- a/src/frontend/rest/services/mod.rs +++ b/src/frontend/rest/services/mod.rs @@ -21,7 +21,7 @@ use futures::future::Future as _; use futures::sink::Sink; mod attributes; -mod authentication; +pub mod authentication; mod browser; mod config; mod default_path; diff --git a/src/frontend/ui/mod.rs b/src/frontend/ui/mod.rs index 149a67b..82378cd 100644 --- a/src/frontend/ui/mod.rs +++ b/src/frontend/ui/mod.rs @@ -17,7 +17,7 @@ enum CallbackType { /// Starts the main web UI. Will return when UI is closed. pub fn start_ui(app_name: &str, http_address: &str, is_launcher: bool) { - let size = if is_launcher { (600, 300) } else { (1024, 500) }; + let size = (1024, 500); info!("Spawning web view instance"); diff --git a/src/installer.rs b/src/installer.rs index d0c9f10..2c6256b 100644 --- a/src/installer.rs +++ b/src/installer.rs @@ -50,6 +50,7 @@ pub enum InstallMessage { Status(String, f64), PackageInstalled, Error(String), + AuthorizationRequired(String), EOF, } @@ -92,7 +93,6 @@ pub struct InstallerFramework { // If we just completed an uninstall, and we should clean up after ourselves. pub burn_after_exit: bool, pub launcher_path: Option, - pub authorization_token: Option, } /// Contains basic properties on the status of the session. Subset of InstallationFramework. @@ -125,6 +125,11 @@ macro_rules! declare_messenger_callback { error!("Failed to submit queue message: {:?}", v); } } + TaskMessage::AuthorizationRequired(msg) => { + if let Err(v) = $target.send(InstallMessage::AuthorizationRequired(msg.to_string())) { + error!("Failed to submit queue message: {:?}", v); + } + } TaskMessage::PackageInstalled => { if let Err(v) = $target.send(InstallMessage::PackageInstalled) { error!("Failed to submit queue message: {:?}", v); @@ -441,7 +446,6 @@ impl InstallerFramework { is_launcher: false, burn_after_exit: false, launcher_path: None, - authorization_token: None, } } @@ -469,7 +473,6 @@ impl InstallerFramework { is_launcher: false, burn_after_exit: false, launcher_path: None, - authorization_token: None, }) } } diff --git a/src/tasks/check_authorization.rs b/src/tasks/check_authorization.rs new file mode 100644 index 0000000..e2ffb9f --- /dev/null +++ b/src/tasks/check_authorization.rs @@ -0,0 +1,69 @@ + +use installer::InstallerFramework; +use tasks::{Task, TaskDependency, TaskMessage, TaskOrdering, TaskParamType}; +use logging::LoggingErrors; +use frontend::rest::services::authentication; +use futures::{Stream, Future}; +use tasks::resolver::ResolvePackageTask; + +pub struct CheckAuthorizationTask { + pub name: String, +} + +impl Task for CheckAuthorizationTask { + fn execute( + &mut self, + mut input: Vec, + context: &mut InstallerFramework, + messenger: &dyn Fn(&TaskMessage), + ) -> Result { + + assert_eq!(input.len(), 1); + let params = input.pop().log_expect("Should have input from resolver!"); + let (version, file) = match params { + TaskParamType::File(v, f) => { Ok((v, f)) }, + _ => { Err("Unexpected TaskParamType in CheckAuthorization: {:?}") } + }?; + + if !file.requires_authorization { + return Ok(TaskParamType::Authentication(version, file, None)); + } + + let username = context.database.credentials.username.clone(); + let token = context.database.credentials.token.clone(); + let authentication = context.config.clone().unwrap().authentication.unwrap(); + let auth_url = authentication.auth_url.clone(); + let pub_key_base64 = authentication.pub_key_base64.clone(); + let validation = authentication.validation.clone(); + // Authorizaion is required for this package so post the username and token and get a jwt_token response + let jwt_token = match authentication::authenticate_sync(auth_url, username, token) { + Ok(jwt) => jwt, + Err(_) => return Ok(TaskParamType::Authentication(version, file, None)) + }; + let claims = match authentication::validate_token(jwt_token.clone(), pub_key_base64, validation) { + Ok(c) => c, + Err(_) => return Ok(TaskParamType::Authentication(version, file, None)) + }; + // Validate that they are authorized + let authorized = + claims.roles.contains(&"vip".to_string()) || (claims.channels.contains(&"early-access".to_string())); + + if !authorized { + return Ok(TaskParamType::Authentication(version, file, None)); + } + Ok(TaskParamType::Authentication(version, file, Some(jwt_token))) + } + + fn dependencies(&self) -> Vec { + vec![TaskDependency::build( + TaskOrdering::Pre, + Box::new(ResolvePackageTask { + name: self.name.clone(), + }), + )] + } + + fn name(&self) -> String { + format!("CheckAuthorizationTask (for {:?})", self.name) + } +} \ No newline at end of file diff --git a/src/tasks/download_pkg.rs b/src/tasks/download_pkg.rs index 3262ec0..396063c 100644 --- a/src/tasks/download_pkg.rs +++ b/src/tasks/download_pkg.rs @@ -2,11 +2,8 @@ use installer::InstallerFramework; -use tasks::Task; -use tasks::TaskDependency; -use tasks::TaskMessage; -use tasks::TaskOrdering; -use tasks::TaskParamType; +use tasks::check_authorization::CheckAuthorizationTask; +use tasks::{Task, TaskDependency, TaskMessage, TaskOrdering, TaskParamType}; use tasks::resolver::ResolvePackageTask; @@ -30,11 +27,18 @@ impl Task for DownloadPackageTask { assert_eq!(input.len(), 1); let file = input.pop().log_expect("Should have input from resolver!"); - let (version, file) = match file { - TaskParamType::File(v, f) => (v, f), + let (version, file, auth) = match file { + TaskParamType::Authentication(v, f, auth) => (v, f, auth), _ => return Err("Unexpected param type to download package".to_string()), }; +// TODO: move this back below checking for latest version after testing is done + if file.requires_authorization && auth.is_none() { + info!("Authorization required to update this package!"); + messenger(&TaskMessage::AuthorizationRequired("AuthorizationRequired")); + return Ok(TaskParamType::Break); + } + // Check to see if this is the newest file available already for element in &context.database.packages { if element.name == self.name { @@ -54,7 +58,7 @@ impl Task for DownloadPackageTask { let mut downloaded = 0; let mut data_storage: Vec = Vec::new(); - stream_file(&file.url, context.authorization_token.clone(), |data, size| { + stream_file(&file.url, auth, |data, size| { { data_storage.extend_from_slice(&data); } @@ -90,12 +94,12 @@ impl Task for DownloadPackageTask { } fn dependencies(&self) -> Vec { - vec![TaskDependency::build( - TaskOrdering::Pre, - Box::new(ResolvePackageTask { - name: self.name.clone(), - }), - )] + vec![TaskDependency::build( + TaskOrdering::Pre, + Box::new(CheckAuthorizationTask { + name: self.name.clone(), + }), + )] } fn name(&self) -> String { diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index ed6154b..a898f50 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -9,6 +9,7 @@ use installer::InstallerFramework; use sources::types::File; use sources::types::Version; +pub mod check_authorization; pub mod download_pkg; pub mod ensure_only_instance; pub mod install; @@ -29,6 +30,8 @@ pub enum TaskParamType { None, /// Metadata about a file File(Version, File), + /// Authentication token for a package + Authentication(Version, File, Option), /// Downloaded contents of a file FileContents(Version, File, Vec), /// List of shortcuts that have been generated @@ -62,6 +65,7 @@ impl TaskDependency { /// A message from a task. pub enum TaskMessage<'a> { DisplayMessage(&'a str, f64), + AuthorizationRequired(&'a str), PackageInstalled, } diff --git a/ui/src/main.js b/ui/src/main.js index fc9dca2..787db99 100644 --- a/ui/src/main.js +++ b/ui/src/main.js @@ -123,7 +123,9 @@ var app = new Vue({ var app = this.$root; app.ajax('/api/check-auth', function (auth) { - that.jwt_token = auth; + app.$data.username = auth.username; + app.$data.token = auth.token; + that.jwt_token = auth.jwt_token; that.is_authenticated = Object.keys(that.jwt_token).length !== 0 && that.jwt_token.constructor === Object; if (that.is_authenticated) { // Give all permissions to vip roles @@ -132,9 +134,9 @@ var app = new Vue({ that.is_subscribed = true; that.has_reward_tier = true; } else { - that.is_linked = that.jwt_token.IsPatreonAccountLinked; - that.is_subscribed = that.jwt_token.IsPatreonSubscriptionActive; - that.has_reward_tier = that.jwt_token.releaseChannels.indexOf("early-release") > -1; + that.is_linked = that.jwt_token.isPatreonAccountLinked; + that.is_subscribed = that.jwt_token.isPatreonSubscriptionActive; + that.has_reward_tier = that.jwt_token.releaseChannels.indexOf("early-access") > -1; } } if (success) { diff --git a/ui/src/router.js b/ui/src/router.js index 29db2f4..8b021d7 100644 --- a/ui/src/router.js +++ b/ui/src/router.js @@ -8,6 +8,7 @@ import InstallPackages from './views/InstallPackages.vue' import CompleteView from './views/CompleteView.vue' import ModifyView from './views/ModifyView.vue' import AuthenticationView from './views/AuthenticationView.vue' +import ReAuthenticationView from './views/ReAuthenticationView.vue' Vue.use(Router) @@ -53,6 +54,11 @@ export default new Router({ name: 'authentication', component: AuthenticationView }, + { + path: '/reauthenticate', + name: 'reauthenticate', + component: ReAuthenticationView + }, { path: '/', redirect: '/config' diff --git a/ui/src/views/AuthenticationView.vue b/ui/src/views/AuthenticationView.vue index 1735e4d..e1dc633 100644 --- a/ui/src/views/AuthenticationView.vue +++ b/ui/src/views/AuthenticationView.vue @@ -6,7 +6,7 @@

Before you can install this Early Access, you need to verify your account. - Click here to link your yuzu-emu.org account + Click here to link your yuzu-emu.org account and paste the token below.

@@ -30,17 +30,17 @@ Your credentials are valid, but you still need to link your patreon! - If this is an error, then click here to link your yuzu-emu.org account + If this is an error, then click here to link your yuzu-emu.org account Your patreon is linked, but you are not a current subscriber. - Log into your patreon account and support the project! + Log into your patreon account and support the project! Your patreon is linked, and you are supporting the project, but you must first join the Early Access reward tier! - Log into your patreon account and choose to back the Early Access reward tier. + Log into your patreon account and choose to back the Early Access reward tier.
diff --git a/ui/src/views/InstallPackages.vue b/ui/src/views/InstallPackages.vue index f90032b..514b6fd 100644 --- a/ui/src/views/InstallPackages.vue +++ b/ui/src/views/InstallPackages.vue @@ -25,6 +25,7 @@ export default { is_updater_update: false, is_update: false, failed_with_error: false, + authorization_required: false, packages_installed: 0 } }, @@ -41,10 +42,12 @@ export default { var app = this.$root var results = {} + var requires_authorization = false; for (var package_index = 0; package_index < app.config.packages.length; package_index++) { var current_package = app.config.packages[package_index] if (current_package.default != null) { + requires_authorization |= current_package.requires_authorization; results[current_package.name] = current_package.default } } @@ -71,6 +74,10 @@ export default { that.packages_installed += 1 } + if (line.hasOwnProperty('AuthorizationRequired')) { + that.authorization_required = true + } + if (line.hasOwnProperty('Error')) { that.failed_with_error = true that.$router.replace({ name: 'showerr', params: { msg: line.Error } }) @@ -90,7 +97,9 @@ export default { } } } else { - if (app.metadata.is_launcher) { + if (that.authorization_required) { + that.$router.push('/reauthenticate') + } else if (app.metadata.is_launcher) { app.exit() } else if (!that.failed_with_error) { if (that.is_uninstall) { diff --git a/ui/src/views/ReAuthenticationView.vue b/ui/src/views/ReAuthenticationView.vue new file mode 100644 index 0000000..7f16c3b --- /dev/null +++ b/ui/src/views/ReAuthenticationView.vue @@ -0,0 +1,47 @@ + + + + + \ No newline at end of file