Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(users): Create API to Verify TOTP #4597

Merged
merged 1 commit into from
May 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading