mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-10-29 09:07:09 +08:00
feat(users): Create generate recovery codes API (#4708)
This commit is contained in:
@ -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")]
|
||||||
|
|||||||
@ -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>>,
|
||||||
|
}
|
||||||
|
|||||||
@ -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_";
|
||||||
|
|||||||
@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@ -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")]
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
52
crates/router/src/utils/user/two_factor_auth.rs
Normal file
52
crates/router/src/utils/user/two_factor_auth.rs
Normal 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")
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user