feat(users): Create generate recovery codes API (#4708)

This commit is contained in:
Mani Chandra
2024-05-22 13:58:42 +05:30
committed by GitHub
parent ae601e8e1b
commit 8fa2cd556b
11 changed files with 135 additions and 38 deletions

View File

@ -14,10 +14,10 @@ use crate::user::{
ConnectAccountRequest, CreateInternalUserRequest, DashboardEntryResponse,
ForgotPasswordRequest, GetUserDetailsResponse, GetUserRoleDetailsRequest,
GetUserRoleDetailsResponse, InviteUserRequest, ListUsersResponse, ReInviteUserRequest,
ResetPasswordRequest, RotatePasswordRequest, SendVerifyEmailRequest, SignInResponse,
SignUpRequest, SignUpWithMerchantIdRequest, SwitchMerchantIdRequest, TokenOrPayloadResponse,
TokenResponse, UpdateUserAccountDetailsRequest, UserFromEmailRequest, UserMerchantCreate,
VerifyEmailRequest, VerifyTotpRequest,
RecoveryCodes, ResetPasswordRequest, RotatePasswordRequest, SendVerifyEmailRequest,
SignInResponse, SignUpRequest, SignUpWithMerchantIdRequest, SwitchMerchantIdRequest,
TokenOrPayloadResponse, TokenResponse, UpdateUserAccountDetailsRequest, UserFromEmailRequest,
UserMerchantCreate, VerifyEmailRequest, VerifyTotpRequest,
};
impl ApiEventMetric for DashboardEntryResponse {
@ -75,7 +75,8 @@ common_utils::impl_misc_api_event_type!(
TokenResponse,
UserFromEmailRequest,
BeginTotpResponse,
VerifyTotpRequest
VerifyTotpRequest,
RecoveryCodes
);
#[cfg(feature = "dummy_connector")]

View File

@ -257,3 +257,8 @@ pub struct TotpSecret {
pub struct VerifyTotpRequest {
pub totp: Option<Secret<String>>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct RecoveryCodes {
pub recovery_codes: Vec<Secret<String>>,
}

View File

@ -12,3 +12,5 @@ pub const TOTP_VALIDITY_DURATION_IN_SECONDS: u64 = 30;
pub const TOTP_TOLERANCE: u8 = 1;
pub const MAX_PASSWORD_LENGTH: usize = 70;
pub const MIN_PASSWORD_LENGTH: usize = 8;
pub const TOTP_PREFIX: &str = "TOTP_";

View File

@ -66,10 +66,12 @@ pub enum UserErrors {
RoleNameParsingError,
#[error("RoleNameAlreadyExists")]
RoleNameAlreadyExists,
#[error("TOTPNotSetup")]
#[error("TotpNotSetup")]
TotpNotSetup,
#[error("InvalidTOTP")]
#[error("InvalidTotp")]
InvalidTotp,
#[error("TotpRequired")]
TotpRequired,
}
impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors {
@ -179,6 +181,9 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
Self::InvalidTotp => {
AER::BadRequest(ApiError::new(sub_code, 37, self.get_error_message(), None))
}
Self::TotpRequired => {
AER::BadRequest(ApiError::new(sub_code, 38, self.get_error_message(), None))
}
}
}
}
@ -217,6 +222,7 @@ impl UserErrors {
Self::RoleNameAlreadyExists => "Role name already exists",
Self::TotpNotSetup => "TOTP not setup",
Self::InvalidTotp => "Invalid TOTP",
Self::TotpRequired => "TOTP required",
}
}
}

View File

@ -1631,7 +1631,7 @@ pub async fn begin_totp(
}));
}
let totp = utils::user::generate_default_totp(user_from_db.get_email(), None)?;
let totp = utils::user::two_factor_auth::generate_default_totp(user_from_db.get_email(), None)?;
let recovery_codes = domain::RecoveryCodes::generate_new();
let key_store = user_from_db.get_or_create_key_store(&state).await?;
@ -1693,8 +1693,10 @@ pub async fn verify_totp(
.await?
.ok_or(UserErrors::InternalServerError)?;
let totp =
utils::user::generate_default_totp(user_from_db.get_email(), Some(user_totp_secret))?;
let totp = utils::user::two_factor_auth::generate_default_totp(
user_from_db.get_email(),
Some(user_totp_secret),
)?;
if totp
.generate_current()
@ -1732,3 +1734,35 @@ pub async fn verify_totp(
token,
)
}
pub async fn generate_recovery_codes(
state: AppState,
user_token: auth::UserFromSinglePurposeToken,
) -> UserResponse<user_api::RecoveryCodes> {
if !utils::user::two_factor_auth::check_totp_in_redis(&state, &user_token.user_id).await? {
return Err(UserErrors::TotpRequired.into());
}
let recovery_codes = domain::RecoveryCodes::generate_new();
state
.store
.update_user_by_user_id(
&user_token.user_id,
storage_user::UserUpdate::TotpUpdate {
totp_status: None,
totp_secret: None,
totp_recovery_codes: Some(
recovery_codes
.get_hashed()
.change_context(UserErrors::InternalServerError)?,
),
},
)
.await
.change_context(UserErrors::InternalServerError)?;
Ok(ApplicationResponse::Json(user_api::RecoveryCodes {
recovery_codes: recovery_codes.into_inner(),
}))
}

