feat(users): Add redis in Begin and Verify TOTP and create a new API that updates TOTP (#4765)

This commit is contained in:
Mani Chandra
2024-05-29 15:39:28 +05:30
committed by GitHub
parent a6570b6a06
commit cd9c9b609c
10 changed files with 209 additions and 100 deletions

View File

@ -255,12 +255,11 @@ pub struct BeginTotpResponse {
pub struct TotpSecret { pub struct TotpSecret {
pub secret: Secret<String>, pub secret: Secret<String>,
pub totp_url: Secret<String>, pub totp_url: Secret<String>,
pub recovery_codes: Vec<Secret<String>>,
} }
#[derive(Debug, serde::Deserialize, serde::Serialize)] #[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct VerifyTotpRequest { pub struct VerifyTotpRequest {
pub totp: Option<Secret<String>>, pub totp: Secret<String>,
} }
#[derive(Debug, serde::Deserialize, serde::Serialize)] #[derive(Debug, serde::Deserialize, serde::Serialize)]

View File

@ -13,5 +13,7 @@ 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_"; pub const REDIS_TOTP_PREFIX: &str = "TOTP_";
pub const REDIS_RECOVERY_CODE_PREFIX: &str = "RC_"; pub const REDIS_RECOVERY_CODE_PREFIX: &str = "RC_";
pub const REDIS_TOTP_SECRET_PREFIX: &str = "TOTP_SEC_";
pub const REDIS_TOTP_SECRET_TTL_IN_SECS: i64 = 5 * 60; // 5 minutes

View File

@ -78,6 +78,8 @@ pub enum UserErrors {
TwoFactorAuthRequired, TwoFactorAuthRequired,
#[error("TwoFactorAuthNotSetup")] #[error("TwoFactorAuthNotSetup")]
TwoFactorAuthNotSetup, TwoFactorAuthNotSetup,
#[error("TOTP secret not found")]
TotpSecretNotFound,
} }
impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors { impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors {
@ -199,6 +201,9 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
Self::TwoFactorAuthNotSetup => { Self::TwoFactorAuthNotSetup => {
AER::BadRequest(ApiError::new(sub_code, 41, self.get_error_message(), None)) AER::BadRequest(ApiError::new(sub_code, 41, self.get_error_message(), None))
} }
Self::TotpSecretNotFound => {
AER::BadRequest(ApiError::new(sub_code, 42, self.get_error_message(), None))
}
} }
} }
} }
@ -241,6 +246,7 @@ impl UserErrors {
Self::InvalidRecoveryCode => "Invalid Recovery Code", Self::InvalidRecoveryCode => "Invalid Recovery Code",
Self::TwoFactorAuthRequired => "Two factor auth required", Self::TwoFactorAuthRequired => "Two factor auth required",
Self::TwoFactorAuthNotSetup => "Two factor auth not setup", Self::TwoFactorAuthNotSetup => "Two factor auth not setup",
Self::TotpSecretNotFound => "TOTP secret not found",
} }
} }
} }

View File

