diff --git a/config/config.example.toml b/config/config.example.toml index 5d575b5ba095..d5bdfb5a6a7c 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -351,7 +351,8 @@ email_role_arn = "" # The amazon resource name ( arn ) of the role which sts_role_session_name = "" # An identifier for the assumed role session, used to uniquely identify a session. [user] -password_validity_in_days = 90 # Number of days after which password should be updated +password_validity_in_days = 90 # Number of days after which password should be updated +two_factor_auth_expiry_in_secs = 300 # Number of seconds after which 2FA should be done again if doing update/change from inside #tokenization configuration which describe token lifetime and payment method for specific connector [tokenization] diff --git a/config/deployments/integration_test.toml b/config/deployments/integration_test.toml index 5b6c5fe152de..9551cd2a80d8 100644 --- a/config/deployments/integration_test.toml +++ b/config/deployments/integration_test.toml @@ -113,6 +113,7 @@ slack_invite_url = "https://join.slack.com/t/hyperswitch-io/shared_invite/zt-2aw [user] password_validity_in_days = 90 +two_factor_auth_expiry_in_secs = 300 [frm] enabled = true diff --git a/config/deployments/production.toml b/config/deployments/production.toml index b5441f0059de..5f9408674383 100644 --- a/config/deployments/production.toml +++ b/config/deployments/production.toml @@ -120,6 +120,7 @@ slack_invite_url = "https://join.slack.com/t/hyperswitch-io/shared_invite/zt-2aw [user] password_validity_in_days = 90 +two_factor_auth_expiry_in_secs = 300 [frm] enabled = false diff --git a/config/deployments/sandbox.toml b/config/deployments/sandbox.toml index e168fab121d2..d5f63524e6bd 100644 --- a/config/deployments/sandbox.toml +++ b/config/deployments/sandbox.toml @@ -120,6 +120,7 @@ slack_invite_url = "https://join.slack.com/t/hyperswitch-io/shared_invite/zt-2aw [user] password_validity_in_days = 90 +two_factor_auth_expiry_in_secs = 300 [frm] enabled = true diff --git a/config/development.toml b/config/development.toml index af26d91446f8..a9f7f4d7b4d8 100644 --- a/config/development.toml +++ b/config/development.toml @@ -269,6 +269,7 @@ sts_role_session_name = "" [user] password_validity_in_days = 90 +two_factor_auth_expiry_in_secs = 300 [bank_config.eps] stripe = { banks = "arzte_und_apotheker_bank,austrian_anadi_bank_ag,bank_austria,bankhaus_carl_spangler,bankhaus_schelhammer_und_schattera_ag,bawag_psk_ag,bks_bank_ag,brull_kallmus_bank_ag,btv_vier_lander_bank,capital_bank_grawe_gruppe_ag,dolomitenbank,easybank_ag,erste_bank_und_sparkassen,hypo_alpeadriabank_international_ag,hypo_noe_lb_fur_niederosterreich_u_wien,hypo_oberosterreich_salzburg_steiermark,hypo_tirol_bank_ag,hypo_vorarlberg_bank_ag,hypo_bank_burgenland_aktiengesellschaft,marchfelder_bank,oberbank_ag,raiffeisen_bankengruppe_osterreich,schoellerbank_ag,sparda_bank_wien,volksbank_gruppe,volkskreditbank_ag,vr_bank_braunau" } diff --git a/config/docker_compose.toml b/config/docker_compose.toml index 0d615b11b5cf..6661d164032d 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -53,6 +53,7 @@ recon_admin_api_key = "recon_test_admin" [user] password_validity_in_days = 90 +two_factor_auth_expiry_in_secs = 300 [locker] host = "" diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs index b7d7adbf8e39..a472b3a76e68 100644 --- a/crates/api_models/src/events/user.rs +++ b/crates/api_models/src/events/user.rs @@ -17,7 +17,7 @@ use crate::user::{ RecoveryCodes, ResetPasswordRequest, RotatePasswordRequest, SendVerifyEmailRequest, SignInResponse, SignUpRequest, SignUpWithMerchantIdRequest, SwitchMerchantIdRequest, TokenOrPayloadResponse, TokenResponse, UpdateUserAccountDetailsRequest, UserFromEmailRequest, - UserMerchantCreate, VerifyEmailRequest, VerifyTotpRequest, + UserMerchantCreate, VerifyEmailRequest, VerifyRecoveryCodeRequest, VerifyTotpRequest, }; impl ApiEventMetric for DashboardEntryResponse { @@ -75,6 +75,7 @@ common_utils::impl_misc_api_event_type!( TokenResponse, UserFromEmailRequest, BeginTotpResponse, + VerifyRecoveryCodeRequest, VerifyTotpRequest, RecoveryCodes ); diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs index 6b2748ca3442..5423fc830a5c 100644 --- a/crates/api_models/src/user.rs +++ b/crates/api_models/src/user.rs @@ -263,6 +263,11 @@ pub struct VerifyTotpRequest { pub totp: Option>, } +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct VerifyRecoveryCodeRequest { + pub recovery_code: Secret, +} + #[derive(Debug, serde::Deserialize, serde::Serialize)] pub struct RecoveryCodes { pub recovery_codes: Vec>, diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index 9564a759ccd8..b8a315f944b8 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -395,6 +395,7 @@ pub struct Secrets { #[derive(Debug, Clone, Default, Deserialize)] pub struct UserSettings { pub password_validity_in_days: u16, + pub two_factor_auth_expiry_in_secs: i64, } #[derive(Debug, Deserialize, Clone)] diff --git a/crates/router/src/consts/user.rs b/crates/router/src/consts/user.rs index 7b61a834f603..2f347dc3d3eb 100644 --- a/crates/router/src/consts/user.rs +++ b/crates/router/src/consts/user.rs @@ -14,4 +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_"; +pub const REDIS_RECOVERY_CODE_PREFIX: &str = "RC_"; diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs index cda850787184..adcf8794b83d 100644 --- a/crates/router/src/core/errors/user.rs +++ b/crates/router/src/core/errors/user.rs @@ -72,6 +72,8 @@ pub enum UserErrors { InvalidTotp, #[error("TotpRequired")] TotpRequired, + #[error("InvalidRecoveryCode")] + InvalidRecoveryCode, #[error("TwoFactorAuthRequired")] TwoFactorAuthRequired, #[error("TwoFactorAuthNotSetup")] @@ -188,12 +190,15 @@ impl common_utils::errors::ErrorSwitch { AER::BadRequest(ApiError::new(sub_code, 38, self.get_error_message(), None)) } - Self::TwoFactorAuthRequired => { + Self::InvalidRecoveryCode => { AER::BadRequest(ApiError::new(sub_code, 39, self.get_error_message(), None)) } - Self::TwoFactorAuthNotSetup => { + Self::TwoFactorAuthRequired => { AER::BadRequest(ApiError::new(sub_code, 40, self.get_error_message(), None)) } + Self::TwoFactorAuthNotSetup => { + AER::BadRequest(ApiError::new(sub_code, 41, self.get_error_message(), None)) + } } } } @@ -233,6 +238,7 @@ impl UserErrors { Self::TotpNotSetup => "TOTP not setup", Self::InvalidTotp => "Invalid TOTP", Self::TotpRequired => "TOTP required", + Self::InvalidRecoveryCode => "Invalid Recovery Code", 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 705c12907ff5..4fd9fa865cd4 100644 --- a/crates/router/src/core/user.rs +++ b/crates/router/src/core/user.rs @@ -179,7 +179,7 @@ pub async fn signin( })? .into(); - user_from_db.compare_password(request.password)?; + user_from_db.compare_password(&request.password)?; let signin_strategy = if let Some(preferred_merchant_id) = user_from_db.get_preferred_merchant_id() { @@ -217,7 +217,7 @@ pub async fn signin_token_only_flow( .to_not_found_response(UserErrors::InvalidCredentials)? .into(); - user_from_db.compare_password(request.password)?; + user_from_db.compare_password(&request.password)?; let next_flow = domain::NextFlow::from_origin(domain::Origin::SignIn, user_from_db.clone(), &state).await?; @@ -341,7 +341,7 @@ pub async fn change_password( .change_context(UserErrors::InternalServerError)? .into(); - user.compare_password(request.old_password.to_owned()) + user.compare_password(&request.old_password) .change_context(UserErrors::InvalidOldPassword)?; if request.old_password == request.new_password { @@ -439,7 +439,7 @@ pub async fn rotate_password( let password = domain::UserPassword::new(request.password.to_owned())?; let hash_password = utils::user::password::generate_password_hash(password.get_secret())?; - if user.compare_password(request.password).is_ok() { + if user.compare_password(&request.password).is_ok() { return Err(UserErrors::ChangePasswordError.into()); } @@ -1766,6 +1766,51 @@ pub async fn generate_recovery_codes( })) } +pub async fn verify_recovery_code( + state: AppState, + user_token: auth::UserFromSinglePurposeToken, + req: user_api::VerifyRecoveryCodeRequest, +) -> UserResponse { + let user_from_db: domain::UserFromStorage = state + .store + .find_user_by_id(&user_token.user_id) + .await + .change_context(UserErrors::InternalServerError)? + .into(); + + if user_from_db.get_totp_status() != TotpStatus::Set { + return Err(UserErrors::TwoFactorAuthNotSetup.into()); + } + + let mut recovery_codes = user_from_db + .get_recovery_codes() + .ok_or(UserErrors::InternalServerError)?; + + let matching_index = utils::user::password::get_index_for_correct_recovery_code( + &req.recovery_code, + &recovery_codes, + )? + .ok_or(UserErrors::InvalidRecoveryCode)?; + + tfa_utils::insert_recovery_code_in_redis(&state, user_from_db.get_user_id()).await?; + let _ = recovery_codes.remove(matching_index); + + state + .store + .update_user_by_user_id( + user_from_db.get_user_id(), + storage_user::UserUpdate::TotpUpdate { + totp_status: None, + totp_secret: None, + totp_recovery_codes: Some(recovery_codes), + }, + ) + .await + .change_context(UserErrors::InternalServerError)?; + + Ok(ApplicationResponse::StatusOk) +} + pub async fn terminate_two_factor_auth( state: AppState, user_token: auth::UserFromSinglePurposeToken, diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index cf2e986c325f..bad5ab5a9268 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -1212,14 +1212,16 @@ impl User { ) .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("/recovery_codes/generate") - .route(web::get().to(generate_recovery_codes)), - ) .service( web::resource("/2fa/terminate").route(web::get().to(terminate_two_factor_auth)), ); + route = route.service( + web::scope("/recovery_code") + .service(web::resource("/verify").route(web::post().to(verify_recovery_code))) + .service(web::resource("/generate").route(web::post().to(generate_recovery_codes))), + ); + #[cfg(feature = "email")] { route = route diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index 97d92d49911d..75821bbd2ba2 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -215,9 +215,9 @@ impl From for ApiIdentifier { | Flow::UpdateUserAccountDetails | Flow::TotpBegin | Flow::TotpVerify - | Flow::TerminateTwoFactorAuth - | Flow::GenerateRecoveryCodes => Self::User, - + | Flow::RecoveryCodeVerify + | Flow::RecoveryCodesGenerate + | Flow::TerminateTwoFactorAuth => Self::User, Flow::ListRoles | Flow::GetRole | Flow::GetRoleFromToken diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs index 2019855114a3..6b8015ac3e58 100644 --- a/crates/router/src/routes/user.rs +++ b/crates/router/src/routes/user.rs @@ -666,8 +666,26 @@ pub async fn totp_verify( .await } +pub async fn verify_recovery_code( + state: web::Data, + req: HttpRequest, + json_payload: web::Json, +) -> HttpResponse { + let flow = Flow::RecoveryCodeVerify; + Box::pin(api::server_wrap( + flow, + state.clone(), + &req, + json_payload.into_inner(), + |state, user, req_body, _| user_core::verify_recovery_code(state, user, req_body), + &auth::SinglePurposeJWTAuth(TokenPurpose::TOTP), + api_locking::LockAction::NotApplicable, + )) + .await +} + pub async fn generate_recovery_codes(state: web::Data, req: HttpRequest) -> HttpResponse { - let flow = Flow::GenerateRecoveryCodes; + let flow = Flow::RecoveryCodesGenerate; Box::pin(api::server_wrap( flow, state.clone(), diff --git a/crates/router/src/types/domain/user.rs b/crates/router/src/types/domain/user.rs index 0704e51e7813..91323b0c16d3 100644 --- a/crates/router/src/types/domain/user.rs +++ b/crates/router/src/types/domain/user.rs @@ -774,8 +774,8 @@ impl UserFromStorage { self.0.user_id.as_str() } - pub fn compare_password(&self, candidate: Secret) -> UserResult<()> { - match password::is_correct_password(candidate, self.0.password.clone()) { + pub fn compare_password(&self, candidate: &Secret) -> UserResult<()> { + match password::is_correct_password(candidate, &self.0.password) { Ok(true) => Ok(()), Ok(false) => Err(UserErrors::InvalidCredentials.into()), Err(e) => Err(e), diff --git a/crates/router/src/utils/user/password.rs b/crates/router/src/utils/user/password.rs index beb87c325b62..e01181acb9d0 100644 --- a/crates/router/src/utils/user/password.rs +++ b/crates/router/src/utils/user/password.rs @@ -7,7 +7,7 @@ use argon2::{ }; use common_utils::errors::CustomResult; use error_stack::ResultExt; -use masking::{ExposeInterface, Secret}; +use masking::{ExposeInterface, PeekInterface, Secret}; use rand::{seq::SliceRandom, Rng}; use crate::core::errors::UserErrors; @@ -25,13 +25,13 @@ pub fn generate_password_hash( } pub fn is_correct_password( - candidate: Secret, - password: Secret, + candidate: &Secret, + password: &Secret, ) -> CustomResult { - let password = password.expose(); + let password = password.peek(); let parsed_hash = - PasswordHash::new(&password).change_context(UserErrors::InternalServerError)?; - let result = Argon2::default().verify_password(candidate.expose().as_bytes(), &parsed_hash); + PasswordHash::new(password).change_context(UserErrors::InternalServerError)?; + let result = Argon2::default().verify_password(candidate.peek().as_bytes(), &parsed_hash); match result { Ok(_) => Ok(true), Err(argon2Err::Password) => Ok(false), @@ -40,6 +40,19 @@ pub fn is_correct_password( .change_context(UserErrors::InternalServerError) } +pub fn get_index_for_correct_recovery_code( + candidate: &Secret, + recovery_codes: &[Secret], +) -> CustomResult, UserErrors> { + for (index, recovery_code) in recovery_codes.iter().enumerate() { + let is_match = is_correct_password(candidate, recovery_code)?; + if is_match { + return Ok(Some(index)); + } + } + Ok(None) +} + pub fn get_temp_password() -> Secret { let uuid_pass = uuid::Uuid::new_v4().to_string(); let mut rng = rand::thread_rng(); diff --git a/crates/router/src/utils/user/two_factor_auth.rs b/crates/router/src/utils/user/two_factor_auth.rs index 479c346b6e53..e9e3f66005cf 100644 --- a/crates/router/src/utils/user/two_factor_auth.rs +++ b/crates/router/src/utils/user/two_factor_auth.rs @@ -45,7 +45,7 @@ pub async fn check_totp_in_redis(state: &AppState, user_id: &str) -> UserResult< pub async fn check_recovery_code_in_redis(state: &AppState, user_id: &str) -> UserResult { let redis_conn = get_redis_connection(state)?; - let key = format!("{}{}", consts::user::REDIS_RECOVERY_CODES_PREFIX, user_id); + let key = format!("{}{}", consts::user::REDIS_RECOVERY_CODE_PREFIX, user_id); redis_conn .exists::<()>(&key) .await @@ -59,3 +59,16 @@ fn get_redis_connection(state: &AppState) -> UserResult .change_context(UserErrors::InternalServerError) .attach_printable("Failed to get redis connection") } + +pub async fn insert_recovery_code_in_redis(state: &AppState, user_id: &str) -> UserResult<()> { + let redis_conn = get_redis_connection(state)?; + let key = format!("{}{}", consts::user::REDIS_RECOVERY_CODE_PREFIX, user_id); + redis_conn + .set_key_with_expiry( + key.as_str(), + common_utils::date_time::now_unix_timestamp(), + state.conf.user.two_factor_auth_expiry_in_secs, + ) + .await + .change_context(UserErrors::InternalServerError) +} diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index be071ffefc37..35b55b6fb979 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -406,8 +406,10 @@ pub enum Flow { TotpBegin, /// Verify TOTP TotpVerify, + /// Verify Access Code + RecoveryCodeVerify, /// Generate or Regenerate recovery codes - GenerateRecoveryCodes, + RecoveryCodesGenerate, // Terminate two factor authentication TerminateTwoFactorAuth, /// List initial webhook delivery attempts diff --git a/loadtest/config/development.toml b/loadtest/config/development.toml index a104f5760b40..2c3e83ad2ba9 100644 --- a/loadtest/config/development.toml +++ b/loadtest/config/development.toml @@ -30,6 +30,7 @@ jwt_secret = "secret" [user] password_validity_in_days = 90 +two_factor_auth_expiry_in_secs = 300 [locker] host = ""