Add authentication task dependency to check for auth on install

This commit is contained in:
James Rowe 2019-11-01 11:15:16 -06:00
parent 288518cd78
commit 2b4b59320e
14 changed files with 316 additions and 125 deletions

6
Cargo.lock generated
View File

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

View File

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

View File

@ -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<String>,
#[serde(rename = "releaseChannels", default)]
channels: Vec<String>,
#[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<Item = reqwest::async::Response, Error = reqwest::Error>) -> impl Future<Item = String, Error = Response> {
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<String>,
#[serde(rename = "releaseChannels", default)]
pub channels: Vec<String>,
#[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<String> with the response
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()));
},
};
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<String, String> {
// 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<JWTValidation>) -> Result<JWTClaims, String> {
// 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::<JWTClaims>(&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::<JWTClaims>(&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)

View File

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

View File

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

View File

@ -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<String>,
pub authorization_token: Option<String>,
}
/// 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,
})
}
}

View File

@ -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<TaskParamType>,
context: &mut InstallerFramework,
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: {:?}") }
}?;
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<TaskDependency> {
vec![TaskDependency::build(
TaskOrdering::Pre,
Box::new(ResolvePackageTask {
name: self.name.clone(),
}),
)]
}
fn name(&self) -> String {
format!("CheckAuthorizationTask (for {:?})", self.name)
}
}

View File

@ -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<u8> = 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);
}
@ -92,7 +96,7 @@ impl Task for DownloadPackageTask {
fn dependencies(&self) -> Vec<TaskDependency> {
vec![TaskDependency::build(
TaskOrdering::Pre,
Box::new(ResolvePackageTask {
Box::new(CheckAuthorizationTask {
name: self.name.clone(),
}),
)]

View File

@ -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<String>),
/// Downloaded contents of a file
FileContents(Version, File, Vec<u8>),
/// 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,
}

View File

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

View File

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

View File

@ -6,7 +6,7 @@
</b-message>
<p>
Before you can install this Early Access, you need to verify your account.
<a v-on:click="launch_browser('https://yuzu-emu.org/')">Click here to link your yuzu-emu.org account</a>
<a v-on:click="launch_browser('https://profile.yuzu-emu.org/external/patreon/connect/')">Click here to link your yuzu-emu.org account</a>
and paste the token below.
</p>
@ -30,17 +30,17 @@
<b-message type="is-danger" :active.sync="unlinked_patreon">
Your credentials are valid, but you still need to link your patreon!
If this is an error, then <a v-on:click="launch_browser('https://yuzu-emu.org/')">click here to link your yuzu-emu.org account</a>
If this is an error, then <a v-on:click="launch_browser('https://profile.yuzu-emu.org/external/patreon/connect/')">click here to link your yuzu-emu.org account</a>
</b-message>
<b-message type="is-danger" :active.sync="no_subscription">
Your patreon is linked, but you are not a current subscriber.
<a v-on:click="launch_browser('https://patreon.com/')">Log into your patreon account</a> and support the project!
<a v-on:click="launch_browser('https://profile.yuzu-emu.org/')">Log into your patreon account</a> and support the project!
</b-message>
<b-message type="is-danger" :active.sync="tier_not_selected">
Your patreon is linked, and you are supporting the project, but you must first join the Early Access reward tier!
<a v-on:click="launch_browser('https://patreon.com/')">Log into your patreon account</a> and choose to back the Early Access reward tier.
<a v-on:click="launch_browser('https://profile.yuzu-emu.org/')">Log into your patreon account</a> and choose to back the Early Access reward tier.
</b-message>
<div class="is-left-floating is-bottom-floating">

View File

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

View File

@ -0,0 +1,47 @@
<template>
<div class="column has-padding">
<b-message type="is-info">
<h4 class="subtitle">Update Available!</h4>
There is a new Early Access update available, but you are no longer in the early access reward tier.
We'd love to have you check out this update, so <a v-on:click="go_authenticate">click here to refresh your access!</a>
</b-message>
<br>
Alternatively, you can install the regular version of yuzu by clicking <strong>Back</strong> below.
<br>
Click <strong>Launch Old Version</strong> to continue using the old version of yuzu Early Access.
<div class="is-left-floating is-bottom-floating">
<p class="control">
<a class="button is-medium" v-on:click="go_packages">Back</a>
</p>
</div>
<div class="is-right-floating is-bottom-floating">
<p class="control">
<a class="button is-dark is-medium" v-on:click="launch_old_version">Launch Old Version</a>
</p>
</div>
</div>
</template>
<script>
export default {
name: "ReAuthenticationView",
methods: {
go_authenticate: function() {
this.$router.replace('/authentication')
},
launch_old_version: function () {
this.$root.exit()
},
go_packages: function () {
this.$router.push('/packages')
}
}
}
</script>
<style scoped>
</style>