Bug: fix(users): max attempts for two-fa



- Previously, there was no limit on the number of failed attempts for TOTP and recovery code. With this PR, a maximum of 4 wrong attempts is introduced for both TOTP and recovery code. After 4 failed attempts, a cooldown period of 5 minutes applies for TOTP, and 10 minutes for the recovery code.

- A new endpoint is introduced to check the no of attempts of the user and also if the user has done two-fa recently .
diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs
index bb61a025180..4dc2a1a301a 100644
--- a/crates/api_models/src/events/user.rs
+++ b/crates/api_models/src/events/user.rs
@@ -18,8 +18,9 @@ use crate::user::{
     ResetPasswordRequest, RotatePasswordRequest, SendVerifyEmailRequest, SignUpRequest,
     SignUpWithMerchantIdRequest, SsoSignInRequest, SwitchMerchantRequest,
     SwitchOrganizationRequest, SwitchProfileRequest, TokenResponse, TwoFactorAuthStatusResponse,
-    UpdateUserAccountDetailsRequest, UpdateUserAuthenticationMethodRequest, UserFromEmailRequest,
-    UserMerchantCreate, VerifyEmailRequest, VerifyRecoveryCodeRequest, VerifyTotpRequest,
+    TwoFactorStatus, UpdateUserAccountDetailsRequest, UpdateUserAuthenticationMethodRequest,
+    UserFromEmailRequest, UserMerchantCreate, VerifyEmailRequest, VerifyRecoveryCodeRequest,
+    VerifyTotpRequest,
 };
 
 #[cfg(feature = "recon")]
@@ -62,6 +63,7 @@ common_utils::impl_api_event_type!(
         GetUserRoleDetailsResponseV2,
         TokenResponse,
         TwoFactorAuthStatusResponse,
+        TwoFactorStatus,
         UserFromEmailRequest,
         BeginTotpResponse,
         VerifyRecoveryCodeRequest,
diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs
index d66f9f3bc03..089089038b8 100644
--- a/crates/api_models/src/user.rs
+++ b/crates/api_models/src/user.rs
@@ -196,6 +196,23 @@ pub struct TwoFactorAuthStatusResponse {
     pub recovery_code: bool,
 }
 
+#[derive(Debug, serde::Deserialize, serde::Serialize)]
+pub struct TwoFactorAuthAttempts {
+    pub is_completed: bool,
+    pub remaining_attempts: u8,
+}
+
+#[derive(Debug, serde::Deserialize, serde::Serialize)]
+pub struct TwoFactorAuthStatusResponseWithAttempts {
+    pub totp: TwoFactorAuthAttempts,
+    pub recovery_code: TwoFactorAuthAttempts,
+}
+
+#[derive(Debug, serde::Deserialize, serde::Serialize)]
+pub struct TwoFactorStatus {
+    pub status: Option<TwoFactorAuthStatusResponseWithAttempts>,
+}
+
 #[derive(Debug, serde::Deserialize, serde::Serialize)]
 pub struct UserFromEmailRequest {
     pub token: Secret<String>,
diff --git a/crates/router/src/consts/user.rs b/crates/router/src/consts/user.rs
index 6ac2b08e6bb..2b90f9fa488 100644
--- a/crates/router/src/consts/user.rs
+++ b/crates/router/src/consts/user.rs
@@ -15,6 +15,10 @@ pub const TOTP_DIGITS: usize = 6;
 pub const TOTP_VALIDITY_DURATION_IN_SECONDS: u64 = 30;
 /// Number of totps allowed as network delay. 1 would mean one totp before current totp and one totp after are valids.
 pub const TOTP_TOLERANCE: u8 = 1;
+/// Number of maximum attempts user has for totp
+pub const TOTP_MAX_ATTEMPTS: u8 = 4;
+/// Number of maximum attempts user has for recovery code
+pub const RECOVERY_CODE_MAX_ATTEMPTS: u8 = 4;
 
 pub const MAX_PASSWORD_LENGTH: usize = 70;
 pub const MIN_PASSWORD_LENGTH: usize = 8;
@@ -23,6 +27,10 @@ pub const REDIS_TOTP_PREFIX: &str = "TOTP_";
 pub const REDIS_RECOVERY_CODE_PREFIX: &str = "RC_";
 pub const REDIS_TOTP_SECRET_PREFIX: &str = "TOTP_SEC_";
 pub const REDIS_TOTP_SECRET_TTL_IN_SECS: i64 = 15 * 60; // 15 minutes
+pub const REDIS_TOTP_ATTEMPTS_PREFIX: &str = "TOTP_ATTEMPTS_";
+pub const REDIS_RECOVERY_CODE_ATTEMPTS_PREFIX: &str = "RC_ATTEMPTS_";
+pub const REDIS_TOTP_ATTEMPTS_TTL_IN_SECS: i64 = 5 * 60; // 5 mins
+pub const REDIS_RECOVERY_CODE_ATTEMPTS_TTL_IN_SECS: i64 = 10 * 60; // 10 mins
 
 pub const REDIS_SSO_PREFIX: &str = "SSO_";
 pub const REDIS_SSO_TTL: i64 = 5 * 60; // 5 minutes
diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs
index e3c4bedab72..df4ce1d8148 100644
--- a/crates/router/src/core/errors/user.rs
+++ b/crates/router/src/core/errors/user.rs
@@ -90,6 +90,10 @@ pub enum UserErrors {
     SSOFailed,
     #[error("profile_id missing in JWT")]
     JwtProfileIdMissing,
+    #[error("Maximum attempts reached for TOTP")]
+    MaxTotpAttemptsReached,
+    #[error("Maximum attempts reached for Recovery Code")]
+    MaxRecoveryCodeAttemptsReached,
 }
 
 impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors {
@@ -229,6 +233,12 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
             Self::JwtProfileIdMissing => {
                 AER::Unauthorized(ApiError::new(sub_code, 47, self.get_error_message(), None))
             }
+            Self::MaxTotpAttemptsReached => {
+                AER::BadRequest(ApiError::new(sub_code, 48, self.get_error_message(), None))
+            }
+            Self::MaxRecoveryCodeAttemptsReached => {
+                AER::BadRequest(ApiError::new(sub_code, 49, self.get_error_message(), None))
+            }
         }
     }
 }
