From e8d8ac99de9aa5cb12af837c29a661545553de0e Mon Sep 17 00:00:00 2001 From: abduldayantech Date: Sat, 17 Oct 2026 08:45:59 +0000 Subject: [PATCH 1/4] fix(api): authenticate 2FA endpoints Bind 2FA operations to the JWT-authenticated user and require access tokens for enable, verify, and backup-code routes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api-server/src/handlers.rs | 30 ++++++++++++++++++++++++++++++ api-server/src/main.rs | 6 +++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/api-server/src/handlers.rs b/api-server/src/handlers.rs index 69bbf272..1b6bc1d0 100644 --- a/api-server/src/handlers.rs +++ b/api-server/src/handlers.rs @@ -1592,6 +1592,7 @@ static TWO_FACTOR_STORE: once_cell::sync::Lazy )] #[instrument(skip(body))] pub async fn enable_2fa( + crate::auth::AuthExtension(claims): crate::auth::AuthExtension, Json(body): Json, ) -> Result, (StatusCode, Json)> { if body.user_id.is_empty() { @@ -1603,6 +1604,15 @@ pub async fn enable_2fa( )); } + if claims.sub != body.user_id { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Cannot manage 2FA for another user".to_string(), + }), + )); + } + // Generate TOTP secret let secret = crate::auth_2fa::generate_totp_secret(); let backup_codes = crate::auth_2fa::generate_backup_codes(8); @@ -1658,6 +1668,7 @@ pub async fn enable_2fa( )] #[instrument(skip(body))] pub async fn verify_2fa( + crate::auth::AuthExtension(claims): crate::auth::AuthExtension, Json(body): Json, ) -> Result, (StatusCode, Json)> { if body.user_id.is_empty() { @@ -1669,6 +1680,15 @@ pub async fn verify_2fa( )); } + if claims.sub != body.user_id { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Cannot manage 2FA for another user".to_string(), + }), + )); + } + if body.totp_code.len() != 6 || !body.totp_code.chars().all(|c| c.is_numeric()) { return Err(( StatusCode::BAD_REQUEST, @@ -1761,6 +1781,7 @@ pub async fn verify_2fa( )] #[instrument(skip(body))] pub async fn use_backup_code( + crate::auth::AuthExtension(claims): crate::auth::AuthExtension, Json(body): Json, ) -> Result, (StatusCode, Json)> { if body.user_id.is_empty() || body.backup_code.is_empty() { @@ -1772,6 +1793,15 @@ pub async fn use_backup_code( )); } + if claims.sub != body.user_id { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Cannot manage 2FA for another user".to_string(), + }), + )); + } + // Check if backup code exists and is valid let used = crate::auth_2fa::mark_backup_code_used(&TWO_FACTOR_STORE, &body.user_id, &body.backup_code) .map_err(|err| { diff --git a/api-server/src/main.rs b/api-server/src/main.rs index 053e7b74..433fb469 100644 --- a/api-server/src/main.rs +++ b/api-server/src/main.rs @@ -488,9 +488,9 @@ fn build_app() -> Router { .route("/v1/bulk/commit-ip", post(handlers::bulk_commit_ip)) .route("/v1/bulk/initiate-swap", post(handlers::bulk_initiate_swap)) .route("/v1/swaps/execute-batch", post(handlers::execute_batch_swaps)) - .route("/auth/2fa/enable", post(handlers::enable_2fa)) - .route("/auth/2fa/verify", post(handlers::verify_2fa)) - .route("/auth/2fa/backup-code", post(handlers::use_backup_code)) + .route("/auth/2fa/enable", post(handlers::enable_2fa).layer(middleware::from_fn(auth::require_auth))) + .route("/auth/2fa/verify", post(handlers::verify_2fa).layer(middleware::from_fn(auth::require_auth))) + .route("/auth/2fa/backup-code", post(handlers::use_backup_code).layer(middleware::from_fn(auth::require_auth))) .route("/auth/session/status", post(handlers::check_session)) .route("/auth/extend-session", post(handlers::extend_session)) .route("/openapi.json", get(openapi_handler)) From 08e86cd90cbf80efb447cd8114c3b5010440228b Mon Sep 17 00:00:00 2001 From: abduldayantech Date: Sat, 17 Oct 2026 08:46:11 +0000 Subject: [PATCH 2/4] fix(api): prevent 2FA secret replacement Reject a second 2FA enrollment rather than replacing a user's existing secret and backup codes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api-server/src/auth_2fa.rs | 41 ++++++++++++++++++++++++++++++++++++++ api-server/src/handlers.rs | 19 ++++++++++++++---- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/api-server/src/auth_2fa.rs b/api-server/src/auth_2fa.rs index 836665ee..08cccd8e 100644 --- a/api-server/src/auth_2fa.rs +++ b/api-server/src/auth_2fa.rs @@ -85,6 +85,16 @@ impl TwoFactorStore { Self::default() } + /// Create a user's 2FA configuration without replacing an existing one. + pub fn create_config(&self, config: TwoFactorConfig) -> Result { + let mut store = self.configs.lock().map_err(|e| e.to_string())?; + if store.contains_key(&config.user_id) { + return Ok(false); + } + store.insert(config.user_id.clone(), config); + Ok(true) + } + /// Store user's 2FA configuration pub fn store_config(&self, config: TwoFactorConfig) -> Result<(), String> { let mut store = self.configs.lock().map_err(|e| e.to_string())?; @@ -180,3 +190,34 @@ pub fn mark_backup_code_used( } Ok(false) } + +#[cfg(test)] +mod tests { + use super::*; + + fn config(user_id: &str, secret: &str) -> TwoFactorConfig { + TwoFactorConfig { + user_id: user_id.to_string(), + totp: Some(TotpSecret { + secret: secret.to_string(), + enabled_at: 0, + verified: true, + }), + backup_codes: Vec::new(), + created_at: 0, + last_verified: None, + } + } + + #[test] + fn create_config_does_not_replace_existing_secret() { + let store = TwoFactorStore::new(); + assert!(store.create_config(config("user", "original")).unwrap()); + assert!(!store.create_config(config("user", "replacement")).unwrap()); + + assert_eq!( + store.get_config("user").unwrap().unwrap().totp.unwrap().secret, + "original" + ); + } +} diff --git a/api-server/src/handlers.rs b/api-server/src/handlers.rs index 1b6bc1d0..89239c0c 100644 --- a/api-server/src/handlers.rs +++ b/api-server/src/handlers.rs @@ -1586,8 +1586,9 @@ static TWO_FACTOR_STORE: once_cell::sync::Lazy request_body = Enable2faRequest, responses( (status = 200, description = "2FA enabled successfully, returns secret and backup codes", body = Enable2faResponse), - (status = 400, description = "Invalid user ID", body = ErrorResponse), - (status = 500, description = "Internal server error", body = ErrorResponse), + (status = 409, description = "2FA is already configured for this user", body = ErrorResponse), + (status = 400, description = "Invalid user ID", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), ) )] #[instrument(skip(body))] @@ -1638,12 +1639,22 @@ pub async fn enable_2fa( last_verified: None, }; - TWO_FACTOR_STORE.store_config(config).map_err(|err| { + match TWO_FACTOR_STORE.create_config(config).map_err(|err| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: err }), ) - })?; + })? { + true => {} + false => { + return Err(( + StatusCode::CONFLICT, + Json(ErrorResponse { + error: "2FA is already configured for this user".to_string(), + }), + )); + } + } Ok(Json(Enable2faResponse { secret, From fd83a3b43cc611241a6f1feb4708f7ea218c3576 Mon Sep 17 00:00:00 2001 From: abduldayantech Date: Sat, 17 Oct 2026 08:46:51 +0000 Subject: [PATCH 3/4] feat(contract): configure expired swap penalty Add an admin-controlled seller reputation penalty for expired swaps, with a default of 10 points and validation between 0 and 100. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- contracts/atomic_swap/src/lib.rs | 34 +++++++++++++++++-- contracts/atomic_swap/src/reputation_tests.rs | 25 ++++++++++++++ contracts/atomic_swap/src/upgrade.rs | 4 +++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/contracts/atomic_swap/src/lib.rs b/contracts/atomic_swap/src/lib.rs index 3e42236c..dfe208ce 100644 --- a/contracts/atomic_swap/src/lib.rs +++ b/contracts/atomic_swap/src/lib.rs @@ -125,6 +125,7 @@ pub enum ContractError { /// #906: Treasury address validation - rejects zero or placeholder addresses InvalidTreasuryAddress = 68, BatchDependencyNotMet = 69, + InvalidReputationPenalty = 71, } // ── TTL ─────────────────────────────────────────────────────────────────────── @@ -224,6 +225,8 @@ pub enum DataKey { EscrowDeposit(u64), /// Maps address → reputation score (0–100). UserReputation(Address), + /// Reputation points deducted from sellers when an accepted swap expires unrevealed. + ExpiredSwapReputationPenalty, /// Maps ip_id → minimum buyer reputation required (set by seller per swap). ReputationMultiplier(u64), /// Maps swap_id → timestamp when arbitration was requested. @@ -2413,8 +2416,13 @@ impl AtomicSwap { // #253: Log history entry Self::append_history(&env, swap_id, SwapStatus::Cancelled); - // Seller defaulted (expired without revealing key): seller loses 10 points - Self::update_reputation(&env, &swap.seller, -10); + // Seller defaulted (expired without revealing key). + let penalty: u32 = env + .storage() + .persistent() + .get(&DataKey::ExpiredSwapReputationPenalty) + .unwrap_or(10); + Self::update_reputation(&env, &swap.seller, -(penalty as i32)); env.events().publish( (soroban_sdk::symbol_short!("s_cancel"),), @@ -5304,6 +5312,28 @@ impl AtomicSwap { // ── Reputation ──────────────────────────────────────────────────────────── + /// Returns the configured seller reputation penalty for expired swaps. Defaults to 10. + pub fn get_expired_swap_reputation_penalty(env: Env) -> u32 { + env.storage() + .persistent() + .get(&DataKey::ExpiredSwapReputationPenalty) + .unwrap_or(10) + } + + /// Set the seller reputation penalty for expired swaps. Only the admin may call this. + pub fn set_expired_swap_reputation_penalty(env: Env, caller: Address, penalty: u32) { + caller.require_auth(); + require_admin(&env, &caller); + if penalty > 100 { + env.panic_with_error(Error::from_contract_error( + ContractError::InvalidReputationPenalty as u32, + )); + } + env.storage() + .persistent() + .set(&DataKey::ExpiredSwapReputationPenalty, &penalty); + } + /// Returns the reputation score (0–100) for an address. Defaults to 50. pub fn get_reputation(env: Env, address: Address) -> u32 { env.storage() diff --git a/contracts/atomic_swap/src/reputation_tests.rs b/contracts/atomic_swap/src/reputation_tests.rs index 98a2bc68..97ae797a 100644 --- a/contracts/atomic_swap/src/reputation_tests.rs +++ b/contracts/atomic_swap/src/reputation_tests.rs @@ -128,6 +128,31 @@ mod reputation_tests { assert_eq!(client.get_reputation(&seller), 40); } + #[test] + fn test_admin_can_configure_expired_swap_reputation_penalty() { + let env = Env::default(); + env.mock_all_auths(); + + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let (registry_id, ip_id, _, _) = setup_registry(&env, &seller); + let token_id = setup_token(&env, &seller, &buyer, 1_000_000); + let contract_id = setup_swap_contract(&env, ®istry_id); + let client = AtomicSwapClient::new(&env, &contract_id); + + let swap_id = client.initiate_swap( + &token_id, &ip_id, &seller, &1000i128, &buyer, + &0u32, &None, &0i128, &false, + ); + client.set_expired_swap_reputation_penalty(&seller, &7); + client.accept_swap(&swap_id); + env.ledger().with_mut(|l| l.timestamp += 604801); + client.cancel_expired_swap(&swap_id, &buyer); + + assert_eq!(client.get_expired_swap_reputation_penalty(), 7); + assert_eq!(client.get_reputation(&seller), 43); + } + #[test] fn test_reputation_clamped_at_zero() { let env = Env::default(); diff --git a/contracts/atomic_swap/src/upgrade.rs b/contracts/atomic_swap/src/upgrade.rs index 3257c502..22b7329d 100644 --- a/contracts/atomic_swap/src/upgrade.rs +++ b/contracts/atomic_swap/src/upgrade.rs @@ -246,6 +246,8 @@ pub fn build_v1_schema(env: &Env) -> ContractSchema { f!("initialize", "initialize(ip_registry:Address)->()"); f!("set_admin", "set_admin(new_admin:Address)->()"); + f!("get_expired_swap_reputation_penalty", "get_expired_swap_reputation_penalty()->u32"); + f!("set_expired_swap_reputation_penalty", "set_expired_swap_reputation_penalty(caller:Address,penalty:u32)->()"); f!("pause", "pause(caller:Address)->()"); f!("unpause", "unpause(caller:Address)->()"); f!("upgrade", "upgrade(new_wasm_hash:BytesN<32>)->()"); @@ -332,6 +334,7 @@ pub fn build_v1_schema(env: &Env) -> ContractSchema { e!("InsuranceNotClaimable", 60); e!("HashlockNotMet", 69); e!("TimelockExpired", 70); + e!("InvalidReputationPenalty", 71); let mut storage_keys: Vec = Vec::new(env); @@ -363,6 +366,7 @@ pub fn build_v1_schema(env: &Env) -> ContractSchema { k!("InsuranceReserved"); k!("InsuranceReservedTotal"); k!("SwapContingencies"); + k!("ExpiredSwapReputationPenalty"); ContractSchema { version: 1, From c3db46ffa86afbfe78b7f0945f88296050b7d164 Mon Sep 17 00:00:00 2001 From: abduldayantech Date: Sat, 17 Oct 2026 08:49:29 +0000 Subject: [PATCH 4/4] fix(api): implement replay-safe TOTP verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify six-digit codes with HMAC-SHA1 and a ±1-step RFC 6238 window, then atomically record accepted steps to prevent replay. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api-server/Cargo.lock | 2 + api-server/Cargo.toml | 2 + api-server/src/auth_2fa.rs | 161 ++++++++++++++++++++++++++++++++----- api-server/src/handlers.rs | 66 ++++----------- 4 files changed, 157 insertions(+), 74 deletions(-) diff --git a/api-server/Cargo.lock b/api-server/Cargo.lock index 0a61c7d0..7ba3891f 100644 --- a/api-server/Cargo.lock +++ b/api-server/Cargo.lock @@ -73,6 +73,7 @@ dependencies = [ "async-graphql-axum", "async-trait", "axum 0.8.9", + "base32", "base64 0.22.1", "brotli", "chrono", @@ -98,6 +99,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha1", "sha2", "stellar-strkey", "testcontainers", diff --git a/api-server/Cargo.toml b/api-server/Cargo.toml index 7ac43891..1691e04d 100644 --- a/api-server/Cargo.toml +++ b/api-server/Cargo.toml @@ -45,7 +45,9 @@ hex = "0.4" async-trait = "0.1" chrono = { version = "0.4", features = ["serde"] } ed25519-dalek = "2" +base32 = "0.4" hmac = "0.12" +sha1 = "0.10" jsonwebtoken = "9" stellar-strkey = "0.0.8" rand = "0.8.6" diff --git a/api-server/src/auth_2fa.rs b/api-server/src/auth_2fa.rs index 08cccd8e..540f0757 100644 --- a/api-server/src/auth_2fa.rs +++ b/api-server/src/auth_2fa.rs @@ -1,7 +1,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use chrono::Utc; +use hmac::{Hmac, Mac}; +use sha1::Sha1; /// #984: Two-Factor Authentication (2FA) module for Time-based One-Time Passwords (TOTP) /// and backup codes for account recovery. @@ -15,6 +16,9 @@ pub struct TotpSecret { pub enabled_at: i64, /// Whether 2FA is currently active/verified pub verified: bool, + /// Last successfully used TOTP time step, preventing code replay. + #[serde(default)] + pub last_totp_step: Option, } /// Backup code for account recovery @@ -95,13 +99,6 @@ impl TwoFactorStore { Ok(true) } - /// Store user's 2FA configuration - pub fn store_config(&self, config: TwoFactorConfig) -> Result<(), String> { - let mut store = self.configs.lock().map_err(|e| e.to_string())?; - store.insert(config.user_id.clone(), config); - Ok(()) - } - /// Retrieve user's 2FA configuration pub fn get_config(&self, user_id: &str) -> Result, String> { let store = self.configs.lock().map_err(|e| e.to_string())?; @@ -115,6 +112,33 @@ impl TwoFactorStore { .map(|c| c.totp.map(|t| t.verified).unwrap_or(false)) .unwrap_or(false)) } + + /// Verify a code and record its time step under the same lock. + pub fn verify_totp_code( + &self, + user_id: &str, + code: &str, + timestamp: i64, + ) -> Result, String> { + let mut configs = self.configs.lock().map_err(|e| e.to_string())?; + let Some(config) = configs.get_mut(user_id) else { + return Ok(None); + }; + let Some(totp) = config.totp.as_mut() else { + return Ok(None); + }; + let Some(step) = matching_totp_step(&totp.secret, code, timestamp)? else { + return Ok(Some(false)); + }; + if totp.last_totp_step.is_some_and(|last| step <= last) { + return Ok(Some(false)); + } + + totp.last_totp_step = Some(step); + totp.verified = true; + config.last_verified = Some(timestamp); + Ok(Some(true)) + } } /// Generate a TOTP secret (base32-encoded random bytes) @@ -154,23 +178,55 @@ pub fn generate_qr_code_uri(user_id: &str, secret: &str, issuer: &str) -> String ) } -/// Verify a TOTP code against a secret (simplified without time-window checking) -/// In production, use a proper TOTP library like `totp-lite` or `totp-rs` -pub fn verify_totp_code(secret: &str, code: &str) -> Result { - // This is a simplified implementation - // In production, use a proper TOTP validation library that: - // 1. Handles time window (±30 seconds) - // 2. Prevents code reuse - // 3. Validates base32 decoding - // 4. Uses HMAC-SHA1 for code generation +/// Find the matching TOTP counter in the current 30-second window or either adjacent step. +pub fn matching_totp_step( + secret: &str, + code: &str, + timestamp: i64, +) -> Result, String> { + if code.len() != 6 || !code.bytes().all(|byte| byte.is_ascii_digit()) { + return Ok(None); + } - if code.len() != 6 || !code.chars().all(|c| c.is_numeric()) { - return Ok(false); + let key = base32::decode(base32::Alphabet::RFC4648 { padding: false }, secret) + .ok_or_else(|| "Invalid base32 TOTP secret".to_string())?; + if key.is_empty() { + return Err("TOTP secret must not be empty".to_string()); } - // TODO: Implement proper TOTP verification with totp-rs crate - // For now, accept any valid 6-digit code as a placeholder - Ok(true) + let current_step = timestamp.max(0) as u64 / 30; + for step in [ + current_step.saturating_sub(1), + current_step, + current_step.saturating_add(1), + ] { + let expected = generate_totp_code(&key, step)?; + if constant_time_eq(expected.as_bytes(), code.as_bytes()) { + return Ok(Some(step)); + } + } + Ok(None) +} + +fn generate_totp_code(key: &[u8], step: u64) -> Result { + let mut mac = Hmac::::new_from_slice(key).map_err(|err| err.to_string())?; + mac.update(&step.to_be_bytes()); + let digest = mac.finalize().into_bytes(); + let offset = (digest[19] & 0x0f) as usize; + let binary = (u32::from(digest[offset] & 0x7f) << 24) + | (u32::from(digest[offset + 1]) << 16) + | (u32::from(digest[offset + 2]) << 8) + | u32::from(digest[offset + 3]); + Ok(format!("{:06}", binary % 1_000_000)) +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + left.len() == right.len() + && left + .iter() + .zip(right) + .fold(0u8, |difference, (a, b)| difference | (a ^ b)) + == 0 } /// Mark a backup code as used @@ -202,6 +258,7 @@ mod tests { secret: secret.to_string(), enabled_at: 0, verified: true, + last_totp_step: None, }), backup_codes: Vec::new(), created_at: 0, @@ -220,4 +277,64 @@ mod tests { "original" ); } + + #[test] + fn matches_rfc_6238_sha1_test_vector() { + assert_eq!( + matching_totp_step( + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + "287082", + 59 + ) + .unwrap(), + Some(1) + ); + } + + #[test] + fn accepts_adjacent_totp_steps_only() { + let secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + let key = base32::decode(base32::Alphabet::RFC4648 { padding: false }, secret).unwrap(); + let adjacent_code = generate_totp_code(&key, 2).unwrap(); + let outside_window_code = generate_totp_code(&key, 3).unwrap(); + + assert_eq!( + matching_totp_step(secret, &adjacent_code, 30).unwrap(), + Some(2) + ); + assert_eq!( + matching_totp_step(secret, &outside_window_code, 30).unwrap(), + None + ); + } + + #[test] + fn rejects_invalid_totp_codes_and_secrets() { + assert_eq!( + matching_totp_step("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", "123456", 59).unwrap(), + None + ); + assert_eq!( + matching_totp_step("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", "123456", 59).unwrap(), + None + ); + assert!(matching_totp_step("not-base32!", "287082", 59).is_err()); + } + + #[test] + fn rejects_reuse_of_a_totp_step() { + let store = TwoFactorStore::new(); + store + .create_config(config("user", "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")) + .unwrap(); + + assert_eq!( + store.verify_totp_code("user", "287082", 59).unwrap(), + Some(true) + ); + assert_eq!( + store.verify_totp_code("user", "287082", 59).unwrap(), + Some(false) + ); + } } diff --git a/api-server/src/handlers.rs b/api-server/src/handlers.rs index 89239c0c..81f1b751 100644 --- a/api-server/src/handlers.rs +++ b/api-server/src/handlers.rs @@ -1591,7 +1591,7 @@ static TWO_FACTOR_STORE: once_cell::sync::Lazy (status = 500, description = "Internal server error", body = ErrorResponse), ) )] -#[instrument(skip(body))] +#[instrument(skip(body, claims))] pub async fn enable_2fa( crate::auth::AuthExtension(claims): crate::auth::AuthExtension, Json(body): Json, @@ -1626,6 +1626,7 @@ pub async fn enable_2fa( secret: secret.clone(), enabled_at: chrono::Utc::now().timestamp(), verified: false, + last_totp_step: None, }), backup_codes: backup_codes .iter() @@ -1677,7 +1678,7 @@ pub async fn enable_2fa( (status = 404, description = "2FA not configured for user", body = ErrorResponse), ) )] -#[instrument(skip(body))] +#[instrument(skip(body, claims))] pub async fn verify_2fa( crate::auth::AuthExtension(claims): crate::auth::AuthExtension, Json(body): Json, @@ -1709,70 +1710,31 @@ pub async fn verify_2fa( )); } - // Get user's 2FA config - let mut config = match TWO_FACTOR_STORE - .get_config(&body.user_id) + match TWO_FACTOR_STORE + .verify_totp_code(&body.user_id, &body.totp_code, chrono::Utc::now().timestamp()) .map_err(|err| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: err }), ) - })? - { - Some(cfg) => cfg, - None => { - return Err(( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: "2FA not configured for this user".to_string(), - }), - )) - } - }; - - // Get TOTP secret - let secret = match &config.totp { - Some(totp) => &totp.secret, - None => { - return Err(( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: "2FA not configured for this user".to_string(), - }), - )) - } - }; - - // Verify TOTP code - match crate::auth_2fa::verify_totp_code(secret, &body.totp_code) { - Ok(true) => { - // Mark 2FA as verified - if let Some(ref mut totp) = config.totp { - totp.verified = true; - } - config.last_verified = Some(chrono::Utc::now().timestamp()); - - TWO_FACTOR_STORE.store_config(config).map_err(|err| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { error: err }), - ) - })?; - + })? { + Some(true) => { Ok(Json(Verify2faResponse { success: true, message: "2FA verified successfully".to_string(), })) } - Ok(false) => Err(( + Some(false) => Err(( StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "Invalid TOTP code".to_string(), }), )), - Err(err) => Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { error: err }), + None => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "2FA not configured for this user".to_string(), + }), )), } } @@ -1790,7 +1752,7 @@ pub async fn verify_2fa( (status = 404, description = "User not found", body = ErrorResponse), ) )] -#[instrument(skip(body))] +#[instrument(skip(body, claims))] pub async fn use_backup_code( crate::auth::AuthExtension(claims): crate::auth::AuthExtension, Json(body): Json,