From ce1e6a6e1e34c3c638d60471f30c31003fa703a1 Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:51:03 +0300 Subject: [PATCH] Remove Hardcoded OAuth Secret, Enforce JWT Secret Validation, and Add Challenge Expiry/Rate Limiting ### Description This pull request addresses critical and high-severity security vulnerabilities identified in `task-master` during the Quantus workspace security audit (**FM-01, FM-02, FM-03**)[cite: 54]. A live X (Twitter) OAuth client secret was previously committed in plaintext within `default.toml`, creating an immediate credential exposure risk[cite: 54]. Additionally, the server could start using committed placeholder JWT secrets, allowing arbitrary token forgery[cite: 54]. Furthermore, the unauthenticated `request-challenge` endpoint was susceptible to unbounded memory growth (DoS) and authentication replay attacks due to missing TTLs and capacity bounds[cite: 54]. ### Key Changes & Remediations #### 1. OAuth Secret Redaction (FM-01 - `config/default.toml`) * **Redacted Credentials:** Replaced the committed live X OAuth credentials with explicit `"replace-me"` placeholders[cite: 54, 55]. * **Operator Documentation:** Added explicit security comments requiring credentials to be injected via environment-specific configuration[cite: 55]. *(Note: Maintainers must rotate the compromised secret with X and purge historical git references)[cite: 54].* #### 2. Fail-Fast JWT Secret Validation (FM-02 - `src/config.rs`) * **Placeholder Detection:** Added `validate()` to `Config::load()`, checking whether `jwt.secret` or `jwt.admin_secret` equals `"this-should-be-overriden"`[cite: 54, 56]. * **Server Boot Refusal:** Halts process startup with an actionable `ConfigError` instructing operators to set `TASKMASTER_JWT__SECRET` and `TASKMASTER_JWT__ADMIN_SECRET` rather than running with known signing keys[cite: 54, 56]. #### 3. Challenge Memory Capping & Replay Protection (FM-03 - `src/handlers/auth.rs`, `src/http_server.rs`) * **TTL & Capacity Limits:** Defined `Challenge::TTL_SECONDS = 300` and `Challenge::MAX_PENDING = 10_000`[cite: 54, 58]. * **Eviction & Back-Pressure:** In `request_challenge`, expired entries are pruned via `retain()`[cite: 54, 57]. If pending challenges still exceed `MAX_PENDING`, the handler responds with `StatusCode::TOO_MANY_REQUESTS`[cite: 54, 57]. * **Replay Mitigation:** `verify_login` validates expiration via `is_expired(Utc::now())` and removes consumed or expired challenges from the store[cite: 54, 57]. ### How to Review 1. Inspect `config/default.toml` to verify that no live credentials remain[cite: 55]. 2. Review `src/config.rs` to confirm `Config::validate()` properly errors out when default JWT placeholders are used[cite: 56]. 3. Inspect `src/handlers/auth.rs` and `src/http_server.rs` to verify challenge eviction logic, the `MAX_PENDING` check, and single-use replay deletion[cite: 57, 58]. --- config/default.toml | 90 +++---- src/config.rs | 286 ++++++++++++---------- src/handlers/auth.rs | 570 ++++++++++++++++++++++--------------------- src/http_server.rs | 227 +++++++++-------- 4 files changed, 619 insertions(+), 554 deletions(-) diff --git a/config/default.toml b/config/default.toml index 697f989..61dacba 100644 --- a/config/default.toml +++ b/config/default.toml @@ -1,43 +1,47 @@ -# TaskMaster Configuration - -[server] -host = "127.0.0.1" -port = 3000 -cors_allowed_origins = ["http://localhost:4321"] - -[candidates] -# GraphQL endpoint to fetch candidate addresses -graphql_url = "https://subsquid.quantus.com/graphql" - -[data] -# Database configuration -database_url = "postgres://postgres:postgres@127.0.0.1:55432/task_master" - -[logging] -# Log level: error, warn, info, debug, trace -level = "info" - -[jwt] -admin_secret = "this-should-be-overriden" -exp_in_hours = 24 -secret = "this-should-be-overriden" - -[x_oauth] -callback_url = "http://localhost:3000/api/auth/x/callback" -client_id = "WlVrcm4xSEpXQ2l3TURFM3lLZnE6MTpjaQ" -client_secret = "lfXc45dZLqYTzP62Ms32EhXinGQzxcIP9TvjJml2B-h0T1nIJK" - -[remote_configs] -wallet_configs_file = "../wallet_configs/default_configs.json" - -[risk_checker] -etherscan_api_key = "change-me" -etherscan_base_url = "https://api.etherscan.io/v2/api?chainid=1" -infura_api_key = "change-me" -infura_base_url = "https://mainnet.infura.io/v3" -etherscan_calls_per_sec = 3 -max_concurrent_requests = 1 - -[exchange_rate] -# https://www.exchangerate-api.com/ — v6 key for latest/{base} rates -api_key = "change-me" +# TaskMaster Configuration + +[server] +host = "127.0.0.1" +port = 3000 +cors_allowed_origins = ["http://localhost:4321"] + +[candidates] +# GraphQL endpoint to fetch candidate addresses +graphql_url = "https://subsquid.quantus.com/graphql" + +[data] +# Database configuration +database_url = "postgres://postgres:postgres@127.0.0.1:55432/task_master" + +[logging] +# Log level: error, warn, info, debug, trace +level = "info" + +[jwt] +admin_secret = "this-should-be-overriden" +exp_in_hours = 24 +secret = "this-should-be-overriden" + +[x_oauth] +callback_url = "http://localhost:3000/api/auth/x/callback" +# OAuth credentials must be provided per environment (env vars or a +# git-ignored config file). Never commit real credentials here. +# SECURITY: a live client secret was previously committed in this file and +# must be rotated with X (Twitter) before the history purge completes. +client_id = "replace-me" +client_secret = "replace-me" + +[remote_configs] +wallet_configs_file = "../wallet_configs/default_configs.json" + +[risk_checker] +etherscan_api_key = "change-me" +etherscan_base_url = "https://api.etherscan.io/v2/api?chainid=1" +infura_api_key = "change-me" +infura_base_url = "https://mainnet.infura.io/v3" +etherscan_calls_per_sec = 3 +max_concurrent_requests = 1 + +[exchange_rate] +# https://www.exchangerate-api.com/ — v6 key for latest/{base} rates +api_key = "change-me" diff --git a/src/config.rs b/src/config.rs index 3e1c2bd..36f7417 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,131 +1,155 @@ -use std::path::Path; - -use axum::http::HeaderValue; -use rusx::config::OauthConfig; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Config { - pub server: ServerConfig, - pub candidates: CandidatesConfig, - pub data: DataConfig, - pub logging: LoggingConfig, - pub jwt: JwtConfig, - pub x_oauth: OauthConfig, - pub remote_configs: RemoteConfigsConfig, - pub risk_checker: RiskCheckerConfig, - pub exchange_rate: ExchangeRateConfig, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RemoteConfigsConfig { - pub wallet_configs_file: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerConfig { - pub host: String, - pub port: u16, - pub cors_allowed_origins: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CandidatesConfig { - pub graphql_url: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DataConfig { - pub database_url: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LoggingConfig { - pub level: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JwtConfig { - pub secret: String, - pub admin_secret: String, - pub exp_in_hours: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RiskCheckerConfig { - pub etherscan_api_key: String, - pub etherscan_base_url: String, - pub infura_api_key: String, - pub infura_base_url: String, - pub etherscan_calls_per_sec: u32, - pub max_concurrent_requests: usize, -} - -/// Exchange rate API (e.g. [ExchangeRate-API v6](https://www.exchangerate-api.com/)). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ExchangeRateConfig { - pub api_key: String, -} - -impl Config { - pub fn load(config_path: &str) -> Result { - let settings = config::Config::builder() - .add_source(config::File::new(config_path, config::FileFormat::Toml)) - .add_source(config::Environment::with_prefix("TASKMASTER")) - .build()?; - - let mut config: Self = settings.try_deserialize()?; - config.resolve_relative_paths(config_path); - Ok(config) - } - - #[cfg(test)] - pub fn load_test_env() -> Result { - let test_config_path = "config/test.toml"; - let settings = config::Config::builder() - // Load the test-specific configuration file - .add_source(config::File::new(test_config_path, config::FileFormat::Toml)) - // You can still layer environment variables for testing if you need to - .add_source(config::Environment::with_prefix("TASKMASTER")) - .build()?; - - let mut config: Self = settings.try_deserialize()?; - config.resolve_relative_paths(test_config_path); - Ok(config) - } - - pub fn get_database_url(&self) -> &str { - &self.data.database_url - } - - pub fn get_server_address(&self) -> String { - format!("{}:{}", self.server.host, self.server.port) - } - - pub fn get_jwt_expiration(&self) -> chrono::Duration { - chrono::Duration::hours(self.jwt.exp_in_hours) - } - - pub fn get_cors_allowed_origins(&self) -> Vec { - self.server - .cors_allowed_origins - .iter() - .filter_map(|o| match o.parse() { - Ok(v) => Some(v), - Err(e) => { - tracing::warn!("Skipping invalid CORS origin {:?}: {}", o, e); - None - } - }) - .collect() - } - - fn resolve_relative_paths(&mut self, config_path: &str) { - let wallet_configs_path = Path::new(&self.remote_configs.wallet_configs_file); - if wallet_configs_path.is_absolute() { - return; - } - let base_dir = Path::new(config_path).parent().expect("Failed to get base directory"); - self.remote_configs.wallet_configs_file = base_dir.join(wallet_configs_path).to_string_lossy().to_string(); - } -} +use std::path::Path; + +use axum::http::HeaderValue; +use rusx::config::OauthConfig; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub server: ServerConfig, + pub candidates: CandidatesConfig, + pub data: DataConfig, + pub logging: LoggingConfig, + pub jwt: JwtConfig, + pub x_oauth: OauthConfig, + pub remote_configs: RemoteConfigsConfig, + pub risk_checker: RiskCheckerConfig, + pub exchange_rate: ExchangeRateConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteConfigsConfig { + pub wallet_configs_file: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + pub host: String, + pub port: u16, + pub cors_allowed_origins: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CandidatesConfig { + pub graphql_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DataConfig { + pub database_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoggingConfig { + pub level: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JwtConfig { + pub secret: String, + pub admin_secret: String, + pub exp_in_hours: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RiskCheckerConfig { + pub etherscan_api_key: String, + pub etherscan_base_url: String, + pub infura_api_key: String, + pub infura_base_url: String, + pub etherscan_calls_per_sec: u32, + pub max_concurrent_requests: usize, +} + +/// Exchange rate API (e.g. [ExchangeRate-API v6](https://www.exchangerate-api.com/)). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExchangeRateConfig { + pub api_key: String, +} + +impl Config { + /// Placeholder values that must never reach a running deployment. If a + /// JWT secret is left at this value, every access token (including admin + /// tokens) can be forged by anyone who has read the committed default. + const PLACEHOLDER_JWT_SECRET: &str = "this-should-be-overriden"; + + pub fn load(config_path: &str) -> Result { + let settings = config::Config::builder() + .add_source(config::File::new(config_path, config::FileFormat::Toml)) + .add_source(config::Environment::with_prefix("TASKMASTER")) + .build()?; + + let mut config: Self = settings.try_deserialize()?; + config.resolve_relative_paths(config_path); + config.validate()?; + Ok(config) + } + + /// Fail fast when security-critical secrets are left at their committed + /// placeholder values. Override them via `TASKMASTER_JWT__SECRET` / + /// `TASKMASTER_JWT__ADMIN_SECRET` or an environment-specific config file. + fn validate(&self) -> Result<(), config::ConfigError> { + if self.jwt.secret == Self::PLACEHOLDER_JWT_SECRET + || self.jwt.admin_secret == Self::PLACEHOLDER_JWT_SECRET + { + return Err(config::ConfigError::Message( + "JWT secrets are still set to the committed placeholder \ + \"this-should-be-overriden\". Refusing to start: set \ + TASKMASTER_JWT__SECRET and TASKMASTER_JWT__ADMIN_SECRET (or a \ + non-default config file) before running the server." + .to_string(), + )); + } + Ok(()) + } + + #[cfg(test)] + pub fn load_test_env() -> Result { + let test_config_path = "config/test.toml"; + let settings = config::Config::builder() + // Load the test-specific configuration file + .add_source(config::File::new(test_config_path, config::FileFormat::Toml)) + // You can still layer environment variables for testing if you need to + .add_source(config::Environment::with_prefix("TASKMASTER")) + .build()?; + + let mut config: Self = settings.try_deserialize()?; + config.resolve_relative_paths(test_config_path); + Ok(config) + } + + pub fn get_database_url(&self) -> &str { + &self.data.database_url + } + + pub fn get_server_address(&self) -> String { + format!("{}:{}", self.server.host, self.server.port) + } + + pub fn get_jwt_expiration(&self) -> chrono::Duration { + chrono::Duration::hours(self.jwt.exp_in_hours) + } + + pub fn get_cors_allowed_origins(&self) -> Vec { + self.server + .cors_allowed_origins + .iter() + .filter_map(|o| match o.parse() { + Ok(v) => Some(v), + Err(e) => { + tracing::warn!("Skipping invalid CORS origin {:?}: {}", o, e); + None + } + }) + .collect() + } + + fn resolve_relative_paths(&mut self, config_path: &str) { + let wallet_configs_path = Path::new(&self.remote_configs.wallet_configs_file); + if wallet_configs_path.is_absolute() { + return; + } + let base_dir = Path::new(config_path).parent().expect("Failed to get base directory"); + self.remote_configs.wallet_configs_file = base_dir.join(wallet_configs_path).to_string_lossy().to_string(); + } +} diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 34d1416..cf045f8 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -1,275 +1,295 @@ -use argon2::{Argon2, PasswordHash, PasswordVerifier}; -use axum::{extract::State, http::StatusCode, response::Json, Extension}; -use chrono::Utc; -use jsonwebtoken::{encode, EncodingKey, Header}; -use uuid::Uuid; - -use crate::{ - db_persistence::DbError, - handlers::{HandlerError, SuccessResponse}, - http_server::{AppState, Challenge}, - models::{ - address::{Address, AddressInput}, - admin::{Admin, AdminAuthCheckResponse, AdminClaims, AdminLoginPayload, AdminLoginResponse}, - auth::{RequestChallengeBody, RequestChallengeResponse, TokenClaims, VerifyLoginBody, VerifyLoginResponse}, - }, - services::signature_service::SignatureService, - utils::{generate_referral_code::generate_referral_code, jwt::get_default_jwt_config}, - AppError, -}; -use tracing::{debug, warn}; - -#[derive(Debug, thiserror::Error)] -pub enum AuthHandlerError { - #[error("Not authorized: {0}")] - Unauthorized(String), -} - -pub async fn request_challenge( - State(state): State, - Json(_body): Json, -) -> Result, StatusCode> { - let temp_session_id = Uuid::new_v4().to_string(); - let challenge = Uuid::new_v4().to_string(); - let entry = Challenge { - challenge: challenge.clone(), - created_at: Utc::now(), - }; - state.challenges.write().await.insert(temp_session_id.clone(), entry); - Ok(Json(RequestChallengeResponse { - temp_session_id, - challenge, - })) -} - -pub async fn verify_login( - State(state): State, - Json(body): Json, -) -> Result, AppError> { - let sig_len = body.signature.strip_prefix("0x").unwrap_or(&body.signature).len(); - let pk_len = body.public_key.strip_prefix("0x").unwrap_or(&body.public_key).len(); - debug!( - temp_session_id = %body.temp_session_id, - address = %body.address, - signature_len = sig_len, - public_key_len = pk_len, - "verify_login: received payload" - ); - let Some(chal) = state.challenges.read().await.get(&body.temp_session_id).cloned() else { - return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized( - format!("no challenge with key {} found", &body.temp_session_id), - )))); - }; - let message = format!( - "taskmaster:login:1|challenge={}|address={}", - chal.challenge, body.address - ); - debug!(message = %message, message_len = message.len(), message_hex = %hex::encode(message.as_bytes()), "verify_login: constructed message"); - - let addr_res = SignatureService::verify_address(&body.public_key, &body.address); - if let Err(e) = &addr_res { - warn!(error = %e, "verify_login: verify_address error"); - } - let addr_ok = addr_res.map_err(|_| { - AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized( - "address verification failed".to_string(), - ))) - })?; - if !addr_ok { - return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized( - "address verification failed".to_string(), - )))); - } - let sig_res = SignatureService::verify_message(message.as_bytes(), &body.signature, &body.public_key); - if let Err(e) = &sig_res { - warn!(error = %e, "verify_login: verify_message error"); - } - let sig_ok = sig_res.map_err(|_| { - AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized( - "message verification failed".to_string(), - ))) - })?; - debug!(addr_ok = addr_ok, sig_ok = sig_ok, "verify_login: verification results"); - if !sig_ok { - return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized( - "message verification failed".to_string(), - )))); - } - - if state.db.addresses.find_by_id(&body.address).await?.is_none() { - tracing::info!("Address is not saved yet, proceed to saving..."); - - tracing::debug!("Generating address referral code..."); - let referral_code = generate_referral_code(body.address.clone()).await?; - - tracing::debug!("Creating address struct..."); - let address = Address::new(AddressInput { - quan_address: body.address.clone(), - referral_code, - })?; - - tracing::debug!("Saving address to DB..."); - state.db.addresses.create(&address).await?; - } - - let (iat, exp) = get_default_jwt_config(&state); - let claims: TokenClaims = TokenClaims { - sub: body.address, - iat, - exp, - }; - - let access_token = encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(state.config.jwt.secret.as_ref()), - ) - .unwrap(); - - state.challenges.write().await.remove(&body.temp_session_id); - Ok(Json(VerifyLoginResponse { access_token })) -} - -pub async fn auth_me(Extension(address): Extension
) -> Result>, StatusCode> { - Ok(SuccessResponse::new(address)) -} - -pub async fn handle_admin_login( - State(state): State, - Json(body): Json, -) -> Result, AppError> { - tracing::info!("Handling admin login..."); - - let admin = state - .db - .admin - .find_by_username(&body.username) - .await? - .ok_or(AppError::Database(DbError::RecordNotFound(format!( - "Admin with username {} is not exist", - &body.username, - ))))?; - - let parsed_hash = - PasswordHash::new(&admin.password).map_err(|_| AppError::Server("Failed generating token".to_string()))?; - - Argon2::default() - .verify_password(body.password.as_bytes(), &parsed_hash) - .map_err(|_| { - HandlerError::Auth(AuthHandlerError::Unauthorized( - "Invalid username or password".to_string(), - )) - })?; - - let (iat, exp) = get_default_jwt_config(&state); - let claims: AdminClaims = AdminClaims { - sub: admin.id.to_string(), - iat, - exp, - }; - - tracing::info!("Generating admin token..."); - - let access_token = encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(state.config.jwt.admin_secret.as_ref()), - ) - .unwrap(); - - Ok(Json(AdminLoginResponse { access_token })) -} - -pub async fn auth_admin( - Extension(admin): Extension, -) -> Result>, StatusCode> { - Ok(SuccessResponse::new(AdminAuthCheckResponse { - id: admin.id, - username: admin.username, - })) -} - -#[cfg(test)] -mod tests { - use crate::{routes::auth::auth_routes, utils::test_app_state::create_test_app_state}; - use axum::{body::Body, http}; - use qp_rusty_crystals_dilithium::SensitiveBytes32; - use sp_core::crypto::{self, Ss58AddressFormat, Ss58Codec}; - use sp_runtime::traits::IdentifyAccount; - use tower::ServiceExt; - - async fn test_app() -> axum::Router { - let state = create_test_app_state().await; - auth_routes(state.clone()).with_state(state) - } - - #[tokio::test] - async fn auth_challenge_and_verify_flow() { - crypto::set_default_ss58_version(Ss58AddressFormat::custom(189)); - let app = test_app().await; - - let resp = app - .clone() - .oneshot( - http::Request::builder() - .method("POST") - .uri("/auth/request-challenge") - .header(http::header::CONTENT_TYPE, "application/json") - .body(Body::from("{}")) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), http::StatusCode::OK); - let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024).await.unwrap(); - let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - let temp_session_id = v["temp_session_id"].as_str().unwrap().to_string(); - let challenge = v["challenge"].as_str().unwrap().to_string(); - let entropy = SensitiveBytes32::from(&mut [3u8; 32]); - let kp = qp_rusty_crystals_dilithium::ml_dsa_87::Keypair::generate(entropy); - let pk_hex = hex::encode(kp.public.to_bytes()); - let addr = quantus_cli::qp_dilithium_crypto::types::DilithiumPublic::try_from(kp.public.to_bytes().as_slice()) - .unwrap() - .into_account() - .to_ss58check(); - let msg = format!("taskmaster:login:1|challenge={}|address={}", challenge, addr); - let sig_hex = hex::encode(kp.sign(msg.as_bytes(), None, Some([7u8; 32])).unwrap()); - - let verify_payload = serde_json::json!({ - "temp_session_id": temp_session_id, - "address": addr, - "public_key": pk_hex, - "signature": sig_hex, - }); - let resp = app - .clone() - .oneshot( - http::Request::builder() - .method("POST") - .uri("/auth/verify") - .header(http::header::CONTENT_TYPE, "application/json") - .body(Body::from(serde_json::to_vec(&verify_payload).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), http::StatusCode::OK); - let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024).await.unwrap(); - let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - let access_token = v["access_token"].as_str().unwrap(); - - let resp = app - .clone() - .oneshot( - http::Request::builder() - .method("GET") - .uri("/auth/me") - .header(http::header::AUTHORIZATION, format!("Bearer {}", access_token)) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), http::StatusCode::OK); - } -} +use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use axum::{extract::State, http::StatusCode, response::Json, Extension}; +use chrono::Utc; +use jsonwebtoken::{encode, EncodingKey, Header}; +use uuid::Uuid; + +use crate::{ + db_persistence::DbError, + handlers::{HandlerError, SuccessResponse}, + http_server::{AppState, Challenge}, + models::{ + address::{Address, AddressInput}, + admin::{Admin, AdminAuthCheckResponse, AdminClaims, AdminLoginPayload, AdminLoginResponse}, + auth::{RequestChallengeBody, RequestChallengeResponse, TokenClaims, VerifyLoginBody, VerifyLoginResponse}, + }, + services::signature_service::SignatureService, + utils::{generate_referral_code::generate_referral_code, jwt::get_default_jwt_config}, + AppError, +}; +use tracing::{debug, warn}; + +#[derive(Debug, thiserror::Error)] +pub enum AuthHandlerError { + #[error("Not authorized: {0}")] + Unauthorized(String), +} + +pub async fn request_challenge( + State(state): State, + Json(_body): Json, +) -> Result, StatusCode> { + // The endpoint is unauthenticated, so the pending-challenge map must not + // grow without bound: drop expired entries first, then reject when the + // store is still full (simple back-pressure against challenge flooding). + { + let mut challenges = state.challenges.write().await; + let now = Utc::now(); + challenges.retain(|_, entry| !entry.is_expired(now)); + if challenges.len() >= Challenge::MAX_PENDING { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + } + + let temp_session_id = Uuid::new_v4().to_string(); + let challenge = Uuid::new_v4().to_string(); + let entry = Challenge { + challenge: challenge.clone(), + created_at: Utc::now(), + }; + state.challenges.write().await.insert(temp_session_id.clone(), entry); + Ok(Json(RequestChallengeResponse { + temp_session_id, + challenge, + })) +} + +pub async fn verify_login( + State(state): State, + Json(body): Json, +) -> Result, AppError> { + let sig_len = body.signature.strip_prefix("0x").unwrap_or(&body.signature).len(); + let pk_len = body.public_key.strip_prefix("0x").unwrap_or(&body.public_key).len(); + debug!( + temp_session_id = %body.temp_session_id, + address = %body.address, + signature_len = sig_len, + public_key_len = pk_len, + "verify_login: received payload" + ); + let Some(chal) = state.challenges.read().await.get(&body.temp_session_id).cloned() else { + return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized( + format!("no challenge with key {} found", &body.temp_session_id), + )))); + }; + // Reject stale challenges so a captured challenge/message pair cannot be + // replayed indefinitely. + if chal.is_expired(Utc::now()) { + state.challenges.write().await.remove(&body.temp_session_id); + return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized( + "challenge expired".to_string(), + )))); + } + let message = format!( + "taskmaster:login:1|challenge={}|address={}", + chal.challenge, body.address + ); + debug!(message = %message, message_len = message.len(), message_hex = %hex::encode(message.as_bytes()), "verify_login: constructed message"); + + let addr_res = SignatureService::verify_address(&body.public_key, &body.address); + if let Err(e) = &addr_res { + warn!(error = %e, "verify_login: verify_address error"); + } + let addr_ok = addr_res.map_err(|_| { + AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized( + "address verification failed".to_string(), + ))) + })?; + if !addr_ok { + return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized( + "address verification failed".to_string(), + )))); + } + let sig_res = SignatureService::verify_message(message.as_bytes(), &body.signature, &body.public_key); + if let Err(e) = &sig_res { + warn!(error = %e, "verify_login: verify_message error"); + } + let sig_ok = sig_res.map_err(|_| { + AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized( + "message verification failed".to_string(), + ))) + })?; + debug!(addr_ok = addr_ok, sig_ok = sig_ok, "verify_login: verification results"); + if !sig_ok { + return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized( + "message verification failed".to_string(), + )))); + } + + if state.db.addresses.find_by_id(&body.address).await?.is_none() { + tracing::info!("Address is not saved yet, proceed to saving..."); + + tracing::debug!("Generating address referral code..."); + let referral_code = generate_referral_code(body.address.clone()).await?; + + tracing::debug!("Creating address struct..."); + let address = Address::new(AddressInput { + quan_address: body.address.clone(), + referral_code, + })?; + + tracing::debug!("Saving address to DB..."); + state.db.addresses.create(&address).await?; + } + + let (iat, exp) = get_default_jwt_config(&state); + let claims: TokenClaims = TokenClaims { + sub: body.address, + iat, + exp, + }; + + let access_token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(state.config.jwt.secret.as_ref()), + ) + .unwrap(); + + state.challenges.write().await.remove(&body.temp_session_id); + Ok(Json(VerifyLoginResponse { access_token })) +} + +pub async fn auth_me(Extension(address): Extension
) -> Result>, StatusCode> { + Ok(SuccessResponse::new(address)) +} + +pub async fn handle_admin_login( + State(state): State, + Json(body): Json, +) -> Result, AppError> { + tracing::info!("Handling admin login..."); + + let admin = state + .db + .admin + .find_by_username(&body.username) + .await? + .ok_or(AppError::Database(DbError::RecordNotFound(format!( + "Admin with username {} is not exist", + &body.username, + ))))?; + + let parsed_hash = + PasswordHash::new(&admin.password).map_err(|_| AppError::Server("Failed generating token".to_string()))?; + + Argon2::default() + .verify_password(body.password.as_bytes(), &parsed_hash) + .map_err(|_| { + HandlerError::Auth(AuthHandlerError::Unauthorized( + "Invalid username or password".to_string(), + )) + })?; + + let (iat, exp) = get_default_jwt_config(&state); + let claims: AdminClaims = AdminClaims { + sub: admin.id.to_string(), + iat, + exp, + }; + + tracing::info!("Generating admin token..."); + + let access_token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(state.config.jwt.admin_secret.as_ref()), + ) + .unwrap(); + + Ok(Json(AdminLoginResponse { access_token })) +} + +pub async fn auth_admin( + Extension(admin): Extension, +) -> Result>, StatusCode> { + Ok(SuccessResponse::new(AdminAuthCheckResponse { + id: admin.id, + username: admin.username, + })) +} + +#[cfg(test)] +mod tests { + use crate::{routes::auth::auth_routes, utils::test_app_state::create_test_app_state}; + use axum::{body::Body, http}; + use qp_rusty_crystals_dilithium::SensitiveBytes32; + use sp_core::crypto::{self, Ss58AddressFormat, Ss58Codec}; + use sp_runtime::traits::IdentifyAccount; + use tower::ServiceExt; + + async fn test_app() -> axum::Router { + let state = create_test_app_state().await; + auth_routes(state.clone()).with_state(state) + } + + #[tokio::test] + async fn auth_challenge_and_verify_flow() { + crypto::set_default_ss58_version(Ss58AddressFormat::custom(189)); + let app = test_app().await; + + let resp = app + .clone() + .oneshot( + http::Request::builder() + .method("POST") + .uri("/auth/request-challenge") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), http::StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let temp_session_id = v["temp_session_id"].as_str().unwrap().to_string(); + let challenge = v["challenge"].as_str().unwrap().to_string(); + let entropy = SensitiveBytes32::from(&mut [3u8; 32]); + let kp = qp_rusty_crystals_dilithium::ml_dsa_87::Keypair::generate(entropy); + let pk_hex = hex::encode(kp.public.to_bytes()); + let addr = quantus_cli::qp_dilithium_crypto::types::DilithiumPublic::try_from(kp.public.to_bytes().as_slice()) + .unwrap() + .into_account() + .to_ss58check(); + let msg = format!("taskmaster:login:1|challenge={}|address={}", challenge, addr); + let sig_hex = hex::encode(kp.sign(msg.as_bytes(), None, Some([7u8; 32])).unwrap()); + + let verify_payload = serde_json::json!({ + "temp_session_id": temp_session_id, + "address": addr, + "public_key": pk_hex, + "signature": sig_hex, + }); + let resp = app + .clone() + .oneshot( + http::Request::builder() + .method("POST") + .uri("/auth/verify") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&verify_payload).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), http::StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let access_token = v["access_token"].as_str().unwrap(); + + let resp = app + .clone() + .oneshot( + http::Request::builder() + .method("GET") + .uri("/auth/me") + .header(http::header::AUTHORIZATION, format!("Bearer {}", access_token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), http::StatusCode::OK); + } +} diff --git a/src/http_server.rs b/src/http_server.rs index 86c6f99..2a3da6c 100644 --- a/src/http_server.rs +++ b/src/http_server.rs @@ -1,105 +1,122 @@ -use axum::http::Method; -use axum::{middleware, response::Json, routing::get, Router}; -use rusx::TwitterGateway; -use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, sync::Arc}; -use tower::ServiceBuilder; -use tower_http::{ - cors::{AllowHeaders, CorsLayer}, - trace::TraceLayer, -}; - -use crate::services::exchange_rate_service::ExchangeRateService; -use crate::{ - db_persistence::DbPersistence, - metrics::{metrics_handler, track_metrics, Metrics}, - routes::api_routes, - services::{risk_checker_service::RiskCheckerService, wallet_config_service::WalletConfigService}, - Config, -}; -use chrono::{DateTime, Utc}; -use tokio::sync::RwLock; - -#[derive(Debug, Clone)] -pub struct AppState { - pub db: Arc, - pub metrics: Arc, - pub wallet_config_service: Arc, - pub risk_checker_service: Arc, - pub exchange_rate_service: Arc, - pub config: Arc, - pub challenges: Arc>>, - pub twitter_gateway: Arc, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Challenge { - pub challenge: String, - pub created_at: DateTime, -} - -#[derive(Debug, Serialize)] -pub struct HealthResponse { - pub healthy: bool, - pub service: String, - pub version: String, - pub timestamp: String, -} - -/// Create the HTTP server router -pub fn create_router(state: AppState) -> Router { - Router::new() - .route("/health", get(health_check)) - .route("/metrics", get(metrics_handler)) - .nest("/api", api_routes(state.clone())) - .layer(middleware::from_fn(track_metrics)) - .layer( - ServiceBuilder::new().layer(TraceLayer::new_for_http()).layer( - CorsLayer::new() - .allow_origin(state.config.get_cors_allowed_origins()) - .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS]) - .allow_headers(AllowHeaders::mirror_request()) - .allow_credentials(true), - ), - ) - .with_state(state) -} - -/// Health check endpoint -async fn health_check() -> Json { - Json(HealthResponse { - healthy: true, - service: "TaskMaster".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - timestamp: chrono::Utc::now().to_rfc3339(), - }) -} - -/// Start the HTTP server -pub async fn start_server( - db: Arc, - twitter_gateway: Arc, - bind_address: &str, - config: Arc, -) -> Result<(), Box> { - let state = AppState { - db, - metrics: Arc::new(Metrics::new()), - wallet_config_service: Arc::new(WalletConfigService::new( - config.remote_configs.wallet_configs_file.clone(), - )?), - risk_checker_service: Arc::new(RiskCheckerService::new(&config.risk_checker)), - exchange_rate_service: Arc::new(ExchangeRateService::new(&config.exchange_rate.api_key)), - config, - twitter_gateway, - challenges: Arc::new(RwLock::new(HashMap::new())), - }; - let app = create_router(state); - - tracing::info!("Starting HTTP server on {}", bind_address); - - let listener = tokio::net::TcpListener::bind(bind_address).await?; - axum::serve(listener, app).await?; - - Ok(()) -} +use axum::http::Method; +use axum::{middleware, response::Json, routing::get, Router}; +use rusx::TwitterGateway; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::Arc}; +use tower::ServiceBuilder; +use tower_http::{ + cors::{AllowHeaders, CorsLayer}, + trace::TraceLayer, +}; + +use crate::services::exchange_rate_service::ExchangeRateService; +use crate::{ + db_persistence::DbPersistence, + metrics::{metrics_handler, track_metrics, Metrics}, + routes::api_routes, + services::{risk_checker_service::RiskCheckerService, wallet_config_service::WalletConfigService}, + Config, +}; +use chrono::{DateTime, Utc}; +use tokio::sync::RwLock; + +#[derive(Debug, Clone)] +pub struct AppState { + pub db: Arc, + pub metrics: Arc, + pub wallet_config_service: Arc, + pub risk_checker_service: Arc, + pub exchange_rate_service: Arc, + pub config: Arc, + pub challenges: Arc>>, + pub twitter_gateway: Arc, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Challenge { + pub challenge: String, + pub created_at: DateTime, +} + +impl Challenge { + /// A login challenge older than this is stale and must be rejected. This + /// bounds how long an unverified challenge stays usable. + pub const TTL_SECONDS: i64 = 300; + + /// Maximum number of pending challenges kept in memory. `request_challenge` + /// is unauthenticated, so without this cap an attacker can grow the map + /// without bound (memory-exhaustion DoS). + pub const MAX_PENDING: usize = 10_000; + + pub fn is_expired(&self, now: DateTime) -> bool { + now.signed_duration_since(self.created_at) + .num_seconds() + >= Self::TTL_SECONDS + } +} + +#[derive(Debug, Serialize)] +pub struct HealthResponse { + pub healthy: bool, + pub service: String, + pub version: String, + pub timestamp: String, +} + +/// Create the HTTP server router +pub fn create_router(state: AppState) -> Router { + Router::new() + .route("/health", get(health_check)) + .route("/metrics", get(metrics_handler)) + .nest("/api", api_routes(state.clone())) + .layer(middleware::from_fn(track_metrics)) + .layer( + ServiceBuilder::new().layer(TraceLayer::new_for_http()).layer( + CorsLayer::new() + .allow_origin(state.config.get_cors_allowed_origins()) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS]) + .allow_headers(AllowHeaders::mirror_request()) + .allow_credentials(true), + ), + ) + .with_state(state) +} + +/// Health check endpoint +async fn health_check() -> Json { + Json(HealthResponse { + healthy: true, + service: "TaskMaster".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + }) +} + +/// Start the HTTP server +pub async fn start_server( + db: Arc, + twitter_gateway: Arc, + bind_address: &str, + config: Arc, +) -> Result<(), Box> { + let state = AppState { + db, + metrics: Arc::new(Metrics::new()), + wallet_config_service: Arc::new(WalletConfigService::new( + config.remote_configs.wallet_configs_file.clone(), + )?), + risk_checker_service: Arc::new(RiskCheckerService::new(&config.risk_checker)), + exchange_rate_service: Arc::new(ExchangeRateService::new(&config.exchange_rate.api_key)), + config, + twitter_gateway, + challenges: Arc::new(RwLock::new(HashMap::new())), + }; + let app = create_router(state); + + tracing::info!("Starting HTTP server on {}", bind_address); + + let listener = tokio::net::TcpListener::bind(bind_address).await?; + axum::serve(listener, app).await?; + + Ok(()) +}