Skip to content

Commit

Permalink
feat(users): Create API to Verify TOTP (#4597)
Browse files Browse the repository at this point in the history
  • Loading branch information
ThisIsMani authored May 9, 2024
1 parent f3115c4 commit 9135423
Show file tree
Hide file tree
Showing 9 changed files with 133 additions and 5 deletions.
5 changes: 3 additions & 2 deletions crates/api_models/src/events/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use crate::user::{
ResetPasswordRequest, RotatePasswordRequest, SendVerifyEmailRequest, SignInResponse,
SignUpRequest, SignUpWithMerchantIdRequest, SwitchMerchantIdRequest, TokenOrPayloadResponse,
TokenResponse, UpdateUserAccountDetailsRequest, UserFromEmailRequest, UserMerchantCreate,
VerifyEmailRequest,
VerifyEmailRequest, VerifyTotpRequest,
};

impl ApiEventMetric for DashboardEntryResponse {
Expand Down Expand Up @@ -74,7 +74,8 @@ common_utils::impl_misc_api_event_type!(
GetUserRoleDetailsResponse,
TokenResponse,
UserFromEmailRequest,
BeginTotpResponse
BeginTotpResponse,
VerifyTotpRequest
);

#[cfg(feature = "dummy_connector")]
Expand Down
5 changes: 5 additions & 0 deletions crates/api_models/src/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,8 @@ pub struct TotpSecret {
pub totp_url: Secret<String>,
pub recovery_codes: Vec<Secret<String>>,
}

#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct VerifyTotpRequest {
pub totp: Option<Secret<String>>,
}
12 changes: 12 additions & 0 deletions crates/router/src/core/errors/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ pub enum UserErrors {
RoleNameParsingError,
#[error("RoleNameAlreadyExists")]
RoleNameAlreadyExists,
#[error("TOTPNotSetup")]
TotpNotSetup,
#[error("InvalidTOTP")]
InvalidTotp,
}

impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors {
Expand Down Expand Up @@ -169,6 +173,12 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
Self::RoleNameAlreadyExists => {
AER::BadRequest(ApiError::new(sub_code, 35, self.get_error_message(), None))
}
Self::TotpNotSetup => {
AER::BadRequest(ApiError::new(sub_code, 36, self.get_error_message(), None))
}
Self::InvalidTotp => {
AER::BadRequest(ApiError::new(sub_code, 37, self.get_error_message(), None))
}
}
}
}
Expand Down Expand Up @@ -205,6 +215,8 @@ impl UserErrors {
Self::InvalidRoleOperationWithMessage(error_message) => error_message,
Self::RoleNameParsingError => "Invalid Role Name",
Self::RoleNameAlreadyExists => "Role name already exists",
Self::TotpNotSetup => "TOTP not setup",
Self::InvalidTotp => "Invalid TOTP",
}
}
}
62 changes: 62 additions & 0 deletions crates/router/src/core/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1642,3 +1642,65 @@ pub async fn begin_totp(
}),
}))
}

pub async fn verify_totp(
state: AppState,
user_token: auth::UserFromSinglePurposeToken,
req: user_api::VerifyTotpRequest,
) -> 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 let Some(user_totp) = req.totp {
if user_from_db.get_totp_status() == TotpStatus::NotSet {
return Err(UserErrors::TotpNotSetup.into());
}

let user_totp_secret = user_from_db
.decrypt_and_get_totp_secret(&state)
.await?
.ok_or(UserErrors::InternalServerError)?;

let totp =
utils::user::generate_default_totp(user_from_db.get_email(), Some(user_totp_secret))?;

if totp
.generate_current()
.change_context(UserErrors::InternalServerError)?
!= user_totp.expose()
{
return Err(UserErrors::InvalidTotp.into());
}

if user_from_db.get_totp_status() == TotpStatus::InProgress {
state
.store
.update_user_by_user_id(
user_from_db.get_user_id(),
storage_user::UserUpdate::TotpUpdate {
totp_status: Some(TotpStatus::Set),
totp_secret: None,
totp_recovery_codes: None,
},
)
.await
.change_context(UserErrors::InternalServerError)?;
}
}

