diff --git a/Cargo.lock b/Cargo.lock index 1e6023cd4..f1aee7e8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -634,6 +634,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + [[package]] name = "base64" version = "0.21.7" @@ -2557,7 +2563,7 @@ dependencies = [ "arrayvec", "cc", "cfg-if", - "constant_time_eq", + "constant_time_eq 0.3.1", ] [[package]] @@ -2947,6 +2953,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87ca1caa64ef4ed453e68bb3db612e51cf1b2f5b871337f0fcab1c8f87cc3dff" +[[package]] +name = "constant_time_eq" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6" + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -3355,6 +3367,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -4373,6 +4386,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.11" @@ -6079,6 +6101,23 @@ dependencies = [ "psl-types", ] +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + +[[package]] +name = "qrcodegen-image" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221b7eace1aef8c95d65dbe09fb7a1a43d006045394a89afba6997721fcb7708" +dependencies = [ + "base64 0.22.1", + "image", + "qrcodegen", +] + [[package]] name = "quick-xml" version = "0.36.2" @@ -6663,6 +6702,7 @@ dependencies = [ "sysinfo 0.33.0", "tempfile", "tokio", + "totp-rs", "tracing", "tracing-subscriber", "url", @@ -6866,6 +6906,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -7516,6 +7567,24 @@ dependencies = [ "winnow", ] +[[package]] +name = "totp-rs" +version = "5.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b2f27dad992486c26b4e7455f38aa487e838d6d61b57e72906ee2b8c287a90" +dependencies = [ + "base32", + "constant_time_eq 0.2.6", + "hmac", + "qrcodegen-image", + "rand", + "sha1", + "sha2", + "url", + "urlencoding", + "zeroize", +] + [[package]] name = "tower" version = "0.4.13" @@ -7865,6 +7934,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -9153,6 +9228,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", +] [[package]] name = "zerovec" diff --git a/sandpolis/Cargo.toml b/sandpolis/Cargo.toml index 7b6fc32c5..47fe005f6 100644 --- a/sandpolis/Cargo.toml +++ b/sandpolis/Cargo.toml @@ -49,6 +49,7 @@ axum = { version = "0.8.0", optional = true, features = ["ws", "json"] } jsonwebtoken = { version = "9.3.0", optional = true } rcgen = { version = "0.13.1", optional = true } ring = { version = "0.17.8", optional = true } +totp-rs = { version = "5.6.0", optional = true, features = ["otpauth", "qr", "gen_secret", "zeroize"] } # TODO client QR support # Client dependencies bevy = { version = "0.15.0", optional = true } @@ -61,7 +62,7 @@ sysinfo = { version = "0.33.0", optional = true } [features] # Instances -server = [ "dep:axum", "dep:axum-server", "dep:axum-macros", "dep:axum-extra", "dep:rcgen", "dep:ring", "dep:jsonwebtoken" ] +server = [ "dep:axum", "dep:axum-server", "dep:axum-macros", "dep:axum-extra", "dep:rcgen", "dep:ring", "dep:jsonwebtoken", "dep:totp-rs" ] agent = [ "dep:axum", "dep:axum-server", "dep:axum-macros", "dep:sysinfo" ] client = [ "dep:bevy", "dep:bevy_rapier2d", "dep:bevy_egui", "dep:egui" ] diff --git a/sandpolis/src/core/database.rs b/sandpolis/src/core/database.rs index 56c47e0a1..ab064d279 100644 --- a/sandpolis/src/core/database.rs +++ b/sandpolis/src/core/database.rs @@ -256,6 +256,7 @@ impl Document { Ok(Document { db: self.db.clone(), data: if let Some(data) = self.db.get(&oid)? { + trace!(oid = %oid, "Loading document"); serde_cbor::from_slice::(&data)? } else { trace!(oid = %oid, "Creating new document"); @@ -265,6 +266,12 @@ impl Document { }) } + pub fn create_document(&mut self, mutator: F) -> Result<()> + where + F: Fn() -> Result, + { + } + pub fn collection(&self, oid: impl TryInto) -> Result> where U: Serialize + DeserializeOwned, diff --git a/sandpolis/src/core/layer/server/user.rs b/sandpolis/src/core/layer/server/user.rs index 13b01143b..53edaf631 100644 --- a/sandpolis/src/core/layer/server/user.rs +++ b/sandpolis/src/core/layer/server/user.rs @@ -1,3 +1,5 @@ +use std::net::SocketAddr; + use serde::{Deserialize, Serialize}; use validator::Validate; @@ -21,6 +23,14 @@ pub struct UserData { pub expiration: Option, } +#[derive(Serialize, Deserialize)] +#[cfg_attr(feature = "client", derive(bevy::prelude::Component))] +pub struct LoginAttempt { + pub timestamp: u64, + + pub address: SocketAddr, +} + /// Create a new user account. #[derive(Serialize, Deserialize, Validate)] pub struct CreateUserRequest { @@ -29,13 +39,16 @@ pub struct CreateUserRequest { /// Password as unsalted hash pub password: String, - /// TOTP secret URL - pub totp_secret: Option, + /// Whether a TOTP secret should be generated + pub totp: bool, } #[derive(Serialize, Deserialize)] pub enum CreateUserResponse { - Ok, + Ok { + /// TOTP secret URL + totp_secret: Option, + }, } #[derive(Serialize, Deserialize)] @@ -50,6 +63,7 @@ pub struct GetUsersRequest { #[derive(Serialize, Deserialize)] pub enum GetUsersResponse { Ok(Vec), + PermissionDenied, } /// Update an existing user account. diff --git a/sandpolis/src/server/layer/server/mod.rs b/sandpolis/src/server/layer/server/mod.rs index bed14025a..59a48a9bb 100644 --- a/sandpolis/src/server/layer/server/mod.rs +++ b/sandpolis/src/server/layer/server/mod.rs @@ -38,6 +38,7 @@ impl ServerLayer { .route("/banner", get(banner)) .route("/users", get(user::get_users)) .route("/users", post(user::create_user)) + .route("/login", post(user::login)) } } diff --git a/sandpolis/src/server/layer/server/user.rs b/sandpolis/src/server/layer/server/user.rs index 353a62d63..a4e5990be 100644 --- a/sandpolis/src/server/layer/server/user.rs +++ b/sandpolis/src/server/layer/server/user.rs @@ -18,6 +18,7 @@ use std::{ fmt::{Debug, Display}, num::NonZeroU32, }; +use totp_rs::{Secret, TOTP}; use tracing::{debug, error}; use validator::Validate; @@ -31,10 +32,7 @@ use crate::core::{ use super::ServerState; -static KEY: LazyLock = LazyLock::new(|| { - let secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"); - ServerKey::new(secret.as_bytes()) -}); +static KEY: LazyLock = LazyLock::new(|| ServerKey::new()); struct ServerKey { encoding: EncodingKey, @@ -42,10 +40,11 @@ struct ServerKey { } impl ServerKey { - fn new(secret: &[u8]) -> Self { + fn new() -> Self { + let secret = rand::thread_rng().gen::<[u8; 32]>().to_vec(); Self { - encoding: EncodingKey::from_secret(secret), - decoding: DecodingKey::from_secret(secret), + encoding: EncodingKey::from_secret(&secret), + decoding: DecodingKey::from_secret(&secret), } } } @@ -135,25 +134,41 @@ pub async fn login( } }; - match pbkdf2::verify( + // Check TOTP token if there is one + if let Some(totp_url) = password.data.totp_secret { + if request.totp_token.unwrap_or(String::new()) + != TOTP::from_url(totp_url) + .unwrap() + .generate_current() + .unwrap() + { + debug!("TOTP check failed"); + return Err(Json(LoginResponse::Denied)); + } + } + + // Check password + if pbkdf2::verify( pbkdf2::PBKDF2_HMAC_SHA256, NonZeroU32::new(password.data.iterations).unwrap_or(NonZeroU32::new(1).unwrap()), &password.data.salt, request.password.as_bytes(), &password.data.hash, - ) { - Ok(_) => { - let claims = Claims { - sub: user.data.username.clone(), - exp: todo!(), - }; - Ok(Json(LoginResponse::Ok( - encode(&Header::default(), &claims, &KEY.encoding) - .map_err(|_| Json(LoginResponse::Denied))?, - ))) - } - Err(_) => Err(Json(LoginResponse::Denied)), + ) + .is_err() + { + debug!("Password check failed"); + return Err(Json(LoginResponse::Denied)); } + + let claims = Claims { + sub: user.data.username.clone(), + exp: todo!(), + }; + Ok(Json(LoginResponse::Ok( + encode(&Header::default(), &claims, &KEY.encoding) + .map_err(|_| Json(LoginResponse::Denied))?, + ))) } #[debug_handler] @@ -165,7 +180,23 @@ pub async fn create_user( iterations: 15000, salt: rand::thread_rng().gen::<[u8; 32]>().to_vec(), hash: Vec::new(), - totp_secret: None, + totp_secret: if request.totp { + Some( + TOTP::new( + totp_rs::Algorithm::SHA1, + 6, + 1, + 30, + Secret::default().to_bytes().unwrap(), + Some("Sandpolis".to_string()), + "".to_string(), + ) + .unwrap() + .get_url(), + ) + } else { + None + }, }; pbkdf2::derive( pbkdf2::PBKDF2_HMAC_SHA256, @@ -176,9 +207,10 @@ pub async fn create_user( ); // TODO atomic insert + let users = &state.server.users; // state.server.users.document(request.data.username); - Ok(Json(CreateUserResponse::Ok)) + Ok(Json(CreateUserResponse::Ok { totp_secret: None })) } #[debug_handler] @@ -186,6 +218,16 @@ pub async fn get_users( state: State, claims: Claims, extract::Json(request): extract::Json, -) -> Result, Json> { +) -> Result, Json> { + let users = &state.server.users; + + if let Some(username) = request.username { + match users.get_document(&username) { + Ok(Some(user)) => return Ok(Json(GetUsersResponse::Ok(vec![user.data]))), + Ok(None) => return Ok(Json(GetUsersResponse::Ok(Vec::new()))), + Err(_) => todo!(), + } + } + todo!() }