From 7392e1ef91c0987456b9f7654b92ad0b3c6544ad Mon Sep 17 00:00:00 2001 From: James Date: Sat, 16 Nov 2019 05:43:11 +0000 Subject: [PATCH] Tweak Patreon authentication implementation --- build.rs | 10 +- src/frontend/rest/services/authentication.rs | 266 +++++++++++-------- src/frontend/rest/services/browser.rs | 19 +- src/frontend/rest/services/mod.rs | 2 +- src/frontend/ui/mod.rs | 4 +- src/http.rs | 40 ++- src/installer.rs | 8 +- src/main.rs | 4 +- src/sources/patreon.rs | 18 +- src/tasks/check_authorization.rs | 55 ++-- src/tasks/download_pkg.rs | 16 +- 11 files changed, 261 insertions(+), 181 deletions(-) diff --git a/build.rs b/build.rs index 1baf0b5..5e332e2 100644 --- a/build.rs +++ b/build.rs @@ -87,8 +87,8 @@ fn main() { // Copy for the main build copy(&target_config, output_dir.join("bootstrap.toml")).expect("Unable to copy config file"); - let yarn_binary = which::which("yarn") - .expect("Failed to find yarn - please go ahead and install it!"); + let yarn_binary = + which::which("yarn").expect("Failed to find yarn - please go ahead and install it!"); // Build and deploy frontend files Command::new(&yarn_binary) @@ -100,7 +100,8 @@ fn main() { .arg(ui_dir.to_str().expect("Unable to covert path")) .spawn() .unwrap() - .wait().expect("Unable to install Node.JS dependencies using Yarn"); + .wait() + .expect("Unable to install Node.JS dependencies using Yarn"); Command::new(&yarn_binary) .args(&[ "--cwd", @@ -115,5 +116,6 @@ fn main() { ]) .spawn() .unwrap() - .wait().expect("Unable to build frontend assets using Webpack"); + .wait() + .expect("Unable to build frontend assets using Webpack"); } diff --git a/src/frontend/rest/services/authentication.rs b/src/frontend/rest/services/authentication.rs index 5f403d7..4364640 100644 --- a/src/frontend/rest/services/authentication.rs +++ b/src/frontend/rest/services/authentication.rs @@ -1,19 +1,29 @@ +//! frontend/rest/services/authentication.rs +//! +//! Provides mechanisms to authenticate users using JWT. -use http::{build_client, build_async_client}; - -use hyper::header::{ContentLength, ContentType}; -use reqwest::header::{USER_AGENT}; -use futures::{Stream, Future}; -use jwt::{decode, Validation, Algorithm}; - -use frontend::rest::services::{WebService, Request, Response, default_future}; -use frontend::rest::services::Future as InternalFuture; -use logging::LoggingErrors; -use url::form_urlencoded; use std::collections::HashMap; use std::sync::Arc; + +use futures::{Future, Stream}; + +use hyper::header::{ContentLength, ContentType}; + +use jwt::{decode, Algorithm, Validation}; + +use reqwest::header::USER_AGENT; + +use url::form_urlencoded; + +use frontend::rest::services::Future as InternalFuture; +use frontend::rest::services::{default_future, Request, Response, WebService}; + +use http::{build_async_client, build_client}; + use config::JWTValidation; +use logging::LoggingErrors; + #[derive(Debug, Serialize, Deserialize)] struct Auth { username: String, @@ -39,15 +49,19 @@ pub struct JWTClaims { } /// 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> { - +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())); - }, + return Box::new(futures::future::err( + "Unable to build async web client".to_string(), + )); + } }; Box::new(client.post(&url) @@ -77,9 +91,7 @@ pub fn authenticate_async(url: String, username: String, token: String) ) } -pub fn authenticate_sync(url: String, username: String, token: String) - -> Result { - +pub fn authenticate_sync(url: String, username: String, token: String) -> Result { // Build the HTTP client up let client = build_client()?; @@ -95,18 +107,21 @@ pub fn authenticate_sync(url: String, username: String, token: String) })?; 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())) - } + 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 { +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![] @@ -114,8 +129,11 @@ pub fn validate_token(body: String, pub_key_base64: String, validation: Option v, Err(err) => { - return Err(format!("Configured public key was not empty and did not decode as base64 {:?}", err)); - }, + return Err(format!( + "Configured public key was not empty and did not decode as base64 {:?}", + err + )); + } } }; @@ -124,24 +142,35 @@ pub fn validate_token(body: String, pub_key_base64: String, validation: Option { let mut valid = Validation::new(Algorithm::RS256); valid.iss = v.iss; - if v.aud.is_some() { - valid.set_audience(&v.aud.unwrap()); + if let &Some(ref v) = &v.aud { + valid.set_audience(v); } valid } - None => Validation::default() + 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)) + .map_err(|err| { + format!( + "Error while decoding the JWT. error: {:?} jwt: {:?}", + err, body + ) + }) } pub fn handle(service: &WebService, _req: Request) -> InternalFuture { - let framework = service.framework.read().log_expect("InstallerFramework has been dirtied"); + let framework = service + .framework + .read() + .log_expect("InstallerFramework has been dirtied"); let credentials = framework.database.credentials.clone(); - let config = framework.config.clone().unwrap(); + let config = framework + .config + .clone() + .log_expect("No in-memory configuration found"); // If authentication isn't configured, just return immediately if config.authentication.is_none() { @@ -152,83 +181,100 @@ pub fn handle(service: &WebService, _req: Request) -> InternalFuture { let write_cred_fw = Arc::clone(&service.framework); Box::new( - _req.body().concat2().map(move |body| { - let req = form_urlencoded::parse(body.as_ref()) - .into_owned() - .collect::>(); + _req.body() + .concat2() + .map(move |body| { + let req = form_urlencoded::parse(body.as_ref()) + .into_owned() + .collect::>(); - // Determine which credentials we should use - let (username, token) = { - let req_username = req.get("username").unwrap(); - let req_token = req.get("token").unwrap(); - // if the user didn't provide credentials, and theres nothing stored in the database, return an early error - let req_cred_valid = !req_username.is_empty() && !req_token.is_empty(); - let stored_cred_valid = !credentials.username.is_empty() && !credentials.token.is_empty(); - if !req_cred_valid && !stored_cred_valid { - info!("No passed in credential and no stored credentials to validate"); - return default_future(Response::new().with_status(hyper::BadRequest)); - } - if req_cred_valid { - (req.get("username").unwrap().clone(), req.get("token").unwrap().clone()) - } else { - (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()); + // Determine which credentials we should use + let (username, token) = { + let req_username = req.get("username").log_expect("No username in request"); + let req_token = req.get("token").log_expect("No token in request"); - let authentication = config.authentication.unwrap(); - let auth_url = authentication.auth_url.clone(); - let pub_key_base64 = authentication.pub_key_base64.clone(); - let validation = authentication.validation.clone(); + // if the user didn't provide credentials, and theres nothing stored in the + // database, return an early error + let req_cred_valid = !req_username.is_empty() && !req_token.is_empty(); + let stored_cred_valid = + !credentials.username.is_empty() && !credentials.token.is_empty(); - // call the authentication URL to see if we are authenticated - 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: Some(claims.clone()), - }; - // Convert the json to a string and return the json token - match serde_json::to_string(&out) { - Ok(v) => Ok(v), - Err(e) => { - Err(format!("Error while converting the claims to JSON string: {:?}", e)) - } - } - }) - .and_then(|res| res) - .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; + if !req_cred_valid && !stored_cred_valid { + info!("No passed in credential and no stored credentials to validate"); + return default_future(Response::new().with_status(hyper::BadRequest)); } - // Finally return the JSON with the response - info!("successfully verified username and token"); - Response::new() - .with_header(ContentLength(json.len() as u64)) - .with_header(ContentType::json()) - .with_status(hyper::StatusCode::Ok) - .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) - Ok(err) - }) - ) - }) - // Flatten the internal future into the output response future - .flatten() + if req_cred_valid { + (req_username.clone(), req_token.clone()) + } else { + (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 + .log_expect("No authentication configuration"); + 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( + 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: Some(claims.clone()), + }; + // Convert the json to a string and return the json token + match serde_json::to_string(&out) { + Ok(v) => Ok(v), + Err(e) => Err(format!( + "Error while converting the claims to JSON string: {:?}", + e + )), + } + }) + .and_then(|res| res) + .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(json.len() as u64)) + .with_header(ContentType::json()) + .with_status(hyper::StatusCode::Ok) + .with_body(json) + }) + .map_err(|err| { + error!( + "Got an internal error while processing user token: {:?}", + 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) + Ok(err) + }), + ) + }) + // Flatten the internal future into the output response future + .flatten(), ) -} \ No newline at end of file +} diff --git a/src/frontend/rest/services/browser.rs b/src/frontend/rest/services/browser.rs index 32885cc..9d8b261 100644 --- a/src/frontend/rest/services/browser.rs +++ b/src/frontend/rest/services/browser.rs @@ -1,19 +1,21 @@ +//! frontend/rest/services/browser.rs +//! +//! Launches the user's web browser on request from the frontend. - -use frontend::rest::services::{WebService, Request, Response}; use frontend::rest::services::Future as InternalFuture; -use futures::{Stream, Future}; -use url::form_urlencoded; -use std::collections::HashMap; +use frontend::rest::services::{Request, Response, WebService}; +use futures::{Future, Stream}; use hyper::header::ContentType; +use logging::LoggingErrors; +use std::collections::HashMap; +use url::form_urlencoded; pub fn handle(_service: &WebService, _req: Request) -> InternalFuture { - Box::new( - _req.body().concat2().map(move |body| { + Box::new(_req.body().concat2().map(move |body| { let req = form_urlencoded::parse(body.as_ref()) .into_owned() .collect::>(); - if webbrowser::open( req.get("url").unwrap()).is_ok() { + if webbrowser::open(req.get("url").log_expect("No URL to launch")).is_ok() { Response::new() .with_status(hyper::Ok) .with_header(ContentType::json()) @@ -26,4 +28,3 @@ pub fn handle(_service: &WebService, _req: Request) -> InternalFuture { } })) } - diff --git a/src/frontend/rest/services/mod.rs b/src/frontend/rest/services/mod.rs index f5d984d..390eb3c 100644 --- a/src/frontend/rest/services/mod.rs +++ b/src/frontend/rest/services/mod.rs @@ -24,8 +24,8 @@ mod attributes; pub mod authentication; mod browser; mod config; -mod default_path; mod dark_mode; +mod default_path; mod exit; mod install; mod installation_status; diff --git a/src/frontend/ui/mod.rs b/src/frontend/ui/mod.rs index 8d9028e..e42b0a4 100644 --- a/src/frontend/ui/mod.rs +++ b/src/frontend/ui/mod.rs @@ -12,11 +12,11 @@ use log::Level; enum CallbackType { SelectInstallDir { callback_name: String }, Log { msg: String, kind: String }, - Test {} + Test {}, } /// Starts the main web UI. Will return when UI is closed. -pub fn start_ui(app_name: &str, http_address: &str, is_launcher: bool) { +pub fn start_ui(app_name: &str, http_address: &str, _is_launcher: bool) { let size = (1024, 550); info!("Spawning web view instance"); diff --git a/src/http.rs b/src/http.rs index e892d5e..59928d4 100644 --- a/src/http.rs +++ b/src/http.rs @@ -9,6 +9,7 @@ use std::time::Duration; use reqwest::async::Client as AsyncClient; use reqwest::Client; +use reqwest::StatusCode; /// Asserts that a URL is valid HTTPS, else returns an error. pub fn assert_ssl(url: &str) -> Result<(), String> { @@ -36,22 +37,39 @@ pub fn build_async_client() -> Result { } /// Streams a file from a HTTP server. -pub fn stream_file(url: &str, authorization: Option, mut callback: F) -> Result<(), String> +pub fn stream_file( + url: &str, + authorization: Option, + mut callback: F, +) -> Result<(), String> where F: FnMut(Vec, u64) -> (), { assert_ssl(url)?; - let mut client = if authorization.is_some() { - build_client()?.get(url) - .header("Authorization", format!("Bearer {}", authorization.unwrap())) - .send() - .map_err(|x| format!("Failed to GET resource: {:?}", x))? - } else { - build_client()?.get(url) - .send() - .map_err(|x| format!("Failed to GET resource: {:?}", x))? - }; + let mut client = build_client()?.get(url); + + if let Some(auth) = authorization { + client = client.header("Authorization", format!("Bearer {}", auth)); + } + + let mut client = client + .send() + .map_err(|x| format!("Failed to GET resource: {:?}", x))?; + + match client.status() { + StatusCode::OK => {} + StatusCode::TOO_MANY_REQUESTS => { + return Err( + "Your token has exceeded the number of daily allowable IP addresses. \ + Please wait 24 hours and try again." + .to_string(), + ); + } + x => { + return Err(format!("Bad status code: {:?}.", x)); + } + } let size = match client.headers().get(CONTENT_LENGTH) { Some(ref v) => v diff --git a/src/installer.rs b/src/installer.rs index c758c48..d72ba5c 100644 --- a/src/installer.rs +++ b/src/installer.rs @@ -77,7 +77,10 @@ impl InstallationDatabase { InstallationDatabase { packages: Vec::new(), shortcuts: Vec::new(), - credentials: Credentials{username: String::new(), token: String::new()}, + credentials: Credentials { + username: String::new(), + token: String::new(), + }, } } } @@ -127,7 +130,8 @@ macro_rules! declare_messenger_callback { } } TaskMessage::AuthorizationRequired(msg) => { - if let Err(v) = $target.send(InstallMessage::AuthorizationRequired(msg.to_string())) { + if let Err(v) = $target.send(InstallMessage::AuthorizationRequired(msg.to_string())) + { error!("Failed to submit queue message: {:?}", v); } } diff --git a/src/main.rs b/src/main.rs index 22f04a2..2643c3c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,9 +38,9 @@ extern crate chrono; extern crate clap; #[cfg(windows)] -extern crate winapi; -#[cfg(windows)] extern crate widestring; +#[cfg(windows)] +extern crate winapi; #[cfg(not(windows))] extern crate slug; diff --git a/src/sources/patreon.rs b/src/sources/patreon.rs index b2b888b..e66a2ae 100644 --- a/src/sources/patreon.rs +++ b/src/sources/patreon.rs @@ -1,11 +1,11 @@ -//! github/mod.rs +//! patreon.rs //! -//! Contains the Github API implementation of a release source. +//! Contains the yuzu-emu core API implementation of a release source. -use sources::types::*; use http::build_client; use reqwest::header::USER_AGENT; use reqwest::StatusCode; +use sources::types::*; pub struct PatreonReleases {} @@ -44,9 +44,7 @@ impl ReleaseSource for PatreonReleases { match response.status() { StatusCode::OK => {} StatusCode::FORBIDDEN => { - return Err( - "You are not eligible to download this release".to_string(), - ); + return Err("You are not eligible to download this release".to_string()); } _ => { return Err(format!("Bad status code: {:?}.", response.status())); @@ -77,18 +75,14 @@ impl ReleaseSource for PatreonReleases { let string = match file["name"].as_str() { Some(v) => v, None => { - return Err( - "JSON payload missing information about release name".to_string() - ); + return Err("JSON payload missing information about release name".to_string()); } }; let url = match file["url"].as_str() { Some(v) => v, None => { - return Err( - "JSON payload missing information about release URL".to_string() - ); + return Err("JSON payload missing information about release URL".to_string()); } }; diff --git a/src/tasks/check_authorization.rs b/src/tasks/check_authorization.rs index e2ffb9f..de72a87 100644 --- a/src/tasks/check_authorization.rs +++ b/src/tasks/check_authorization.rs @@ -1,10 +1,13 @@ +//! Validates that users have correct authorization to download packages. + +use frontend::rest::services::authentication; 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; +use tasks::{Task, TaskDependency, TaskMessage, TaskOrdering, TaskParamType}; pub struct CheckAuthorizationTask { pub name: String, @@ -15,14 +18,14 @@ impl Task for CheckAuthorizationTask { &mut self, mut input: Vec, context: &mut InstallerFramework, - messenger: &dyn Fn(&TaskMessage), + _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: {:?}") } + TaskParamType::File(v, f) => Ok((v, f)), + _ => Err("Unexpected TaskParamType in CheckAuthorization: {:?}"), }?; if !file.requires_authorization { @@ -31,27 +34,41 @@ impl Task for CheckAuthorizationTask { let username = context.database.credentials.username.clone(); let token = context.database.credentials.token.clone(); - let authentication = context.config.clone().unwrap().authentication.unwrap(); + let authentication = context + .config + .clone() + .log_expect("In-memory configuration doesn't exist") + .authentication + .log_expect("No authentication configuration exists while checking authorization"); + 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)) + 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 { + 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 + if !claims.roles.contains(&"vip".to_string()) + && !claims.channels.contains(&"early-access".to_string()) + { return Ok(TaskParamType::Authentication(version, file, None)); } - Ok(TaskParamType::Authentication(version, file, Some(jwt_token))) + + Ok(TaskParamType::Authentication( + version, + file, + Some(jwt_token), + )) } fn dependencies(&self) -> Vec { @@ -66,4 +83,4 @@ impl Task for CheckAuthorizationTask { 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 396063c..205599a 100644 --- a/src/tasks/download_pkg.rs +++ b/src/tasks/download_pkg.rs @@ -5,8 +5,6 @@ use installer::InstallerFramework; use tasks::check_authorization::CheckAuthorizationTask; use tasks::{Task, TaskDependency, TaskMessage, TaskOrdering, TaskParamType}; -use tasks::resolver::ResolvePackageTask; - use http::stream_file; use number_prefix::{NumberPrefix, Prefixed, Standalone}; @@ -32,7 +30,7 @@ impl Task for DownloadPackageTask { _ => return Err("Unexpected param type to download package".to_string()), }; -// TODO: move this back below checking for latest version after testing is done + // 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")); @@ -94,12 +92,12 @@ impl Task for DownloadPackageTask { } fn dependencies(&self) -> Vec { - vec![TaskDependency::build( - TaskOrdering::Pre, - Box::new(CheckAuthorizationTask { - name: self.name.clone(), - }), - )] + vec![TaskDependency::build( + TaskOrdering::Pre, + Box::new(CheckAuthorizationTask { + name: self.name.clone(), + }), + )] } fn name(&self) -> String {