@@ -269,6 +279,8 @@ impl UserErrors {
             Self::InvalidTotp => "Invalid TOTP",
             Self::TotpRequired => "TOTP required",
             Self::InvalidRecoveryCode => "Invalid Recovery Code",
+            Self::MaxTotpAttemptsReached => "Maximum attempts reached for TOTP",
+            Self::MaxRecoveryCodeAttemptsReached => "Maximum attempts reached for Recovery Code",
             Self::TwoFactorAuthRequired => "Two factor auth required",
             Self::TwoFactorAuthNotSetup => "Two factor auth not setup",
             Self::TotpSecretNotFound => "TOTP secret not found",
diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs
index 046675f5200..6974287f2e1 100644
--- a/crates/router/src/core/user.rs
+++ b/crates/router/src/core/user.rs
@@ -1686,6 +1686,13 @@ pub async fn verify_totp(
         return Err(UserErrors::TotpNotSetup.into());
     }
 
+    let user_totp_attempts =
+        tfa_utils::get_totp_attempts_from_redis(&state, &user_token.user_id).await?;
+
+    if user_totp_attempts >= consts::user::TOTP_MAX_ATTEMPTS {
+        return Err(UserErrors::MaxTotpAttemptsReached.into());
+    }
+
     let user_totp_secret = user_from_db
         .decrypt_and_get_totp_secret(&state)
         .await?
@@ -1702,6 +1709,13 @@ pub async fn verify_totp(
         .change_context(UserErrors::InternalServerError)?
         != req.totp.expose()
     {
+        let _ = tfa_utils::insert_totp_attempts_in_redis(
+            &state,
+            &user_token.user_id,
+            user_totp_attempts + 1,
+        )
+        .await
+        .inspect_err(|error| logger::error!(?error));
         return Err(UserErrors::InvalidTotp.into());
     }
 
@@ -1856,15 +1870,31 @@ pub async fn verify_recovery_code(
         return Err(UserErrors::TwoFactorAuthNotSetup.into());
     }
 
+    let user_recovery_code_attempts =
+        tfa_utils::get_recovery_code_attempts_from_redis(&state, &user_token.user_id).await?;
+
+    if user_recovery_code_attempts >= consts::user::RECOVERY_CODE_MAX_ATTEMPTS {
+        return Err(UserErrors::MaxRecoveryCodeAttemptsReached.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(
+    let Some(matching_index) = utils::user::password::get_index_for_correct_recovery_code(
         &req.recovery_code,
         &recovery_codes,
     )?
-    .ok_or(UserErrors::InvalidRecoveryCode)?;
+    else {
+        let _ = tfa_utils::insert_recovery_code_attempts_in_redis(
+            &state,
+            &user_token.user_id,
+            user_recovery_code_attempts + 1,
+        )
+        .await
+        .inspect_err(|error| logger::error!(?error));
+        return Err(UserErrors::InvalidRecoveryCode.into());
+    };
 
     tfa_utils::insert_recovery_code_in_redis(&state, user_from_db.get_user_id()).await?;
     let _ = recovery_codes.remove(matching_index);
@@ -1924,10 +1954,17 @@ pub async fn terminate_two_factor_auth(
         }
     }
 
-    let current_flow = domain::CurrentFlow::new(user_token, domain::SPTFlow::TOTP.into())?;
+    let current_flow = domain::CurrentFlow::new(user_token.clone(), domain::SPTFlow::TOTP.into())?;
     let next_flow = current_flow.next(user_from_db, &state).await?;
     let token = next_flow.get_token(&state).await?;
 
+    let _ = tfa_utils::delete_totp_attempts_from_redis(&state, &user_token.user_id)
+        .await
+        .inspect_err(|error| logger::error!(?error));
+    let _ = tfa_utils::delete_recovery_code_attempts_from_redis(&state, &user_token.user_id)
+        .await
+        .inspect_err(|error| logger::error!(?error));
+
     auth::cookies::set_cookie_response(
         user_api::TokenResponse {
             token: token.clone(),
@@ -1950,6 +1987,40 @@ pub async fn check_two_factor_auth_status(
     ))
 }
 
+pub async fn check_two_factor_auth_status_with_attempts(
+    state: SessionState,
+    user_token: auth::UserIdFromAuth,
+) -> UserResponse<user_api::TwoFactorStatus> {
+    let user_from_db: domain::UserFromStorage = state
+        .global_store
+        .find_user_by_id(&user_token.user_id)
+        .await
+        .change_context(UserErrors::InternalServerError)?
+        .into();
+    if user_from_db.get_totp_status() == TotpStatus::NotSet {
+        return Ok(ApplicationResponse::Json(user_api::TwoFactorStatus {
+            status: None,
+        }));
+    };
+
+    let totp = user_api::TwoFactorAuthAttempts {
+        is_completed: tfa_utils::check_totp_in_redis(&state, &user_token.user_id).await?,
+        remaining_attempts: consts::user::TOTP_MAX_ATTEMPTS
+            - tfa_utils::get_totp_attempts_from_redis(&state, &user_token.user_id).await?,
+    };
+    let recovery_code = user_api::TwoFactorAuthAttempts {
+        is_completed: tfa_utils::check_recovery_code_in_redis(&state, &user_token.user_id).await?,
+        remaining_attempts: consts::user::RECOVERY_CODE_MAX_ATTEMPTS
+            - tfa_utils::get_recovery_code_attempts_from_redis(&state, &user_token.user_id).await?,
+    };
+    Ok(ApplicationResponse::Json(user_api::TwoFactorStatus {
+        status: Some(user_api::TwoFactorAuthStatusResponseWithAttempts {
+            totp,
+            recovery_code,
+        }),
+    }))
+}
+
 pub async fn create_user_authentication_method(
     state: SessionState,
     req: user_api::CreateUserAuthenticationMethodRequest,
diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs
index 54e4471b057..b9175fa9bbd 100644
--- a/crates/router/src/routes/app.rs
+++ b/crates/router/src/routes/app.rs
@@ -1849,7 +1849,12 @@ impl User {
         // Two factor auth routes
         route = route.service(
             web::scope("/2fa")
+                // TODO: to be deprecated
                 .service(web::resource("").route(web::get().to(user::check_two_factor_auth_status)))
+                .service(
+                    web::resource("/v2")
+                        .route(web::get().to(user::check_two_factor_auth_status_with_attempts)),
+                )
                 .service(
                     web::scope("/totp")
                         .service(web::resource("/begin").route(web::get().to(user::totp_begin)))
diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs
index 16786e71768..e07a2fa10ef 100644
--- a/crates/router/src/routes/user.rs
+++ b/crates/router/src/routes/user.rs
@@ -682,6 +682,23 @@ pub async fn check_two_factor_auth_status(
     .await
 }
 
+pub async fn check_two_factor_auth_status_with_attempts(
+    state: web::Data<AppState>,
+    req: HttpRequest,
+) -> HttpResponse {
+    let flow = Flow::TwoFactorAuthStatus;
+    Box::pin(api::server_wrap(
+        flow,
+        state.clone(),
+        &req,
+        (),
+        |state, user, _, _| user_core::check_two_factor_auth_status_with_attempts(state, user),
+        &auth::SinglePurposeOrLoginTokenAuth(TokenPurpose::TOTP),
+        api_locking::LockAction::NotApplicable,
+    ))
+    .await
+}
+
 pub async fn get_sso_auth_url(
     state: web::Data<AppState>,
     req: HttpRequest,
diff --git a/crates/router/src/utils/user/two_factor_auth.rs b/crates/router/src/utils/user/two_factor_auth.rs
index bebe58ebd86..73d692a00e0 100644
--- a/crates/router/src/utils/user/two_factor_auth.rs
+++ b/crates/router/src/utils/user/two_factor_auth.rs
@@ -139,3 +139,90 @@ pub async fn delete_recovery_code_from_redis(
         .change_context(UserErrors::InternalServerError)
         .map(|_| ())
 }
+
+fn get_totp_attempts_key(user_id: &str) -> String {
+    format!("{}{}", consts::user::REDIS_TOTP_ATTEMPTS_PREFIX, user_id)
+}
+fn get_recovery_code_attempts_key(user_id: &str) -> String {
+    format!(
+        "{}{}",
+        consts::user::REDIS_RECOVERY_CODE_ATTEMPTS_PREFIX,
+        user_id
+    )
+}
+
+pub async fn insert_totp_attempts_in_redis(
+    state: &SessionState,
+    user_id: &str,
+    user_totp_attempts: u8,
+) -> UserResult<()> {
+    let redis_conn = super::get_redis_connection(state)?;
+    redis_conn
+        .set_key_with_expiry(
+            &get_totp_attempts_key(user_id),
+            user_totp_attempts,
+            consts::user::REDIS_TOTP_ATTEMPTS_TTL_IN_SECS,
+        )
+        .await
+        .change_context(UserErrors::InternalServerError)
+}
+pub async fn get_totp_attempts_from_redis(state: &SessionState, user_id: &str) -> UserResult<u8> {
+    let redis_conn = super::get_redis_connection(state)?;
+    redis_conn
+        .get_key::<Option<u8>>(&get_totp_attempts_key(user_id))
+        .await
+        .change_context(UserErrors::InternalServerError)
+        .map(|v| v.unwrap_or(0))
+}
+
+pub async fn insert_recovery_code_attempts_in_redis(
+    state: &SessionState,
+    user_id: &str,
+    user_recovery_code_attempts: u8,
+) -> UserResult<()> {
+    let redis_conn = super::get_redis_connection(state)?;
+    redis_conn
+        .set_key_with_expiry(
+            &get_recovery_code_attempts_key(user_id),
+            user_recovery_code_attempts,
+            consts::user::REDIS_RECOVERY_CODE_ATTEMPTS_TTL_IN_SECS,
+        )
+        .await
+        .change_context(UserErrors::InternalServerError)
+}
+
+pub async fn get_recovery_code_attempts_from_redis(
+    state: &SessionState,
+    user_id: &str,
+) -> UserResult<u8> {
+    let redis_conn = super::get_redis_connection(state)?;
+    redis_conn
+        .get_key::<Option<u8>>(&get_recovery_code_attempts_key(user_id))
+        .await
+        .change_context(UserErrors::InternalServerError)
+        .map(|v| v.unwrap_or(0))
+}
+
+pub async fn delete_totp_attempts_from_redis(
+    state: &SessionState,
+    user_id: &str,
+) -> UserResult<()> {
+    let redis_conn = super::get_redis_connection(state)?;
+    redis_conn
+        .delete_key(&get_totp_attempts_key(user_id))
+        .await
+        .change_context(UserErrors::InternalServerError)
+        .map(|_| ())
+}
+
+pub async fn delete_recovery_code_attempts_from_redis(
+    state: &SessionState,
+    user_id: &str,
+) -> UserResult<()> {
+    let redis_conn = super::get_redis_connection(state)?;
+    redis_conn
+        .delete_key(&get_recovery_code_attempts_key(user_id))
+        .await
+        .change_context(UserErrors::InternalServerError)
+        .map(|_| ())
+}

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

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

## Description
<!-- Describe your changes in detail -->
- Previously, there was no limit on the number of failed attempts for TOTP and recovery code. With this PR, a maximum of 4 wrong attempts is introduced for both TOTP and recovery code. After 4 failed attempts, a cooldown period of 5 minutes applies for TOTP, and 10 minutes for the recovery code.

- A new endpoint is introduced to check the no of attempts of the user and also if the user has done two-fa recently .


### 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
<!--
Why is this change required? What problem does it solve?
If it fixes an open issue, please link to the issue here.

If you don't have an issue, we'd recommend starting with one first so the PR
can focus on the implementation (unless it is an obvious bug or documentation fix
that will have little conversation).
-->


## How did you test it?
<!--
Did you write an integration/unit/API test to verify the code changes?
Or did you test this change manually (provide relevant screenshots)?
-->
Request : 
```
curl --location 'http://localhost:8080/user/2fa/v2' \
--header 'Authorization: Bearer JWT'
```

Response:
- If the user has not set twofa 
```
{
    "status": null
}
```
- If the user has twofa set and no attempts is remaining for both recovery code and totp 
```
{
    "status": {
        "totp": {
            "is_completed": false,
            "remaining_attempts": 0
        },
        "recovery_code": {
            "is_completed": false,
            "remaining_attempts": 0
        }
    }
}
```
- If the user has twofa set and no attempts is remaining for recovery code  
```
{
    "status": {
        "totp": {
            "is_completed": false,
            "remaining_attempts": 4
        },
        "recovery_code": {
            "is_completed": false,
            "remaining_attempts": 0
        }
    }
}
```
- If the user has twofa set and no attempts is remaining for totp
```
{
    "status": {
        "totp": {
            "is_completed": false,
            "remaining_attempts": 0
        },
        "recovery_code": {
            "is_completed": false,
            "remaining_attempts": 4
        }
    }
}
```

After 4 wrong recovery code attempts , `2fa/recovery_code/verify` will throw `UR_49` error
Request :
```
curl --location 'http://localhost:8080/user/2fa/recovery_code/verify' \
--header 'authorization: Bearer JWT' \
--data '{"recovery_code":"some_invalid_recovery_code"}'
```

Response :
```
{
    "error": {
        "type": "invalid_request",
        "message": "Maximum recovery code attempts per user reached",
        "code": "UR_49"
    }
}
```


After 4 wrong totp attempts , `2fa/recovery_code/verify` will throw `UR_48` error
Request :
```
curl 'http://localhost:8080/user/2fa/totp/verify' \
  -H 'authorization: Bearer JWT' \
  --data-raw '{"totp":"some_invalid_totp"}'
```
  
Response :
```
{
    "error": {
        "type": "invalid_request",
        "message": "Maximum totp attempts per user reached",
        "code": "UR_48"
    }
}
```
  

## 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`
- [ ] I reviewed the submitted code
- [ ] I added unit tests for my changes where possible
