Merge pull request #19 from j-selby/patreon-tweaking

Tweak Patreon authentication implementation
This commit is contained in:
bunnei 2019-12-01 13:40:26 -05:00 committed by GitHub
commit fccd1c9bd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 262 additions and 182 deletions

View File

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

View File

@ -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<String> with the response
pub fn authenticate_async(url: String, username: String, token: String)
-> Box<dyn futures::Future<Item = String, Error = String>> {
pub fn authenticate_async(
url: String,
username: String,
token: String,
) -> Box<dyn futures::Future<Item = String, Error = String>> {
// 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<String, String> {
pub fn authenticate_sync(url: String, username: String, token: String) -> Result<String, String> {
// 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<JWTValidation>) -> Result<JWTClaims, String> {
pub fn validate_token(
body: String,
pub_key_base64: String,
validation: Option<JWTValidation>,
) -> Result<JWTClaims, String> {
// 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<J
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));
},
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<J
Some(v) => {
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::<JWTClaims>(&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::<HashMap<String, String>>();
_req.body()
.concat2()
.map(move |body| {
let req = form_urlencoded::parse(body.as_ref())
.into_owned()
.collect::<HashMap<String, String>>();
// 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(),
)
}
}

View File

@ -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| {
pub fn handle(_service: &WebService, req: Request) -> InternalFuture {
Box::new(req.body().concat2().map(move |body| {
let req = form_urlencoded::parse(body.as_ref())
.into_owned()
.collect::<HashMap<String, String>>();
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 {
}
}))
}

View File

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

View File

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

View File

@ -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<AsyncClient, String> {
}
/// Streams a file from a HTTP server.
pub fn stream_file<F>(url: &str, authorization: Option<String>, mut callback: F) -> Result<(), String>
pub fn stream_file<F>(
url: &str,
authorization: Option<String>,
mut callback: F,
) -> Result<(), String>
where
F: FnMut(Vec<u8>, 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

View File

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

View File

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

View File

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

View File

@ -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<TaskParamType>,
context: &mut InstallerFramework,
messenger: &dyn Fn(&TaskMessage),
_messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
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<TaskDependency> {
@ -66,4 +83,4 @@ impl Task for CheckAuthorizationTask {
fn name(&self) -> String {
format!("CheckAuthorizationTask (for {:?})", self.name)
}
}
}

View File

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