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:
Apoorv Dixit
2024-05-23 19:10:27 +05:30
committed by GitHub
parent 42e5ef1551
commit f04c6ac030
20 changed files with 140 additions and 27 deletions

View File

@ -352,6 +352,7 @@ sts_role_session_name = "" # An identifier for the assumed role session, used to
[user]
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]

View File

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

View File

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

View File

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

View File

@ -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" }

View File

@ -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 = ""

View File

@ -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
);

View File

@ -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>>,

View File

@ -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)]

View File

@ -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_";

View File

@ -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",
}

View File

@ -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,

View File

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

View File

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

View File

@ -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(),

View File

@ -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),

View File

@ -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();

View File

@ -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)
}

View File

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

View File

@ -30,6 +30,7 @@ jwt_secret = "secret"
[user]
password_validity_in_days = 90
two_factor_auth_expiry_in_secs = 300
[locker]
host = ""