diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs index 8f017ca8c20b..594b60b58162 100644 --- a/crates/api_models/src/events/user.rs +++ b/crates/api_models/src/events/user.rs @@ -14,9 +14,9 @@ use crate::user::{ CreateInternalUserRequest, DashboardEntryResponse, ForgotPasswordRequest, GetUserDetailsResponse, GetUserRoleDetailsRequest, GetUserRoleDetailsResponse, InviteUserRequest, ListUsersResponse, ReInviteUserRequest, ResetPasswordRequest, - SendVerifyEmailRequest, SignInResponse, SignUpRequest, SignUpWithMerchantIdRequest, - SwitchMerchantIdRequest, UpdateUserAccountDetailsRequest, UserMerchantCreate, - VerifyEmailRequest, + SendVerifyEmailRequest, SignInResponse, SignInWithTokenResponse, SignUpRequest, + SignUpWithMerchantIdRequest, SwitchMerchantIdRequest, UpdateUserAccountDetailsRequest, + UserMerchantCreate, VerifyEmailRequest, }; impl ApiEventMetric for DashboardEntryResponse { @@ -62,6 +62,7 @@ common_utils::impl_misc_api_event_type!( SignInResponse, UpdateUserAccountDetailsRequest, GetUserDetailsResponse, + SignInWithTokenResponse, GetUserRoleDetailsRequest, GetUserRoleDetailsResponse ); diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs index 700c27461cda..b4d53a92c1a6 100644 --- a/crates/api_models/src/user.rs +++ b/crates/api_models/src/user.rs @@ -1,4 +1,4 @@ -use common_enums::{PermissionGroup, RoleScope}; +use common_enums::{PermissionGroup, RoleScope, TokenPurpose}; use common_utils::{crypto::OptionalEncryptableName, pii}; use masking::Secret; @@ -213,3 +213,21 @@ pub struct UpdateUserAccountDetailsRequest { pub name: Option>, pub preferred_merchant_id: Option, } + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct TokenOnlyQueryParam { + pub token_only: Option, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct TokenResponse { + pub token: Secret, + pub token_type: TokenPurpose, +} + +#[derive(Debug, serde::Serialize)] +#[serde(untagged)] +pub enum SignInWithTokenResponse { + Token(TokenResponse), + SignInResponse(SignInResponse), +} diff --git a/crates/common_enums/src/enums.rs b/crates/common_enums/src/enums.rs index 18545eb4a4da..3c86064731b5 100644 --- a/crates/common_enums/src/enums.rs +++ b/crates/common_enums/src/enums.rs @@ -2706,3 +2706,17 @@ pub enum BankHolderType { Personal, 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, +} diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs index 2e439a6eb525..0ae1b162e0ed 100644 --- a/crates/router/src/core/user.rs +++ b/crates/router/src/core/user.rs @@ -123,7 +123,7 @@ pub async fn signup( pub async fn signin( state: AppState, request: user_api::SignInRequest, -) -> UserResponse { +) -> UserResponse { let user_from_db: domain::UserFromStorage = state .store .find_user_by_email(&request.email) @@ -161,6 +161,48 @@ pub async fn signin( let response = signin_strategy.get_signin_response(&state).await?; 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 { + 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) } diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs index 0657d781b083..4d841913bc0c 100644 --- a/crates/router/src/routes/user.rs +++ b/crates/router/src/routes/user.rs @@ -76,15 +76,23 @@ pub async fn user_signin( state: web::Data, http_req: HttpRequest, json_payload: web::Json, + query: web::Query, ) -> HttpResponse { let flow = Flow::UserSignIn; let req_payload = json_payload.into_inner(); + let is_token_only = query.into_inner().token_only; Box::pin(api::server_wrap( flow.clone(), state, &http_req, 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, api_locking::LockAction::NotApplicable, )) diff --git a/crates/router/src/routes/user_role.rs b/crates/router/src/routes/user_role.rs index 7247c858f4d3..59c07b798555 100644 --- a/crates/router/src/routes/user_role.rs +++ b/crates/router/src/routes/user_role.rs @@ -1,5 +1,6 @@ use actix_web::{web, HttpRequest, HttpResponse}; use api_models::user_role::{self as user_role_api, role as role_api}; +use common_enums::TokenPurpose; use router_env::Flow; use super::AppState; @@ -214,7 +215,7 @@ pub async fn accept_invitation( &req, payload, user_role_core::accept_invitation, - &auth::SinglePurposeJWTAuth(auth::Purpose::AcceptInvite), + &auth::SinglePurposeJWTAuth(TokenPurpose::AcceptInvite), api_locking::LockAction::NotApplicable, )) .await diff --git a/crates/router/src/services/authentication.rs b/crates/router/src/services/authentication.rs index c49ad81d9197..5f15474115dd 100644 --- a/crates/router/src/services/authentication.rs +++ b/crates/router/src/services/authentication.rs @@ -4,6 +4,7 @@ use api_models::{ payments, }; use async_trait::async_trait; +use common_enums::TokenPurpose; use common_utils::date_time; use error_stack::{report, ResultExt}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; @@ -66,7 +67,7 @@ pub enum AuthenticationType { }, SinglePurposeJWT { user_id: String, - purpose: Purpose, + purpose: TokenPurpose, }, MerchantId { merchant_id: String, @@ -113,28 +114,28 @@ impl AuthenticationType { } } +#[cfg(feature = "olap")] #[derive(Clone, Debug)] pub struct UserFromSinglePurposeToken { pub user_id: String, + pub origin: domain::Origin, } +#[cfg(feature = "olap")] #[derive(serde::Serialize, serde::Deserialize)] pub struct SinglePurposeToken { pub user_id: String, - pub purpose: Purpose, + pub purpose: TokenPurpose, + pub origin: domain::Origin, pub exp: u64, } -#[derive(Debug, Clone, PartialEq, Eq, strum::Display, serde::Deserialize, serde::Serialize)] -pub enum Purpose { - AcceptInvite, -} - #[cfg(feature = "olap")] impl SinglePurposeToken { pub async fn new_token( user_id: String, - purpose: Purpose, + purpose: TokenPurpose, + origin: domain::Origin, settings: &Settings, ) -> UserResult { let exp_duration = @@ -143,6 +144,7 @@ impl SinglePurposeToken { let token_payload = Self { user_id, purpose, + origin, exp, }; jwt::generate_jwt(&token_payload, settings).await @@ -308,8 +310,9 @@ where } } +#[cfg(feature = "olap")] #[derive(Debug)] -pub(crate) struct SinglePurposeJWTAuth(pub Purpose); +pub(crate) struct SinglePurposeJWTAuth(pub TokenPurpose); #[cfg(feature = "olap")] #[async_trait] @@ -334,6 +337,7 @@ where Ok(( UserFromSinglePurposeToken { user_id: payload.user_id.clone(), + origin: payload.origin.clone(), }, AuthenticationType::SinglePurposeJWT { user_id: payload.user_id, diff --git a/crates/router/src/services/authentication/blacklist.rs b/crates/router/src/services/authentication/blacklist.rs index bc11ee190896..c1abb0647525 100644 --- a/crates/router/src/services/authentication/blacklist.rs +++ b/crates/router/src/services/authentication/blacklist.rs @@ -5,7 +5,9 @@ use common_utils::date_time; use error_stack::ResultExt; use redis_interface::RedisConnectionPool; -use super::{AuthToken, SinglePurposeToken}; +use super::AuthToken; +#[cfg(feature = "olap")] +use super::SinglePurposeToken; #[cfg(feature = "email")] use crate::consts::{EMAIL_TOKEN_BLACKLIST_PREFIX, EMAIL_TOKEN_TIME_IN_SECS}; use crate::{ @@ -154,6 +156,7 @@ impl BlackList for AuthToken { } } +#[cfg(feature = "olap")] #[async_trait::async_trait] impl BlackList for SinglePurposeToken { async fn check_in_blacklist(&self, state: &A) -> RouterResult diff --git a/crates/router/src/types/domain/user.rs b/crates/router/src/types/domain/user.rs index 0b971d820329..aed1bb02e727 100644 --- a/crates/router/src/types/domain/user.rs +++ b/crates/router/src/types/domain/user.rs @@ -3,6 +3,7 @@ use std::{collections::HashSet, ops, str::FromStr}; 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 diesel_models::{ enums::UserStatus, @@ -31,6 +32,8 @@ use crate::{ }; pub mod dashboard_metadata; +pub mod decision_manager; +pub use decision_manager::*; #[derive(Clone)] pub struct UserName(Secret); @@ -810,6 +813,29 @@ impl UserFromStorage { .find_user_role_by_user_id_merchant_id(self.get_user_id(), merchant_id) .await } + + pub async fn get_preferred_or_active_user_role_from_db( + &self, + state: &AppState, + ) -> CustomResult { + 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 for user_role_api::ModuleInfo { @@ -937,7 +963,8 @@ impl SignInWithMultipleRolesStrategy { email: self.user.get_email(), token: auth::SinglePurposeToken::new_token( self.user.get_user_id().to_string(), - auth::Purpose::AcceptInvite, + TokenPurpose::AcceptInvite, + Origin::SignIn, &state.conf, ) .await? diff --git a/crates/router/src/types/domain/user/decision_manager.rs b/crates/router/src/types/domain/user/decision_manager.rs new file mode 100644 index 000000000000..b5aff7791066 --- /dev/null +++ b/crates/router/src/types/domain/user/decision_manager.rs @@ -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 { + 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 { + 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> { + 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 { + Ok(true) + } + + pub async fn generate_jwt( + self, + state: &AppState, + next_flow: &NextFlow, + user_role: &UserRole, + ) -> UserResult> { + 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 { + let flows = origin.get_flows(); + let index = flows + .iter() + .position(|flow| flow == ¤t_flow) + .ok_or(UserErrors::InternalServerError)?; + + Ok(Self { + origin, + current_flow_index: index, + }) + } + + pub async fn next(&self, user: UserFromStorage, state: &AppState) -> UserResult { + 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 { + 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 for TokenPurpose { + fn from(value: UserFlow) -> Self { + match value { + UserFlow::SPTFlow(flow) => flow.into(), + UserFlow::JWTFlow(flow) => flow.into(), + } + } +} + +impl From 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 for TokenPurpose { + fn from(value: JWTFlow) -> Self { + match value { + JWTFlow::UserInfo => Self::UserInfo, + } + } +}