feat(users): Create Decision manager for User Flows (#4518)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
Mani Chandra
2024-05-02 20:28:44 +05:30
committed by GitHub
parent 3ed0e8b764
commit 4b3faf6781
10 changed files with 393 additions and 18 deletions

View File

@ -14,9 +14,9 @@ use crate::user::{
CreateInternalUserRequest, DashboardEntryResponse, ForgotPasswordRequest, CreateInternalUserRequest, DashboardEntryResponse, ForgotPasswordRequest,
GetUserDetailsResponse, GetUserRoleDetailsRequest, GetUserRoleDetailsResponse, GetUserDetailsResponse, GetUserRoleDetailsRequest, GetUserRoleDetailsResponse,
InviteUserRequest, ListUsersResponse, ReInviteUserRequest, ResetPasswordRequest, InviteUserRequest, ListUsersResponse, ReInviteUserRequest, ResetPasswordRequest,
SendVerifyEmailRequest, SignInResponse, SignUpRequest, SignUpWithMerchantIdRequest, SendVerifyEmailRequest, SignInResponse, SignInWithTokenResponse, SignUpRequest,
SwitchMerchantIdRequest, UpdateUserAccountDetailsRequest, UserMerchantCreate, SignUpWithMerchantIdRequest, SwitchMerchantIdRequest, UpdateUserAccountDetailsRequest,
VerifyEmailRequest, UserMerchantCreate, VerifyEmailRequest,
}; };
impl ApiEventMetric for DashboardEntryResponse { impl ApiEventMetric for DashboardEntryResponse {
@ -62,6 +62,7 @@ common_utils::impl_misc_api_event_type!(
SignInResponse, SignInResponse,
UpdateUserAccountDetailsRequest, UpdateUserAccountDetailsRequest,
GetUserDetailsResponse, GetUserDetailsResponse,
SignInWithTokenResponse,
GetUserRoleDetailsRequest, GetUserRoleDetailsRequest,
GetUserRoleDetailsResponse GetUserRoleDetailsResponse
); );

View File

@ -1,4 +1,4 @@
use common_enums::{PermissionGroup, RoleScope}; use common_enums::{PermissionGroup, RoleScope, TokenPurpose};
use common_utils::{crypto::OptionalEncryptableName, pii}; use common_utils::{crypto::OptionalEncryptableName, pii};
use masking::Secret; use masking::Secret;
@ -213,3 +213,21 @@ pub struct UpdateUserAccountDetailsRequest {
pub name: Option<Secret<String>>, pub name: Option<Secret<String>>,
pub preferred_merchant_id: Option<String>, pub preferred_merchant_id: Option<String>,
} }
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct TokenOnlyQueryParam {
pub token_only: Option<bool>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct TokenResponse {
pub token: Secret<String>,
pub token_type: TokenPurpose,
}
#[derive(Debug, serde::Serialize)]
#[serde(untagged)]
pub enum SignInWithTokenResponse {
Token(TokenResponse),
SignInResponse(SignInResponse),
}

View File

@ -2706,3 +2706,17 @@ pub enum BankHolderType {
Personal, Personal,
Business, Business,
} }
#[derive(Debug, Clone, PartialEq, Eq, strum::Display, serde::Deserialize, serde::Serialize)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum TokenPurpose {
#[serde(rename = "totp")]
#[strum(serialize = "totp")]
TOTP,
VerifyEmail,
AcceptInvitationFromEmail,
ResetPassword,
AcceptInvite,
UserInfo,
}

View File

