Bug: feat: Create API for TOTP Verification



To complete the TOTP Flow, there needs to be an API which needs to verify the totp entered by the user and give the next token and flow that user should go to enter into the dashboard.
diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs
index 1d91a47bf56..e9eb5157095 100644
--- a/crates/api_models/src/events/user.rs
+++ b/crates/api_models/src/events/user.rs
@@ -17,7 +17,7 @@ use crate::user::{
     ResetPasswordRequest, RotatePasswordRequest, SendVerifyEmailRequest, SignInResponse,
     SignUpRequest, SignUpWithMerchantIdRequest, SwitchMerchantIdRequest, TokenOrPayloadResponse,
     TokenResponse, UpdateUserAccountDetailsRequest, UserFromEmailRequest, UserMerchantCreate,
-    VerifyEmailRequest,
+    VerifyEmailRequest, VerifyTotpRequest,
 };
 
 impl ApiEventMetric for DashboardEntryResponse {
@@ -74,7 +74,8 @@ common_utils::impl_misc_api_event_type!(
     GetUserRoleDetailsResponse,
     TokenResponse,
     UserFromEmailRequest,
-    BeginTotpResponse
+    BeginTotpResponse,
+    VerifyTotpRequest
 );
 
 #[cfg(feature = "dummy_connector")]
diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs
index 0dde73d0545..7dbf867d1a0 100644
--- a/crates/api_models/src/user.rs
+++ b/crates/api_models/src/user.rs
@@ -252,3 +252,8 @@ pub struct TotpSecret {
     pub totp_url: Secret<String>,
     pub recovery_codes: Vec<Secret<String>>,
 }
+
+#[derive(Debug, serde::Deserialize, serde::Serialize)]
+pub struct VerifyTotpRequest {
+    pub totp: Option<Secret<String>>,
+}
diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs
index ddcd10c32e4..580e34ba7e8 100644
--- a/crates/router/src/core/errors/user.rs
+++ b/crates/router/src/core/errors/user.rs
@@ -66,6 +66,10 @@ pub enum UserErrors {
     RoleNameParsingError,
     #[error("RoleNameAlreadyExists")]
     RoleNameAlreadyExists,
+    #[error("TOTPNotSetup")]
+    TotpNotSetup,
+    #[error("InvalidTOTP")]
+    InvalidTotp,
 }
 
 impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors {
@@ -169,6 +173,12 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
             Self::RoleNameAlreadyExists => {
                 AER::BadRequest(ApiError::new(sub_code, 35, self.get_error_message(), None))
             }
+            Self::TotpNotSetup => {
+                AER::BadRequest(ApiError::new(sub_code, 36, self.get_error_message(), None))
+            }
+            Self::InvalidTotp => {
+                AER::BadRequest(ApiError::new(sub_code, 37, self.get_error_message(), None))
+            }
         }
     }
 }
@@ -205,6 +215,8 @@ impl UserErrors {
             Self::InvalidRoleOperationWithMessage(error_message) => error_message,
             Self::RoleNameParsingError => "Invalid Role Name",
             Self::RoleNameAlreadyExists => "Role name already exists",
+            Self::TotpNotSetup => "TOTP not setup",
+            Self::InvalidTotp => "Invalid TOTP",
         }
     }
 }
diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs
index e01ed4b1a23..83cdd1d318b 100644
--- a/crates/router/src/core/user.rs
+++ b/crates/router/src/core/user.rs
@@ -1642,3 +1642,65 @@ pub async fn begin_totp(
         }),
     }))
 }
