diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs index e9eb515709..b7d7adbf8e 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 7dbf867d1a..7864117856 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>, } + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct RecoveryCodes { + pub recovery_codes: Vec>, +} diff --git a/crates/router/src/consts/user.rs b/crates/router/src/consts/user.rs index 8d6aa6265d..33642205d5 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 580e34ba7e..2d1c196f5d 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 for UserErrors { @@ -179,6 +181,9 @@ impl common_utils::errors::ErrorSwitch { 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 5d1eaeaeb8..7a0ef683e7 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 { + 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 c00c0e17ff..0e152ec32c 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -1211,7 +1211,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 6a73643ce6..706726979d 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -214,7 +214,8 @@ impl From 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 f28064901e..f542c446e4 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, 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 4980958c9a..9c67dbfcab 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>, -) -> UserResult { - 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 0000000000..62bcf2f7eb --- /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>, +) -> UserResult { + 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 { + 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> { + 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 07a45f5cdb..0e35aba317 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -406,6 +406,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