diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs index 1d91a47bf56f..e9eb51570952 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 0dde73d0545a..7dbf867d1a0b 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, pub recovery_codes: Vec>, } + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct VerifyTotpRequest { + pub totp: Option>, +} diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs index ddcd10c32e40..580e34ba7e81 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 for UserErrors { @@ -169,6 +173,12 @@ impl common_utils::errors::ErrorSwitch { 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 e01ed4b1a236..83cdd1d318bc 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 { + 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 1560578f66f1..49a8e0631831 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 5bef68073f0d..ee42cc50fe35 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -211,7 +211,8 @@ impl From 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 db12729d01a0..a901988e51e1 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, req: HttpRequest) -> HttpRes )) .await } + +pub async fn totp_verify( + state: web::Data, + req: HttpRequest, + json_payload: web::Json, +) -> 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 00881626c1c6..45f5d74d6f60 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>> { + 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::( + self.0.totp_secret.clone(), + user_key_store.key.peek(), + ) + .await + .change_context(UserErrors::InternalServerError)? + .map(Encryptable::into_inner)) + } } impl From for user_role_api::ModuleInfo { diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index b3252302413b..9ea86167fcf9 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