@ -1633,41 +1633,14 @@ pub async fn begin_totp(
} }
let totp = tfa_utils::generate_default_totp(user_from_db.get_email(), None)?; let totp = tfa_utils::generate_default_totp(user_from_db.get_email(), None)?;
let recovery_codes = domain::RecoveryCodes::generate_new(); let secret = totp.get_secret_base32().into();
let key_store = user_from_db.get_or_create_key_store(&state).await?; tfa_utils::insert_totp_secret_in_redis(&state, &user_token.user_id, &secret).await?;
state
.store
.update_user_by_user_id(
user_from_db.get_user_id(),
storage_user::UserUpdate::TotpUpdate {
totp_status: Some(TotpStatus::InProgress),
totp_secret: Some(
// TODO: Impl conversion trait for User and move this there
domain::types::encrypt::<String, masking::WithType>(
totp.get_secret_base32().into(),
key_store.key.peek(),
)
.await
.change_context(UserErrors::InternalServerError)?
.into(),
),
totp_recovery_codes: Some(
recovery_codes
.get_hashed()
.change_context(UserErrors::InternalServerError)?,
),
},
)
.await
.change_context(UserErrors::InternalServerError)?;
Ok(ApplicationResponse::Json(user_api::BeginTotpResponse { Ok(ApplicationResponse::Json(user_api::BeginTotpResponse {
secret: Some(user_api::TotpSecret { secret: Some(user_api::TotpSecret {
secret: totp.get_secret_base32().into(), secret,
totp_url: totp.get_url().into(), totp_url: totp.get_url().into(),
recovery_codes: recovery_codes.into_inner(),
}), }),
})) }))
} }
@ -1684,8 +1657,7 @@ pub async fn verify_totp(
.change_context(UserErrors::InternalServerError)? .change_context(UserErrors::InternalServerError)?
.into(); .into();
if let Some(user_totp) = req.totp { if user_from_db.get_totp_status() != TotpStatus::Set {
if user_from_db.get_totp_status() == TotpStatus::NotSet {
return Err(UserErrors::TotpNotSetup.into()); return Err(UserErrors::TotpNotSetup.into());
} }
@ -1694,44 +1666,84 @@ pub async fn verify_totp(
.await? .await?
.ok_or(UserErrors::InternalServerError)?; .ok_or(UserErrors::InternalServerError)?;
let totp = let totp = tfa_utils::generate_default_totp(user_from_db.get_email(), Some(user_totp_secret))?;
tfa_utils::generate_default_totp(user_from_db.get_email(), Some(user_totp_secret))?;
if totp if totp
.generate_current() .generate_current()
.change_context(UserErrors::InternalServerError)? .change_context(UserErrors::InternalServerError)?
!= user_totp.expose() != req.totp.expose()
{ {
return Err(UserErrors::InvalidTotp.into()); return Err(UserErrors::InvalidTotp.into());
} }
if user_from_db.get_totp_status() == TotpStatus::InProgress { tfa_utils::insert_totp_in_redis(&state, &user_token.user_id).await?;
Ok(ApplicationResponse::StatusOk)
}
pub async fn update_totp(
state: AppState,
user_token: auth::UserFromSinglePurposeToken,
req: user_api::VerifyTotpRequest,
) -> UserResponse<()> {
let user_from_db: domain::UserFromStorage = state
.store
.find_user_by_id(&user_token.user_id)
.await
.change_context(UserErrors::InternalServerError)?
.into();
let new_totp_secret = tfa_utils::get_totp_secret_from_redis(&state, &user_token.user_id)
.await?
.ok_or(UserErrors::TotpSecretNotFound)?;
let totp = tfa_utils::generate_default_totp(user_from_db.get_email(), Some(new_totp_secret))?;
if totp
.generate_current()
.change_context(UserErrors::InternalServerError)?
!= req.totp.expose()
{
return Err(UserErrors::InvalidTotp.into());
}
let key_store = user_from_db.get_or_create_key_store(&state).await?;
state state
.store .store
.update_user_by_user_id( .update_user_by_user_id(
user_from_db.get_user_id(), &user_token.user_id,
storage_user::UserUpdate::TotpUpdate { storage_user::UserUpdate::TotpUpdate {
totp_status: Some(TotpStatus::Set), totp_status: None,
totp_secret: None, totp_secret: Some(
// TODO: Impl conversion trait for User and move this there
domain::types::encrypt::<String, masking::WithType>(
totp.get_secret_base32().into(),
key_store.key.peek(),
)
.await
.change_context(UserErrors::InternalServerError)?
.into(),
),
totp_recovery_codes: None, totp_recovery_codes: None,
}, },
) )
.await .await
.change_context(UserErrors::InternalServerError)?; .change_context(UserErrors::InternalServerError)?;
}
}
let current_flow = domain::CurrentFlow::new(user_token.origin, domain::SPTFlow::TOTP.into())?; let _ = tfa_utils::delete_totp_secret_from_redis(&state, &user_token.user_id)
let next_flow = current_flow.next(user_from_db, &state).await?; .await
let token = next_flow.get_token(&state).await?; .map_err(|e| logger::error!(?e));
auth::cookies::set_cookie_response( // This is not the main task of this API, so we don't throw error if this fails.
user_api::TokenResponse { // Any following API which requires TOTP will throw error if TOTP is not set in redis
token: token.clone(), // and FE will ask user to enter TOTP again
token_type: next_flow.get_flow().into(), let _ = tfa_utils::insert_totp_in_redis(&state, &user_token.user_id)
}, .await
token, .map_err(|e| logger::error!(?e));
)
Ok(ApplicationResponse::StatusOk)
} }
pub async fn generate_recovery_codes( pub async fn generate_recovery_codes(

View File

@ -1209,17 +1209,33 @@ impl User {
web::resource("/data") web::resource("/data")
.route(web::get().to(get_multiple_dashboard_metadata)) .route(web::get().to(get_multiple_dashboard_metadata))
.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/verify").route(web::post().to(totp_verify)))
.service(
web::resource("/2fa/terminate").route(web::get().to(terminate_two_factor_auth)),
); );
// Two factor auth routes
route = route.service( route = route.service(
web::scope("/2fa")
.service(
web::scope("/totp")
.service(web::resource("/begin").route(web::get().to(totp_begin)))
.service(
web::resource("/verify")
.route(web::post().to(totp_verify))
.route(web::put().to(totp_update)),
),
)
.service(
web::scope("/recovery_code") web::scope("/recovery_code")
.service(web::resource("/verify").route(web::post().to(verify_recovery_code))) .service(
.service(web::resource("/generate").route(web::post().to(generate_recovery_codes))), web::resource("/verify").route(web::post().to(verify_recovery_code)),
)
.service(
web::resource("/generate")
.route(web::get().to(generate_recovery_codes)),
),
)
.service(
web::resource("/terminate").route(web::get().to(terminate_two_factor_auth)),
),
); );
#[cfg(feature = "email")] #[cfg(feature = "email")]

View File

@ -215,9 +215,11 @@ impl From<Flow> for ApiIdentifier {
| Flow::UpdateUserAccountDetails | Flow::UpdateUserAccountDetails
| Flow::TotpBegin | Flow::TotpBegin
| Flow::TotpVerify | Flow::TotpVerify
| Flow::TotpUpdate
| Flow::RecoveryCodeVerify | Flow::RecoveryCodeVerify
| Flow::RecoveryCodesGenerate | Flow::RecoveryCodesGenerate
| Flow::TerminateTwoFactorAuth => Self::User, | Flow::TerminateTwoFactorAuth => Self::User,
Flow::ListRoles Flow::ListRoles
| Flow::GetRole | Flow::GetRole
| Flow::GetRoleFromToken | Flow::GetRoleFromToken

View File

@ -684,6 +684,24 @@ pub async fn verify_recovery_code(
.await .await
} }
pub async fn totp_update(
state: web::Data<AppState>,
req: HttpRequest,
json_payload: web::Json<user_api::VerifyTotpRequest>,
) -> HttpResponse {
let flow = Flow::TotpUpdate;
Box::pin(api::server_wrap(
flow,
state.clone(),
&req,
json_payload.into_inner(),
|state, user, req_body, _| user_core::update_totp(state, user, req_body),
&auth::SinglePurposeJWTAuth(TokenPurpose::TOTP),
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn generate_recovery_codes(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse { pub async fn generate_recovery_codes(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let flow = Flow::RecoveryCodesGenerate; let flow = Flow::RecoveryCodesGenerate;
Box::pin(api::server_wrap( Box::pin(api::server_wrap(

View File

@ -1,9 +1,10 @@
use std::collections::HashMap; use std::{collections::HashMap, sync::Arc};
use api_models::user as user_api; use api_models::user as user_api;
use common_utils::errors::CustomResult; 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 redis_interface::RedisConnectionPool;
use crate::{ use crate::{
core::errors::{StorageError, UserErrors, UserResult}, core::errors::{StorageError, UserErrors, UserResult},
@ -191,3 +192,11 @@ 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 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

@ -1,9 +1,6 @@
use std::sync::Arc;
use common_utils::pii; use common_utils::pii;
use error_stack::ResultExt; use error_stack::ResultExt;
use masking::ExposeInterface; use masking::{ExposeInterface, PeekInterface};
use redis_interface::RedisConnectionPool;
use totp_rs::{Algorithm, TOTP}; use totp_rs::{Algorithm, TOTP};
use crate::{ use crate::{
@ -35,8 +32,8 @@ pub fn generate_default_totp(
} }
pub async fn check_totp_in_redis(state: &AppState, user_id: &str) -> UserResult<bool> { pub async fn check_totp_in_redis(state: &AppState, user_id: &str) -> UserResult<bool> {
let redis_conn = get_redis_connection(state)?; let redis_conn = super::get_redis_connection(state)?;
let key = format!("{}{}", consts::user::TOTP_PREFIX, user_id); let key = format!("{}{}", consts::user::REDIS_TOTP_PREFIX, user_id);
redis_conn redis_conn
.exists::<()>(&key) .exists::<()>(&key)
.await .await
@ -44,7 +41,7 @@ pub async fn check_totp_in_redis(state: &AppState, user_id: &str) -> UserResult<
} }
pub async fn check_recovery_code_in_redis(state: &AppState, user_id: &str) -> UserResult<bool> { pub async fn check_recovery_code_in_redis(state: &AppState, user_id: &str) -> UserResult<bool> {
let redis_conn = get_redis_connection(state)?; let redis_conn = super::get_redis_connection(state)?;
let key = format!("{}{}", consts::user::REDIS_RECOVERY_CODE_PREFIX, user_id); let key = format!("{}{}", consts::user::REDIS_RECOVERY_CODE_PREFIX, user_id);
redis_conn redis_conn
.exists::<()>(&key) .exists::<()>(&key)
@ -52,16 +49,62 @@ pub async fn check_recovery_code_in_redis(state: &AppState, user_id: &str) -> Us
.change_context(UserErrors::InternalServerError) .change_context(UserErrors::InternalServerError)
} }
fn get_redis_connection(state: &AppState) -> UserResult<Arc<RedisConnectionPool>> { pub async fn insert_totp_in_redis(state: &AppState, user_id: &str) -> UserResult<()> {
state let redis_conn = super::get_redis_connection(state)?;
.store let key = format!("{}{}", consts::user::REDIS_TOTP_PREFIX, user_id);
.get_redis_conn() redis_conn
.set_key_with_expiry(
key.as_str(),
common_utils::date_time::now_unix_timestamp(),
state.conf.user.two_factor_auth_expiry_in_secs,
)
.await
.change_context(UserErrors::InternalServerError) .change_context(UserErrors::InternalServerError)
.attach_printable("Failed to get redis connection") }
pub async fn insert_totp_secret_in_redis(
state: &AppState,
user_id: &str,
secret: &masking::Secret<String>,
) -> UserResult<()> {
let redis_conn = super::get_redis_connection(state)?;
redis_conn
.set_key_with_expiry(
&get_totp_secret_key(user_id),
secret.peek(),
consts::user::REDIS_TOTP_SECRET_TTL_IN_SECS,
)
.await
.change_context(UserErrors::InternalServerError)
}
pub async fn get_totp_secret_from_redis(
state: &AppState,
user_id: &str,
) -> UserResult<Option<masking::Secret<String>>> {
let redis_conn = super::get_redis_connection(state)?;
redis_conn
.get_key::<Option<String>>(&get_totp_secret_key(user_id))
.await
.change_context(UserErrors::InternalServerError)
.map(|secret| secret.map(Into::into))
}
pub async fn delete_totp_secret_from_redis(state: &AppState, user_id: &str) -> UserResult<()> {
let redis_conn = super::get_redis_connection(state)?;
redis_conn
.delete_key(&get_totp_secret_key(user_id))
.await
.change_context(UserErrors::InternalServerError)
.map(|_| ())
}
fn get_totp_secret_key(user_id: &str) -> String {
format!("{}{}", consts::user::REDIS_TOTP_SECRET_PREFIX, user_id)
} }
pub async fn insert_recovery_code_in_redis(state: &AppState, user_id: &str) -> UserResult<()> { pub async fn insert_recovery_code_in_redis(state: &AppState, user_id: &str) -> UserResult<()> {
let redis_conn = get_redis_connection(state)?; let redis_conn = super::get_redis_connection(state)?;
let key = format!("{}{}", consts::user::REDIS_RECOVERY_CODE_PREFIX, user_id); let key = format!("{}{}", consts::user::REDIS_RECOVERY_CODE_PREFIX, user_id);
redis_conn redis_conn
.set_key_with_expiry( .set_key_with_expiry(

View File

@ -406,6 +406,8 @@ pub enum Flow {
TotpBegin, TotpBegin,
/// Verify TOTP /// Verify TOTP
TotpVerify, TotpVerify,
/// Update TOTP secret
TotpUpdate,
/// Verify Access Code /// Verify Access Code
RecoveryCodeVerify, RecoveryCodeVerify,
/// Generate or Regenerate recovery codes /// Generate or Regenerate recovery codes