+
+pub async fn verify_totp(
+    state: AppState,
+    user_token: auth::UserFromSinglePurposeToken,
+    req: user_api::VerifyTotpRequest,
+) -> 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 let Some(user_totp) = req.totp {
+        if user_from_db.get_totp_status() == TotpStatus::NotSet {
+            return Err(UserErrors::TotpNotSetup.into());
+        }
+
+        let user_totp_secret = user_from_db
+            .decrypt_and_get_totp_secret(&state)
+            .await?
+            .ok_or(UserErrors::InternalServerError)?;
+
+        let totp =
+            utils::user::generate_default_totp(user_from_db.get_email(), Some(user_totp_secret))?;
+
+        if totp
+            .generate_current()
+            .change_context(UserErrors::InternalServerError)?
+            != user_totp.expose()
+        {
+            return Err(UserErrors::InvalidTotp.into());
+        }
+
+        if user_from_db.get_totp_status() == TotpStatus::InProgress {
+            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 1560578f66f..49a8e063183 100644
--- a/crates/router/src/routes/app.rs
+++ b/crates/router/src/routes/app.rs
@@ -1199,7 +1199,8 @@ impl User {
                     .route(web::get().to(get_multiple_dashboard_metadata))
                     .route(web::post().to(set_dashboard_metadata)),
             )
-            .service(web::resource("/totp/begin").route(web::get().to(totp_begin)));
+            .service(web::resource("/totp/begin").route(web::get().to(totp_begin)))
+            .service(web::resource("/totp/verify").route(web::post().to(totp_verify)));
 
         #[cfg(feature = "email")]
         {
diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs
index 5bef68073f0..ee42cc50fe3 100644
--- a/crates/router/src/routes/lock_utils.rs
+++ b/crates/router/src/routes/lock_utils.rs
@@ -211,7 +211,8 @@ impl From<Flow> for ApiIdentifier {
             | Flow::AcceptInviteFromEmail
             | Flow::VerifyEmailRequest
             | Flow::UpdateUserAccountDetails
-            | Flow::TotpBegin => Self::User,
+            | Flow::TotpBegin
+            | Flow::TotpVerify => Self::User,
 
             Flow::ListRoles
             | Flow::GetRole
diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs
index db12729d01a..a901988e51e 100644
--- a/crates/router/src/routes/user.rs
+++ b/crates/router/src/routes/user.rs
@@ -626,3 +626,21 @@ pub async fn totp_begin(state: web::Data<AppState>, req: HttpRequest) -> HttpRes
     ))
     .await
 }
+
+pub async fn totp_verify(
+    state: web::Data<AppState>,
+    req: HttpRequest,
+    json_payload: web::Json<user_api::VerifyTotpRequest>,
+) -> HttpResponse {
+    let flow = Flow::TotpVerify;
+    Box::pin(api::server_wrap(
+        flow,
+        state.clone(),
+        &req,
+        json_payload.into_inner(),
+        |state, user, req_body, _| user_core::verify_totp(state, user, req_body),
+        &auth::SinglePurposeJWTAuth(common_enums::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 00881626c1c..45f5d74d6f6 100644
--- a/crates/router/src/types/domain/user.rs
+++ b/crates/router/src/types/domain/user.rs
@@ -4,7 +4,7 @@ use api_models::{
     admin as admin_api, organization as api_org, user as user_api, user_role as user_role_api,
 };
 use common_enums::TokenPurpose;
-use common_utils::{errors::CustomResult, pii};
+use common_utils::{crypto::Encryptable, errors::CustomResult, pii};
 use diesel_models::{
     enums::{TotpStatus, UserStatus},
     organization as diesel_org,
@@ -909,6 +909,32 @@ impl UserFromStorage {
     pub fn get_totp_status(&self) -> TotpStatus {
         self.0.totp_status
     }
+
+    pub async fn decrypt_and_get_totp_secret(
+        &self,
+        state: &AppState,
+    ) -> UserResult<Option<Secret<String>>> {
+        if self.0.totp_secret.is_none() {
+            return Ok(None);
+        }
+
+        let user_key_store = state
+            .store
+            .get_user_key_store_by_user_id(
+                self.get_user_id(),
+                &state.store.get_master_key().to_vec().into(),
+            )
+            .await
+            .change_context(UserErrors::InternalServerError)?;
+
+        Ok(domain_types::decrypt::<String, masking::WithType>(
+            self.0.totp_secret.clone(),
+            user_key_store.key.peek(),
+        )
+        .await
+        .change_context(UserErrors::InternalServerError)?
+        .map(Encryptable::into_inner))
+    }
 }
 
 impl From<info::ModuleInfo> for user_role_api::ModuleInfo {
diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs
index b3252302413..9ea86167fcf 100644
--- a/crates/router_env/src/logger/types.rs
+++ b/crates/router_env/src/logger/types.rs
@@ -398,6 +398,8 @@ pub enum Flow {
     UserFromEmail,
     /// Begin TOTP
     TotpBegin,
+    /// Verify TOTP
+    TotpVerify,
     /// 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
<!-- Describe your changes in detail -->
This PR allows users to complete the TOTP flow.
It adds a new api `/user/totp/verify`, which will verify the totp entered by the user and mark the user totp status as set if it is in `InProgress`.

### 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).
-->
Closes #4596.

## 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)?
-->
```
curl --location 'http://localhost:8080/user/totp/verify' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer SPT with Purpose as TOTP' \
--data '{
    "totp": "189646"
}'
```
If the TOTP is correct, the the API should return the next token that is in the current flow.
```
{
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZjYwOWFiMDAtNzg5Ny00NTU2LWFlMGMtNGQ4MDZmZGFkZTkxIiwicHVycG9zZSI6ImZvcmNlX3NldF9wYXNzd29yZCIsIm9yaWdpbiI6InNpZ25fdXAiLCJleHAiOjE3MTUyNjQ0NTF9.jkhMT8x1o5WhzNxqBbBwX4jS0TQuSpemlg9_5K5Kgk8",
    "token_type": "force_set_password"
}
```


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