let current_flow = domain::CurrentFlow::new(user_token.origin, domain::SPTFlow::TOTP.into())?;
let next_flow = current_flow.next(user_from_db, &state).await?;
let token = next_flow.get_token(&state).await?;

auth::cookies::set_cookie_response(
user_api::TokenResponse {
token: token.clone(),
token_type: next_flow.get_flow().into(),
},
token,
)
}
3 changes: 2 additions & 1 deletion crates/router/src/routes/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1199,7 +1199,8 @@ impl User {
.route(web::get().to(get_multiple_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)));

#[cfg(feature = "email")]
{
Expand Down
3 changes: 2 additions & 1 deletion crates/router/src/routes/lock_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@ impl From<Flow> for ApiIdentifier {
| Flow::AcceptInviteFromEmail
| Flow::VerifyEmailRequest
| Flow::UpdateUserAccountDetails
| Flow::TotpBegin => Self::User,
| Flow::TotpBegin
| Flow::TotpVerify => Self::User,

Flow::ListRoles
| Flow::GetRole
Expand Down
18 changes: 18 additions & 0 deletions crates/router/src/routes/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,3 +626,21 @@ pub async fn totp_begin(state: web::Data<AppState>, req: HttpRequest) -> HttpRes
))
.await
}

pub async fn totp_verify(
state: web::Data<AppState>,
req: HttpRequest,
json_payload: web::Json<user_api::VerifyTotpRequest>,
) -> HttpResponse {
let flow = Flow::TotpVerify;
Box::pin(api::server_wrap(
flow,
state.clone(),
&req,
json_payload.into_inner(),
|state, user, req_body, _| user_core::verify_totp(state, user, req_body),
&auth::SinglePurposeJWTAuth(common_enums::TokenPurpose::TOTP),
api_locking::LockAction::NotApplicable,
))
.await
}
28 changes: 27 additions & 1 deletion crates/router/src/types/domain/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use api_models::{
admin as admin_api, organization as api_org, user as user_api, user_role as user_role_api,
};
use common_enums::TokenPurpose;
use common_utils::{errors::CustomResult, pii};
use common_utils::{crypto::Encryptable, errors::CustomResult, pii};
use diesel_models::{
enums::{TotpStatus, UserStatus},
organization as diesel_org,
Expand Down Expand Up @@ -909,6 +909,32 @@ impl UserFromStorage {
pub fn get_totp_status(&self) -> TotpStatus {
self.0.totp_status
}

pub async fn decrypt_and_get_totp_secret(
&self,
state: &AppState,
) -> UserResult<Option<Secret<String>>> {
if self.0.totp_secret.is_none() {
return Ok(None);
}

let user_key_store = state
.store
.get_user_key_store_by_user_id(
self.get_user_id(),
&state.store.get_master_key().to_vec().into(),
)
.await
.change_context(UserErrors::InternalServerError)?;

Ok(domain_types::decrypt::<String, masking::WithType>(
self.0.totp_secret.clone(),
user_key_store.key.peek(),
)
.await
.change_context(UserErrors::InternalServerError)?
.map(Encryptable::into_inner))
}
}

impl From<info::ModuleInfo> for user_role_api::ModuleInfo {
Expand Down
2 changes: 2 additions & 0 deletions crates/router_env/src/logger/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,8 @@ pub enum Flow {
UserFromEmail,
/// Begin TOTP
TotpBegin,
/// Verify TOTP
TotpVerify,
/// List initial webhook delivery attempts
WebhookEventInitialDeliveryAttemptList,
/// List delivery attempts for a webhook event
Expand Down

0 comments on commit 9135423

Please sign in to comment.