Bug: refactor: Separate out recovery codes from TOTP



Even though recovery codes is dependent entity on TOTP. But the new findings suggest that TOTP is a part of entity called 2FA and TOTP is depended on the 2FA entity not the TOTP entity.

This will be more clear when we add multiple 2FAs like SMS OTP.

So, we want to separate out recovery codes from the TOTP. This means `begin_totp` should not return the recovery codes anymore.
diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs
index e9eb5157095..b7d7adbf8e3 100644
--- a/crates/api_models/src/events/user.rs
+++ b/crates/api_models/src/events/user.rs
@@ -14,10 +14,10 @@ use crate::user::{
     ConnectAccountRequest, CreateInternalUserRequest, DashboardEntryResponse,
     ForgotPasswordRequest, GetUserDetailsResponse, GetUserRoleDetailsRequest,
     GetUserRoleDetailsResponse, InviteUserRequest, ListUsersResponse, ReInviteUserRequest,
-    ResetPasswordRequest, RotatePasswordRequest, SendVerifyEmailRequest, SignInResponse,
-    SignUpRequest, SignUpWithMerchantIdRequest, SwitchMerchantIdRequest, TokenOrPayloadResponse,
-    TokenResponse, UpdateUserAccountDetailsRequest, UserFromEmailRequest, UserMerchantCreate,
-    VerifyEmailRequest, VerifyTotpRequest,
+    RecoveryCodes, ResetPasswordRequest, RotatePasswordRequest, SendVerifyEmailRequest,
+    SignInResponse, SignUpRequest, SignUpWithMerchantIdRequest, SwitchMerchantIdRequest,
+    TokenOrPayloadResponse, TokenResponse, UpdateUserAccountDetailsRequest, UserFromEmailRequest,
+    UserMerchantCreate, VerifyEmailRequest, VerifyTotpRequest,
 };
 
 impl ApiEventMetric for DashboardEntryResponse {
@@ -75,7 +75,8 @@ common_utils::impl_misc_api_event_type!(
     TokenResponse,
     UserFromEmailRequest,
     BeginTotpResponse,
-    VerifyTotpRequest
+    VerifyTotpRequest,
+    RecoveryCodes
 );
 
 #[cfg(feature = "dummy_connector")]
diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs
index 7dbf867d1a0..7864117856e 100644
--- a/crates/api_models/src/user.rs
+++ b/crates/api_models/src/user.rs
@@ -257,3 +257,8 @@ pub struct TotpSecret {
 pub struct VerifyTotpRequest {
     pub totp: Option<Secret<String>>,
 }
+
+#[derive(Debug, serde::Deserialize, serde::Serialize)]
+pub struct RecoveryCodes {
+    pub recovery_codes: Vec<Secret<String>>,
+}
diff --git a/crates/router/src/consts/user.rs b/crates/router/src/consts/user.rs
index 8d6aa6265d8..33642205d57 100644
--- a/crates/router/src/consts/user.rs
+++ b/crates/router/src/consts/user.rs
@@ -12,3 +12,5 @@ pub const TOTP_VALIDITY_DURATION_IN_SECONDS: u64 = 30;
 pub const TOTP_TOLERANCE: u8 = 1;
 pub const MAX_PASSWORD_LENGTH: usize = 70;
 pub const MIN_PASSWORD_LENGTH: usize = 8;
+
+pub const TOTP_PREFIX: &str = "TOTP_";
diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs
index 580e34ba7e8..2d1c196f5df 100644
--- a/crates/router/src/core/errors/user.rs
+++ b/crates/router/src/core/errors/user.rs
@@ -66,10 +66,12 @@ pub enum UserErrors {
     RoleNameParsingError,
     #[error("RoleNameAlreadyExists")]
     RoleNameAlreadyExists,
-    #[error("TOTPNotSetup")]
+    #[error("TotpNotSetup")]
     TotpNotSetup,
-    #[error("InvalidTOTP")]
+    #[error("InvalidTotp")]
     InvalidTotp,
+    #[error("TotpRequired")]
+    TotpRequired,
 }
 
 impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors {
@@ -179,6 +181,9 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
             Self::InvalidTotp => {
                 AER::BadRequest(ApiError::new(sub_code, 37, self.get_error_message(), None))
             }
+            Self::TotpRequired => {
+                AER::BadRequest(ApiError::new(sub_code, 38, self.get_error_message(), None))
+            }
         }
     }
 }
@@ -217,6 +222,7 @@ impl UserErrors {
             Self::RoleNameAlreadyExists => "Role name already exists",
             Self::TotpNotSetup => "TOTP not setup",
             Self::InvalidTotp => "Invalid TOTP",
+            Self::TotpRequired => "TOTP required",
         }
     }
 }
diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs
index 5d1eaeaeb8d..7a0ef683e7a 100644
--- a/crates/router/src/core/user.rs
+++ b/crates/router/src/core/user.rs
@@ -1631,7 +1631,7 @@ pub async fn begin_totp(
         }));
     }
 
-    let totp = utils::user::generate_default_totp(user_from_db.get_email(), None)?;
+    let totp = utils::user::two_factor_auth::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,8 +1693,10 @@ pub async fn verify_totp(
             .await?
             .ok_or(UserErrors::InternalServerError)?;
 
-        let totp =
-            utils::user::generate_default_totp(user_from_db.get_email(), Some(user_totp_secret))?;
+        let totp = utils::user::two_factor_auth::generate_default_totp(
+            user_from_db.get_email(),
+            Some(user_totp_secret),
+        )?;
 
         if totp
             .generate_current()
@@ -1732,3 +1734,35 @@ pub async fn verify_totp(
         token,
     )
 }
+
+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? {
+        return Err(UserErrors::TotpRequired.into());
+    }
+
+    let recovery_codes = domain::RecoveryCodes::generate_new();
+
+    state
+        .store
+        .update_user_by_user_id(
+            &user_token.user_id,
+            storage_user::UserUpdate::TotpUpdate {
+                totp_status: None,
+                totp_secret: None,
+                totp_recovery_codes: Some(
+                    recovery_codes
+                        .get_hashed()
+                        .change_context(UserErrors::InternalServerError)?,
+                ),
+            },
+        )
+        .await
+        .change_context(UserErrors::InternalServerError)?;
+
+    Ok(ApplicationResponse::Json(user_api::RecoveryCodes {
+        recovery_codes: recovery_codes.into_inner(),
+    }))
+}
diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs
index f4eded24be6..7c8f7e32be1 100644
--- a/crates/router/src/routes/app.rs
+++ b/crates/router/src/routes/app.rs
@@ -1207,7 +1207,11 @@ impl User {
                     .route(web::post().to(set_dashboard_metadata)),
             )
             .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("/totp/verify").route(web::post().to(totp_verify)))
+            .service(
+                web::resource("/recovery_codes/generate")
+                    .route(web::get().to(generate_recovery_codes)),
+            );
 
         #[cfg(feature = "email")]
         {
diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs
index 30b582079e3..542a18d45be 100644
--- a/crates/router/src/routes/lock_utils.rs
+++ b/crates/router/src/routes/lock_utils.rs
@@ -213,7 +213,8 @@ impl From<Flow> for ApiIdentifier {
             | Flow::VerifyEmailRequest
             | Flow::UpdateUserAccountDetails
             | Flow::TotpBegin
-            | Flow::TotpVerify => Self::User,
+            | Flow::TotpVerify
+            | Flow::GenerateRecoveryCodes => Self::User,
 
             Flow::ListRoles
             | Flow::GetRole
diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs
index f28064901e5..f542c446e49 100644
--- a/crates/router/src/routes/user.rs
+++ b/crates/router/src/routes/user.rs
@@ -665,3 +665,17 @@ pub async fn totp_verify(
     ))
     .await
 }
+
+pub async fn generate_recovery_codes(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
+    let flow = Flow::GenerateRecoveryCodes;
+    Box::pin(api::server_wrap(
+        flow,
+        state.clone(),
+        &req,
+        (),
+        |state, user, _, _| user_core::generate_recovery_codes(state, user),
+        &auth::SinglePurposeJWTAuth(TokenPurpose::TOTP),
+        api_locking::LockAction::NotApplicable,
+    ))
+    .await
+}
diff --git a/crates/router/src/utils/user.rs b/crates/router/src/utils/user.rs
index 4980958c9ac..9c67dbfcab5 100644
--- a/crates/router/src/utils/user.rs
+++ b/crates/router/src/utils/user.rs
@@ -1,14 +1,11 @@
 use std::collections::HashMap;
 
 use api_models::user as user_api;
-use common_utils::{errors::CustomResult, pii};
+use common_utils::errors::CustomResult;
 use diesel_models::{enums::UserStatus, user_role::UserRole};
 use error_stack::ResultExt;
