From 537472f2504e9353681868b8b55daf2e809809cc Mon Sep 17 00:00:00 2001 From: caxtonacollins Date: Thu, 26 Feb 2026 14:06:03 +0100 Subject: [PATCH 1/4] feat: create user 2FA endpoint --- .../20260226140000_create_user_2fa.sql | 22 +++ backend/src/app.rs | 2 + backend/src/auth.rs | 141 +++++++++++++++++- backend/tests/helpers/mod.rs | 5 + 4 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/20260226140000_create_user_2fa.sql diff --git a/backend/migrations/20260226140000_create_user_2fa.sql b/backend/migrations/20260226140000_create_user_2fa.sql new file mode 100644 index 0000000..2059e1b --- /dev/null +++ b/backend/migrations/20260226140000_create_user_2fa.sql @@ -0,0 +1,22 @@ +-- Migration to create user_2fa table +CREATE TABLE IF NOT EXISTS user_2fa ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + otp_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + attempts INTEGER DEFAULT 0 NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_user_2fa_user_id ON user_2fa(user_id); + +-- Trigger to update updated_at timestamp +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_user_2fa_updated_at') THEN + CREATE TRIGGER update_user_2fa_updated_at + BEFORE UPDATE ON user_2fa + FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); + END IF; +END $$; diff --git a/backend/src/app.rs b/backend/src/app.rs index eda13af..bcd169c 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -65,6 +65,8 @@ pub async fn create_app(db: PgPool, config: Config) -> Result ) .route("/api/auth/web3-login", post(crate::auth::web3_login)) .route("/api/auth/wallet-login", post(crate::auth::web3_login)) + .route("/user/send-2fa", post(crate::auth::send_2fa)) + .route("/user/verify-2fa", post(crate::auth::verify_2fa)) .layer( ServiceBuilder::new() .layer(axum::middleware::from_fn_with_state( diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 5c8d7f4..3a80fd5 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -1,9 +1,10 @@ use crate::api_error::ApiError; use crate::app::AppState; use crate::config::Config; +use crate::notifications::AuditLogService; use axum::{extract::State, Json}; use bcrypt::verify; -use chrono::{Duration, Utc}; +use chrono::{DateTime, Duration, Utc}; use hex; use jsonwebtoken::{encode, EncodingKey, Header}; use ring::signature; @@ -41,6 +42,22 @@ pub struct LoginResponse { pub token: String, } +#[derive(Debug, Deserialize)] +pub struct Send2faRequest { + pub user_id: Uuid, +} + +#[derive(Debug, Deserialize)] +pub struct Verify2faRequest { + pub user_id: Uuid, + pub otp: String, +} + +#[derive(Debug, Serialize)] +pub struct TwoFaResponse { + pub message: String, +} + pub async fn get_nonce( State(state): State>, Json(payload): Json, @@ -308,6 +325,128 @@ pub async fn wallet_login( web3_login(State(state), Json(payload)).await } +pub async fn send_2fa( + State(state): State>, + Json(payload): Json, +) -> Result, ApiError> { + // 1. Check if user exists + let user_exists = sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)") + .bind(&payload.user_id) + .fetch_one(&state.db) + .await?; + + if !user_exists { + return Err(ApiError::NotFound("User not found".to_string())); + } + + // 2. Generate 6-digit OTP + use ring::rand::SecureRandom; + let rng = ring::rand::SystemRandom::new(); + let mut bytes = [0u8; 4]; + rng.fill(&mut bytes).map_err(|_| ApiError::Internal(anyhow::anyhow!("Failed to generate random bytes")))?; + + // Generate a number between 100,000 and 999,999 + let otp_num = (u32::from_be_bytes(bytes) % 900_000) + 100_000; + let otp = otp_num.to_string(); + + // 3. Hash OTP + let otp_hash = bcrypt::hash(&otp, bcrypt::DEFAULT_COST) + .map_err(|e| ApiError::Internal(anyhow::anyhow!("Failed to hash OTP: {}", e)))?; + + let expires_at = Utc::now() + Duration::minutes(5); + + // 4. Store/Update OTP in user_2fa + sqlx::query( + r#" + INSERT INTO user_2fa (user_id, otp_hash, expires_at, attempts) + VALUES ($1, $2, $3, 0) + ON CONFLICT (user_id) DO UPDATE + SET otp_hash = EXCLUDED.otp_hash, + expires_at = EXCLUDED.expires_at, + attempts = 0, + updated_at = NOW() + "#, + ) + .bind(&payload.user_id) + .bind(&otp_hash) + .bind(expires_at) + .execute(&state.db) + .await?; + + // 5. Mock Email Notification + tracing::info!("--- [2FA OTP] ---"); + tracing::info!("User ID: {}", payload.user_id); + tracing::info!("OTP Code: {}", otp); + tracing::info!("-----------------"); + + // Optional: Log to audit logs and notifications + AuditLogService::log( + &state.db, + Some(payload.user_id), + "2fa_sent", + Some(payload.user_id), + Some("user"), + ) + .await?; + + Ok(Json(TwoFaResponse { + message: "OTP sent successfully".to_string(), + })) +} + +pub async fn verify_2fa( + State(state): State>, + Json(payload): Json, +) -> Result, ApiError> { + let mut tx = state.db.begin().await?; + + // 1. Retrieve OTP record + let row: Option<(String, DateTime, i32)> = + sqlx::query_as("SELECT otp_hash, expires_at, attempts FROM user_2fa WHERE user_id = $1 FOR UPDATE") + .bind(&payload.user_id) + .fetch_optional(&mut *tx) + .await?; + + let (otp_hash, expires_at, attempts) = row.ok_or_else(|| ApiError::BadRequest("No pending OTP found".to_string()))?; + + // 2. Check attempts + if attempts >= 3 { + return Err(ApiError::BadRequest("Too many verification attempts. Please request a new OTP.".to_string())); + } + + // 3. Check expiry + if expires_at < Utc::now() { + return Err(ApiError::BadRequest("OTP has expired".to_string())); + } + + // 4. Verify OTP + let valid = bcrypt::verify(&payload.otp, &otp_hash) + .map_err(|e| ApiError::Internal(anyhow::anyhow!("Failed to verify OTP: {}", e)))?; + + if !valid { + // Increment attempts + sqlx::query("UPDATE user_2fa SET attempts = attempts + 1, updated_at = NOW() WHERE user_id = $1") + .bind(&payload.user_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + return Err(ApiError::Unauthorized); + } + + // 5. Successful verification - Clear OTP + sqlx::query("DELETE FROM user_2fa WHERE user_id = $1") + .bind(&payload.user_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(Json(TwoFaResponse { + message: "OTP verified successfully".to_string(), + })) +} + use axum::extract::FromRequestParts; use axum::http::request::Parts; use sqlx::PgPool; diff --git a/backend/tests/helpers/mod.rs b/backend/tests/helpers/mod.rs index 79cf30d..c7ff826 100644 --- a/backend/tests/helpers/mod.rs +++ b/backend/tests/helpers/mod.rs @@ -38,6 +38,11 @@ impl TestContext { jwt_secret: env::var("JWT_SECRET").unwrap_or_else(|_| "test-jwt-secret".to_string()), }; + // Run migrations + inheritx_backend::db::run_migrations(&pool) + .await + .expect("failed to run migrations"); + let app = create_app(pool.clone(), config) .await .expect("failed to create app"); From 223d766e9f9274da105990a4c11ae410960905ea Mon Sep 17 00:00:00 2001 From: caxtonacollins Date: Thu, 26 Feb 2026 14:34:38 +0100 Subject: [PATCH 2/4] feat: create user 2FA endpoint close --- backend/src/app.rs | 14 +-- backend/src/auth.rs | 31 +++-- backend/src/service.rs | 6 +- backend/tests/claim_tests.rs | 49 ++++++-- backend/tests/helpers/mod.rs | 20 ++- backend/tests/plan_tests.rs | 68 +++++++++- backend/tests/two_fa_tests.rs | 229 ++++++++++++++++++++++++++++++++++ 7 files changed, 386 insertions(+), 31 deletions(-) create mode 100644 backend/tests/two_fa_tests.rs diff --git a/backend/src/app.rs b/backend/src/app.rs index bcd169c..51205d8 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -163,10 +163,9 @@ async fn create_plan( return Err(ApiError::Forbidden("KYC not approved".to_string())); } - // Require 2FA verification (stub, replace with actual logic) - // if !verify_2fa(user.user_id, req.2fa_code) { - // return Err(ApiError::Forbidden("2FA verification failed".to_string())); - // } + // Require 2FA verification + crate::auth::verify_2fa_internal(&state.db, user.user_id, &req.two_fa_code).await?; + // Validate input amounts crate::safe_math::SafeMath::ensure_non_negative(req.net_amount, "net_amount")?; @@ -296,10 +295,9 @@ async fn claim_plan( return Err(ApiError::Forbidden("KYC not approved".to_string())); } - // Require 2FA verification (stub, replace with actual logic) - // if !verify_2fa(user.user_id, req.2fa_code) { - // return Err(ApiError::Forbidden("2FA verification failed".to_string())); - // } + // Require 2FA verification + crate::auth::verify_2fa_internal(&state.db, user.user_id, &req.two_fa_code).await?; + let plan = PlanService::claim_plan(&state.db, plan_id, user.user_id, &req).await?; diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 3a80fd5..58b6243 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -42,12 +42,12 @@ pub struct LoginResponse { pub token: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct Send2faRequest { pub user_id: Uuid, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct Verify2faRequest { pub user_id: Uuid, pub otp: String, @@ -398,12 +398,24 @@ pub async fn verify_2fa( State(state): State>, Json(payload): Json, ) -> Result, ApiError> { - let mut tx = state.db.begin().await?; + verify_2fa_internal(&state.db, payload.user_id, &payload.otp).await?; + + Ok(Json(TwoFaResponse { + message: "OTP verified successfully".to_string(), + })) +} + +pub async fn verify_2fa_internal( + db: &PgPool, + user_id: Uuid, + otp: &str, +) -> Result<(), ApiError> { + let mut tx = db.begin().await?; // 1. Retrieve OTP record let row: Option<(String, DateTime, i32)> = sqlx::query_as("SELECT otp_hash, expires_at, attempts FROM user_2fa WHERE user_id = $1 FOR UPDATE") - .bind(&payload.user_id) + .bind(user_id) .fetch_optional(&mut *tx) .await?; @@ -420,13 +432,13 @@ pub async fn verify_2fa( } // 4. Verify OTP - let valid = bcrypt::verify(&payload.otp, &otp_hash) + let valid = bcrypt::verify(otp, &otp_hash) .map_err(|e| ApiError::Internal(anyhow::anyhow!("Failed to verify OTP: {}", e)))?; if !valid { // Increment attempts sqlx::query("UPDATE user_2fa SET attempts = attempts + 1, updated_at = NOW() WHERE user_id = $1") - .bind(&payload.user_id) + .bind(user_id) .execute(&mut *tx) .await?; @@ -436,17 +448,16 @@ pub async fn verify_2fa( // 5. Successful verification - Clear OTP sqlx::query("DELETE FROM user_2fa WHERE user_id = $1") - .bind(&payload.user_id) + .bind(user_id) .execute(&mut *tx) .await?; tx.commit().await?; - Ok(Json(TwoFaResponse { - message: "OTP verified successfully".to_string(), - })) + Ok(()) } + use axum::extract::FromRequestParts; use axum::http::request::Parts; use sqlx::PgPool; diff --git a/backend/src/service.rs b/backend/src/service.rs index df240ff..543a860 100644 --- a/backend/src/service.rs +++ b/backend/src/service.rs @@ -109,15 +109,17 @@ pub struct CreatePlanRequest { pub bank_account_number: Option, pub bank_name: Option, pub currency_preference: String, + pub two_fa_code: String, } + #[derive(Debug, Deserialize)] pub struct ClaimPlanRequest { pub beneficiary_email: String, - #[allow(dead_code)] - pub claim_code: Option, + pub two_fa_code: String, } + #[derive(sqlx::FromRow)] struct PlanRowFull { id: Uuid, diff --git a/backend/tests/claim_tests.rs b/backend/tests/claim_tests.rs index 23cff8d..0eb759e 100644 --- a/backend/tests/claim_tests.rs +++ b/backend/tests/claim_tests.rs @@ -108,7 +108,7 @@ async fn test_claim_before_maturity_returns_400() { }; let pool = test_context.pool.clone(); - let app = test_context.app; + let app = test_context.app.clone(); let user_id = Uuid::new_v4(); let email = format!("test_{}@example.com", user_id); @@ -165,12 +165,17 @@ async fn test_claim_before_maturity_returns_400() { .expect("Server failed"); }); + let otp = test_context.prepare_2fa(user_id, "123456").await; let token = generate_test_token(user_id, &email); let client = reqwest::Client::new(); let response = client .post(format!("http://{}/api/plans/{}/claim", addr, plan_id)) .header("Authorization", format!("Bearer {}", token)) - .json(&json!({ "beneficiary_email": "beneficiary@example.com" })) + .json(&json!({ + "beneficiary_email": "beneficiary@example.com", + "two_fa_code": otp + })) + .send() .await .expect("Failed to send request"); @@ -198,9 +203,14 @@ async fn test_claim_plan_is_due() { approve_kyc_direct(&ctx.pool, user_id).await; let plan_id = insert_due_plan(&ctx.pool, user_id).await; - let body = serde_json::json!({ "beneficiary_email": "beneficiary@example.com" }); + let otp = ctx.prepare_2fa(user_id, "123456").await; + let body = serde_json::json!({ + "beneficiary_email": "beneficiary@example.com", + "two_fa_code": otp + }); let response = ctx .app + .clone() .oneshot( Request::builder() .method("POST") @@ -210,6 +220,7 @@ async fn test_claim_plan_is_due() { .body(Body::from( serde_json::to_string(&body).expect("Failed to serialize request body"), )) + .expect("Failed to build request"), ) .await @@ -233,9 +244,14 @@ async fn test_claim_requires_kyc_approved() { let token = generate_user_token(user_id); let plan_id = insert_due_plan(&ctx.pool, user_id).await; - let body = serde_json::json!({ "beneficiary_email": "beneficiary@example.com" }); + let otp = ctx.prepare_2fa(user_id, "111111").await; + let body = serde_json::json!({ + "beneficiary_email": "beneficiary@example.com", + "two_fa_code": otp + }); let response = ctx .app + .clone() .oneshot( Request::builder() .method("POST") @@ -245,6 +261,7 @@ async fn test_claim_requires_kyc_approved() { .body(Body::from( serde_json::to_string(&body).expect("Failed to serialize request body"), )) + .expect("Failed to build request"), ) .await @@ -265,9 +282,14 @@ async fn test_claim_recorded_on_success() { approve_kyc_direct(&ctx.pool, user_id).await; let plan_id = insert_due_plan(&ctx.pool, user_id).await; - let body = serde_json::json!({ "beneficiary_email": "claim-record@example.com" }); + let otp = ctx.prepare_2fa(user_id, "123456").await; + let body = serde_json::json!({ + "beneficiary_email": "claim-record@example.com", + "two_fa_code": otp + }); let response = ctx .app + .clone() .oneshot( Request::builder() .method("POST") @@ -277,6 +299,7 @@ async fn test_claim_recorded_on_success() { .body(Body::from( serde_json::to_string(&body).expect("Failed to serialize request body"), )) + .expect("Failed to build request"), ) .await @@ -311,9 +334,14 @@ async fn test_claim_audit_log_inserted() { approve_kyc_direct(&ctx.pool, user_id).await; let plan_id = insert_due_plan(&ctx.pool, user_id).await; - let body = serde_json::json!({ "beneficiary_email": "audit-test@example.com" }); + let otp = ctx.prepare_2fa(user_id, "123456").await; + let body = serde_json::json!({ + "beneficiary_email": "audit-test@example.com", + "two_fa_code": otp + }); let response = ctx .app + .clone() .oneshot( Request::builder() .method("POST") @@ -323,6 +351,7 @@ async fn test_claim_audit_log_inserted() { .body(Body::from( serde_json::to_string(&body).expect("Failed to serialize request body"), )) + .expect("Failed to build request"), ) .await @@ -368,9 +397,14 @@ async fn test_claim_notification_created() { .await .expect("Failed to count notifications before claim"); - let body = serde_json::json!({ "beneficiary_email": "notify-test@example.com" }); + let otp = ctx.prepare_2fa(user_id, "123456").await; + let body = serde_json::json!({ + "beneficiary_email": "notify-test@example.com", + "two_fa_code": otp + }); let response = ctx .app + .clone() .oneshot( Request::builder() .method("POST") @@ -380,6 +414,7 @@ async fn test_claim_notification_created() { .body(Body::from( serde_json::to_string(&body).expect("Failed to serialize request body"), )) + .expect("Failed to build request"), ) .await diff --git a/backend/tests/helpers/mod.rs b/backend/tests/helpers/mod.rs index c7ff826..becc76c 100644 --- a/backend/tests/helpers/mod.rs +++ b/backend/tests/helpers/mod.rs @@ -46,7 +46,25 @@ impl TestContext { let app = create_app(pool.clone(), config) .await .expect("failed to create app"); - Some(Self { app, pool }) } + + pub async fn prepare_2fa(&self, user_id: uuid::Uuid, otp: &str) -> String { + + let otp_hash = bcrypt::hash(otp, bcrypt::DEFAULT_COST).unwrap(); + let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5); + + sqlx::query( + "INSERT INTO user_2fa (user_id, otp_hash, expires_at) VALUES ($1, $2, $3) ON CONFLICT (user_id) DO UPDATE SET otp_hash = $2, expires_at = $3" + ) + .bind(user_id) + .bind(otp_hash) + .bind(expires_at) + .execute(&self.pool) + .await + .unwrap(); + + otp.to_string() + } } + diff --git a/backend/tests/plan_tests.rs b/backend/tests/plan_tests.rs index e8ea437..033da77 100644 --- a/backend/tests/plan_tests.rs +++ b/backend/tests/plan_tests.rs @@ -364,6 +364,9 @@ async fn test_create_plan_wallet_balance_check() { .await .expect("Failed to approve KYC"); + // Prepare 2FA + let otp = test_context.prepare_2fa(user_id, "123456").await; + // Create a plan via API let create_request = serde_json::json!({ "title": "New Plan", @@ -373,9 +376,11 @@ async fn test_create_plan_wallet_balance_check() { "beneficiary_name": "Jane Doe", "bank_account_number": "9876543210", "bank_name": "Test Bank", - "currency_preference": "USDC" + "currency_preference": "USDC", + "two_fa_code": otp }); + let response = test_context .app .oneshot( @@ -417,6 +422,9 @@ async fn test_create_plan_audit_log_inserted() { .await .expect("Failed to approve KYC"); + // Prepare 2FA + let otp = test_context.prepare_2fa(user_id, "654321").await; + // Create a plan via API let create_request = serde_json::json!({ "title": "Plan for Audit Test", @@ -426,9 +434,11 @@ async fn test_create_plan_audit_log_inserted() { "beneficiary_name": "Audit Beneficiary", "bank_account_number": "1111111111", "bank_name": "Audit Bank", - "currency_preference": "USDC" + "currency_preference": "USDC", + "two_fa_code": otp }); + let response = test_context .app .oneshot( @@ -499,6 +509,9 @@ async fn test_create_plan_notification_created() { .await .expect("Failed to query notifications"); + // Prepare 2FA + let otp = test_context.prepare_2fa(user_id, "112233").await; + // Create a plan via API let create_request = serde_json::json!({ "title": "Plan for Notification Test", @@ -508,9 +521,11 @@ async fn test_create_plan_notification_created() { "beneficiary_name": "Notification Beneficiary", "bank_account_number": "2222222222", "bank_name": "Notification Bank", - "currency_preference": "USDC" + "currency_preference": "USDC", + "two_fa_code": otp }); + let response = test_context .app .oneshot( @@ -547,3 +562,50 @@ async fn test_create_plan_notification_created() { "Expected notification count to increase or stay the same" ); } + +#[tokio::test] +async fn test_create_plan_invalid_2fa() { + let Some(test_context) = helpers::TestContext::from_env().await else { + return; + }; + + let user_id = Uuid::new_v4(); + let token = generate_user_token(user_id); + + // Approve KYC + approve_kyc(&test_context.pool, user_id) + .await + .expect("Failed to approve KYC"); + + // Try to create a plan with WRONG 2FA + let create_request = serde_json::json!({ + "title": "Invalid 2FA Plan", + "description": "Testing rejection", + "fee": "10.00", + "net_amount": "490.00", + "beneficiary_name": "Jane Doe", + "bank_account_number": "9876543210", + "bank_name": "Test Bank", + "currency_preference": "USDC", + "two_fa_code": "000000" + }); + + let response = test_context + .app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/plans") + .header("Authorization", format!("Bearer {}", token)) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&create_request).unwrap())) + .unwrap(), + ) + .await + .expect("request failed"); + + // It should be UNAUTHORIZED or BAD REQUEST depending on the error + // verify_2fa_internal returns Unauthorized on wrong OTP + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + diff --git a/backend/tests/two_fa_tests.rs b/backend/tests/two_fa_tests.rs new file mode 100644 index 0000000..4b80fe7 --- /dev/null +++ b/backend/tests/two_fa_tests.rs @@ -0,0 +1,229 @@ +mod helpers; + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use inheritx_backend::auth::{Send2faRequest, Verify2faRequest}; +use chrono::{Duration, Utc}; +use serde_json::Value; +use tower::ServiceExt; +use uuid::Uuid; + +#[tokio::test] +async fn test_2fa_full_flow() { + let Some(ctx) = helpers::TestContext::from_env().await else { + return; + }; + + // 1. Create a test user + let user_id = Uuid::new_v4(); + let email = format!("test-{}@example.com", user_id); + sqlx::query("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)") + .bind(user_id) + .bind(&email) + .bind("dummy-hash") + .execute(&ctx.pool) + .await + .unwrap(); + + // 2. Request 2FA + let response = ctx.app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/user/send-2fa") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&Send2faRequest { user_id }).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + // 3. Manually update OTP to a known one for verification + let otp = "123456"; + let otp_hash = bcrypt::hash(otp, bcrypt::DEFAULT_COST).unwrap(); + sqlx::query("UPDATE user_2fa SET otp_hash = $1 WHERE user_id = $2") + .bind(otp_hash) + .bind(user_id) + .execute(&ctx.pool) + .await + .unwrap(); + + // 4. Verify 2FA + let response = ctx.app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/user/verify-2fa") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&Verify2faRequest { + user_id, + otp: otp.to_string() + }).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + // 5. Verify record is deleted + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_2fa WHERE user_id = $1)") + .bind(user_id) + .fetch_one(&ctx.pool) + .await + .unwrap(); + assert!(!exists); +} + +#[tokio::test] +async fn test_verify_2fa_invalid_otp() { + let Some(ctx) = helpers::TestContext::from_env().await else { + return; + }; + + let user_id = Uuid::new_v4(); + sqlx::query("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)") + .bind(user_id) + .bind(format!("test-{}@example.com", user_id)) + .bind("dummy-hash") + .execute(&ctx.pool) + .await + .unwrap(); + + // Send 2FA + ctx.app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/user/send-2fa") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&Send2faRequest { user_id }).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + // Verify with WRONG OTP + let response = ctx.app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/user/verify-2fa") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&Verify2faRequest { + user_id, + otp: "000000".to_string() + }).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + // Verify attempts incremented + let attempts: i32 = sqlx::query_scalar("SELECT attempts FROM user_2fa WHERE user_id = $1") + .bind(user_id) + .fetch_one(&ctx.pool) + .await + .unwrap(); + assert_eq!(attempts, 1); +} + +#[tokio::test] +async fn test_verify_2fa_too_many_attempts() { + let Some(ctx) = helpers::TestContext::from_env().await else { + return; + }; + + let user_id = Uuid::new_v4(); + sqlx::query("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)") + .bind(user_id) + .bind(format!("test-{}@example.com", user_id)) + .bind("dummy-hash") + .execute(&ctx.pool) + .await + .unwrap(); + + // Set 3 attempts in DB + let expires_at = Utc::now() + Duration::minutes(5); + sqlx::query("INSERT INTO user_2fa (user_id, otp_hash, expires_at, attempts) VALUES ($1, $2, $3, 3)") + .bind(user_id) + .bind("some-hash") + .bind(expires_at) + .execute(&ctx.pool) + .await + .unwrap(); + + let response = ctx.app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/user/verify-2fa") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&Verify2faRequest { + user_id, + otp: "123456".to_string() + }).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert!(body["message"].as_str().unwrap().contains("Too many verification attempts")); +} + +#[tokio::test] +async fn test_verify_2fa_expired() { + let Some(ctx) = helpers::TestContext::from_env().await else { + return; + }; + + let user_id = Uuid::new_v4(); + sqlx::query("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)") + .bind(user_id) + .bind(format!("test-{}@example.com", user_id)) + .bind("dummy-hash") + .execute(&ctx.pool) + .await + .unwrap(); + + // Set expired OTP in DB + let expires_at = Utc::now() - Duration::minutes(1); + sqlx::query("INSERT INTO user_2fa (user_id, otp_hash, expires_at, attempts) VALUES ($1, $2, $3, 0)") + .bind(user_id) + .bind("some-hash") + .bind(expires_at) + .execute(&ctx.pool) + .await + .unwrap(); + + let response = ctx.app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/user/verify-2fa") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&Verify2faRequest { + user_id, + otp: "123456".to_string() + }).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert!(body["message"].as_str().unwrap().contains("expired")); +} From 0bcfd64494a5de0473012dcd3764fa412eee6b8c Mon Sep 17 00:00:00 2001 From: caxtonacollins Date: Thu, 26 Feb 2026 14:50:15 +0100 Subject: [PATCH 3/4] fix: fmt --- backend/src/app.rs | 2 - backend/src/auth.rs | 51 +++++++------ backend/src/service.rs | 2 - backend/tests/claim_tests.rs | 24 +++--- backend/tests/helpers/mod.rs | 2 - backend/tests/plan_tests.rs | 4 - backend/tests/two_fa_tests.rs | 137 ++++++++++++++++++++++------------ 7 files changed, 124 insertions(+), 98 deletions(-) diff --git a/backend/src/app.rs b/backend/src/app.rs index 51205d8..2a89561 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -166,7 +166,6 @@ async fn create_plan( // Require 2FA verification crate::auth::verify_2fa_internal(&state.db, user.user_id, &req.two_fa_code).await?; - // Validate input amounts crate::safe_math::SafeMath::ensure_non_negative(req.net_amount, "net_amount")?; crate::safe_math::SafeMath::ensure_non_negative(req.fee, "fee")?; @@ -298,7 +297,6 @@ async fn claim_plan( // Require 2FA verification crate::auth::verify_2fa_internal(&state.db, user.user_id, &req.two_fa_code).await?; - let plan = PlanService::claim_plan(&state.db, plan_id, user.user_id, &req).await?; // Transfer USDC to user wallet (stub) diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 58b6243..53ade8d 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -330,10 +330,11 @@ pub async fn send_2fa( Json(payload): Json, ) -> Result, ApiError> { // 1. Check if user exists - let user_exists = sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)") - .bind(&payload.user_id) - .fetch_one(&state.db) - .await?; + let user_exists = + sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)") + .bind(&payload.user_id) + .fetch_one(&state.db) + .await?; if !user_exists { return Err(ApiError::NotFound("User not found".to_string())); @@ -343,8 +344,9 @@ pub async fn send_2fa( use ring::rand::SecureRandom; let rng = ring::rand::SystemRandom::new(); let mut bytes = [0u8; 4]; - rng.fill(&mut bytes).map_err(|_| ApiError::Internal(anyhow::anyhow!("Failed to generate random bytes")))?; - + rng.fill(&mut bytes) + .map_err(|_| ApiError::Internal(anyhow::anyhow!("Failed to generate random bytes")))?; + // Generate a number between 100,000 and 999,999 let otp_num = (u32::from_be_bytes(bytes) % 900_000) + 100_000; let otp = otp_num.to_string(); @@ -405,25 +407,25 @@ pub async fn verify_2fa( })) } -pub async fn verify_2fa_internal( - db: &PgPool, - user_id: Uuid, - otp: &str, -) -> Result<(), ApiError> { +pub async fn verify_2fa_internal(db: &PgPool, user_id: Uuid, otp: &str) -> Result<(), ApiError> { let mut tx = db.begin().await?; // 1. Retrieve OTP record - let row: Option<(String, DateTime, i32)> = - sqlx::query_as("SELECT otp_hash, expires_at, attempts FROM user_2fa WHERE user_id = $1 FOR UPDATE") - .bind(user_id) - .fetch_optional(&mut *tx) - .await?; + let row: Option<(String, DateTime, i32)> = sqlx::query_as( + "SELECT otp_hash, expires_at, attempts FROM user_2fa WHERE user_id = $1 FOR UPDATE", + ) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; - let (otp_hash, expires_at, attempts) = row.ok_or_else(|| ApiError::BadRequest("No pending OTP found".to_string()))?; + let (otp_hash, expires_at, attempts) = + row.ok_or_else(|| ApiError::BadRequest("No pending OTP found".to_string()))?; // 2. Check attempts if attempts >= 3 { - return Err(ApiError::BadRequest("Too many verification attempts. Please request a new OTP.".to_string())); + return Err(ApiError::BadRequest( + "Too many verification attempts. Please request a new OTP.".to_string(), + )); } // 3. Check expiry @@ -437,11 +439,13 @@ pub async fn verify_2fa_internal( if !valid { // Increment attempts - sqlx::query("UPDATE user_2fa SET attempts = attempts + 1, updated_at = NOW() WHERE user_id = $1") - .bind(user_id) - .execute(&mut *tx) - .await?; - + sqlx::query( + "UPDATE user_2fa SET attempts = attempts + 1, updated_at = NOW() WHERE user_id = $1", + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; return Err(ApiError::Unauthorized); } @@ -457,7 +461,6 @@ pub async fn verify_2fa_internal( Ok(()) } - use axum::extract::FromRequestParts; use axum::http::request::Parts; use sqlx::PgPool; diff --git a/backend/src/service.rs b/backend/src/service.rs index 543a860..824a3ee 100644 --- a/backend/src/service.rs +++ b/backend/src/service.rs @@ -112,14 +112,12 @@ pub struct CreatePlanRequest { pub two_fa_code: String, } - #[derive(Debug, Deserialize)] pub struct ClaimPlanRequest { pub beneficiary_email: String, pub two_fa_code: String, } - #[derive(sqlx::FromRow)] struct PlanRowFull { id: Uuid, diff --git a/backend/tests/claim_tests.rs b/backend/tests/claim_tests.rs index 0eb759e..67a5bbd 100644 --- a/backend/tests/claim_tests.rs +++ b/backend/tests/claim_tests.rs @@ -171,11 +171,10 @@ async fn test_claim_before_maturity_returns_400() { let response = client .post(format!("http://{}/api/plans/{}/claim", addr, plan_id)) .header("Authorization", format!("Bearer {}", token)) - .json(&json!({ + .json(&json!({ "beneficiary_email": "beneficiary@example.com", "two_fa_code": otp })) - .send() .await .expect("Failed to send request"); @@ -204,7 +203,7 @@ async fn test_claim_plan_is_due() { let plan_id = insert_due_plan(&ctx.pool, user_id).await; let otp = ctx.prepare_2fa(user_id, "123456").await; - let body = serde_json::json!({ + let body = serde_json::json!({ "beneficiary_email": "beneficiary@example.com", "two_fa_code": otp }); @@ -220,7 +219,6 @@ async fn test_claim_plan_is_due() { .body(Body::from( serde_json::to_string(&body).expect("Failed to serialize request body"), )) - .expect("Failed to build request"), ) .await @@ -245,7 +243,7 @@ async fn test_claim_requires_kyc_approved() { let plan_id = insert_due_plan(&ctx.pool, user_id).await; let otp = ctx.prepare_2fa(user_id, "111111").await; - let body = serde_json::json!({ + let body = serde_json::json!({ "beneficiary_email": "beneficiary@example.com", "two_fa_code": otp }); @@ -261,7 +259,6 @@ async fn test_claim_requires_kyc_approved() { .body(Body::from( serde_json::to_string(&body).expect("Failed to serialize request body"), )) - .expect("Failed to build request"), ) .await @@ -283,9 +280,9 @@ async fn test_claim_recorded_on_success() { let plan_id = insert_due_plan(&ctx.pool, user_id).await; let otp = ctx.prepare_2fa(user_id, "123456").await; - let body = serde_json::json!({ + let body = serde_json::json!({ "beneficiary_email": "claim-record@example.com", - "two_fa_code": otp + "two_fa_code": otp }); let response = ctx .app @@ -299,7 +296,6 @@ async fn test_claim_recorded_on_success() { .body(Body::from( serde_json::to_string(&body).expect("Failed to serialize request body"), )) - .expect("Failed to build request"), ) .await @@ -335,9 +331,9 @@ async fn test_claim_audit_log_inserted() { let plan_id = insert_due_plan(&ctx.pool, user_id).await; let otp = ctx.prepare_2fa(user_id, "123456").await; - let body = serde_json::json!({ + let body = serde_json::json!({ "beneficiary_email": "audit-test@example.com", - "two_fa_code": otp + "two_fa_code": otp }); let response = ctx .app @@ -351,7 +347,6 @@ async fn test_claim_audit_log_inserted() { .body(Body::from( serde_json::to_string(&body).expect("Failed to serialize request body"), )) - .expect("Failed to build request"), ) .await @@ -398,9 +393,9 @@ async fn test_claim_notification_created() { .expect("Failed to count notifications before claim"); let otp = ctx.prepare_2fa(user_id, "123456").await; - let body = serde_json::json!({ + let body = serde_json::json!({ "beneficiary_email": "notify-test@example.com", - "two_fa_code": otp + "two_fa_code": otp }); let response = ctx .app @@ -414,7 +409,6 @@ async fn test_claim_notification_created() { .body(Body::from( serde_json::to_string(&body).expect("Failed to serialize request body"), )) - .expect("Failed to build request"), ) .await diff --git a/backend/tests/helpers/mod.rs b/backend/tests/helpers/mod.rs index becc76c..39a6942 100644 --- a/backend/tests/helpers/mod.rs +++ b/backend/tests/helpers/mod.rs @@ -50,7 +50,6 @@ impl TestContext { } pub async fn prepare_2fa(&self, user_id: uuid::Uuid, otp: &str) -> String { - let otp_hash = bcrypt::hash(otp, bcrypt::DEFAULT_COST).unwrap(); let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5); @@ -67,4 +66,3 @@ impl TestContext { otp.to_string() } } - diff --git a/backend/tests/plan_tests.rs b/backend/tests/plan_tests.rs index 033da77..1c8789f 100644 --- a/backend/tests/plan_tests.rs +++ b/backend/tests/plan_tests.rs @@ -380,7 +380,6 @@ async fn test_create_plan_wallet_balance_check() { "two_fa_code": otp }); - let response = test_context .app .oneshot( @@ -438,7 +437,6 @@ async fn test_create_plan_audit_log_inserted() { "two_fa_code": otp }); - let response = test_context .app .oneshot( @@ -525,7 +523,6 @@ async fn test_create_plan_notification_created() { "two_fa_code": otp }); - let response = test_context .app .oneshot( @@ -608,4 +605,3 @@ async fn test_create_plan_invalid_2fa() { // verify_2fa_internal returns Unauthorized on wrong OTP assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } - diff --git a/backend/tests/two_fa_tests.rs b/backend/tests/two_fa_tests.rs index 4b80fe7..dcb4595 100644 --- a/backend/tests/two_fa_tests.rs +++ b/backend/tests/two_fa_tests.rs @@ -4,8 +4,8 @@ use axum::{ body::Body, http::{Request, StatusCode}, }; -use inheritx_backend::auth::{Send2faRequest, Verify2faRequest}; use chrono::{Duration, Utc}; +use inheritx_backend::auth::{Send2faRequest, Verify2faRequest}; use serde_json::Value; use tower::ServiceExt; use uuid::Uuid; @@ -28,13 +28,17 @@ async fn test_2fa_full_flow() { .unwrap(); // 2. Request 2FA - let response = ctx.app.clone() + let response = ctx + .app + .clone() .oneshot( Request::builder() .method("POST") .uri("/user/send-2fa") .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&Send2faRequest { user_id }).unwrap())) + .body(Body::from( + serde_json::to_vec(&Send2faRequest { user_id }).unwrap(), + )) .unwrap(), ) .await @@ -53,16 +57,21 @@ async fn test_2fa_full_flow() { .unwrap(); // 4. Verify 2FA - let response = ctx.app.clone() + let response = ctx + .app + .clone() .oneshot( Request::builder() .method("POST") .uri("/user/verify-2fa") .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&Verify2faRequest { - user_id, - otp: otp.to_string() - }).unwrap())) + .body(Body::from( + serde_json::to_vec(&Verify2faRequest { + user_id, + otp: otp.to_string(), + }) + .unwrap(), + )) .unwrap(), ) .await @@ -71,11 +80,12 @@ async fn test_2fa_full_flow() { assert_eq!(response.status(), StatusCode::OK); // 5. Verify record is deleted - let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_2fa WHERE user_id = $1)") - .bind(user_id) - .fetch_one(&ctx.pool) - .await - .unwrap(); + let exists: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_2fa WHERE user_id = $1)") + .bind(user_id) + .fetch_one(&ctx.pool) + .await + .unwrap(); assert!(!exists); } @@ -95,29 +105,37 @@ async fn test_verify_2fa_invalid_otp() { .unwrap(); // Send 2FA - ctx.app.clone() + ctx.app + .clone() .oneshot( Request::builder() .method("POST") .uri("/user/send-2fa") .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&Send2faRequest { user_id }).unwrap())) + .body(Body::from( + serde_json::to_vec(&Send2faRequest { user_id }).unwrap(), + )) .unwrap(), ) .await .unwrap(); // Verify with WRONG OTP - let response = ctx.app.clone() + let response = ctx + .app + .clone() .oneshot( Request::builder() .method("POST") .uri("/user/verify-2fa") .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&Verify2faRequest { - user_id, - otp: "000000".to_string() - }).unwrap())) + .body(Body::from( + serde_json::to_vec(&Verify2faRequest { + user_id, + otp: "000000".to_string(), + }) + .unwrap(), + )) .unwrap(), ) .await @@ -151,34 +169,46 @@ async fn test_verify_2fa_too_many_attempts() { // Set 3 attempts in DB let expires_at = Utc::now() + Duration::minutes(5); - sqlx::query("INSERT INTO user_2fa (user_id, otp_hash, expires_at, attempts) VALUES ($1, $2, $3, 3)") - .bind(user_id) - .bind("some-hash") - .bind(expires_at) - .execute(&ctx.pool) - .await - .unwrap(); + sqlx::query( + "INSERT INTO user_2fa (user_id, otp_hash, expires_at, attempts) VALUES ($1, $2, $3, 3)", + ) + .bind(user_id) + .bind("some-hash") + .bind(expires_at) + .execute(&ctx.pool) + .await + .unwrap(); - let response = ctx.app.clone() + let response = ctx + .app + .clone() .oneshot( Request::builder() .method("POST") .uri("/user/verify-2fa") .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&Verify2faRequest { - user_id, - otp: "123456".to_string() - }).unwrap())) + .body(Body::from( + serde_json::to_vec(&Verify2faRequest { + user_id, + otp: "123456".to_string(), + }) + .unwrap(), + )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); let body: Value = serde_json::from_slice(&body).unwrap(); - assert!(body["message"].as_str().unwrap().contains("Too many verification attempts")); + assert!(body["message"] + .as_str() + .unwrap() + .contains("Too many verification attempts")); } #[tokio::test] @@ -198,32 +228,41 @@ async fn test_verify_2fa_expired() { // Set expired OTP in DB let expires_at = Utc::now() - Duration::minutes(1); - sqlx::query("INSERT INTO user_2fa (user_id, otp_hash, expires_at, attempts) VALUES ($1, $2, $3, 0)") - .bind(user_id) - .bind("some-hash") - .bind(expires_at) - .execute(&ctx.pool) - .await - .unwrap(); + sqlx::query( + "INSERT INTO user_2fa (user_id, otp_hash, expires_at, attempts) VALUES ($1, $2, $3, 0)", + ) + .bind(user_id) + .bind("some-hash") + .bind(expires_at) + .execute(&ctx.pool) + .await + .unwrap(); - let response = ctx.app.clone() + let response = ctx + .app + .clone() .oneshot( Request::builder() .method("POST") .uri("/user/verify-2fa") .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&Verify2faRequest { - user_id, - otp: "123456".to_string() - }).unwrap())) + .body(Body::from( + serde_json::to_vec(&Verify2faRequest { + user_id, + otp: "123456".to_string(), + }) + .unwrap(), + )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); let body: Value = serde_json::from_slice(&body).unwrap(); assert!(body["message"].as_str().unwrap().contains("expired")); } From f069983ee3278b40e0dd935ed2bcf86b36dc2104 Mon Sep 17 00:00:00 2001 From: caxtonacollins Date: Thu, 26 Feb 2026 14:56:38 +0100 Subject: [PATCH 4/4] fix: fmt --- backend/src/auth.rs | 4 ++-- backend/tests/helpers/mod.rs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 53ade8d..5255802 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -332,7 +332,7 @@ pub async fn send_2fa( // 1. Check if user exists let user_exists = sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)") - .bind(&payload.user_id) + .bind(payload.user_id) .fetch_one(&state.db) .await?; @@ -369,7 +369,7 @@ pub async fn send_2fa( updated_at = NOW() "#, ) - .bind(&payload.user_id) + .bind(payload.user_id) .bind(&otp_hash) .bind(expires_at) .execute(&state.db) diff --git a/backend/tests/helpers/mod.rs b/backend/tests/helpers/mod.rs index 39a6942..62f497b 100644 --- a/backend/tests/helpers/mod.rs +++ b/backend/tests/helpers/mod.rs @@ -49,6 +49,7 @@ impl TestContext { Some(Self { app, pool }) } + #[allow(dead_code)] pub async fn prepare_2fa(&self, user_id: uuid::Uuid, otp: &str) -> String { let otp_hash = bcrypt::hash(otp, bcrypt::DEFAULT_COST).unwrap(); let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5);