View File

@ -1211,7 +1211,11 @@ impl User {
.route(web::post().to(set_dashboard_metadata)),
)
.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("/totp/verify").route(web::post().to(totp_verify)))
.service(
web::resource("/recovery_codes/generate")
.route(web::get().to(generate_recovery_codes)),
);
#[cfg(feature = "email")]
{

View File

@ -214,7 +214,8 @@ impl From<Flow> for ApiIdentifier {
| Flow::VerifyEmailRequest
| Flow::UpdateUserAccountDetails
| Flow::TotpBegin
| Flow::TotpVerify => Self::User,
| Flow::TotpVerify
| Flow::GenerateRecoveryCodes => Self::User,
Flow::ListRoles
| Flow::GetRole

View File

@ -665,3 +665,17 @@ pub async fn totp_verify(
))
.await
}
pub async fn generate_recovery_codes(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let flow = Flow::GenerateRecoveryCodes;
Box::pin(api::server_wrap(
flow,
state.clone(),
&req,
(),
|state, user, _, _| user_core::generate_recovery_codes(state, user),
&auth::SinglePurposeJWTAuth(TokenPurpose::TOTP),
api_locking::LockAction::NotApplicable,
))
.await
}

View File

@ -1,14 +1,11 @@
use std::collections::HashMap;
use api_models::user as user_api;
use common_utils::{errors::CustomResult, pii};
use common_utils::errors::CustomResult;
use diesel_models::{enums::UserStatus, user_role::UserRole};
use error_stack::ResultExt;
use masking::ExposeInterface;
use totp_rs::{Algorithm, TOTP};
use crate::{
consts,
core::errors::{StorageError, UserErrors, UserResult},
routes::AppState,
services::{
@ -22,6 +19,7 @@ pub mod dashboard_metadata;
pub mod password;
#[cfg(feature = "dummy_connector")]
pub mod sample_data;
pub mod two_factor_auth;
impl UserFromToken {
pub async fn get_merchant_account_from_db(
@ -193,25 +191,3 @@ pub fn get_token_from_signin_response(resp: &user_api::SignInResponse) -> maskin
user_api::SignInResponse::MerchantSelect(data) => data.token.clone(),
}
}
pub fn generate_default_totp(
email: pii::Email,
secret: Option<masking::Secret<String>>,
) -> UserResult<TOTP> {
let secret = secret
.map(|sec| totp_rs::Secret::Encoded(sec.expose()))
.unwrap_or_else(totp_rs::Secret::generate_secret)
.to_bytes()
.change_context(UserErrors::InternalServerError)?;
TOTP::new(
Algorithm::SHA1,
consts::user::TOTP_DIGITS,
consts::user::TOTP_TOLERANCE,
consts::user::TOTP_VALIDITY_DURATION_IN_SECONDS,
secret,
Some(consts::user::TOTP_ISSUER_NAME.to_string()),
email.expose().expose(),
)
.change_context(UserErrors::InternalServerError)
}

View File

@ -0,0 +1,52 @@
use std::sync::Arc;
use common_utils::pii;
use error_stack::ResultExt;
use masking::ExposeInterface;
use redis_interface::RedisConnectionPool;
use totp_rs::{Algorithm, TOTP};
use crate::{
consts,
core::errors::{UserErrors, UserResult},
routes::AppState,
};
pub fn generate_default_totp(
email: pii::Email,
secret: Option<masking::Secret<String>>,
) -> UserResult<TOTP> {
let secret = secret
.map(|sec| totp_rs::Secret::Encoded(sec.expose()))
.unwrap_or_else(totp_rs::Secret::generate_secret)
.to_bytes()
.change_context(UserErrors::InternalServerError)?;
TOTP::new(
Algorithm::SHA1,
consts::user::TOTP_DIGITS,
consts::user::TOTP_TOLERANCE,
consts::user::TOTP_VALIDITY_DURATION_IN_SECONDS,
secret,
Some(consts::user::TOTP_ISSUER_NAME.to_string()),
email.expose().expose(),
)
.change_context(UserErrors::InternalServerError)
}
pub async fn check_totp_in_redis(state: &AppState, user_id: &str) -> UserResult<bool> {
let redis_conn = get_redis_connection(state)?;
let key = format!("{}{}", consts::user::TOTP_PREFIX, user_id);
redis_conn
.exists::<()>(&key)
.await
.change_context(UserErrors::InternalServerError)
}
fn get_redis_connection(state: &AppState) -> UserResult<Arc<RedisConnectionPool>> {
state
.store
.get_redis_conn()
.change_context(UserErrors::InternalServerError)
.attach_printable("Failed to get redis connection")
}

View File

@ -406,6 +406,8 @@ pub enum Flow {
TotpBegin,
/// Verify TOTP
TotpVerify,
/// Generate or Regenerate recovery codes
GenerateRecoveryCodes,
/// List initial webhook delivery attempts
WebhookEventInitialDeliveryAttemptList,
/// List delivery attempts for a webhook event