Bug: feat: add support to verify using recovery code



Create a new endpoint that adds,
- support to verify 2FA with recovery code
- the provided recovery code can be used only once 
- then verification can be done with the remaining recovery codes only next time ( there are total 8 set of recovery codes that are being generated when configuring 2FA)
diff --git a/config/config.example.toml b/config/config.example.toml
index 5d575b5ba09..d5bdfb5a6a7 100644
--- a/config/config.example.toml
+++ b/config/config.example.toml
@@ -351,7 +351,8 @@ email_role_arn = ""        # The amazon resource name ( arn ) of the role which
 sts_role_session_name = "" # An identifier for the assumed role session, used to uniquely identify a session.
 
 [user]
-password_validity_in_days = 90        # Number of days after which password should be updated
+password_validity_in_days = 90             # Number of days after which password should be updated
+two_factor_auth_expiry_in_secs = 300       # Number of seconds after which 2FA should be done again if doing update/change from inside
 
 #tokenization configuration which describe token lifetime and payment method for specific connector
 [tokenization]
diff --git a/config/deployments/integration_test.toml b/config/deployments/integration_test.toml
index 5b6c5fe152d..9551cd2a80d 100644
--- a/config/deployments/integration_test.toml
+++ b/config/deployments/integration_test.toml
@@ -113,6 +113,7 @@ slack_invite_url = "https://join.slack.com/t/hyperswitch-io/shared_invite/zt-2aw
 
 [user]
 password_validity_in_days = 90
+two_factor_auth_expiry_in_secs = 300
 
 [frm]
 enabled = true
diff --git a/config/deployments/production.toml b/config/deployments/production.toml
index b5441f0059d..5f940867438 100644
--- a/config/deployments/production.toml
+++ b/config/deployments/production.toml
@@ -120,6 +120,7 @@ slack_invite_url = "https://join.slack.com/t/hyperswitch-io/shared_invite/zt-2aw
 
 [user]
 password_validity_in_days = 90
+two_factor_auth_expiry_in_secs = 300
 
 [frm]
 enabled = false
diff --git a/config/deployments/sandbox.toml b/config/deployments/sandbox.toml
index e168fab121d..d5f63524e6b 100644
--- a/config/deployments/sandbox.toml
+++ b/config/deployments/sandbox.toml
@@ -120,6 +120,7 @@ slack_invite_url = "https://join.slack.com/t/hyperswitch-io/shared_invite/zt-2aw
 
 [user]
 password_validity_in_days = 90
+two_factor_auth_expiry_in_secs = 300
 
 [frm]
 enabled = true
diff --git a/config/development.toml b/config/development.toml
index af26d91446f..a9f7f4d7b4d 100644
--- a/config/development.toml
+++ b/config/development.toml
@@ -269,6 +269,7 @@ sts_role_session_name = ""
 
 [user]
 password_validity_in_days = 90
+two_factor_auth_expiry_in_secs = 300
 
 [bank_config.eps]
 stripe = { banks = "arzte_und_apotheker_bank,austrian_anadi_bank_ag,bank_austria,bankhaus_carl_spangler,bankhaus_schelhammer_und_schattera_ag,bawag_psk_ag,bks_bank_ag,brull_kallmus_bank_ag,btv_vier_lander_bank,capital_bank_grawe_gruppe_ag,dolomitenbank,easybank_ag,erste_bank_und_sparkassen,hypo_alpeadriabank_international_ag,hypo_noe_lb_fur_niederosterreich_u_wien,hypo_oberosterreich_salzburg_steiermark,hypo_tirol_bank_ag,hypo_vorarlberg_bank_ag,hypo_bank_burgenland_aktiengesellschaft,marchfelder_bank,oberbank_ag,raiffeisen_bankengruppe_osterreich,schoellerbank_ag,sparda_bank_wien,volksbank_gruppe,volkskreditbank_ag,vr_bank_braunau" }
diff --git a/config/docker_compose.toml b/config/docker_compose.toml
index 0d615b11b5c..6661d164032 100644
--- a/config/docker_compose.toml
+++ b/config/docker_compose.toml
@@ -53,6 +53,7 @@ recon_admin_api_key = "recon_test_admin"
 
 [user]
 password_validity_in_days = 90
+two_factor_auth_expiry_in_secs = 300
 
 [locker]
 host = ""
diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs
index b7d7adbf8e3..a472b3a76e6 100644
--- a/crates/api_models/src/events/user.rs
+++ b/crates/api_models/src/events/user.rs
@@ -17,7 +17,7 @@ use crate::user::{
     RecoveryCodes, ResetPasswordRequest, RotatePasswordRequest, SendVerifyEmailRequest,
     SignInResponse, SignUpRequest, SignUpWithMerchantIdRequest, SwitchMerchantIdRequest,
     TokenOrPayloadResponse, TokenResponse, UpdateUserAccountDetailsRequest, UserFromEmailRequest,
-    UserMerchantCreate, VerifyEmailRequest, VerifyTotpRequest,
+    UserMerchantCreate, VerifyEmailRequest, VerifyRecoveryCodeRequest, VerifyTotpRequest,
 };
 
 impl ApiEventMetric for DashboardEntryResponse {
@@ -75,6 +75,7 @@ common_utils::impl_misc_api_event_type!(
     TokenResponse,
     UserFromEmailRequest,
     BeginTotpResponse,
+    VerifyRecoveryCodeRequest,
     VerifyTotpRequest,
     RecoveryCodes
 );
diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs
index 6b2748ca344..5423fc830a5 100644
--- a/crates/api_models/src/user.rs
+++ b/crates/api_models/src/user.rs
@@ -263,6 +263,11 @@ pub struct VerifyTotpRequest {
     pub totp: Option<Secret<String>>,
 }
 
+#[derive(Debug, serde::Deserialize, serde::Serialize)]
+pub struct VerifyRecoveryCodeRequest {
+    pub recovery_code: Secret<String>,
+}
+
 #[derive(Debug, serde::Deserialize, serde::Serialize)]
 pub struct RecoveryCodes {
     pub recovery_codes: Vec<Secret<String>>,
diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs
index 9564a759ccd..b8a315f944b 100644
--- a/crates/router/src/configs/settings.rs
+++ b/crates/router/src/configs/settings.rs
@@ -395,6 +395,7 @@ pub struct Secrets {
 #[derive(Debug, Clone, Default, Deserialize)]
 pub struct UserSettings {
     pub password_validity_in_days: u16,
+    pub two_factor_auth_expiry_in_secs: i64,
 }
 
 #[derive(Debug, Deserialize, Clone)]
diff --git a/crates/router/src/consts/user.rs b/crates/router/src/consts/user.rs
index 7b61a834f60..2f347dc3d3e 100644
--- a/crates/router/src/consts/user.rs
+++ b/crates/router/src/consts/user.rs
@@ -14,4 +14,4 @@ pub const MAX_PASSWORD_LENGTH: usize = 70;
 pub const MIN_PASSWORD_LENGTH: usize = 8;
 
 pub const TOTP_PREFIX: &str = "TOTP_";
-pub const REDIS_RECOVERY_CODES_PREFIX: &str = "RC_";
+pub const REDIS_RECOVERY_CODE_PREFIX: &str = "RC_";
diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs
index cda85078718..adcf8794b83 100644
--- a/crates/router/src/core/errors/user.rs
+++ b/crates/router/src/core/errors/user.rs
@@ -72,6 +72,8 @@ pub enum UserErrors {
     InvalidTotp,
     #[error("TotpRequired")]
     TotpRequired,
+    #[error("InvalidRecoveryCode")]
+    InvalidRecoveryCode,
     #[error("TwoFactorAuthRequired")]
     TwoFactorAuthRequired,
     #[error("TwoFactorAuthNotSetup")]
@@ -188,12 +190,15 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
             Self::TotpRequired => {
                 AER::BadRequest(ApiError::new(sub_code, 38, self.get_error_message(), None))
             }
-            Self::TwoFactorAuthRequired => {
+            Self::InvalidRecoveryCode => {
                 AER::BadRequest(ApiError::new(sub_code, 39, self.get_error_message(), None))
             }
-            Self::TwoFactorAuthNotSetup => {
+            Self::TwoFactorAuthRequired => {
                 AER::BadRequest(ApiError::new(sub_code, 40, self.get_error_message(), None))
             }
+            Self::TwoFactorAuthNotSetup => {
+                AER::BadRequest(ApiError::new(sub_code, 41, self.get_error_message(), None))
+            }
         }
     }
 }
@@ -233,6 +238,7 @@ impl UserErrors {
             Self::TotpNotSetup => "TOTP not setup",
             Self::InvalidTotp => "Invalid TOTP",
             Self::TotpRequired => "TOTP required",
+            Self::InvalidRecoveryCode => "Invalid Recovery Code",
             Self::TwoFactorAuthRequired => "Two factor auth required",
             Self::TwoFactorAuthNotSetup => "Two factor auth not setup",
         }
diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs
index 705c12907ff..4fd9fa865cd 100644
--- a/crates/router/src/core/user.rs
+++ b/crates/router/src/core/user.rs
@@ -179,7 +179,7 @@ pub async fn signin(
         })?
         .into();
 
-    user_from_db.compare_password(request.password)?;
+    user_from_db.compare_password(&request.password)?;
 
     let signin_strategy =
         if let Some(preferred_merchant_id) = user_from_db.get_preferred_merchant_id() {
@@ -217,7 +217,7 @@ pub async fn signin_token_only_flow(
         .to_not_found_response(UserErrors::InvalidCredentials)?
         .into();
 
-    user_from_db.compare_password(request.password)?;
+    user_from_db.compare_password(&request.password)?;
 
     let next_flow =
         domain::NextFlow::from_origin(domain::Origin::SignIn, user_from_db.clone(), &state).await?;
@@ -341,7 +341,7 @@ pub async fn change_password(
         .change_context(UserErrors::InternalServerError)?
         .into();
 
-    user.compare_password(request.old_password.to_owned())
+    user.compare_password(&request.old_password)
         .change_context(UserErrors::InvalidOldPassword)?;
 
     if request.old_password == request.new_password {
@@ -439,7 +439,7 @@ pub async fn rotate_password(
     let password = domain::UserPassword::new(request.password.to_owned())?;
     let hash_password = utils::user::password::generate_password_hash(password.get_secret())?;
 
-    if user.compare_password(request.password).is_ok() {
+    if user.compare_password(&request.password).is_ok() {
         return Err(UserErrors::ChangePasswordError.into());
     }
 
@@ -1766,6 +1766,51 @@ pub async fn generate_recovery_codes(
     }))
 }
 
+pub async fn verify_recovery_code(
+    state: AppState,
+    user_token: auth::UserFromSinglePurposeToken,
+    req: user_api::VerifyRecoveryCodeRequest,
+) -> UserResponse<user_api::TokenResponse> {
+    let user_from_db: domain::UserFromStorage = state
+        .store
+        .find_user_by_id(&user_token.user_id)
+        .await
+        .change_context(UserErrors::InternalServerError)?
+        .into();
+
+    if user_from_db.get_totp_status() != TotpStatus::Set {
+        return Err(UserErrors::TwoFactorAuthNotSetup.into());
+    }
+
+    let mut recovery_codes = user_from_db
+        .get_recovery_codes()
+        .ok_or(UserErrors::InternalServerError)?;
+
+    let matching_index = utils::user::password::get_index_for_correct_recovery_code(
+        &req.recovery_code,
+        &recovery_codes,
+    )?
+    .ok_or(UserErrors::InvalidRecoveryCode)?;
+
+    tfa_utils::insert_recovery_code_in_redis(&state, user_from_db.get_user_id()).await?;
+    let _ = recovery_codes.remove(matching_index);
+
+    state
+        .store
+        .update_user_by_user_id(
+            user_from_db.get_user_id(),
+            storage_user::UserUpdate::TotpUpdate {
+                totp_status: None,
+                totp_secret: None,
+                totp_recovery_codes: Some(recovery_codes),
+            },
+        )
+        .await
+        .change_context(UserErrors::InternalServerError)?;
+
+    Ok(ApplicationResponse::StatusOk)
+}
+
 pub async fn terminate_two_factor_auth(
     state: AppState,
     user_token: auth::UserFromSinglePurposeToken,
diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs
index cf2e986c325..bad5ab5a926 100644
--- a/crates/router/src/routes/app.rs
+++ b/crates/router/src/routes/app.rs
@@ -1212,14 +1212,16 @@ impl User {
             )
             .service(web::resource("/totp/begin").route(web::get().to(totp_begin)))
             .service(web::resource("/totp/verify").route(web::post().to(totp_verify)))
-            .service(
-                web::resource("/recovery_codes/generate")
-                    .route(web::get().to(generate_recovery_codes)),
-            )
             .service(
                 web::resource("/2fa/terminate").route(web::get().to(terminate_two_factor_auth)),
             );
 
+        route = route.service(
+            web::scope("/recovery_code")
+                .service(web::resource("/verify").route(web::post().to(verify_recovery_code)))
+                .service(web::resource("/generate").route(web::post().to(generate_recovery_codes))),
+        );
+
         #[cfg(feature = "email")]
         {
             route = route
diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs
index 97d92d49911..75821bbd2ba 100644
--- a/crates/router/src/routes/lock_utils.rs
+++ b/crates/router/src/routes/lock_utils.rs
@@ -215,9 +215,9 @@ impl From<Flow> for ApiIdentifier {
             | Flow::UpdateUserAccountDetails
             | Flow::TotpBegin
             | Flow::TotpVerify
-            | Flow::TerminateTwoFactorAuth
-            | Flow::GenerateRecoveryCodes => Self::User,
-
+            | Flow::RecoveryCodeVerify
+            | Flow::RecoveryCodesGenerate
+            | Flow::TerminateTwoFactorAuth => Self::User,
             Flow::ListRoles
             | Flow::GetRole
             | Flow::GetRoleFromToken
diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs
index 2019855114a..6b8015ac3e5 100644
--- a/crates/router/src/routes/user.rs
+++ b/crates/router/src/routes/user.rs
@@ -666,8 +666,26 @@ pub async fn totp_verify(
     .await
 }
 
+pub async fn verify_recovery_code(
+    state: web::Data<AppState>,
+    req: HttpRequest,
+    json_payload: web::Json<user_api::VerifyRecoveryCodeRequest>,
+) -> HttpResponse {
+    let flow = Flow::RecoveryCodeVerify;
+    Box::pin(api::server_wrap(
+        flow,
+        state.clone(),
+        &req,
+        json_payload.into_inner(),
+        |state, user, req_body, _| user_core::verify_recovery_code(state, user, req_body),
+        &auth::SinglePurposeJWTAuth(TokenPurpose::TOTP),
+        api_locking::LockAction::NotApplicable,
+    ))
+    .await
+}
+
 pub async fn generate_recovery_codes(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
-    let flow = Flow::GenerateRecoveryCodes;
+    let flow = Flow::RecoveryCodesGenerate;
     Box::pin(api::server_wrap(
         flow,
         state.clone(),
diff --git a/crates/router/src/types/domain/user.rs b/crates/router/src/types/domain/user.rs
index 0704e51e781..91323b0c16d 100644
--- a/crates/router/src/types/domain/user.rs
+++ b/crates/router/src/types/domain/user.rs
@@ -774,8 +774,8 @@ impl UserFromStorage {
         self.0.user_id.as_str()
     }
 
-    pub fn compare_password(&self, candidate: Secret<String>) -> UserResult<()> {
-        match password::is_correct_password(candidate, self.0.password.clone()) {
+    pub fn compare_password(&self, candidate: &Secret<String>) -> UserResult<()> {
+        match password::is_correct_password(candidate, &self.0.password) {
             Ok(true) => Ok(()),
             Ok(false) => Err(UserErrors::InvalidCredentials.into()),
             Err(e) => Err(e),
diff --git a/crates/router/src/utils/user/password.rs b/crates/router/src/utils/user/password.rs
index beb87c325b6..e01181acb9d 100644
--- a/crates/router/src/utils/user/password.rs
+++ b/crates/router/src/utils/user/password.rs
@@ -7,7 +7,7 @@ use argon2::{
 };
 use common_utils::errors::CustomResult;
 use error_stack::ResultExt;
-use masking::{ExposeInterface, Secret};
+use masking::{ExposeInterface, PeekInterface, Secret};
 use rand::{seq::SliceRandom, Rng};
 
 use crate::core::errors::UserErrors;
@@ -25,13 +25,13 @@ pub fn generate_password_hash(
 }
 
 pub fn is_correct_password(
-    candidate: Secret<String>,
-    password: Secret<String>,
+    candidate: &Secret<String>,
+    password: &Secret<String>,
 ) -> CustomResult<bool, UserErrors> {
-    let password = password.expose();
+    let password = password.peek();
     let parsed_hash =
-        PasswordHash::new(&password).change_context(UserErrors::InternalServerError)?;
-    let result = Argon2::default().verify_password(candidate.expose().as_bytes(), &parsed_hash);
+        PasswordHash::new(password).change_context(UserErrors::InternalServerError)?;
+    let result = Argon2::default().verify_password(candidate.peek().as_bytes(), &parsed_hash);
     match result {
         Ok(_) => Ok(true),
         Err(argon2Err::Password) => Ok(false),
@@ -40,6 +40,19 @@ pub fn is_correct_password(
     .change_context(UserErrors::InternalServerError)
 }
 
+pub fn get_index_for_correct_recovery_code(
+    candidate: &Secret<String>,
+    recovery_codes: &[Secret<String>],
+) -> CustomResult<Option<usize>, UserErrors> {
+    for (index, recovery_code) in recovery_codes.iter().enumerate() {
+        let is_match = is_correct_password(candidate, recovery_code)?;
+        if is_match {
+            return Ok(Some(index));
+        }
+    }
+    Ok(None)
+}
+
 pub fn get_temp_password() -> Secret<String> {
     let uuid_pass = uuid::Uuid::new_v4().to_string();
     let mut rng = rand::thread_rng();
diff --git a/crates/router/src/utils/user/two_factor_auth.rs b/crates/router/src/utils/user/two_factor_auth.rs
index 479c346b6e5..e9e3f66005c 100644
--- a/crates/router/src/utils/user/two_factor_auth.rs
+++ b/crates/router/src/utils/user/two_factor_auth.rs
@@ -45,7 +45,7 @@ pub async fn check_totp_in_redis(state: &AppState, user_id: &str) -> UserResult<
 
 pub async fn check_recovery_code_in_redis(state: &AppState, user_id: &str) -> UserResult<bool> {
     let redis_conn = get_redis_connection(state)?;
-    let key = format!("{}{}", consts::user::REDIS_RECOVERY_CODES_PREFIX, user_id);
+    let key = format!("{}{}", consts::user::REDIS_RECOVERY_CODE_PREFIX, user_id);
     redis_conn
         .exists::<()>(&key)
         .await
@@ -59,3 +59,16 @@ fn get_redis_connection(state: &AppState) -> UserResult<Arc<RedisConnectionPool>
         .change_context(UserErrors::InternalServerError)
         .attach_printable("Failed to get redis connection")
 }
+
+pub async fn insert_recovery_code_in_redis(state: &AppState, user_id: &str) -> UserResult<()> {
+    let redis_conn = get_redis_connection(state)?;
+    let key = format!("{}{}", consts::user::REDIS_RECOVERY_CODE_PREFIX, user_id);
+    redis_conn
+        .set_key_with_expiry(
+            key.as_str(),
+            common_utils::date_time::now_unix_timestamp(),
+            state.conf.user.two_factor_auth_expiry_in_secs,
+        )
+        .await
+        .change_context(UserErrors::InternalServerError)
+}
diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs
index be071ffefc3..35b55b6fb97 100644
--- a/crates/router_env/src/logger/types.rs
+++ b/crates/router_env/src/logger/types.rs
@@ -406,8 +406,10 @@ pub enum Flow {
     TotpBegin,
     /// Verify TOTP
     TotpVerify,
+    /// Verify Access Code
+    RecoveryCodeVerify,
     /// Generate or Regenerate recovery codes
-    GenerateRecoveryCodes,
+    RecoveryCodesGenerate,
     // Terminate two factor authentication
     TerminateTwoFactorAuth,
     /// List initial webhook delivery attempts
diff --git a/loadtest/config/development.toml b/loadtest/config/development.toml
index a104f5760b4..2c3e83ad2ba 100644
--- a/loadtest/config/development.toml
+++ b/loadtest/config/development.toml
@@ -30,6 +30,7 @@ jwt_secret = "secret"
 
 [user]
 password_validity_in_days = 90
+two_factor_auth_expiry_in_secs = 300
 
 [locker]
 host = ""

## Type of Change
<!-- Put an `x` in the boxes that apply -->

- [ ] Bugfix
- [x] New feature
- [ ] Enhancement
- [ ] Refactoring
- [ ] Dependency updates
- [ ] Documentation
- [ ] CI/CD

## Description
The PR adds support to verify 2FA using recovery code, in case TOTP is lost 

### Additional Changes

- [x] This PR modifies the API contract
- [ ] This PR modifies the database schema
- [ ] This PR modifies application configuration/environment variables

<!--
Provide links to the files with corresponding changes.

Following are the paths where you can find config files:
1. `config`
2. `crates/router/src/configs`
3. `loadtest/config`
-->


## Motivation and Context
Closes [#4736](https://github.com/juspay/hyperswitch/issues/4736)


## How did you test it?
First Sign in for user for which 2FA is already set.
Then to verify 2FA:

Use the below curl to test it
```
curl --location 'http://localhost:8080/user/recovery_code/verify' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer SPT' \
--header 'Cookie: Cookie_1=value' \
--data '{
    "recovery_code": "j4Az-W7Fk"
}'
```
If the recovery code is correct then response will be `200 ok`.
Else proper error would be thrown
```
{
    "error": {
        "type": "invalid_request",
        "message": "Invalid Recovery Code",
        "code": "UR_39"
    }
}
```



## Checklist
<!-- Put an `x` in the boxes that apply -->

- [x] I formatted the code `cargo +nightly fmt --all`
- [x] I addressed lints thrown by `cargo clippy`
- [x] I reviewed the submitted code
- [ ] I added unit tests for my changes where possible
