Bug: refactor: Separate out totp completion from verify



Currently,`verify_totp` is the terminating 2fa API and also it was setting the TOTP status as "SET". 

So , we want to separate the logic of terminating the 2FA to a separate API so that the job of the `verify_totp` API will only be to verify if the totp is valid or not which will also help in reusing it inside dashboard.
diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs
index 7864117856e..6b2748ca344 100644
--- a/crates/api_models/src/user.rs
+++ b/crates/api_models/src/user.rs
@@ -224,6 +224,11 @@ pub struct TokenOnlyQueryParam {
     pub token_only: Option<bool>,
 }
 
+#[derive(Debug, serde::Deserialize, serde::Serialize)]
+pub struct SkipTwoFactorAuthQueryParam {
+    pub skip_two_factor_auth: Option<bool>,
+}
+
 #[derive(Debug, serde::Deserialize, serde::Serialize)]
 pub struct TokenResponse {
     pub token: Secret<String>,
diff --git a/crates/router/src/consts/user.rs b/crates/router/src/consts/user.rs
index 33642205d57..7b61a834f60 100644
--- a/crates/router/src/consts/user.rs
+++ b/crates/router/src/consts/user.rs
@@ -14,3 +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_";
diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs
index 2d1c196f5df..cda85078718 100644
--- a/crates/router/src/core/errors/user.rs
+++ b/crates/router/src/core/errors/user.rs
@@ -72,6 +72,10 @@ pub enum UserErrors {
     InvalidTotp,
     #[error("TotpRequired")]
     TotpRequired,
+    #[error("TwoFactorAuthRequired")]
+    TwoFactorAuthRequired,
+    #[error("TwoFactorAuthNotSetup")]
+    TwoFactorAuthNotSetup,
 }
 
 impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors {
@@ -184,6 +188,12 @@ 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 => {
+                AER::BadRequest(ApiError::new(sub_code, 39, self.get_error_message(), None))
+            }
+            Self::TwoFactorAuthNotSetup => {
+                AER::BadRequest(ApiError::new(sub_code, 40, self.get_error_message(), None))
+            }
         }
     }
 }
@@ -223,6 +233,8 @@ impl UserErrors {
             Self::TotpNotSetup => "TOTP not setup",
             Self::InvalidTotp => "Invalid TOTP",
             Self::TotpRequired => "TOTP required",
+            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 7a0ef683e7a..705c12907ff 100644
--- a/crates/router/src/core/user.rs
+++ b/crates/router/src/core/user.rs
@@ -24,8 +24,9 @@ use crate::{
     routes::{app::ReqState, AppState},
     services::{authentication as auth, authorization::roles, ApplicationResponse},
     types::{domain, transformers::ForeignInto},
-    utils,
+    utils::{self, user::two_factor_auth as tfa_utils},
 };
+
 pub mod dashboard_metadata;
 #[cfg(feature = "dummy_connector")]
 pub mod sample_data;
@@ -1631,7 +1632,7 @@ pub async fn begin_totp(
         }));
     }
 
-    let totp = utils::user::two_factor_auth::generate_default_totp(user_from_db.get_email(), None)?;
+    let totp = tfa_utils::generate_default_totp(user_from_db.get_email(), None)?;
     let recovery_codes = domain::RecoveryCodes::generate_new();
 
     let key_store = user_from_db.get_or_create_key_store(&state).await?;
@@ -1693,10 +1694,8 @@ pub async fn verify_totp(
             .await?
             .ok_or(UserErrors::InternalServerError)?;
 
-        let totp = utils::user::two_factor_auth::generate_default_totp(
-            user_from_db.get_email(),
-            Some(user_totp_secret),
-        )?;
+        let totp =
+            tfa_utils::generate_default_totp(user_from_db.get_email(), Some(user_totp_secret))?;
 
         if totp
             .generate_current()
@@ -1739,7 +1738,7 @@ pub async fn generate_recovery_codes(
     state: AppState,
     user_token: auth::UserFromSinglePurposeToken,
 ) -> UserResponse<user_api::RecoveryCodes> {
-    if !utils::user::two_factor_auth::check_totp_in_redis(&state, &user_token.user_id).await? {
+    if !tfa_utils::check_totp_in_redis(&state, &user_token.user_id).await? {
         return Err(UserErrors::TotpRequired.into());
     }
 
@@ -1766,3 +1765,55 @@ pub async fn generate_recovery_codes(
         recovery_codes: recovery_codes.into_inner(),
     }))
 }
