fix(users): Add max wrong attempts for two-fa (#6247)

This commit is contained in:
Riddhiagrawal001
2024-10-17 12:56:52 +05:30
committed by GitHub
parent 881f5fd014
commit 2798f57560
8 changed files with 224 additions and 5 deletions

View File

@ -18,8 +18,9 @@ use crate::user::{
ResetPasswordRequest, RotatePasswordRequest, SendVerifyEmailRequest, SignUpRequest, ResetPasswordRequest, RotatePasswordRequest, SendVerifyEmailRequest, SignUpRequest,
SignUpWithMerchantIdRequest, SsoSignInRequest, SwitchMerchantRequest, SignUpWithMerchantIdRequest, SsoSignInRequest, SwitchMerchantRequest,
SwitchOrganizationRequest, SwitchProfileRequest, TokenResponse, TwoFactorAuthStatusResponse, SwitchOrganizationRequest, SwitchProfileRequest, TokenResponse, TwoFactorAuthStatusResponse,
UpdateUserAccountDetailsRequest, UpdateUserAuthenticationMethodRequest, UserFromEmailRequest, TwoFactorStatus, UpdateUserAccountDetailsRequest, UpdateUserAuthenticationMethodRequest,
UserMerchantCreate, VerifyEmailRequest, VerifyRecoveryCodeRequest, VerifyTotpRequest, UserFromEmailRequest, UserMerchantCreate, VerifyEmailRequest, VerifyRecoveryCodeRequest,
VerifyTotpRequest,
}; };
#[cfg(feature = "recon")] #[cfg(feature = "recon")]
@ -62,6 +63,7 @@ common_utils::impl_api_event_type!(
GetUserRoleDetailsResponseV2, GetUserRoleDetailsResponseV2,
TokenResponse, TokenResponse,
TwoFactorAuthStatusResponse, TwoFactorAuthStatusResponse,
TwoFactorStatus,
UserFromEmailRequest, UserFromEmailRequest,
BeginTotpResponse, BeginTotpResponse,
VerifyRecoveryCodeRequest, VerifyRecoveryCodeRequest,

View File

@ -196,6 +196,23 @@ pub struct TwoFactorAuthStatusResponse {
pub recovery_code: bool, pub recovery_code: bool,
} }
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct TwoFactorAuthAttempts {
pub is_completed: bool,
pub remaining_attempts: u8,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct TwoFactorAuthStatusResponseWithAttempts {
pub totp: TwoFactorAuthAttempts,
pub recovery_code: TwoFactorAuthAttempts,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct TwoFactorStatus {
pub status: Option<TwoFactorAuthStatusResponseWithAttempts>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)] #[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct UserFromEmailRequest { pub struct UserFromEmailRequest {
pub token: Secret<String>, pub token: Secret<String>,

View File

@ -15,6 +15,10 @@ pub const TOTP_DIGITS: usize = 6;
pub const TOTP_VALIDITY_DURATION_IN_SECONDS: u64 = 30; pub const TOTP_VALIDITY_DURATION_IN_SECONDS: u64 = 30;
/// Number of totps allowed as network delay. 1 would mean one totp before current totp and one totp after are valids. /// Number of totps allowed as network delay. 1 would mean one totp before current totp and one totp after are valids.
pub const TOTP_TOLERANCE: u8 = 1; pub const TOTP_TOLERANCE: u8 = 1;
/// Number of maximum attempts user has for totp
pub const TOTP_MAX_ATTEMPTS: u8 = 4;
/// Number of maximum attempts user has for recovery code
pub const RECOVERY_CODE_MAX_ATTEMPTS: u8 = 4;
pub const MAX_PASSWORD_LENGTH: usize = 70; pub const MAX_PASSWORD_LENGTH: usize = 70;
pub const MIN_PASSWORD_LENGTH: usize = 8; pub const MIN_PASSWORD_LENGTH: usize = 8;
@ -23,6 +27,10 @@ pub const REDIS_TOTP_PREFIX: &str = "TOTP_";
pub const REDIS_RECOVERY_CODE_PREFIX: &str = "RC_"; pub const REDIS_RECOVERY_CODE_PREFIX: &str = "RC_";
pub const REDIS_TOTP_SECRET_PREFIX: &str = "TOTP_SEC_"; pub const REDIS_TOTP_SECRET_PREFIX: &str = "TOTP_SEC_";
pub const REDIS_TOTP_SECRET_TTL_IN_SECS: i64 = 15 * 60; // 15 minutes pub const REDIS_TOTP_SECRET_TTL_IN_SECS: i64 = 15 * 60; // 15 minutes
pub const REDIS_TOTP_ATTEMPTS_PREFIX: &str = "TOTP_ATTEMPTS_";
pub const REDIS_RECOVERY_CODE_ATTEMPTS_PREFIX: &str = "RC_ATTEMPTS_";
pub const REDIS_TOTP_ATTEMPTS_TTL_IN_SECS: i64 = 5 * 60; // 5 mins
pub const REDIS_RECOVERY_CODE_ATTEMPTS_TTL_IN_SECS: i64 = 10 * 60; // 10 mins
pub const REDIS_SSO_PREFIX: &str = "SSO_"; pub const REDIS_SSO_PREFIX: &str = "SSO_";
pub const REDIS_SSO_TTL: i64 = 5 * 60; // 5 minutes pub const REDIS_SSO_TTL: i64 = 5 * 60; // 5 minutes

View File

@ -90,6 +90,10 @@ pub enum UserErrors {
SSOFailed, SSOFailed,
#[error("profile_id missing in JWT")] #[error("profile_id missing in JWT")]
JwtProfileIdMissing, JwtProfileIdMissing,
#[error("Maximum attempts reached for TOTP")]
MaxTotpAttemptsReached,
#[error("Maximum attempts reached for Recovery Code")]
MaxRecoveryCodeAttemptsReached,
} }
impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors { impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors {
@ -229,6 +233,12 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
Self::JwtProfileIdMissing => { Self::JwtProfileIdMissing => {
AER::Unauthorized(ApiError::new(sub_code, 47, self.get_error_message(), None)) AER::Unauthorized(ApiError::new(sub_code, 47, self.get_error_message(), None))
} }
Self::MaxTotpAttemptsReached => {
AER::BadRequest(ApiError::new(sub_code, 48, self.get_error_message(), None))
}
Self::MaxRecoveryCodeAttemptsReached => {
AER::BadRequest(ApiError::new(sub_code, 49, self.get_error_message(), None))
}
} }
} }
} }
@ -269,6 +279,8 @@ impl UserErrors {
Self::InvalidTotp => "Invalid TOTP", Self::InvalidTotp => "Invalid TOTP",
Self::TotpRequired => "TOTP required", Self::TotpRequired => "TOTP required",
Self::InvalidRecoveryCode => "Invalid Recovery Code", Self::InvalidRecoveryCode => "Invalid Recovery Code",
Self::MaxTotpAttemptsReached => "Maximum attempts reached for TOTP",
Self::MaxRecoveryCodeAttemptsReached => "Maximum attempts reached for Recovery Code",
Self::TwoFactorAuthRequired => "Two factor auth required", Self::TwoFactorAuthRequired => "Two factor auth required",
Self::TwoFactorAuthNotSetup => "Two factor auth not setup", Self::TwoFactorAuthNotSetup => "Two factor auth not setup",
Self::TotpSecretNotFound => "TOTP secret not found", Self::TotpSecretNotFound => "TOTP secret not found",

View File

@ -1686,6 +1686,13 @@ pub async fn verify_totp(
return Err(UserErrors::TotpNotSetup.into()); return Err(UserErrors::TotpNotSetup.into());
} }
let user_totp_attempts =
tfa_utils::get_totp_attempts_from_redis(&state, &user_token.user_id).await?;
if user_totp_attempts >= consts::user::TOTP_MAX_ATTEMPTS {
return Err(UserErrors::MaxTotpAttemptsReached.into());
}
let user_totp_secret = user_from_db let user_totp_secret = user_from_db
.decrypt_and_get_totp_secret(&state) .decrypt_and_get_totp_secret(&state)
.await? .await?
@ -1702,6 +1709,13 @@ pub async fn verify_totp(
.change_context(UserErrors::InternalServerError)? .change_context(UserErrors::InternalServerError)?
!= req.totp.expose() != req.totp.expose()
{ {
let _ = tfa_utils::insert_totp_attempts_in_redis(
&state,
&user_token.user_id,
user_totp_attempts + 1,
)
.await
.inspect_err(|error| logger::error!(?error));
return Err(UserErrors::InvalidTotp.into()); return Err(UserErrors::InvalidTotp.into());
} }
@ -1856,15 +1870,31 @@ pub async fn verify_recovery_code(
return Err(UserErrors::TwoFactorAuthNotSetup.into()); return Err(UserErrors::TwoFactorAuthNotSetup.into());
} }
let user_recovery_code_attempts =
tfa_utils::get_recovery_code_attempts_from_redis(&state, &user_token.user_id).await?;
if user_recovery_code_attempts >= consts::user::RECOVERY_CODE_MAX_ATTEMPTS {
return Err(UserErrors::MaxRecoveryCodeAttemptsReached.into());
}
let mut recovery_codes = user_from_db let mut recovery_codes = user_from_db
.get_recovery_codes() .get_recovery_codes()
.ok_or(UserErrors::InternalServerError)?; .ok_or(UserErrors::InternalServerError)?;
let matching_index = utils::user::password::get_index_for_correct_recovery_code( let Some(matching_index) = utils::user::password::get_index_for_correct_recovery_code(
&req.recovery_code, &req.recovery_code,
&recovery_codes, &recovery_codes,
)? )?
.ok_or(UserErrors::InvalidRecoveryCode)?; else {
let _ = tfa_utils::insert_recovery_code_attempts_in_redis(
&state,
&user_token.user_id,
user_recovery_code_attempts + 1,
)
.await
.inspect_err(|error| logger::error!(?error));
return Err(UserErrors::InvalidRecoveryCode.into());
};
tfa_utils::insert_recovery_code_in_redis(&state, user_from_db.get_user_id()).await?; tfa_utils::insert_recovery_code_in_redis(&state, user_from_db.get_user_id()).await?;
let _ = recovery_codes.remove(matching_index); let _ = recovery_codes.remove(matching_index);
@ -1924,10 +1954,17 @@ pub async fn terminate_two_factor_auth(
} }
} }
let current_flow = domain::CurrentFlow::new(user_token, domain::SPTFlow::TOTP.into())?; let current_flow = domain::CurrentFlow::new(user_token.clone(), domain::SPTFlow::TOTP.into())?;
let next_flow = current_flow.next(user_from_db, &state).await?; let next_flow = current_flow.next(user_from_db, &state).await?;
let token = next_flow.get_token(&state).await?; let token = next_flow.get_token(&state).await?;
let _ = tfa_utils::delete_totp_attempts_from_redis(&state, &user_token.user_id)
.await
.inspect_err(|error| logger::error!(?error));
let _ = tfa_utils::delete_recovery_code_attempts_from_redis(&state, &user_token.user_id)
.await
.inspect_err(|error| logger::error!(?error));
auth::cookies::set_cookie_response( auth::cookies::set_cookie_response(
user_api::TokenResponse { user_api::TokenResponse {
token: token.clone(), token: token.clone(),
@ -1950,6 +1987,40 @@ pub async fn check_two_factor_auth_status(
)) ))
} }
pub async fn check_two_factor_auth_status_with_attempts(
state: SessionState,
user_token: auth::UserIdFromAuth,
) -> UserResponse<user_api::TwoFactorStatus> {
let user_from_db: domain::UserFromStorage = state
.global_store
.find_user_by_id(&user_token.user_id)
.await
.change_context(UserErrors::InternalServerError)?
.into();
if user_from_db.get_totp_status() == TotpStatus::NotSet {
return Ok(ApplicationResponse::Json(user_api::TwoFactorStatus {
status: None,
}));
};
let totp = user_api::TwoFactorAuthAttempts {
is_completed: tfa_utils::check_totp_in_redis(&state, &user_token.user_id).await?,
remaining_attempts: consts::user::TOTP_MAX_ATTEMPTS
- tfa_utils::get_totp_attempts_from_redis(&state, &user_token.user_id).await?,
};
let recovery_code = user_api::TwoFactorAuthAttempts {
is_completed: tfa_utils::check_recovery_code_in_redis(&state, &user_token.user_id).await?,
remaining_attempts: consts::user::RECOVERY_CODE_MAX_ATTEMPTS
- tfa_utils::get_recovery_code_attempts_from_redis(&state, &user_token.user_id).await?,
};
Ok(ApplicationResponse::Json(user_api::TwoFactorStatus {
status: Some(user_api::TwoFactorAuthStatusResponseWithAttempts {
totp,
recovery_code,
}),
}))
}
pub async fn create_user_authentication_method( pub async fn create_user_authentication_method(
state: SessionState, state: SessionState,
req: user_api::CreateUserAuthenticationMethodRequest, req: user_api::CreateUserAuthenticationMethodRequest,

View File

@ -1853,7 +1853,12 @@ impl User {
// Two factor auth routes // Two factor auth routes
route = route.service( route = route.service(
web::scope("/2fa") web::scope("/2fa")
// TODO: to be deprecated
.service(web::resource("").route(web::get().to(user::check_two_factor_auth_status))) .service(web::resource("").route(web::get().to(user::check_two_factor_auth_status)))
.service(
web::resource("/v2")
.route(web::get().to(user::check_two_factor_auth_status_with_attempts)),
)
.service( .service(
web::scope("/totp") web::scope("/totp")
.service(web::resource("/begin").route(web::get().to(user::totp_begin))) .service(web::resource("/begin").route(web::get().to(user::totp_begin)))

View File

@ -682,6 +682,23 @@ pub async fn check_two_factor_auth_status(
.await .await
} }
pub async fn check_two_factor_auth_status_with_attempts(
state: web::Data<AppState>,
req: HttpRequest,
) -> HttpResponse {
let flow = Flow::TwoFactorAuthStatus;
Box::pin(api::server_wrap(
flow,
state.clone(),
&req,
(),
|state, user, _, _| user_core::check_two_factor_auth_status_with_attempts(state, user),
&auth::SinglePurposeOrLoginTokenAuth(TokenPurpose::TOTP),
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn get_sso_auth_url( pub async fn get_sso_auth_url(
state: web::Data<AppState>, state: web::Data<AppState>,
req: HttpRequest, req: HttpRequest,

View File

@ -139,3 +139,90 @@ pub async fn delete_recovery_code_from_redis(
.change_context(UserErrors::InternalServerError) .change_context(UserErrors::InternalServerError)
.map(|_| ()) .map(|_| ())
} }
fn get_totp_attempts_key(user_id: &str) -> String {
format!("{}{}", consts::user::REDIS_TOTP_ATTEMPTS_PREFIX, user_id)
}
fn get_recovery_code_attempts_key(user_id: &str) -> String {
format!(
"{}{}",
consts::user::REDIS_RECOVERY_CODE_ATTEMPTS_PREFIX,
user_id
)
}
pub async fn insert_totp_attempts_in_redis(
state: &SessionState,
user_id: &str,
user_totp_attempts: u8,
) -> UserResult<()> {
let redis_conn = super::get_redis_connection(state)?;
redis_conn
.set_key_with_expiry(
&get_totp_attempts_key(user_id),
user_totp_attempts,
consts::user::REDIS_TOTP_ATTEMPTS_TTL_IN_SECS,
)
.await
.change_context(UserErrors::InternalServerError)
}
pub async fn get_totp_attempts_from_redis(state: &SessionState, user_id: &str) -> UserResult<u8> {
let redis_conn = super::get_redis_connection(state)?;
redis_conn
.get_key::<Option<u8>>(&get_totp_attempts_key(user_id))
.await
.change_context(UserErrors::InternalServerError)
.map(|v| v.unwrap_or(0))
}
pub async fn insert_recovery_code_attempts_in_redis(
state: &SessionState,
user_id: &str,
user_recovery_code_attempts: u8,
) -> UserResult<()> {
let redis_conn = super::get_redis_connection(state)?;
redis_conn
.set_key_with_expiry(
&get_recovery_code_attempts_key(user_id),
user_recovery_code_attempts,
consts::user::REDIS_RECOVERY_CODE_ATTEMPTS_TTL_IN_SECS,
)
.await
.change_context(UserErrors::InternalServerError)
}
pub async fn get_recovery_code_attempts_from_redis(
state: &SessionState,
user_id: &str,
) -> UserResult<u8> {
let redis_conn = super::get_redis_connection(state)?;
redis_conn
.get_key::<Option<u8>>(&get_recovery_code_attempts_key(user_id))
.await
.change_context(UserErrors::InternalServerError)
.map(|v| v.unwrap_or(0))
}
pub async fn delete_totp_attempts_from_redis(
state: &SessionState,
user_id: &str,
) -> UserResult<()> {
let redis_conn = super::get_redis_connection(state)?;
redis_conn
.delete_key(&get_totp_attempts_key(user_id))
.await
.change_context(UserErrors::InternalServerError)
.map(|_| ())
}
pub async fn delete_recovery_code_attempts_from_redis(
state: &SessionState,
user_id: &str,
) -> UserResult<()> {
let redis_conn = super::get_redis_connection(state)?;
redis_conn
.delete_key(&get_recovery_code_attempts_key(user_id))
.await
.change_context(UserErrors::InternalServerError)
.map(|_| ())
}