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

View File

@ -257,3 +257,8 @@ pub struct TotpSecret {
pub struct VerifyTotpRequest { pub struct VerifyTotpRequest {
pub totp: Option<Secret<String>>, 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 TOTP_TOLERANCE: u8 = 1;
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;
pub const TOTP_PREFIX: &str = "TOTP_";

View File

@ -66,10 +66,12 @@ pub enum UserErrors {
RoleNameParsingError, RoleNameParsingError,
#[error("RoleNameAlreadyExists")] #[error("RoleNameAlreadyExists")]
RoleNameAlreadyExists, RoleNameAlreadyExists,
#[error("TOTPNotSetup")] #[error("TotpNotSetup")]
TotpNotSetup, TotpNotSetup,
#[error("InvalidTOTP")] #[error("InvalidTotp")]
InvalidTotp, InvalidTotp,
#[error("TotpRequired")]
TotpRequired,
} }
impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors { 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 => { Self::InvalidTotp => {
AER::BadRequest(ApiError::new(sub_code, 37, self.get_error_message(), None)) 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::RoleNameAlreadyExists => "Role name already exists",
Self::TotpNotSetup => "TOTP not setup", Self::TotpNotSetup => "TOTP not setup",
Self::InvalidTotp => "Invalid TOTP", 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 recovery_codes = domain::RecoveryCodes::generate_new();
let key_store = user_from_db.get_or_create_key_store(&state).await?; let key_store = user_from_db.get_or_create_key_store(&state).await?;
@ -1693,8 +1693,10 @@ pub async fn verify_totp(
.await? .await?
.ok_or(UserErrors::InternalServerError)?; .ok_or(UserErrors::InternalServerError)?;
let totp = let totp = utils::user::two_factor_auth::generate_default_totp(
utils::user::generate_default_totp(user_from_db.get_email(), Some(user_totp_secret))?; user_from_db.get_email(),
Some(user_totp_secret),
)?;
if totp if totp
.generate_current() .generate_current()
@ -1732,3 +1734,35 @@ pub async fn verify_totp(
token, 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)), .route(web::post().to(set_dashboard_metadata)),
) )
.service(web::resource("/totp/begin").route(web::get().to(totp_begin))) .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")] #[cfg(feature = "email")]
{ {

View File

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

View File

@ -665,3 +665,17 @@ pub async fn totp_verify(
)) ))
.await .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 std::collections::HashMap;
use api_models::user as user_api; 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 diesel_models::{enums::UserStatus, user_role::UserRole};
use error_stack::ResultExt; use error_stack::ResultExt;
use masking::ExposeInterface;
use totp_rs::{Algorithm, TOTP};
use crate::{ use crate::{
consts,
core::errors::{StorageError, UserErrors, UserResult}, core::errors::{StorageError, UserErrors, UserResult},
routes::AppState, routes::AppState,
services::{ services::{
@ -22,6 +19,7 @@ pub mod dashboard_metadata;
pub mod password; pub mod password;
#[cfg(feature = "dummy_connector")] #[cfg(feature = "dummy_connector")]
pub mod sample_data; pub mod sample_data;
pub mod two_factor_auth;
impl UserFromToken { impl UserFromToken {
pub async fn get_merchant_account_from_db( 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(), 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, TotpBegin,
/// Verify TOTP /// Verify TOTP
TotpVerify, TotpVerify,
/// Generate or Regenerate recovery codes
GenerateRecoveryCodes,
/// List initial webhook delivery attempts /// List initial webhook delivery attempts
WebhookEventInitialDeliveryAttemptList, WebhookEventInitialDeliveryAttemptList,
/// List delivery attempts for a webhook event /// List delivery attempts for a webhook event