@ -123,7 +123,7 @@ pub async fn signup(
pub async fn signin( pub async fn signin(
state: AppState, state: AppState,
request: user_api::SignInRequest, request: user_api::SignInRequest,
) -> UserResponse<user_api::SignInResponse> { ) -> UserResponse<user_api::SignInWithTokenResponse> {
let user_from_db: domain::UserFromStorage = state let user_from_db: domain::UserFromStorage = state
.store .store
.find_user_by_email(&request.email) .find_user_by_email(&request.email)
@ -161,6 +161,48 @@ pub async fn signin(
let response = signin_strategy.get_signin_response(&state).await?; let response = signin_strategy.get_signin_response(&state).await?;
let token = utils::user::get_token_from_signin_response(&response); let token = utils::user::get_token_from_signin_response(&response);
auth::cookies::set_cookie_response(
user_api::SignInWithTokenResponse::SignInResponse(response),
token,
)
}
pub async fn signin_token_only_flow(
state: AppState,
request: user_api::SignInRequest,
) -> UserResponse<user_api::SignInWithTokenResponse> {
let user_from_db: domain::UserFromStorage = state
.store
.find_user_by_email(&request.email)
.await
.to_not_found_response(UserErrors::InvalidCredentials)?
.into();
user_from_db.compare_password(request.password)?;
let next_flow =
domain::NextFlow::from_origin(domain::Origin::SignIn, user_from_db.clone(), &state).await?;
let token = match next_flow.get_flow() {
domain::UserFlow::SPTFlow(spt_flow) => spt_flow.generate_spt(&state, &next_flow).await,
domain::UserFlow::JWTFlow(jwt_flow) => {
#[cfg(feature = "email")]
{
user_from_db.get_verification_days_left(&state)?;
}
let user_role = user_from_db
.get_preferred_or_active_user_role_from_db(&state)
.await
.to_not_found_response(UserErrors::InternalServerError)?;
jwt_flow.generate_jwt(&state, &next_flow, &user_role).await
}
}?;
let response = user_api::SignInWithTokenResponse::Token(user_api::TokenResponse {
token: token.clone(),
token_type: next_flow.get_flow().into(),
});
auth::cookies::set_cookie_response(response, token) auth::cookies::set_cookie_response(response, token)
} }

View File

@ -76,15 +76,23 @@ pub async fn user_signin(
state: web::Data<AppState>, state: web::Data<AppState>,
http_req: HttpRequest, http_req: HttpRequest,
json_payload: web::Json<user_api::SignInRequest>, json_payload: web::Json<user_api::SignInRequest>,
query: web::Query<user_api::TokenOnlyQueryParam>,
) -> HttpResponse { ) -> HttpResponse {
let flow = Flow::UserSignIn; let flow = Flow::UserSignIn;
let req_payload = json_payload.into_inner(); let req_payload = json_payload.into_inner();
let is_token_only = query.into_inner().token_only;
Box::pin(api::server_wrap( Box::pin(api::server_wrap(
flow.clone(), flow.clone(),
state, state,
&http_req, &http_req,
req_payload.clone(), req_payload.clone(),
|state, _, req_body, _| user_core::signin(state, req_body), |state, _, req_body, _| async move {
if let Some(true) = is_token_only {
user_core::signin_token_only_flow(state, req_body).await
} else {
user_core::signin(state, req_body).await
}
},
&auth::NoAuth, &auth::NoAuth,
api_locking::LockAction::NotApplicable, api_locking::LockAction::NotApplicable,
)) ))

View File

@ -1,5 +1,6 @@
use actix_web::{web, HttpRequest, HttpResponse}; use actix_web::{web, HttpRequest, HttpResponse};
use api_models::user_role::{self as user_role_api, role as role_api}; use api_models::user_role::{self as user_role_api, role as role_api};
use common_enums::TokenPurpose;
use router_env::Flow; use router_env::Flow;
use super::AppState; use super::AppState;
@ -214,7 +215,7 @@ pub async fn accept_invitation(
&req, &req,
payload, payload,
user_role_core::accept_invitation, user_role_core::accept_invitation,
&auth::SinglePurposeJWTAuth(auth::Purpose::AcceptInvite), &auth::SinglePurposeJWTAuth(TokenPurpose::AcceptInvite),
api_locking::LockAction::NotApplicable, api_locking::LockAction::NotApplicable,
)) ))
.await .await

View File

