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..2a89561 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( @@ -161,10 +163,8 @@ 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")?; @@ -294,10 +294,8 @@ 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 5c8d7f4..5255802 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, Serialize, Deserialize)] +pub struct Send2faRequest { + pub user_id: Uuid, +} + +#[derive(Debug, Serialize, 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,142 @@ 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> { + 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(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(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(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(user_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + 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..824a3ee 100644 --- a/backend/src/service.rs +++ b/backend/src/service.rs @@ -109,13 +109,13 @@ 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)] diff --git a/backend/tests/claim_tests.rs b/backend/tests/claim_tests.rs index 23cff8d..67a5bbd 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,16 @@ 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 +202,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") @@ -233,9 +242,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") @@ -265,9 +279,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") @@ -311,9 +330,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") @@ -368,9 +392,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") diff --git a/backend/tests/helpers/mod.rs b/backend/tests/helpers/mod.rs index 79cf30d..62f497b 100644 --- a/backend/tests/helpers/mod.rs +++ b/backend/tests/helpers/mod.rs @@ -38,10 +38,32 @@ 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"); - 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); + + 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..1c8789f 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,7 +376,8 @@ 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 @@ -417,6 +421,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,7 +433,8 @@ 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 @@ -499,6 +507,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,7 +519,8 @@ 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 @@ -547,3 +559,49 @@ 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..dcb4595 --- /dev/null +++ b/backend/tests/two_fa_tests.rs @@ -0,0 +1,268 @@ +mod helpers; + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use chrono::{Duration, Utc}; +use inheritx_backend::auth::{Send2faRequest, Verify2faRequest}; +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")); +}