+
+pub async fn terminate_two_factor_auth(
+    state: AppState,
+    user_token: auth::UserFromSinglePurposeToken,
+    skip_two_factor_auth: bool,
+) -> 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 !skip_two_factor_auth {
+        if !tfa_utils::check_totp_in_redis(&state, &user_token.user_id).await?
+            && !tfa_utils::check_recovery_code_in_redis(&state, &user_token.user_id).await?
+        {
+            return Err(UserErrors::TwoFactorAuthRequired.into());
+        }
+
+        if user_from_db.get_recovery_codes().is_none() {
+            return Err(UserErrors::TwoFactorAuthNotSetup.into());
+        }
+
+        if user_from_db.get_totp_status() != TotpStatus::Set {
+            state
+                .store
+                .update_user_by_user_id(
+                    user_from_db.get_user_id(),
+                    storage_user::UserUpdate::TotpUpdate {
+                        totp_status: Some(TotpStatus::Set),
+                        totp_secret: None,
+                        totp_recovery_codes: None,
+                    },
+                )
+                .await
+                .change_context(UserErrors::InternalServerError)?;
+        }
+    }
+
+    let current_flow = domain::CurrentFlow::new(user_token.origin, domain::SPTFlow::TOTP.into())?;
+    let next_flow = current_flow.next(user_from_db, &state).await?;
+    let token = next_flow.get_token(&state).await?;
+
+    auth::cookies::set_cookie_response(
+        user_api::TokenResponse {
+            token: token.clone(),
+            token_type: next_flow.get_flow().into(),
+        },
+        token,
+    )
+}
diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs
index 0e152ec32c1..cf2e986c325 100644
--- a/crates/router/src/routes/app.rs
+++ b/crates/router/src/routes/app.rs
@@ -1215,6 +1215,9 @@ impl User {
             .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)),
             );
 
         #[cfg(feature = "email")]
diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs
index 706726979dc..97d92d49911 100644
--- a/crates/router/src/routes/lock_utils.rs
+++ b/crates/router/src/routes/lock_utils.rs
@@ -215,6 +215,7 @@ impl From<Flow> for ApiIdentifier {
             | Flow::UpdateUserAccountDetails
             | Flow::TotpBegin
             | Flow::TotpVerify
+            | Flow::TerminateTwoFactorAuth
             | Flow::GenerateRecoveryCodes => Self::User,
 
             Flow::ListRoles
diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs
index f542c446e49..2019855114a 100644
--- a/crates/router/src/routes/user.rs
+++ b/crates/router/src/routes/user.rs
@@ -679,3 +679,23 @@ pub async fn generate_recovery_codes(state: web::Data<AppState>, req: HttpReques
     ))
     .await
 }
+
+pub async fn terminate_two_factor_auth(
+    state: web::Data<AppState>,
+    req: HttpRequest,
+    query: web::Query<user_api::SkipTwoFactorAuthQueryParam>,
+) -> HttpResponse {
+    let flow = Flow::TerminateTwoFactorAuth;
+    let skip_two_factor_auth = query.into_inner().skip_two_factor_auth.unwrap_or(false);
+
+    Box::pin(api::server_wrap(
+        flow,
+        state.clone(),
+        &req,
+        (),
+        |state, user, _, _| user_core::terminate_two_factor_auth(state, user, skip_two_factor_auth),
+        &auth::SinglePurposeJWTAuth(TokenPurpose::TOTP),
+        api_locking::LockAction::NotApplicable,
+    ))
+    .await
+}
diff --git a/crates/router/src/types/domain/user.rs b/crates/router/src/types/domain/user.rs
index 051e6ccf38e..0704e51e781 100644
--- a/crates/router/src/types/domain/user.rs
+++ b/crates/router/src/types/domain/user.rs
@@ -930,6 +930,10 @@ impl UserFromStorage {
         self.0.totp_status
     }
 
