mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-10-29 00:49:42 +08:00
feat(users): add support to verify 2FA using recovery code (#4737)
Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
@ -263,6 +263,11 @@ pub struct VerifyTotpRequest {
|
||||
pub totp: Option<Secret<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct VerifyRecoveryCodeRequest {
|
||||
pub recovery_code: Secret<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct RecoveryCodes {
|
||||
pub recovery_codes: Vec<Secret<String>>,
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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_";
|
||||
|
||||
@ -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<api_models::errors::types::ApiErrorRespon
|
||||
Self::TotpRequired => {
|
||||
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",
|
||||
}
|
||||
|
||||
@ -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<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 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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -215,9 +215,9 @@ impl From<Flow> 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
|
||||
|
||||
@ -666,8 +666,26 @@ pub async fn totp_verify(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn verify_recovery_code(
|
||||
state: web::Data<AppState>,
|
||||
req: HttpRequest,
|
||||
json_payload: web::Json<user_api::VerifyRecoveryCodeRequest>,
|
||||
) -> 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<AppState>, req: HttpRequest) -> HttpResponse {
|
||||
let flow = Flow::GenerateRecoveryCodes;
|
||||
let flow = Flow::RecoveryCodesGenerate;
|
||||
Box::pin(api::server_wrap(
|
||||
flow,
|
||||
state.clone(),
|
||||
|
||||
@ -774,8 +774,8 @@ impl UserFromStorage {
|
||||
self.0.user_id.as_str()
|
||||
}
|
||||
|
||||
pub fn compare_password(&self, candidate: Secret<String>) -> UserResult<()> {
|
||||
match password::is_correct_password(candidate, self.0.password.clone()) {
|
||||
pub fn compare_password(&self, candidate: &Secret<String>) -> UserResult<()> {
|
||||
match password::is_correct_password(candidate, &self.0.password) {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => Err(UserErrors::InvalidCredentials.into()),
|
||||
Err(e) => Err(e),
|
||||
|
||||
@ -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<String>,
|
||||
password: Secret<String>,
|
||||
candidate: &Secret<String>,
|
||||
password: &Secret<String>,
|
||||
) -> CustomResult<bool, UserErrors> {
|
||||
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<String>,
|
||||
recovery_codes: &[Secret<String>],
|
||||
) -> CustomResult<Option<usize>, 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<String> {
|
||||
let uuid_pass = uuid::Uuid::new_v4().to_string();
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
@ -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<bool> {
|
||||
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<Arc<RedisConnectionPool>
|
||||
.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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -30,6 +30,7 @@ jwt_secret = "secret"
|
||||
|
||||
[user]
|
||||
password_validity_in_days = 90
|
||||
two_factor_auth_expiry_in_secs = 300
|
||||
|
||||
[locker]
|
||||
host = ""
|
||||
|
||||
Reference in New Issue
Block a user