@ -4,6 +4,7 @@ use api_models::{
payments, payments,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use common_enums::TokenPurpose;
use common_utils::date_time; use common_utils::date_time;
use error_stack::{report, ResultExt}; use error_stack::{report, ResultExt};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
@ -66,7 +67,7 @@ pub enum AuthenticationType {
}, },
SinglePurposeJWT { SinglePurposeJWT {
user_id: String, user_id: String,
purpose: Purpose, purpose: TokenPurpose,
}, },
MerchantId { MerchantId {
merchant_id: String, merchant_id: String,
@ -113,28 +114,28 @@ impl AuthenticationType {
} }
} }
#[cfg(feature = "olap")]
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct UserFromSinglePurposeToken { pub struct UserFromSinglePurposeToken {
pub user_id: String, pub user_id: String,
pub origin: domain::Origin,
} }
#[cfg(feature = "olap")]
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
pub struct SinglePurposeToken { pub struct SinglePurposeToken {
pub user_id: String, pub user_id: String,
pub purpose: Purpose, pub purpose: TokenPurpose,
pub origin: domain::Origin,
pub exp: u64, pub exp: u64,
} }
#[derive(Debug, Clone, PartialEq, Eq, strum::Display, serde::Deserialize, serde::Serialize)]
pub enum Purpose {
AcceptInvite,
}
#[cfg(feature = "olap")] #[cfg(feature = "olap")]
impl SinglePurposeToken { impl SinglePurposeToken {
pub async fn new_token( pub async fn new_token(
user_id: String, user_id: String,
purpose: Purpose, purpose: TokenPurpose,
origin: domain::Origin,
settings: &Settings, settings: &Settings,
) -> UserResult<String> { ) -> UserResult<String> {
let exp_duration = let exp_duration =
@ -143,6 +144,7 @@ impl SinglePurposeToken {
let token_payload = Self { let token_payload = Self {
user_id, user_id,
purpose, purpose,
origin,
exp, exp,
}; };
jwt::generate_jwt(&token_payload, settings).await jwt::generate_jwt(&token_payload, settings).await
@ -308,8 +310,9 @@ where
} }
} }
#[cfg(feature = "olap")]
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct SinglePurposeJWTAuth(pub Purpose); pub(crate) struct SinglePurposeJWTAuth(pub TokenPurpose);
#[cfg(feature = "olap")] #[cfg(feature = "olap")]
#[async_trait] #[async_trait]
@ -334,6 +337,7 @@ where
Ok(( Ok((
UserFromSinglePurposeToken { UserFromSinglePurposeToken {
user_id: payload.user_id.clone(), user_id: payload.user_id.clone(),
origin: payload.origin.clone(),
}, },
AuthenticationType::SinglePurposeJWT { AuthenticationType::SinglePurposeJWT {
user_id: payload.user_id, user_id: payload.user_id,

View File

@ -5,7 +5,9 @@ use common_utils::date_time;
use error_stack::ResultExt; use error_stack::ResultExt;
use redis_interface::RedisConnectionPool; use redis_interface::RedisConnectionPool;
use super::{AuthToken, SinglePurposeToken}; use super::AuthToken;
#[cfg(feature = "olap")]
use super::SinglePurposeToken;
#[cfg(feature = "email")] #[cfg(feature = "email")]
use crate::consts::{EMAIL_TOKEN_BLACKLIST_PREFIX, EMAIL_TOKEN_TIME_IN_SECS}; use crate::consts::{EMAIL_TOKEN_BLACKLIST_PREFIX, EMAIL_TOKEN_TIME_IN_SECS};
use crate::{ use crate::{
@ -154,6 +156,7 @@ impl BlackList for AuthToken {
} }
} }
#[cfg(feature = "olap")]
#[async_trait::async_trait] #[async_trait::async_trait]
impl BlackList for SinglePurposeToken { impl BlackList for SinglePurposeToken {
async fn check_in_blacklist<A>(&self, state: &A) -> RouterResult<bool> async fn check_in_blacklist<A>(&self, state: &A) -> RouterResult<bool>

View File

@ -3,6 +3,7 @@ use std::{collections::HashSet, ops, str::FromStr};
use api_models::{ use api_models::{
admin as admin_api, organization as api_org, user as user_api, user_role as user_role_api, 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::{errors::CustomResult, pii};
use diesel_models::{ use diesel_models::{
enums::UserStatus, enums::UserStatus,
@ -31,6 +32,8 @@ use crate::{
}; };
pub mod dashboard_metadata; pub mod dashboard_metadata;
pub mod decision_manager;
pub use decision_manager::*;
#[derive(Clone)] #[derive(Clone)]
pub struct UserName(Secret<String>); pub struct UserName(Secret<String>);
@ -810,6 +813,29 @@ impl UserFromStorage {
.find_user_role_by_user_id_merchant_id(self.get_user_id(), merchant_id) .find_user_role_by_user_id_merchant_id(self.get_user_id(), merchant_id)
.await .await
} }
pub async fn get_preferred_or_active_user_role_from_db(
&self,
state: &AppState,
) -> CustomResult<UserRole, errors::StorageError> {
if let Some(preferred_merchant_id) = self.get_preferred_merchant_id() {
self.get_role_from_db_by_merchant_id(state, &preferred_merchant_id)
.await
} else {
state
.store
.list_user_roles_by_user_id(&self.0.user_id)
.await?
.into_iter()
.find(|role| role.status == UserStatus::Active)
.ok_or(
errors::StorageError::ValueNotFound(
"No active role found for user".to_string(),
)
.into(),
)
}
}
} }
impl From<info::ModuleInfo> for user_role_api::ModuleInfo { impl From<info::ModuleInfo> for user_role_api::ModuleInfo {
@ -937,7 +963,8 @@ impl SignInWithMultipleRolesStrategy {
email: self.user.get_email(), email: self.user.get_email(),
token: auth::SinglePurposeToken::new_token( token: auth::SinglePurposeToken::new_token(
self.user.get_user_id().to_string(), self.user.get_user_id().to_string(),
auth::Purpose::AcceptInvite, TokenPurpose::AcceptInvite,
Origin::SignIn,
&state.conf, &state.conf,
) )
.await? .await?

View File

@ -0,0 +1,257 @@
use common_enums::TokenPurpose;
use diesel_models::{enums::UserStatus, user_role::UserRole};
use masking::Secret;
use super::UserFromStorage;
use crate::{
core::errors::{UserErrors, UserResult},
routes::AppState,
services::authentication as auth,
};
#[derive(Eq, PartialEq, Clone, Copy)]
pub enum UserFlow {
SPTFlow(SPTFlow),
JWTFlow(JWTFlow),
}
impl UserFlow {
async fn is_required(&self, user: &UserFromStorage, state: &AppState) -> UserResult<bool> {
match self {
Self::SPTFlow(flow) => flow.is_required(user, state).await,
Self::JWTFlow(flow) => flow.is_required(user, state).await,
}
}
}
#[derive(Eq, PartialEq, Clone, Copy)]
pub enum SPTFlow {
TOTP,
VerifyEmail,
AcceptInvitationFromEmail,
ForceSetPassword,
MerchantSelect,
ResetPassword,
}
impl SPTFlow {
async fn is_required(&self, user: &UserFromStorage, state: &AppState) -> UserResult<bool> {
match self {
// TOTP
Self::TOTP => Ok(true),
// Main email APIs
Self::AcceptInvitationFromEmail | Self::ResetPassword => Ok(true),
Self::VerifyEmail => Ok(user.0.is_verified),
// Final Checks
// TODO: this should be based on last_password_modified_at as a placeholder using false
Self::ForceSetPassword => Ok(false),
Self::MerchantSelect => user
.get_roles_from_db(state)
.await
.map(|roles| !roles.iter().any(|role| role.status == UserStatus::Active)),
}
}
pub async fn generate_spt(
self,
state: &AppState,
next_flow: &NextFlow,
) -> UserResult<Secret<String>> {
auth::SinglePurposeToken::new_token(
next_flow.user.get_user_id().to_string(),
self.into(),
next_flow.origin.clone(),
&state.conf,
)
.await
.map(|token| token.into())
}
}
#[derive(Eq, PartialEq, Clone, Copy)]
pub enum JWTFlow {
UserInfo,
}
impl JWTFlow {
async fn is_required(&self, _user: &UserFromStorage, _state: &AppState) -> UserResult<bool> {
Ok(true)
}
pub async fn generate_jwt(
self,
state: &AppState,
next_flow: &NextFlow,
user_role: &UserRole,
) -> UserResult<Secret<String>> {
auth::AuthToken::new_token(
next_flow.user.get_user_id().to_string(),
user_role.merchant_id.clone(),
user_role.role_id.clone(),
&state.conf,
user_role.org_id.clone(),
)
.await
.map(|token| token.into())
}
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
#[serde(rename_all = "snake_case")]
pub enum Origin {
SignIn,
SignUp,
MagicLink,
VerifyEmail,
AcceptInvitationFromEmail,
ResetPassword,
}
impl Origin {
fn get_flows(&self) -> &'static [UserFlow] {
match self {
Self::SignIn => &SIGNIN_FLOW,
Self::SignUp => &SIGNUP_FLOW,
Self::VerifyEmail => &VERIFY_EMAIL_FLOW,
Self::MagicLink => &MAGIC_LINK_FLOW,
Self::AcceptInvitationFromEmail => &ACCEPT_INVITATION_FROM_EMAIL_FLOW,
Self::ResetPassword => &RESET_PASSWORD_FLOW,
}
}
}
const SIGNIN_FLOW: [UserFlow; 4] = [
UserFlow::SPTFlow(SPTFlow::TOTP),
UserFlow::SPTFlow(SPTFlow::ForceSetPassword),
UserFlow::SPTFlow(SPTFlow::MerchantSelect),
UserFlow::JWTFlow(JWTFlow::UserInfo),
];
const SIGNUP_FLOW: [UserFlow; 4] = [
UserFlow::SPTFlow(SPTFlow::TOTP),
UserFlow::SPTFlow(SPTFlow::ForceSetPassword),
UserFlow::SPTFlow(SPTFlow::MerchantSelect),
UserFlow::JWTFlow(JWTFlow::UserInfo),
];
const MAGIC_LINK_FLOW: [UserFlow; 5] = [
UserFlow::SPTFlow(SPTFlow::TOTP),
UserFlow::SPTFlow(SPTFlow::VerifyEmail),
UserFlow::SPTFlow(SPTFlow::ForceSetPassword),
UserFlow::SPTFlow(SPTFlow::MerchantSelect),
UserFlow::JWTFlow(JWTFlow::UserInfo),
];
const VERIFY_EMAIL_FLOW: [UserFlow; 5] = [
UserFlow::SPTFlow(SPTFlow::TOTP),
UserFlow::SPTFlow(SPTFlow::VerifyEmail),
UserFlow::SPTFlow(SPTFlow::ForceSetPassword),
UserFlow::SPTFlow(SPTFlow::MerchantSelect),
UserFlow::JWTFlow(JWTFlow::UserInfo),
];
const ACCEPT_INVITATION_FROM_EMAIL_FLOW: [UserFlow; 4] = [
UserFlow::SPTFlow(SPTFlow::TOTP),
UserFlow::SPTFlow(SPTFlow::AcceptInvitationFromEmail),
UserFlow::SPTFlow(SPTFlow::ForceSetPassword),
UserFlow::JWTFlow(JWTFlow::UserInfo),
];
const RESET_PASSWORD_FLOW: [UserFlow; 2] = [
UserFlow::SPTFlow(SPTFlow::TOTP),
UserFlow::SPTFlow(SPTFlow::ResetPassword),
];
pub struct CurrentFlow {
origin: Origin,
current_flow_index: usize,
}
impl CurrentFlow {
pub fn new(origin: Origin, current_flow: UserFlow) -> UserResult<Self> {
let flows = origin.get_flows();
let index = flows
.iter()
.position(|flow| flow == &current_flow)
.ok_or(UserErrors::InternalServerError)?;
Ok(Self {
origin,
current_flow_index: index,
})
}
pub async fn next(&self, user: UserFromStorage, state: &AppState) -> UserResult<NextFlow> {
let flows = self.origin.get_flows();
let remaining_flows = flows.iter().skip(self.current_flow_index + 1);
for flow in remaining_flows {
if flow.is_required(&user, state).await? {
return Ok(NextFlow {
origin: self.origin.clone(),
next_flow: *flow,
user,
});
}
}
Err(UserErrors::InternalServerError.into())
}
}
pub struct NextFlow {
origin: Origin,
next_flow: UserFlow,
user: UserFromStorage,
}
impl NextFlow {
pub async fn from_origin(
origin: Origin,
user: UserFromStorage,
state: &AppState,
) -> UserResult<Self> {
let flows = origin.get_flows();
for flow in flows {
if flow.is_required(&user, state).await? {
return Ok(Self {
origin,
next_flow: *flow,
user,
});
}
}
Err(UserErrors::InternalServerError.into())
}
pub fn get_flow(&self) -> UserFlow {
self.next_flow
}
}
impl From<UserFlow> for TokenPurpose {
fn from(value: UserFlow) -> Self {
match value {
UserFlow::SPTFlow(flow) => flow.into(),
UserFlow::JWTFlow(flow) => flow.into(),
}
}
}
impl From<SPTFlow> for TokenPurpose {
fn from(value: SPTFlow) -> Self {
match value {
SPTFlow::TOTP => Self::TOTP,
SPTFlow::VerifyEmail => Self::VerifyEmail,
SPTFlow::AcceptInvitationFromEmail => Self::AcceptInvitationFromEmail,
SPTFlow::MerchantSelect => Self::AcceptInvite,
SPTFlow::ResetPassword | SPTFlow::ForceSetPassword => Self::ResetPassword,
}
}
}
impl From<JWTFlow> for TokenPurpose {
fn from(value: JWTFlow) -> Self {
match value {
JWTFlow::UserInfo => Self::UserInfo,
}
}
}