diff --git a/Cargo.lock b/Cargo.lock index c034c4a..27c95ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,18 @@ dependencies = [ "num-traits 0.2.19", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -158,11 +170,13 @@ dependencies = [ name = "auth" version = "0.1.0" dependencies = [ + "clap", "env_logger", "hex", "jsonwebtoken", "libfj", "log", + "rc_core", "rocket", "steamworks", "uuid", @@ -201,6 +215,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" + [[package]] name = "binascii" version = "0.1.4" @@ -219,6 +239,24 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -363,6 +401,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.4.2" @@ -378,6 +425,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deranged" version = "0.3.11" @@ -420,6 +477,17 @@ dependencies = [ "syn 2.0.96", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -614,6 +682,16 @@ dependencies = [ "windows", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "genmesh" version = "0.6.2" @@ -1444,6 +1522,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pear" version = "0.2.9" @@ -1751,8 +1840,11 @@ dependencies = [ name = "rc_core" version = "0.1.0" dependencies = [ + "argon2", "chrono", "hex", + "jsonwebtoken", + "libfj", "log", "polariton", "polariton_server", @@ -2730,6 +2822,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + [[package]] name = "ubyte" version = "0.10.4" diff --git a/auth/Cargo.toml b/auth/Cargo.toml index f534414..fc224e3 100644 --- a/auth/Cargo.toml +++ b/auth/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [features] steam = ["steamworks"] -robocraft = [] +robocraft = ["rc_core"] cardlife = [] default = ["robocraft", "cardlife"] @@ -18,3 +18,5 @@ uuid = { version = "1.12", features = [ "v4", "fast-rng" ] } steamworks = { version = "0.11", optional = true } hex = "0.4" jsonwebtoken = "9" +clap.workspace = true +rc_core = { version = "*", optional = true, path = "../rc_core" } diff --git a/auth/src/common/cli.rs b/auth/src/common/cli.rs new file mode 100644 index 0000000..c189444 --- /dev/null +++ b/auth/src/common/cli.rs @@ -0,0 +1,28 @@ +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +pub struct CliArgs { + #[cfg(feature = "robocraft")] + /// Robocraft user data root + #[arg(long, default_value_t = {"../data/robocraft".to_string()})] + pub data_robocraft: String, +} + +impl CliArgs { + pub fn get() -> Self { + Self::parse() + } + + pub fn preloaded(self) -> Config { + Config { + #[cfg(feature = "robocraft")] + robocraft: crate::robocraft::RcConfig::from_args(&self), + } + } +} + +pub struct Config { + #[cfg(feature = "robocraft")] + pub robocraft: crate::robocraft::RcConfig, +} diff --git a/auth/src/common/mod.rs b/auth/src/common/mod.rs index bda8f79..cf26091 100644 --- a/auth/src/common/mod.rs +++ b/auth/src/common/mod.rs @@ -1 +1,2 @@ pub(crate) mod steam_utils; +pub(crate) mod cli; diff --git a/auth/src/main.rs b/auth/src/main.rs index 61519fe..d571141 100644 --- a/auth/src/main.rs +++ b/auth/src/main.rs @@ -13,8 +13,10 @@ fn index() -> &'static str { #[rocket::launch] fn rocket() -> _ { env_logger::init(); + let args = common::cli::CliArgs::get(); #[allow(unused_mut)] - let mut builder = rocket::build().mount("/", rocket::routes![index]); + let mut builder = rocket::build().mount("/", rocket::routes![index]) + .manage(args.preloaded()); #[cfg(feature = "cardlife")] {builder = builder.attach(cardlife::stage());} diff --git a/auth/src/robocraft/email.rs b/auth/src/robocraft/email.rs index 8da7b0a..a5c5212 100644 --- a/auth/src/robocraft/email.rs +++ b/auth/src/robocraft/email.rs @@ -1,35 +1,27 @@ -use rocket::{post, routes, serde::json::Json, http::Status}; +use rc_core::UserAuthenticator; +use rocket::{post, routes, serde::json::Json, http::Status, State}; -fn generate_token(user_auth: &libfj::robocraft::EmailUserAuthenticationPayload) -> String { - let header = jsonwebtoken::Header { - typ: Some("JWT".to_string()), - alg: jsonwebtoken::Algorithm::HS256, - ..Default::default() - }; +#[post("/authenticate/robocraft/game", data = "")] +pub fn email_password_auth(body: Json, config: &State) -> Result, Status> { + log::info!("Authenticating {} user {}", body.target, body.display_name); let payload = libfj::robocraft::TokenPayload { - public_id: user_auth.display_name.to_owned(), - display_name: user_auth.display_name.to_owned(), - robocraft_name: user_auth.display_name.to_owned(), - email_address: user_auth.email_address.to_owned(), + public_id: body.display_name.clone(), + display_name: body.display_name.clone(), + robocraft_name: body.display_name.clone(), + email_address: body.email_address.clone(), email_verified: true, flags: Vec::new(), }; - let secret = jsonwebtoken::EncodingKey::from_secret(user_auth.password.as_bytes()); // FIXME use an actually secret secret - jsonwebtoken::encode(&header, &payload, &secret) - .unwrap_or_else(|e| { - log::error!("Failed to encode JWT: {}", e); - libfj::robocraft::DEFAULT_TOKEN.to_owned() - }) -} - -#[post("/authenticate/robocraft/game", data = "")] -pub fn email_password_auth(body: Json) -> Result, Status> { - log::info!("Authenticating {} user {}", body.target, body.display_name); - Ok(Json(libfj::robocraft::AuthenticationResponseInfo { - token: generate_token(&body), - refresh_token: "qwertyuiop".to_string(), // TODO - refresh_token_expiry: "0".to_string(), // TODO (seems like this isn't actually considered by the client) - })) + let user_info = rc_core::persist::user::UserInfo { + payload, + extra: rc_core::persist::user::ExtraUserInfo::Standalone { password: body.password.clone() }, + }; + let response = config.robocraft.account_provider.login(user_info) + .map_err(|e| { + log::error!("Failed to authenticate {} user {}: {}", body.target, body.display_name, e); + Status { code: 401 } + })?; + Ok(Json(response.response)) } pub fn stage() -> rocket::fairing::AdHoc { diff --git a/auth/src/robocraft/mod.rs b/auth/src/robocraft/mod.rs index b53fff9..5279664 100644 --- a/auth/src/robocraft/mod.rs +++ b/auth/src/robocraft/mod.rs @@ -7,5 +7,31 @@ pub fn stage() -> rocket::fairing::AdHoc { rocket.attach(email::stage()) .attach(steam::stage()) .attach(debug::stage()) + .register("/", rocket::catchers![unauthorized]) + }) +} + +#[allow(dead_code)] +pub struct RcConfig { + pub root: std::path::PathBuf, + pub account_provider: rc_core::UserImpl, +} + +impl RcConfig { + pub fn from_args(args: &crate::common::cli::CliArgs) -> Self { + Self { + account_provider: rc_core::UserImpl::load_for_auth(&args.data_robocraft).expect("Invalid Robocraft user data"), + root: args.data_robocraft.clone().into(), + } + } +} + +use rocket::{catch, serde::json::Json}; + +#[catch(401)] +fn unauthorized() -> Json { + Json(libfj::robocraft::ErrorInfo { + error_code: "204".to_owned(), + error_message: "Invalid username, password, or SteamID".to_owned(), }) } diff --git a/auth/src/robocraft/steam.rs b/auth/src/robocraft/steam.rs index cdc9891..1d77e67 100644 --- a/auth/src/robocraft/steam.rs +++ b/auth/src/robocraft/steam.rs @@ -1,37 +1,29 @@ -use rocket::{post, routes, serde::json::Json, http::Status}; +use rc_core::UserAuthenticator; +use rocket::{http::Status, post, routes, serde::json::Json, State}; -fn generate_token(user_auth: &libfj::robocraft::SteamAuthenticationPayload, steam_id: u64) -> String { - let header = jsonwebtoken::Header { - typ: Some("JWT".to_string()), - alg: jsonwebtoken::Algorithm::HS256, - ..Default::default() - }; +#[post("/authenticate/steam/game", data = "")] +pub fn steam_auth(body: Json, config: &State) -> Result, Status> { + let steam_id = crate::common::steam_utils::authenticate_steam_ticket(&body.steam_ticket) + .map_err(|_| Status { code: 401 })?; + log::info!("Authenticating {} steam user {}", body.target, steam_id); let payload = libfj::robocraft::TokenPayload { public_id: steam_id.to_string(), display_name: steam_id.to_string(), robocraft_name: steam_id.to_string(), - email_address: format!("{}.rc.steam@ngni.us", steam_id), + email_address: format!("{}.rc.steam@ngram.ca", steam_id), email_verified: true, flags: Vec::new(), }; - let secret = jsonwebtoken::EncodingKey::from_secret(user_auth.steam_ticket.as_ref()); // FIXME use an actually secret secret - jsonwebtoken::encode(&header, &payload, &secret) - .unwrap_or_else(|e| { - log::error!("Failed to encode JWT: {}", e); - libfj::robocraft::DEFAULT_TOKEN.to_owned() - }) -} - -#[post("/authenticate/steam/game", data = "")] -pub fn steam_auth(body: Json) -> Result, Status> { - let steam_id = crate::common::steam_utils::authenticate_steam_ticket(&body.steam_ticket) - .map_err(|_| Status { code: 401 })?; - log::info!("Authenticating {} steam user {}", body.target, steam_id); - Ok(Json(libfj::robocraft::AuthenticationResponseInfo { - token: generate_token(&body, steam_id), - refresh_token: "qwertyuiop".to_string(), // TODO - refresh_token_expiry: "0".to_string(), // TODO (seems like this isn't actually considered by the client) - })) + let user_info = rc_core::persist::user::UserInfo { + payload, + extra: rc_core::persist::user::ExtraUserInfo::Steam { id: steam_id }, + }; + let response = config.robocraft.account_provider.login(user_info) + .map_err(|e| { + log::error!("Failed to authenticate {} steam user {}: {}", body.target, steam_id, e); + Status { code: 401 } + })?; + Ok(Json(response.response)) } pub fn stage() -> rocket::fairing::AdHoc { diff --git a/rc_core/Cargo.toml b/rc_core/Cargo.toml index 0d36b75..3e5a0b8 100644 --- a/rc_core/Cargo.toml +++ b/rc_core/Cargo.toml @@ -11,4 +11,9 @@ serde.workspace = true serde_json.workspace = true chrono = "0.4" polariton_server.workspace = true -tokio = { version = "1.43", features = [ "net", "macros", "rt-multi-thread", "io-util", "time"] } +tokio = { version = "1.43", features = [ "net", "macros", "rt-multi-thread", "io-util", "time" ] } + +# auth +libfj.workspace = true +jsonwebtoken = "9" +argon2 = { version = "0.5", features = [ "std" ] } diff --git a/rc_core/src/lib.rs b/rc_core/src/lib.rs index 3c8eeed..010d905 100644 --- a/rc_core/src/lib.rs +++ b/rc_core/src/lib.rs @@ -4,5 +4,5 @@ mod state; pub use state::UserState; pub mod persist; -pub use persist::user::{UserImpl, UserProvider}; +pub use persist::user::{UserImpl, UserProvider, UserAuthenticator}; pub use persist::config::{ConfigImpl, ConfigProvider}; diff --git a/rc_core/src/persist/user/account_json.rs b/rc_core/src/persist/user/account_json.rs index 46d6dbd..ff6d311 100644 --- a/rc_core/src/persist/user/account_json.rs +++ b/rc_core/src/persist/user/account_json.rs @@ -1,3 +1,4 @@ +use argon2::PasswordVerifier; use serde::{Serialize, Deserialize}; use crate::persist::config::ConfigProvider; @@ -5,15 +6,29 @@ use crate::persist::config::ConfigProvider; pub struct AccountProvider { root: std::path::PathBuf, cubes: std::sync::Arc>, + secret: Vec, } impl AccountProvider { pub fn load(root: impl AsRef, cubes: &crate::persist::config::ConfigImpl) -> std::io::Result { + let token_path = root.as_ref().join(super::TOKEN_SECRET_FILENAME); let root = root.as_ref().join(super::USERS_DIR); std::fs::create_dir_all(&root)?; Ok(Self { root, cubes: std::sync::Arc::new(>::ids(cubes)), + secret: std::fs::read(&token_path)?, + }) + } + + pub fn load_for_auth(root: impl AsRef) -> std::io::Result { + let token_path = root.as_ref().join(super::TOKEN_SECRET_FILENAME); + let root = root.as_ref().join(super::USERS_DIR); + std::fs::create_dir_all(&root)?; + Ok(Self { + root, + cubes: std::sync::Arc::new(Vec::default()), + secret: std::fs::read(&token_path)?, }) } } @@ -21,11 +36,10 @@ impl AccountProvider { impl super::UserProvider for AccountProvider { fn authenticate(&self, token: super::UserToken) -> Result + Send + Sync>, String> { let new_root = self.root.join(&token.uuid); - if !new_root.exists() { - std::fs::create_dir(&new_root).map_err(|e| e.to_string())?; - log::info!("New user {}", token.uuid); - super::setup_directory(&new_root).map_err(|e| e.to_string())?; - } + let secret = jsonwebtoken::DecodingKey::from_secret(&self.secret); + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256); + validation.set_required_spec_claims::<&str>(&[]); + jsonwebtoken::decode::(&token.token, &secret, &validation).map_err(|e| e.to_string())?; let account_info = AccountInfo::load(&new_root).map_err(|e| e.to_string())?; Ok(Box::new(UserData { root: new_root, @@ -36,6 +50,73 @@ impl super::UserProvider for AccountProvider { //Err("Unable to authenticate".to_string()) } } +impl super::UserAuthenticator for AccountProvider { + fn login(&self, info: super::UserInfo) -> Result { + let new_root = self.root.join(&info.payload.public_id); + let is_new_user = !new_root.exists(); + if is_new_user { + std::fs::create_dir(&new_root).map_err(|e| e.to_string())?; + log::info!("New user {}", info.payload.public_id); + super::setup_directory(&new_root).map_err(|e| e.to_string())?; + } + let mut account_info = AccountInfo::load(&new_root).map_err(|e| e.to_string())?; + let is_new_user = is_new_user || (account_info.password.is_none() && account_info.steam_id.is_none()); // migration + match info.extra { + super::ExtraUserInfo::Steam { id } => { + if is_new_user { + account_info.steam_id = Some(id); + } + if let Some(expected_steam_id) = account_info.steam_id { + if expected_steam_id != id { + return Err("SteamID does not match".to_owned()) + } + } else { + return Err("SteamID not supported for this user".to_owned()); + } + }, + super::ExtraUserInfo::Standalone { password } => { + use argon2::password_hash::PasswordHasher; + let argon2_algo = argon2::Argon2::default(); + if is_new_user { + let salt = argon2::password_hash::SaltString::generate(&mut argon2::password_hash::rand_core::OsRng); + let password_hash = argon2_algo.hash_password(password.as_bytes(), &salt).map_err(|e| e.to_string())?.to_string(); + account_info.password = Some(password_hash); + } + if let Some(expected_password) = &account_info.password { + let expected = argon2::password_hash::PasswordHash::new(expected_password).map_err(|e| e.to_string())?; + argon2_algo.verify_password(password.as_bytes(), &expected).map_err(|e| e.to_string())?; + } else { + return Err("Password not supported for this user".to_owned()) + } + } + } + // authentication has now definitely succeeded + if is_new_user { + account_info.save(new_root).map_err(|e| e.to_string())?; + } + // build token + let header = jsonwebtoken::Header { + typ: Some("JWT".to_string()), + alg: jsonwebtoken::Algorithm::HS256, + ..Default::default() + }; + let secret = jsonwebtoken::EncodingKey::from_secret(&self.secret); + let token = jsonwebtoken::encode(&header, &info.payload, &secret) + .unwrap_or_else(|e| { + log::error!("Failed to encode JWT: {}", e); + libfj::robocraft::DEFAULT_TOKEN.to_owned() + }); + + Ok(super::UserLoginInfo { + response: libfj::robocraft::AuthenticationResponseInfo { + token, + refresh_token: "qwertyuiop".to_string(), // TODO + refresh_token_expiry: "0".to_string(), // TODO (seems like this isn't actually considered by the client) + }, + is_new: is_new_user, + }) + } +} #[allow(dead_code)] struct UserData { @@ -239,6 +320,8 @@ pub struct AccountInfo { pub is_mod: bool, pub is_admin: bool, pub is_dev: bool, + pub password: Option, + pub steam_id: Option, pub inventory: super::UnlockedParts, pub garage: super::SelectedGarage, } diff --git a/rc_core/src/persist/user/initial_data.rs b/rc_core/src/persist/user/initial_data.rs index 20d1c41..3dd8c7a 100644 --- a/rc_core/src/persist/user/initial_data.rs +++ b/rc_core/src/persist/user/initial_data.rs @@ -28,6 +28,8 @@ fn default_user_data() -> super::AccountInfo { is_mod: false, is_admin: false, is_dev: false, + steam_id: None, + password: None, inventory: super::UnlockedParts { unlocked: vec![], override_: super::inventory::UnlockOverride::Normal, diff --git a/rc_core/src/persist/user/mod.rs b/rc_core/src/persist/user/mod.rs index 9fa2178..9ab4f0c 100644 --- a/rc_core/src/persist/user/mod.rs +++ b/rc_core/src/persist/user/mod.rs @@ -11,7 +11,9 @@ mod inventory; pub use inventory::UnlockedParts; mod traits; -pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData}; +pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserInfo, UserLoginInfo, ExtraUserInfo, UserAuthenticator}; + +pub const TOKEN_SECRET_FILENAME: &str = "token_secret.key"; pub const USERS_DIR: &str = "accounts"; pub const USER_FILE: &str = "user.json"; diff --git a/rc_core/src/persist/user/traits.rs b/rc_core/src/persist/user/traits.rs index 9282444..c334ca5 100644 --- a/rc_core/src/persist/user/traits.rs +++ b/rc_core/src/persist/user/traits.rs @@ -6,10 +6,33 @@ pub struct UserToken { pub refresh_token: String, } +pub struct UserInfo { + pub payload: libfj::robocraft::TokenPayload, + pub extra: ExtraUserInfo, +} + +pub enum ExtraUserInfo { + Steam { + id: u64, + }, + Standalone { + password: String, + } +} + +pub struct UserLoginInfo { + pub response: libfj::robocraft::AuthenticationResponseInfo, + pub is_new: bool, +} + pub trait UserProvider { fn authenticate(&self, user: UserToken) -> Result + Send + Sync>, String>; } +pub trait UserAuthenticator { + fn login(&self, info: UserInfo) -> Result; +} + pub trait User { fn token(&self) -> &'_ super::UserToken; fn is_mod(&self) -> bool;