-use masking::ExposeInterface;
-use totp_rs::{Algorithm, TOTP};
 
 use crate::{
-    consts,
     core::errors::{StorageError, UserErrors, UserResult},
     routes::AppState,
     services::{
@@ -22,6 +19,7 @@ pub mod dashboard_metadata;
 pub mod password;
 #[cfg(feature = "dummy_connector")]
 pub mod sample_data;
+pub mod two_factor_auth;
 
 impl UserFromToken {
     pub async fn get_merchant_account_from_db(
@@ -193,25 +191,3 @@ pub fn get_token_from_signin_response(resp: &user_api::SignInResponse) -> maskin
         user_api::SignInResponse::MerchantSelect(data) => data.token.clone(),
     }
 }
-
-pub fn generate_default_totp(
-    email: pii::Email,
-    secret: Option<masking::Secret<String>>,
-) -> UserResult<TOTP> {
-    let secret = secret
-        .map(|sec| totp_rs::Secret::Encoded(sec.expose()))
-        .unwrap_or_else(totp_rs::Secret::generate_secret)
-        .to_bytes()
-        .change_context(UserErrors::InternalServerError)?;
-
-    TOTP::new(
-        Algorithm::SHA1,
-        consts::user::TOTP_DIGITS,
-        consts::user::TOTP_TOLERANCE,
-        consts::user::TOTP_VALIDITY_DURATION_IN_SECONDS,
-        secret,
-        Some(consts::user::TOTP_ISSUER_NAME.to_string()),
-        email.expose().expose(),
-    )
-    .change_context(UserErrors::InternalServerError)
-}
diff --git a/crates/router/src/utils/user/two_factor_auth.rs b/crates/router/src/utils/user/two_factor_auth.rs
new file mode 100644
index 00000000000..62bcf2f7eb1
--- /dev/null
+++ b/crates/router/src/utils/user/two_factor_auth.rs
@@ -0,0 +1,52 @@
+use std::sync::Arc;
+
+use common_utils::pii;
+use error_stack::ResultExt;
+use masking::ExposeInterface;
+use redis_interface::RedisConnectionPool;
+use totp_rs::{Algorithm, TOTP};
+
+use crate::{
+    consts,
+    core::errors::{UserErrors, UserResult},
+    routes::AppState,
+};
+
+pub fn generate_default_totp(
+    email: pii::Email,
+    secret: Option<masking::Secret<String>>,
+) -> UserResult<TOTP> {
+    let secret = secret
+        .map(|sec| totp_rs::Secret::Encoded(sec.expose()))
+        .unwrap_or_else(totp_rs::Secret::generate_secret)
+        .to_bytes()
+        .change_context(UserErrors::InternalServerError)?;
+
+    TOTP::new(
+        Algorithm::SHA1,
+        consts::user::TOTP_DIGITS,
+        consts::user::TOTP_TOLERANCE,
+        consts::user::TOTP_VALIDITY_DURATION_IN_SECONDS,
+        secret,
+        Some(consts::user::TOTP_ISSUER_NAME.to_string()),
+        email.expose().expose(),
+    )
+    .change_context(UserErrors::InternalServerError)
+}
+
+pub async fn check_totp_in_redis(state: &AppState, user_id: &str) -> UserResult<bool> {
+    let redis_conn = get_redis_connection(state)?;
+    let key = format!("{}{}", consts::user::TOTP_PREFIX, user_id);
+    redis_conn
+        .exists::<()>(&key)
+        .await
+        .change_context(UserErrors::InternalServerError)
+}
+
+fn get_redis_connection(state: &AppState) -> UserResult<Arc<RedisConnectionPool>> {
+    state
+        .store
+        .get_redis_conn()
+        .change_context(UserErrors::InternalServerError)
+        .attach_printable("Failed to get redis connection")
+}
diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs
index ffa492bc60d..186ee4a6704 100644
--- a/crates/router_env/src/logger/types.rs
+++ b/crates/router_env/src/logger/types.rs
@@ -404,6 +404,8 @@ pub enum Flow {
     TotpBegin,
     /// Verify TOTP
     TotpVerify,
+    /// Generate or Regenerate recovery codes
+    GenerateRecoveryCodes,
     /// 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 -->
Currently recovery code are being generated in `begin_totp` API. We want to make a separate out recovery codes and TOTP, so this PR creates a new API which generates the recovery codes.

This API will also be used to regenerate recovery codes later.

### 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).
-->
This is a sub part of #4707

## 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)?
-->
**This can only be tested locally as it requires some changes in the redis.**
```
curl --location 'http://localhost:8080/user/recovery_codes/generate' \
--header 'Authorization: Bearer SPT with purpose as TOTP' \
```
```
{
    "recovery_codes": [
        "w5fM-NLm0",
        "TumH-oyXE",
        "wDIr-zEmu",
        "HZjm-e16M",
        "Q4qB-MupL",
        "5BtN-vRuY",
        "4vG6-URGf",
        "Dbq8-n5V1"
    ]
}
```


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