+    pub fn get_recovery_codes(&self) -> Option<Vec<Secret<String>>> {
+        self.0.totp_recovery_codes.clone()
+    }
+
     pub async fn decrypt_and_get_totp_secret(
         &self,
         state: &AppState,
diff --git a/crates/router/src/utils/user/two_factor_auth.rs b/crates/router/src/utils/user/two_factor_auth.rs
index 62bcf2f7eb1..479c346b6e5 100644
--- a/crates/router/src/utils/user/two_factor_auth.rs
+++ b/crates/router/src/utils/user/two_factor_auth.rs
@@ -43,6 +43,15 @@ pub async fn check_totp_in_redis(state: &AppState, user_id: &str) -> UserResult<
         .change_context(UserErrors::InternalServerError)
 }
 
+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);
+    redis_conn
+        .exists::<()>(&key)
+        .await
+        .change_context(UserErrors::InternalServerError)
+}
+
 fn get_redis_connection(state: &AppState) -> UserResult<Arc<RedisConnectionPool>> {
     state
         .store
diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs
index 0e35aba3174..be071ffefc3 100644
--- a/crates/router_env/src/logger/types.rs
+++ b/crates/router_env/src/logger/types.rs
@@ -408,6 +408,8 @@ pub enum Flow {
     TotpVerify,
     /// Generate or Regenerate recovery codes
     GenerateRecoveryCodes,
+    // Terminate two factor authentication
+    TerminateTwoFactorAuth,
     /// List initial webhook delivery attempts
     WebhookEventInitialDeliveryAttemptList,
     /// List delivery attempts for a webhook event

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

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

## Description
Currently completion of 2fa and setting the status of the 2fa status as "SET" is handled by `verify_totp` API. We want to remove this from `verify_totp`, so this PR creates a new API to terminate the 2fa flow.


### 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
This will close the issue #4730 

## How did you test it?
This can only be tested locally as it requires some changes in the redis.

1. terminate 2fa without skip_two_factor_auth query param
  a. If the keys with `TOTP_{user_id}` or `RC_{user_id}` is not present in redis
  Request
  ```
  curl --location 'http://localhost:8080/user/2fa/terminate' \
  --header 'Authorization: Bearer SPT with purpose as TOTP'
  ```

Response 
```
{
    "error": {
        "type": "invalid_request",
        "message": "Two factor auth required",
        "code": "UR_39"
    }
}
``` 
  b. Add the keys to the redis with prefix as `TOTP_{user_id}` or `RC_{user_id}`

  Request
  ```
  curl --location 'http://localhost:8080/user/2fa/terminate' \
  --header 'Authorization: Bearer SPT with purpose as TOTP'
  ```
  Response 
  This will also set the totp_status for the user as "set"
  ```
  {
      "token": "Bearer token",
      "token_type": "next flow token type"
  }
  ```

2. terminate 2fa with skip_two_factor_auth query param
a. skip_two_factor_auth=true
Irrespective if the keys are present in redis or not if skip_two_factor_auth is sent as true it will not change the status and will give the token and token_type for the next flow
Request
```
curl --location 'http://localhost:8080/user/2fa/terminate?skip_two_factor_auth=true' \
--header 'Authorization: Bearer SPT with purpose as TOTP'
```
Response
```
{
    "token": "Bearer token",
    "token_type": "next flow token type"
}
```

b. skip_two_factor_auth=false
Request
```
curl --location 'http://localhost:8080/user/2fa/terminate?skip_two_factor_auth=false' \
--header 'Authorization: Bearer SPT with purpose as TOTP'
```
Response 
This will also set the totp_status for the user as "set"
  ```
  {
      "token": "Bearer token",
      "token_type": "next flow token type"
  }
  ```

## 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
