diff --git a/Cargo.lock b/Cargo.lock index 8931d30..ecb25a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,7 @@ dependencies = [ "base64 0.22.1", "bson", "chrono", + "clap", "config", "dotenv", "ecdsa", @@ -179,6 +180,55 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" + +[[package]] +name = "anstyle-parse" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + [[package]] name = "anyhow" version = "1.0.86" @@ -464,6 +514,52 @@ dependencies = [ "unsigned-varint 0.8.0", ] +[[package]] +name = "clap" +version = "4.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64acc1846d54c1fe936a78dc189c34e28d3f5afc348403f28ecf53660b9b8462" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fb8393d67ba2e7bfaf28a23458e4e2b543cc73a99595511eb207fdb8aede942" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.66", +] + +[[package]] +name = "clap_lex" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" + +[[package]] +name = "colorchoice" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" + [[package]] name = "config" version = "0.13.4" @@ -581,7 +677,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.10.0", "syn 1.0.109", ] @@ -749,7 +845,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21cdad81446a7f7dc43f6a77409efeb9733d2fa65553efef6018ef257c959b73" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "quote", "syn 1.0.109", @@ -1099,6 +1195,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.3.9" @@ -1436,6 +1538,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" + [[package]] name = "itoa" version = "1.0.11" @@ -1663,7 +1771,7 @@ dependencies = [ "sha2", "socket2", "stringprep", - "strsim", + "strsim 0.10.0", "take_mut", "thiserror", "tokio", @@ -2819,6 +2927,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.5.0" @@ -3335,6 +3449,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.8.0" diff --git a/Cargo.toml b/Cargo.toml index 6a58096..6332e1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,3 +61,4 @@ rand = "0" anyhow = "1" dotenv = "0" sendgrid = "0" +clap = { version = "4", features = ["derive"] } diff --git a/config/default.json b/config/default.json index 9d160bc..4bc7c0a 100644 --- a/config/default.json +++ b/config/default.json @@ -21,5 +21,5 @@ "url": "https://network.ambrosus.io", "requestTimeout": 10000 }, - "serverNodesManagerAddress": "0x" + "serverNodesManagerAddress": "0x55C402b5F9C2c3DfE3d866B36598f0Fd53e03B89" } \ No newline at end of file diff --git a/gov-portal-db/Cargo.toml b/gov-portal-db/Cargo.toml index 32fa493..cb2d58b 100644 --- a/gov-portal-db/Cargo.toml +++ b/gov-portal-db/Cargo.toml @@ -55,6 +55,7 @@ base64 = { workspace = true } anyhow = { workspace = true } dotenv = { workspace = true } rand = { workspace = true } +clap = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } diff --git a/gov-portal-db/src/main.rs b/gov-portal-db/src/main.rs index 41c7f9b..74638c5 100644 --- a/gov-portal-db/src/main.rs +++ b/gov-portal-db/src/main.rs @@ -5,11 +5,15 @@ mod server; mod session_token; mod users_manager; -use std::sync::Arc; +use clap::{Args, Parser, Subcommand}; +use std::{sync::Arc, time::Duration}; +use session_token::SessionManager; use shared::{logger, utils}; use users_manager::UsersManager; +const ONE_YEAR: u64 = 86_400 * 365; // one year in seconds + #[tokio::main] async fn main() -> Result<(), Box> { logger::init(); @@ -20,10 +24,48 @@ async fn main() -> Result<(), Box> { let config = utils::load_config::("./gov-portal-db").await?; + let session_manager = SessionManager::new(config.session.clone()); + + let cli = Cli::parse(); + match &cli.command { + Some(Commands::GenToken(arg)) => { + let lifetime = arg.lifetime.unwrap_or(ONE_YEAR); + + println!( + "{}", + session_manager + .acquire_internal_token_with_lifetime(Duration::from_secs(lifetime))? + ); + return Ok(()); + } + None => (), + }; + let users_manager = Arc::new(UsersManager::new(&config.mongo, config.users_manager.clone()).await?); - server::start(config, users_manager.clone()).await?; + server::start(config, users_manager, session_manager).await?; Ok(()) } + +#[derive(Parser)] +#[command(version, about, long_about = None)] +#[command(propagate_version = true)] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Generates token to access /status endpoint (use -l or --lifetime for custom lifetime set, defaults to 1 year) + GenToken(GetTokenArgs), +} + +#[derive(Args)] +struct GetTokenArgs { + /// Lifetime in seconds for an access token (defaults to 1 year) + #[arg(short, long)] + lifetime: Option, +} diff --git a/gov-portal-db/src/server.rs b/gov-portal-db/src/server.rs index ba4b80d..b626df6 100644 --- a/gov-portal-db/src/server.rs +++ b/gov-portal-db/src/server.rs @@ -39,12 +39,13 @@ impl AppState { pub async fn new( config: AppConfig, users_manager: Arc, + session_manager: SessionManager, ) -> Result { Ok(Self { quiz: Quiz { config: config.quiz.clone(), }, - session_manager: SessionManager::new(config.session.clone()), + session_manager, users_manager, config, }) @@ -144,13 +145,17 @@ impl VerifyEmailRequest { } } -pub async fn start(config: AppConfig, users_manager: Arc) -> Result<(), AppError> { +pub async fn start( + config: AppConfig, + users_manager: Arc, + session_manager: SessionManager, +) -> Result<(), AppError> { let addr = config .listen_address .parse::() .expect("Can't parse socket address"); - let state = AppState::new(config, users_manager).await?; + let state = AppState::new(config, users_manager, session_manager).await?; let app = Router::new() .route("/token", post(token_route)) @@ -200,13 +205,17 @@ async fn status_route( ) -> Result, String> { tracing::debug!("[/status] Request {token:?}"); - let res = state - .users_manager - .mongo_client - .server_status() - .await - .map(|_| ()) - .map_err(|e| e.to_string()); + let res = match state.session_manager.verify_internal_token(&token) { + Ok(_) => state + .users_manager + .mongo_client + .server_status() + .await + .map(|_| ()) + .map_err(|e| e.to_string()), + + Err(e) => Err(format!("Request failure. Error: {e}")), + }; tracing::debug!("[/status] Response {res:?}"); @@ -587,7 +596,7 @@ mod tests { .map(|quiz_response| VerifyQuizRequest { answers: vec![QuizAnswer::new("Q1", "V2")], quiz_token: quiz_response.quiz_token, - session: default_session_token(), + session: SessionToken::default(), }) .unwrap(), expected: true, @@ -628,7 +637,7 @@ mod tests { QuizAnswer::new("Q1", "V2"), ], quiz_token: quiz_response.quiz_token, - session: default_session_token(), + session: SessionToken::default(), }) .unwrap(), expected: true, @@ -669,7 +678,7 @@ mod tests { QuizAnswer::new("Q2", "V2"), ], quiz_token: quiz_response.quiz_token, - session: default_session_token(), + session: SessionToken::default(), }) .unwrap(), expected: false, @@ -688,7 +697,7 @@ mod tests { .map(|quiz_response| VerifyQuizRequest { answers: vec![QuizAnswer::new("Q1", "V2")], quiz_token: quiz_response.quiz_token, - session: default_session_token(), + session: SessionToken::default(), }) .unwrap(), expected: false, @@ -707,7 +716,7 @@ mod tests { .map(|quiz_response| VerifyQuizRequest { answers: vec![QuizAnswer::new("Q1", "V2")], quiz_token: quiz_response.quiz_token, - session: default_session_token(), + session: SessionToken::default(), }) .unwrap(), expected: false, @@ -762,10 +771,4 @@ mod tests { } } } - - fn default_session_token() -> SessionToken { - SessionToken { - token: "".to_string(), - } - } } diff --git a/gov-portal-db/src/session_token.rs b/gov-portal-db/src/session_token.rs index 41fe6d7..8fbf1db 100644 --- a/gov-portal-db/src/session_token.rs +++ b/gov-portal-db/src/session_token.rs @@ -1,7 +1,7 @@ use chrono::Utc; use ethereum_types::Address; use serde::Deserialize; -use shared::common::{RawSessionToken, SessionToken, WalletSignedMessage}; +use shared::common::{RawSessionToken, SessionToken, SessionTokenKind, WalletSignedMessage}; use std::str::FromStr; use tokio::time::Duration; @@ -33,7 +33,9 @@ impl SessionManager { .and_then(|wallet| { SessionToken::new( RawSessionToken { - checksum_wallet: shared::utils::get_checksum_address(&wallet), + kind: SessionTokenKind::Wallet { + checksum_wallet: shared::utils::get_checksum_address(&wallet), + }, expires_at: (Utc::now() + self.config.lifetime).timestamp_millis() as u64, }, self.config.secret.as_bytes(), @@ -42,18 +44,39 @@ impl SessionManager { .map_err(|e| anyhow::Error::msg(format!("Failed to generate JWT token. Error: {}", e))) } + /// Acquires a session JWT token to get an access to MongoDB for internal usage + pub fn acquire_internal_token_with_lifetime( + &self, + lifetime: Duration, + ) -> Result { + SessionToken::new( + RawSessionToken { + kind: SessionTokenKind::Internal, + expires_at: (Utc::now() + lifetime).timestamp_millis() as u64, + }, + self.config.secret.as_bytes(), + ) + } + /// Verifies session JWT token and extracts owning user wallet address pub fn verify_token(&self, token: &SessionToken) -> Result { let wallet = <[u8; 20]>::try_from( - hex::decode(token.verify(self.config.secret.as_bytes())?)?.as_slice(), + hex::decode(token.verify_wallet(self.config.secret.as_bytes())?)?.as_slice(), )?; Ok(ethereum_types::Address::from(&wallet)) } + + /// Verifies internal session JWT token + pub fn verify_internal_token(&self, token: &SessionToken) -> Result<(), anyhow::Error> { + token.verify_internal(self.config.secret.as_bytes()) + } } #[cfg(test)] mod tests { + use std::time::Duration; + use super::SessionManager; #[test] @@ -65,4 +88,16 @@ mod tests { session_manager.acquire_token("eyJtc2ciOiI1NDY1NzM3NDIwNGQ2NTczNzM2MTY3NjUiLCJzaWduIjoiM2E2NjUyOTBlZjEyMTAxNjE5OGEzZjI2ODA5NzY0ZGE0ODQzNzg3NWRjNTY1YmYwY2FhY2Q4OWFhYjMzYmM3MDBjMGIwOWE1ZDdiYjI2MmFkZmNkODEwOGI5NjNkZGVhYTJhNmZiNzFhYTRlYjU5OTIxMWY4M2E4NTIyNzY4MzAxYyJ9").unwrap(); } + + #[test] + fn test_acquire_internal_token() { + let session_manager = SessionManager::new(super::SessionConfig { + lifetime: tokio::time::Duration::from_secs(180), + secret: "TestSecretForJWT".to_owned(), + }); + + session_manager + .acquire_internal_token_with_lifetime(Duration::from_secs(1000)) + .unwrap(); + } } diff --git a/gov-portal-mocker/src/main.rs b/gov-portal-mocker/src/main.rs index 3f9d4de..b7e3697 100644 --- a/gov-portal-mocker/src/main.rs +++ b/gov-portal-mocker/src/main.rs @@ -362,7 +362,7 @@ async fn verify_wallet_route( let (page_name, session_token) = match req { VerifyWalletQuery::WalletSignedMessage { data } => { match state.acquire_session_token(data).await { - Ok(SessionToken { token }) => ("valid-message.html", Ok(Some(token))), + Ok(token) => ("valid-message.html", Ok(Some(token))), Err(e) => ( "error.html", Err(format!("Failed to acquire session token. Error: {e:?}")), @@ -384,7 +384,7 @@ async fn verify_wallet_route( Html( content .replace("{{ERROR_TEXT}}", &error_text.unwrap_or_default()) - .replace("{{SESSION}}", &session_token.unwrap_or_default()), + .replace("{{SESSION}}", session_token.as_deref().unwrap_or_default()), ) }) .ok_or_else(|| "Resource Not Found".to_owned()) diff --git a/shared/src/common.rs b/shared/src/common.rs index 9571519..9b5cc11 100644 --- a/shared/src/common.rs +++ b/shared/src/common.rs @@ -1,9 +1,10 @@ +use anyhow::anyhow; use base64::{engine::general_purpose, Engine}; use chrono::{DateTime, TimeZone, Utc}; use cid::Cid; use ethabi::{encode, Address, ParamType, Token}; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; -use std::{fmt::Display, str::FromStr, time::Duration}; +use std::{fmt::Display, ops::Deref, str::FromStr, time::Duration}; use crate::utils::{self, decode_sbt_request}; @@ -317,22 +318,56 @@ impl User { } /// Session JWT token for an access to MongoDB -#[derive(Debug, Serialize, Deserialize)] -pub struct SessionToken { - pub token: String, +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct SessionToken(String); + +impl std::fmt::Display for SessionToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AsRef for SessionToken { + fn as_ref(&self) -> &String { + &self.0 + } +} + +impl Deref for SessionToken { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.0.as_str() + } +} + +impl From<&str> for SessionToken { + fn from(value: &str) -> Self { + Self(value.to_string()) + } } /// The claims part of session JWT token #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RawSessionToken { - /// User's wallet address - #[serde(rename = "wallet")] - pub checksum_wallet: String, + /// Session token kind + pub kind: SessionTokenKind, /// Expiration date for a session JWT token pub expires_at: u64, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SessionTokenKind { + Wallet { + /// User's gov wallet address + #[serde(rename = "wallet")] + checksum_wallet: String, + }, + Internal, +} + impl RawSessionToken { /// Verifies that session JWT token is not expired pub fn verify(&self) -> bool { @@ -349,24 +384,56 @@ impl SessionToken { &jsonwebtoken::EncodingKey::from_secret(secret), ) .map_err(anyhow::Error::from) - .map(|token| Self { token }) + .map(Self) } /// Verifies that session JWT token is valid and not expired. Returns extracted User's wallet address - pub fn verify(&self, secret: &[u8]) -> Result { + pub fn verify_wallet(&self, secret: &[u8]) -> Result { let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::default()); validation.set_required_spec_claims(&<[&str; 0]>::default()); - let token_data = jsonwebtoken::decode::( - &self.token, + let token = jsonwebtoken::decode::( + self.as_ref(), &jsonwebtoken::DecodingKey::from_secret(secret), &validation, - )?; + )? + .claims; - if !token_data.claims.verify() { - Err(anyhow::Error::msg("Session token expired")) - } else { - Ok(token_data.claims.checksum_wallet) + if !token.verify() { + return Err(anyhow!("Session token expired")); + } + + match token { + RawSessionToken { + kind: SessionTokenKind::Wallet { checksum_wallet }, + .. + } => Ok(checksum_wallet), + _ => Err(anyhow!("Invalid session token kind")), + } + } + + /// Verifies that session JWT token is valid for internal usage and not expired + pub fn verify_internal(&self, secret: &[u8]) -> Result<(), anyhow::Error> { + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::default()); + validation.set_required_spec_claims(&<[&str; 0]>::default()); + + let token = jsonwebtoken::decode::( + self.as_ref(), + &jsonwebtoken::DecodingKey::from_secret(secret), + &validation, + )? + .claims; + + if !token.verify() { + return Err(anyhow!("Session token expired")); + } + + match token { + RawSessionToken { + kind: SessionTokenKind::Internal, + .. + } => Ok(()), + _ => Err(anyhow!("Invalid session token kind")), } } } @@ -385,15 +452,10 @@ impl std::str::FromStr for WalletSignedMessage { fn from_str(encoded_message: &str) -> Result { let decoded = general_purpose::STANDARD .decode(encoded_message) - .map_err(|e| { - anyhow::Error::msg(format!( - "Failed to deserialize base64 encoded message {e:?}" - )) - })?; - - serde_json::from_slice::(&decoded).map_err(|e| { - anyhow::Error::msg(format!("Failed to deserialize wallet signed message {e:?}")) - }) + .map_err(|e| anyhow!("Failed to deserialize base64 encoded message {e:?}"))?; + + serde_json::from_slice::(&decoded) + .map_err(|e| anyhow!("Failed to deserialize wallet signed message {e:?}")) } }