From 2005d3df9fc2e559ea65c57892ab940e38b9af50 Mon Sep 17 00:00:00 2001 From: Apoorv Dixit <64925866+apoorvdixit88@users.noreply.github.com> Date: Fri, 21 Jun 2024 16:29:17 +0530 Subject: [PATCH] feat(users): setup user authentication methods schema and apis (#4999) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> Co-authored-by: Mani Chandra Dulam --- config/config.example.toml | 5 +- config/deployments/env_specific.toml | 5 +- config/development.toml | 3 + config/docker_compose.toml | 5 +- crates/api_models/src/events/user.rs | 22 +- crates/api_models/src/user.rs | 75 +++++++ crates/common_enums/src/enums.rs | 42 ++++ crates/diesel_models/src/lib.rs | 1 + crates/diesel_models/src/query.rs | 1 + .../src/query/user_authentication_method.rs | 60 ++++++ crates/diesel_models/src/schema.rs | 24 +++ .../src/user_authentication_method.rs | 65 ++++++ .../src/configs/secrets_transformers.rs | 25 +++ crates/router/src/configs/settings.rs | 6 + crates/router/src/core/errors/user.rs | 18 ++ crates/router/src/core/user.rs | 185 +++++++++++++++- crates/router/src/db.rs | 2 + crates/router/src/db/kafka_store.rs | 41 ++++ .../src/db/user_authentication_method.rs | 197 ++++++++++++++++++ crates/router/src/routes/app.rs | 12 ++ crates/router/src/routes/lock_utils.rs | 5 +- crates/router/src/routes/user.rs | 57 +++++ crates/router/src/types/storage.rs | 3 +- .../storage/user_authentication_method.rs | 1 + crates/router/src/utils/user.rs | 15 +- crates/router_env/src/logger/types.rs | 6 + crates/storage_impl/src/mock_db.rs | 3 + .../down.sql | 4 + .../up.sql | 16 ++ 29 files changed, 888 insertions(+), 16 deletions(-) create mode 100644 crates/diesel_models/src/query/user_authentication_method.rs create mode 100644 crates/diesel_models/src/user_authentication_method.rs create mode 100644 crates/router/src/db/user_authentication_method.rs create mode 100644 crates/router/src/types/storage/user_authentication_method.rs create mode 100644 migrations/2024-06-10-084722_create_user_authentication_methods_table/down.sql create mode 100644 migrations/2024-06-10-084722_create_user_authentication_methods_table/up.sql diff --git a/config/config.example.toml b/config/config.example.toml index 332ab4bc00..9bdf0b8072 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -644,4 +644,7 @@ enabled = false global_tenant = { schema = "public", redis_key_prefix = "" } [multitenancy.tenants] -public = { name = "hyperswitch", base_url = "http://localhost:8080", schema = "public", redis_key_prefix = "", clickhouse_database = "default"} # schema -> Postgres db schema, redis_key_prefix -> redis key distinguisher, base_url -> url of the tenant \ No newline at end of file +public = { name = "hyperswitch", base_url = "http://localhost:8080", schema = "public", redis_key_prefix = "", clickhouse_database = "default"} # schema -> Postgres db schema, redis_key_prefix -> redis key distinguisher, base_url -> url of the tenant + +[user_auth_methods] +encryption_key = "" # Encryption key used for encrypting data in user_authentication_methods table diff --git a/config/deployments/env_specific.toml b/config/deployments/env_specific.toml index 162444c909..ccb26042f5 100644 --- a/config/deployments/env_specific.toml +++ b/config/deployments/env_specific.toml @@ -259,4 +259,7 @@ enabled = false global_tenant = { schema = "public", redis_key_prefix = "" } [multitenancy.tenants] -public = { name = "hyperswitch", base_url = "http://localhost:8080", schema = "public", redis_key_prefix = "", clickhouse_database = "default"} \ No newline at end of file +public = { name = "hyperswitch", base_url = "http://localhost:8080", schema = "public", redis_key_prefix = "", clickhouse_database = "default"} + +[user_auth_methods] +encryption_key = "user_auth_table_encryption_key" # Encryption key used for encrypting data in user_authentication_methods table diff --git a/config/development.toml b/config/development.toml index c2f8772067..29f693f6bf 100644 --- a/config/development.toml +++ b/config/development.toml @@ -654,3 +654,6 @@ global_tenant = { schema = "public", redis_key_prefix = "" } [multitenancy.tenants] public = { name = "hyperswitch", base_url = "http://localhost:8080", schema = "public", redis_key_prefix = "", clickhouse_database = "default"} + +[user_auth_methods] +encryption_key = "A8EF32E029BC3342E54BF2E172A4D7AA43E8EF9D2C3A624A9F04E2EF79DC698F" diff --git a/config/docker_compose.toml b/config/docker_compose.toml index 328dd30520..1b0538d1af 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -507,4 +507,7 @@ enabled = false global_tenant = { schema = "public", redis_key_prefix = "" } [multitenancy.tenants] -public = { name = "hyperswitch", base_url = "http://localhost:8080", schema = "public", redis_key_prefix = "", clickhouse_database = "default"} \ No newline at end of file +public = { name = "hyperswitch", base_url = "http://localhost:8080", schema = "public", redis_key_prefix = "", clickhouse_database = "default"} + +[user_auth_methods] +encryption_key = "A8EF32E029BC3342E54BF2E172A4D7AA43E8EF9D2C3A624A9F04E2EF79DC698F" diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs index 287bafaace..e5d217cd8e 100644 --- a/crates/api_models/src/events/user.rs +++ b/crates/api_models/src/events/user.rs @@ -11,14 +11,15 @@ use crate::user::{ GetMetaDataRequest, GetMetaDataResponse, GetMultipleMetaDataPayload, SetMetaDataRequest, }, AcceptInviteFromEmailRequest, AuthorizeResponse, BeginTotpResponse, ChangePasswordRequest, - ConnectAccountRequest, CreateInternalUserRequest, DashboardEntryResponse, - ForgotPasswordRequest, GetUserDetailsResponse, GetUserRoleDetailsRequest, - GetUserRoleDetailsResponse, InviteUserRequest, ListUsersResponse, ReInviteUserRequest, - RecoveryCodes, ResetPasswordRequest, RotatePasswordRequest, SendVerifyEmailRequest, - SignInResponse, SignUpRequest, SignUpWithMerchantIdRequest, SwitchMerchantIdRequest, - TokenOrPayloadResponse, TokenResponse, TwoFactorAuthStatusResponse, - UpdateUserAccountDetailsRequest, UserFromEmailRequest, UserMerchantCreate, VerifyEmailRequest, - VerifyRecoveryCodeRequest, VerifyTotpRequest, + ConnectAccountRequest, CreateInternalUserRequest, CreateUserAuthenticationMethodRequest, + DashboardEntryResponse, ForgotPasswordRequest, GetUserAuthenticationMethodsRequest, + GetUserDetailsResponse, GetUserRoleDetailsRequest, GetUserRoleDetailsResponse, + InviteUserRequest, ListUsersResponse, ReInviteUserRequest, RecoveryCodes, ResetPasswordRequest, + RotatePasswordRequest, SendVerifyEmailRequest, SignInResponse, SignUpRequest, + SignUpWithMerchantIdRequest, SwitchMerchantIdRequest, TokenOrPayloadResponse, TokenResponse, + TwoFactorAuthStatusResponse, UpdateUserAccountDetailsRequest, + UpdateUserAuthenticationMethodRequest, UserFromEmailRequest, UserMerchantCreate, + VerifyEmailRequest, VerifyRecoveryCodeRequest, VerifyTotpRequest, }; impl ApiEventMetric for DashboardEntryResponse { @@ -77,7 +78,10 @@ common_utils::impl_misc_api_event_type!( BeginTotpResponse, VerifyRecoveryCodeRequest, VerifyTotpRequest, - RecoveryCodes + RecoveryCodes, + GetUserAuthenticationMethodsRequest, + CreateUserAuthenticationMethodRequest, + UpdateUserAuthenticationMethodRequest ); #[cfg(feature = "dummy_connector")] diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs index a61b9fd7df..6d567e7dca 100644 --- a/crates/api_models/src/user.rs +++ b/crates/api_models/src/user.rs @@ -280,3 +280,78 @@ pub struct VerifyRecoveryCodeRequest { pub struct RecoveryCodes { pub recovery_codes: Vec>, } + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(tag = "auth_type")] +#[serde(rename_all = "snake_case")] +pub enum AuthConfig { + OpenIdConnect { + private_config: OpenIdConnectPrivateConfig, + public_config: OpenIdConnectPublicConfig, + }, + MagicLink, + Password, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] +pub struct OpenIdConnectPrivateConfig { + pub base_url: String, + pub client_id: Secret, + pub client_secret: Secret, + pub private_key: Option>, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] +pub struct OpenIdConnectPublicConfig { + pub name: OpenIdProvider, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum OpenIdProvider { + Okta, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct OpenIdConnect { + pub name: OpenIdProvider, + pub base_url: String, + pub client_id: String, + pub client_secret: Secret, + pub private_key: Option>, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct CreateUserAuthenticationMethodRequest { + pub owner_id: String, + pub owner_type: common_enums::Owner, + pub auth_method: AuthConfig, + pub allow_signup: bool, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct UpdateUserAuthenticationMethodRequest { + pub id: String, + // TODO: When adding more fields make config and new fields option + pub auth_method: AuthConfig, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct GetUserAuthenticationMethodsRequest { + pub auth_id: String, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct UserAuthenticationMethodResponse { + pub id: String, + pub auth_id: String, + pub auth_method: AuthMethodDetails, + pub allow_signup: bool, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct AuthMethodDetails { + #[serde(rename = "type")] + pub auth_type: common_enums::UserAuthType, + pub name: Option, +} diff --git a/crates/common_enums/src/enums.rs b/crates/common_enums/src/enums.rs index f3a3b02155..decbf9fd12 100644 --- a/crates/common_enums/src/enums.rs +++ b/crates/common_enums/src/enums.rs @@ -2768,3 +2768,45 @@ pub enum TokenPurpose { AcceptInvite, UserInfo, } + +#[derive( + Clone, + Copy, + Debug, + Default, + Eq, + PartialEq, + serde::Deserialize, + serde::Serialize, + strum::Display, + strum::EnumString, +)] +#[router_derive::diesel_enum(storage_type = "text")] +#[strum(serialize_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum UserAuthType { + OpenIdConnect, + MagicLink, + #[default] + Password, +} + +#[derive( + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Deserialize, + serde::Serialize, + strum::Display, + strum::EnumString, +)] +#[router_derive::diesel_enum(storage_type = "text")] +#[strum(serialize_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum Owner { + Organization, + Tenant, + Internal, +} diff --git a/crates/diesel_models/src/lib.rs b/crates/diesel_models/src/lib.rs index d7d10569f7..36dec7dde5 100644 --- a/crates/diesel_models/src/lib.rs +++ b/crates/diesel_models/src/lib.rs @@ -44,6 +44,7 @@ pub mod routing_algorithm; #[allow(unused_qualifications)] pub mod schema; pub mod user; +pub mod user_authentication_method; pub mod user_key_store; pub mod user_role; diff --git a/crates/diesel_models/src/query.rs b/crates/diesel_models/src/query.rs index 335c2db916..9fbdf53554 100644 --- a/crates/diesel_models/src/query.rs +++ b/crates/diesel_models/src/query.rs @@ -36,5 +36,6 @@ pub mod reverse_lookup; pub mod role; pub mod routing_algorithm; pub mod user; +pub mod user_authentication_method; pub mod user_key_store; pub mod user_role; diff --git a/crates/diesel_models/src/query/user_authentication_method.rs b/crates/diesel_models/src/query/user_authentication_method.rs new file mode 100644 index 0000000000..08ea6556b8 --- /dev/null +++ b/crates/diesel_models/src/query/user_authentication_method.rs @@ -0,0 +1,60 @@ +use diesel::{associations::HasTable, ExpressionMethods}; + +use crate::{ + query::generics, schema::user_authentication_methods::dsl, user_authentication_method::*, + PgPooledConn, StorageResult, +}; + +impl UserAuthenticationMethodNew { + pub async fn insert(self, conn: &PgPooledConn) -> StorageResult { + generics::generic_insert(conn, self).await + } +} + +impl UserAuthenticationMethod { + pub async fn list_user_authentication_methods_for_auth_id( + conn: &PgPooledConn, + auth_id: &str, + ) -> StorageResult> { + generics::generic_filter::<::Table, _, _, _>( + conn, + dsl::auth_id.eq(auth_id.to_owned()), + None, + None, + Some(dsl::last_modified_at.asc()), + ) + .await + } + + pub async fn list_user_authentication_methods_for_owner_id( + conn: &PgPooledConn, + owner_id: &str, + ) -> StorageResult> { + generics::generic_filter::<::Table, _, _, _>( + conn, + dsl::owner_id.eq(owner_id.to_owned()), + None, + None, + Some(dsl::last_modified_at.asc()), + ) + .await + } + + pub async fn update_user_authentication_method( + conn: &PgPooledConn, + id: &str, + user_authentication_method_update: UserAuthenticationMethodUpdate, + ) -> StorageResult { + generics::generic_update_with_unique_predicate_get_result::< + ::Table, + _, + _, + _, + >( + conn, + dsl::id.eq(id.to_owned()), + OrgAuthenticationMethodUpdateInternal::from(user_authentication_method_update), + ) + .await + } +} diff --git a/crates/diesel_models/src/schema.rs b/crates/diesel_models/src/schema.rs index 08d8f423b7..5d796444d7 100644 --- a/crates/diesel_models/src/schema.rs +++ b/crates/diesel_models/src/schema.rs @@ -1172,6 +1172,29 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + use crate::enums::diesel_exports::*; + + user_authentication_methods (id) { + #[max_length = 64] + id -> Varchar, + #[max_length = 64] + auth_id -> Varchar, + #[max_length = 64] + owner_id -> Varchar, + #[max_length = 64] + owner_type -> Varchar, + #[max_length = 64] + auth_type -> Varchar, + private_config -> Nullable, + public_config -> Nullable, + allow_signup -> Bool, + created_at -> Timestamp, + last_modified_at -> Timestamp, + } +} + diesel::table! { use diesel::sql_types::*; use crate::enums::diesel_exports::*; @@ -1270,6 +1293,7 @@ diesel::allow_tables_to_appear_in_same_query!( reverse_lookup, roles, routing_algorithm, + user_authentication_methods, user_key_store, user_roles, users, diff --git a/crates/diesel_models/src/user_authentication_method.rs b/crates/diesel_models/src/user_authentication_method.rs new file mode 100644 index 0000000000..2b4ed23c1d --- /dev/null +++ b/crates/diesel_models/src/user_authentication_method.rs @@ -0,0 +1,65 @@ +use diesel::{AsChangeset, Identifiable, Insertable, Queryable}; +use time::PrimitiveDateTime; + +use crate::{encryption::Encryption, enums, schema::user_authentication_methods}; + +#[derive(Clone, Debug, Identifiable, Queryable)] +#[diesel(table_name = user_authentication_methods)] +pub struct UserAuthenticationMethod { + pub id: String, + pub auth_id: String, + pub owner_id: String, + pub owner_type: enums::Owner, + pub auth_type: enums::UserAuthType, + pub private_config: Option, + pub public_config: Option, + pub allow_signup: bool, + pub created_at: PrimitiveDateTime, + pub last_modified_at: PrimitiveDateTime, +} + +#[derive(router_derive::Setter, Clone, Debug, Insertable, router_derive::DebugAsDisplay)] +#[diesel(table_name = user_authentication_methods)] +pub struct UserAuthenticationMethodNew { + pub id: String, + pub auth_id: String, + pub owner_id: String, + pub owner_type: enums::Owner, + pub auth_type: enums::UserAuthType, + pub private_config: Option, + pub public_config: Option, + pub allow_signup: bool, + pub created_at: PrimitiveDateTime, + pub last_modified_at: PrimitiveDateTime, +} + +#[derive(Clone, Debug, AsChangeset, router_derive::DebugAsDisplay)] +#[diesel(table_name = user_authentication_methods)] +pub struct OrgAuthenticationMethodUpdateInternal { + pub private_config: Option, + pub public_config: Option, + pub last_modified_at: PrimitiveDateTime, +} + +pub enum UserAuthenticationMethodUpdate { + UpdateConfig { + private_config: Option, + public_config: Option, + }, +} + +impl From for OrgAuthenticationMethodUpdateInternal { + fn from(value: UserAuthenticationMethodUpdate) -> Self { + let last_modified_at = common_utils::date_time::now(); + match value { + UserAuthenticationMethodUpdate::UpdateConfig { + private_config, + public_config, + } => Self { + private_config, + public_config, + last_modified_at, + }, + } + } +} diff --git a/crates/router/src/configs/secrets_transformers.rs b/crates/router/src/configs/secrets_transformers.rs index 2f90833905..1ad65c363b 100644 --- a/crates/router/src/configs/secrets_transformers.rs +++ b/crates/router/src/configs/secrets_transformers.rs @@ -220,6 +220,22 @@ impl SecretsHandler for settings::Secrets { } } +#[async_trait::async_trait] +impl SecretsHandler for settings::UserAuthMethodSettings { + async fn convert_to_raw_secret( + value: SecretStateContainer, + secret_management_client: &dyn SecretManagementInterface, + ) -> CustomResult, SecretsManagementError> { + let user_auth_methods = value.get_inner(); + + let encryption_key = secret_management_client + .get_secret(user_auth_methods.encryption_key.clone()) + .await?; + + Ok(value.transition_state(|_| Self { encryption_key })) + } +} + /// # Panics /// /// Will panic even if kms decryption fails for at least one field @@ -302,6 +318,14 @@ pub(crate) async fn fetch_raw_secrets( .await .expect("Failed to decrypt payment method auth configs"); + #[allow(clippy::expect_used)] + let user_auth_methods = settings::UserAuthMethodSettings::convert_to_raw_secret( + conf.user_auth_methods, + secret_management_client, + ) + .await + .expect("Failed to decrypt user_auth_methods configs"); + Settings { server: conf.server, master_database, @@ -368,5 +392,6 @@ pub(crate) async fn fetch_raw_secrets( unmasked_headers: conf.unmasked_headers, saved_payment_methods: conf.saved_payment_methods, multitenancy: conf.multitenancy, + user_auth_methods, } } diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index daba006a56..9e345315e9 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -123,6 +123,7 @@ pub struct Settings { pub unmasked_headers: UnmaskedHeaders, pub multitenancy: Multitenancy, pub saved_payment_methods: EligiblePaymentMethods, + pub user_auth_methods: SecretStateContainer, } #[derive(Debug, Deserialize, Clone, Default)] @@ -615,6 +616,11 @@ pub struct ConnectorRequestReferenceIdConfig { pub merchant_ids_send_payment_id_as_connector_request_id: HashSet, } +#[derive(Debug, Deserialize, Clone, Default)] +pub struct UserAuthMethodSettings { + pub encryption_key: Secret, +} + impl Settings { pub fn new() -> ApplicationResult { Self::with_config_path(None) diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs index 865bc1d412..28b80e16cc 100644 --- a/crates/router/src/core/errors/user.rs +++ b/crates/router/src/core/errors/user.rs @@ -80,6 +80,12 @@ pub enum UserErrors { TwoFactorAuthNotSetup, #[error("TOTP secret not found")] TotpSecretNotFound, + #[error("User auth method already exists")] + UserAuthMethodAlreadyExists, + #[error("Invalid user auth method operation")] + InvalidUserAuthMethodOperation, + #[error("Auth config parsing error")] + AuthConfigParsingError, } impl common_utils::errors::ErrorSwitch for UserErrors { @@ -204,6 +210,15 @@ impl common_utils::errors::ErrorSwitch { AER::BadRequest(ApiError::new(sub_code, 42, self.get_error_message(), None)) } + Self::UserAuthMethodAlreadyExists => { + AER::BadRequest(ApiError::new(sub_code, 43, self.get_error_message(), None)) + } + Self::InvalidUserAuthMethodOperation => { + AER::BadRequest(ApiError::new(sub_code, 44, self.get_error_message(), None)) + } + Self::AuthConfigParsingError => { + AER::BadRequest(ApiError::new(sub_code, 45, self.get_error_message(), None)) + } } } } @@ -247,6 +262,9 @@ impl UserErrors { Self::TwoFactorAuthRequired => "Two factor auth required", Self::TwoFactorAuthNotSetup => "Two factor auth not setup", Self::TotpSecretNotFound => "TOTP secret not found", + Self::UserAuthMethodAlreadyExists => "User auth method already exists", + Self::InvalidUserAuthMethodOperation => "Invalid user auth method operation", + Self::AuthConfigParsingError => "Auth config parsing error", } } } diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs index 7e79e9e2e8..6803b79d6c 100644 --- a/crates/router/src/core/user.rs +++ b/crates/router/src/core/user.rs @@ -1,11 +1,13 @@ use std::collections::HashMap; use api_models::user::{self as user_api, InviteMultipleUserResponse}; +use common_utils::ext_traits::ValueExt; #[cfg(feature = "email")] use diesel_models::user_role::UserRoleUpdate; use diesel_models::{ enums::{TotpStatus, UserStatus}, user as storage_user, + user_authentication_method::{UserAuthenticationMethodNew, UserAuthenticationMethodUpdate}, user_role::UserRoleNew, }; use error_stack::{report, ResultExt}; @@ -884,7 +886,7 @@ pub async fn resend_invite( if e.current_context().is_db_not_found() { e.change_context(UserErrors::InvalidRoleOperation) .attach_printable(format!( - "User role with user_id = {} and org_id = {} is not found", + "User role with user_id = {} and merchant_id = {} is not found", user.get_user_id(), user_from_token.merchant_id )) @@ -1982,3 +1984,184 @@ pub async fn check_two_factor_auth_status( }, )) } + +pub async fn create_user_authentication_method( + state: SessionState, + req: user_api::CreateUserAuthenticationMethodRequest, +) -> UserResponse<()> { + let user_auth_encryption_key = hex::decode( + state + .conf + .user_auth_methods + .get_inner() + .encryption_key + .clone() + .expose(), + ) + .change_context(UserErrors::InternalServerError) + .attach_printable("Failed to decode DEK")?; + + let (private_config, public_config) = match req.auth_method { + user_api::AuthConfig::OpenIdConnect { + ref private_config, + ref public_config, + } => { + let private_config_value = serde_json::to_value(private_config.clone()) + .change_context(UserErrors::AuthConfigParsingError) + .attach_printable("Failed to convert auth config to json")?; + + let encrypted_config = domain::types::encrypt::( + private_config_value.into(), + &user_auth_encryption_key, + ) + .await + .change_context(UserErrors::InternalServerError) + .attach_printable("Failed to encrypt auth config")?; + + Ok::<_, error_stack::Report>(( + Some(encrypted_config.into()), + Some( + serde_json::to_value(public_config.clone()) + .change_context(UserErrors::AuthConfigParsingError) + .attach_printable("Failed to convert auth config to json")?, + ), + )) + } + _ => Ok((None, None)), + }?; + + let auth_methods = state + .store + .list_user_authentication_methods_for_owner_id(&req.owner_id) + .await + .change_context(UserErrors::InternalServerError) + .attach_printable("Failed to get list of auth methods for the owner id")?; + + let auth_id = auth_methods + .first() + .map(|auth_method| auth_method.auth_id.clone()) + .unwrap_or(uuid::Uuid::new_v4().to_string()); + + let now = common_utils::date_time::now(); + state + .store + .insert_user_authentication_method(UserAuthenticationMethodNew { + id: uuid::Uuid::new_v4().to_string(), + auth_id, + owner_id: req.owner_id, + owner_type: req.owner_type, + auth_type: req.auth_method.foreign_into(), + private_config, + public_config, + allow_signup: req.allow_signup, + created_at: now, + last_modified_at: now, + }) + .await + .to_duplicate_response(UserErrors::UserAuthMethodAlreadyExists)?; + + Ok(ApplicationResponse::StatusOk) +} + +pub async fn update_user_authentication_method( + state: SessionState, + req: user_api::UpdateUserAuthenticationMethodRequest, +) -> UserResponse<()> { + let user_auth_encryption_key = hex::decode( + state + .conf + .user_auth_methods + .get_inner() + .encryption_key + .clone() + .expose(), + ) + .change_context(UserErrors::InternalServerError) + .attach_printable("Failed to decode DEK")?; + + let (private_config, public_config) = match req.auth_method { + user_api::AuthConfig::OpenIdConnect { + ref private_config, + ref public_config, + } => { + let private_config_value = serde_json::to_value(private_config.clone()) + .change_context(UserErrors::AuthConfigParsingError) + .attach_printable("Failed to convert auth config to json")?; + + let encrypted_config = domain::types::encrypt::( + private_config_value.into(), + &user_auth_encryption_key, + ) + .await + .change_context(UserErrors::InternalServerError) + .attach_printable("Failed to encrypt auth config")?; + + Ok::<_, error_stack::Report>(( + Some(encrypted_config.into()), + Some( + serde_json::to_value(public_config.clone()) + .change_context(UserErrors::AuthConfigParsingError) + .attach_printable("Failed to convert auth config to json")?, + ), + )) + } + _ => Ok((None, None)), + }?; + + state + .store + .update_user_authentication_method( + &req.id, + UserAuthenticationMethodUpdate::UpdateConfig { + private_config, + public_config, + }, + ) + .await + .change_context(UserErrors::InvalidUserAuthMethodOperation)?; + Ok(ApplicationResponse::StatusOk) +} + +pub async fn list_user_authentication_methods( + state: SessionState, + req: user_api::GetUserAuthenticationMethodsRequest, +) -> UserResponse> { + let user_authentication_methods = state + .store + .list_user_authentication_methods_for_auth_id(&req.auth_id) + .await + .change_context(UserErrors::InternalServerError)?; + + Ok(ApplicationResponse::Json( + user_authentication_methods + .into_iter() + .map(|auth_method| { + let auth_name = match (auth_method.auth_type, auth_method.public_config) { + (common_enums::UserAuthType::OpenIdConnect, Some(config)) => { + let open_id_public_config: user_api::OpenIdConnectPublicConfig = config + .parse_value("OpenIdConnectPublicConfig") + .change_context(UserErrors::InternalServerError) + .attach_printable("unable to parse generic data value")?; + + Ok(Some(open_id_public_config.name)) + } + (common_enums::UserAuthType::OpenIdConnect, None) => { + Err(UserErrors::InternalServerError) + .attach_printable("No config found for open_id_connect auth_method") + } + _ => Ok(None), + }?; + + Ok(user_api::UserAuthenticationMethodResponse { + id: auth_method.id, + auth_id: auth_method.auth_id, + auth_method: user_api::AuthMethodDetails { + name: auth_name, + auth_type: auth_method.auth_type, + }, + allow_signup: auth_method.allow_signup, + }) + }) + .collect::>()?, + )) +} diff --git a/crates/router/src/db.rs b/crates/router/src/db.rs index b1a00c33df..2bf80827e8 100644 --- a/crates/router/src/db.rs +++ b/crates/router/src/db.rs @@ -32,6 +32,7 @@ pub mod reverse_lookup; pub mod role; pub mod routing_algorithm; pub mod user; +pub mod user_authentication_method; pub mod user_key_store; pub mod user_role; @@ -117,6 +118,7 @@ pub trait StorageInterface: + user::sample_data::BatchSampleDataInterface + health_check::HealthCheckDbInterface + role::RoleInterface + + user_authentication_method::UserAuthenticationMethodInterface + authentication::AuthenticationInterface + 'static { diff --git a/crates/router/src/db/kafka_store.rs b/crates/router/src/db/kafka_store.rs index ca36a53418..2964fd78bc 100644 --- a/crates/router/src/db/kafka_store.rs +++ b/crates/router/src/db/kafka_store.rs @@ -33,6 +33,7 @@ use super::{ dashboard_metadata::DashboardMetadataInterface, role::RoleInterface, user::{sample_data::BatchSampleDataInterface, UserInterface}, + user_authentication_method::UserAuthenticationMethodInterface, user_key_store::UserKeyStoreInterface, user_role::UserRoleInterface, }; @@ -2874,3 +2875,43 @@ impl UserKeyStoreInterface for KafkaStore { .await } } + +#[async_trait::async_trait] +impl UserAuthenticationMethodInterface for KafkaStore { + async fn insert_user_authentication_method( + &self, + user_authentication_method: storage::UserAuthenticationMethodNew, + ) -> CustomResult { + self.diesel_store + .insert_user_authentication_method(user_authentication_method) + .await + } + + async fn list_user_authentication_methods_for_auth_id( + &self, + auth_id: &str, + ) -> CustomResult, errors::StorageError> { + self.diesel_store + .list_user_authentication_methods_for_auth_id(auth_id) + .await + } + + async fn list_user_authentication_methods_for_owner_id( + &self, + owner_id: &str, + ) -> CustomResult, errors::StorageError> { + self.diesel_store + .list_user_authentication_methods_for_owner_id(owner_id) + .await + } + + async fn update_user_authentication_method( + &self, + id: &str, + user_authentication_method_update: storage::UserAuthenticationMethodUpdate, + ) -> CustomResult { + self.diesel_store + .update_user_authentication_method(id, user_authentication_method_update) + .await + } +} diff --git a/crates/router/src/db/user_authentication_method.rs b/crates/router/src/db/user_authentication_method.rs new file mode 100644 index 0000000000..5b9aa5da8c --- /dev/null +++ b/crates/router/src/db/user_authentication_method.rs @@ -0,0 +1,197 @@ +use diesel_models::user_authentication_method::{self as storage}; +use error_stack::report; +use router_env::{instrument, tracing}; + +use super::MockDb; +use crate::{ + connection, + core::errors::{self, CustomResult}, + services::Store, +}; + +#[async_trait::async_trait] +pub trait UserAuthenticationMethodInterface { + async fn insert_user_authentication_method( + &self, + user_authentication_method: storage::UserAuthenticationMethodNew, + ) -> CustomResult; + + async fn list_user_authentication_methods_for_auth_id( + &self, + auth_id: &str, + ) -> CustomResult, errors::StorageError>; + + async fn list_user_authentication_methods_for_owner_id( + &self, + owner_id: &str, + ) -> CustomResult, errors::StorageError>; + + async fn update_user_authentication_method( + &self, + id: &str, + user_authentication_method_update: storage::UserAuthenticationMethodUpdate, + ) -> CustomResult; +} + +#[async_trait::async_trait] +impl UserAuthenticationMethodInterface for Store { + #[instrument(skip_all)] + async fn insert_user_authentication_method( + &self, + user_authentication_method: storage::UserAuthenticationMethodNew, + ) -> CustomResult { + let conn = connection::pg_connection_write(self).await?; + user_authentication_method + .insert(&conn) + .await + .map_err(|error| report!(errors::StorageError::from(error))) + } + + #[instrument(skip_all)] + async fn list_user_authentication_methods_for_auth_id( + &self, + auth_id: &str, + ) -> CustomResult, errors::StorageError> { + let conn = connection::pg_connection_write(self).await?; + storage::UserAuthenticationMethod::list_user_authentication_methods_for_auth_id( + &conn, auth_id, + ) + .await + .map_err(|error| report!(errors::StorageError::from(error))) + } + + #[instrument(skip_all)] + async fn list_user_authentication_methods_for_owner_id( + &self, + owner_id: &str, + ) -> CustomResult, errors::StorageError> { + let conn = connection::pg_connection_write(self).await?; + storage::UserAuthenticationMethod::list_user_authentication_methods_for_owner_id( + &conn, owner_id, + ) + .await + .map_err(|error| report!(errors::StorageError::from(error))) + } + + #[instrument(skip_all)] + async fn update_user_authentication_method( + &self, + id: &str, + user_authentication_method_update: storage::UserAuthenticationMethodUpdate, + ) -> CustomResult { + let conn = connection::pg_connection_write(self).await?; + storage::UserAuthenticationMethod::update_user_authentication_method( + &conn, + id, + user_authentication_method_update, + ) + .await + .map_err(|error| report!(errors::StorageError::from(error))) + } +} + +#[async_trait::async_trait] +impl UserAuthenticationMethodInterface for MockDb { + async fn insert_user_authentication_method( + &self, + user_authentication_method: storage::UserAuthenticationMethodNew, + ) -> CustomResult { + let mut user_authentication_methods = self.user_authentication_methods.lock().await; + let existing_auth_id = user_authentication_methods + .iter() + .find(|uam| uam.owner_id == user_authentication_method.owner_id) + .map(|uam| uam.auth_id.clone()); + + let auth_id = existing_auth_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let user_authentication_method = storage::UserAuthenticationMethod { + id: uuid::Uuid::new_v4().to_string(), + auth_id, + owner_id: user_authentication_method.auth_id, + owner_type: user_authentication_method.owner_type, + auth_type: user_authentication_method.auth_type, + public_config: user_authentication_method.public_config, + private_config: user_authentication_method.private_config, + allow_signup: user_authentication_method.allow_signup, + created_at: user_authentication_method.created_at, + last_modified_at: user_authentication_method.last_modified_at, + }; + + user_authentication_methods.push(user_authentication_method.clone()); + Ok(user_authentication_method) + } + + async fn list_user_authentication_methods_for_auth_id( + &self, + auth_id: &str, + ) -> CustomResult, errors::StorageError> { + let user_authentication_methods = self.user_authentication_methods.lock().await; + + let user_authentication_methods_list: Vec<_> = user_authentication_methods + .iter() + .filter(|auth_method_inner| auth_method_inner.auth_id == auth_id) + .cloned() + .collect(); + if user_authentication_methods_list.is_empty() { + return Err(errors::StorageError::ValueNotFound(format!( + "No user authentication method found for auth_id = {}", + auth_id + )) + .into()); + } + + Ok(user_authentication_methods_list) + } + + async fn list_user_authentication_methods_for_owner_id( + &self, + owner_id: &str, + ) -> CustomResult, errors::StorageError> { + let user_authentication_methods = self.user_authentication_methods.lock().await; + + let user_authentication_methods_list: Vec<_> = user_authentication_methods + .iter() + .filter(|auth_method_inner| auth_method_inner.owner_id == owner_id) + .cloned() + .collect(); + if user_authentication_methods_list.is_empty() { + return Err(errors::StorageError::ValueNotFound(format!( + "No user authentication method found for owner_id = {}", + owner_id + )) + .into()); + } + + Ok(user_authentication_methods_list) + } + + async fn update_user_authentication_method( + &self, + id: &str, + user_authentication_method_update: storage::UserAuthenticationMethodUpdate, + ) -> CustomResult { + let mut user_authentication_methods = self.user_authentication_methods.lock().await; + user_authentication_methods + .iter_mut() + .find(|auth_method_inner| auth_method_inner.id == id) + .map(|auth_method_inner| { + *auth_method_inner = match user_authentication_method_update { + storage::UserAuthenticationMethodUpdate::UpdateConfig { + private_config, + public_config, + } => storage::UserAuthenticationMethod { + private_config, + public_config, + last_modified_at: common_utils::date_time::now(), + ..auth_method_inner.to_owned() + }, + }; + auth_method_inner.to_owned() + }) + .ok_or( + errors::StorageError::ValueNotFound(format!( + "No authentication method available for the id = {id}" + )) + .into(), + ) + } +} diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 2e7a6c4f61..d4aebc3adc 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -1377,6 +1377,18 @@ impl User { ), ); + route = route.service( + web::scope("/auth") + .service( + web::resource("") + .route(web::post().to(create_user_authentication_method)) + .route(web::put().to(update_user_authentication_method)), + ) + .service( + web::resource("/list").route(web::get().to(list_user_authentication_methods)), + ), + ); + #[cfg(feature = "email")] { route = route diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index a64343757b..7201741178 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -223,7 +223,10 @@ impl From for ApiIdentifier { | Flow::RecoveryCodeVerify | Flow::RecoveryCodesGenerate | Flow::TerminateTwoFactorAuth - | Flow::TwoFactorAuthStatus => Self::User, + | Flow::TwoFactorAuthStatus + | Flow::CreateUserAuthenticationMethod + | Flow::UpdateUserAuthenticationMethod + | Flow::ListUserAuthenticationMethods => Self::User, Flow::ListRoles | Flow::GetRole diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs index 5325cbe437..f297e41e4e 100644 --- a/crates/router/src/routes/user.rs +++ b/crates/router/src/routes/user.rs @@ -749,3 +749,60 @@ pub async fn check_two_factor_auth_status( )) .await } + +pub async fn create_user_authentication_method( + state: web::Data, + req: HttpRequest, + json_payload: web::Json, +) -> HttpResponse { + let flow = Flow::CreateUserAuthenticationMethod; + + Box::pin(api::server_wrap( + flow, + state.clone(), + &req, + json_payload.into_inner(), + |state, _, req_body, _| user_core::create_user_authentication_method(state, req_body), + &auth::AdminApiAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} + +pub async fn update_user_authentication_method( + state: web::Data, + req: HttpRequest, + json_payload: web::Json, +) -> HttpResponse { + let flow = Flow::UpdateUserAuthenticationMethod; + + Box::pin(api::server_wrap( + flow, + state.clone(), + &req, + json_payload.into_inner(), + |state, _, req_body, _| user_core::update_user_authentication_method(state, req_body), + &auth::AdminApiAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} + +pub async fn list_user_authentication_methods( + state: web::Data, + req: HttpRequest, + query: web::Query, +) -> HttpResponse { + let flow = Flow::ListUserAuthenticationMethods; + + Box::pin(api::server_wrap( + flow, + state.clone(), + &req, + query.into_inner(), + |state, _, req, _| user_core::list_user_authentication_methods(state, req), + &auth::NoAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} diff --git a/crates/router/src/types/storage.rs b/crates/router/src/types/storage.rs index f5626c267d..5a358c272b 100644 --- a/crates/router/src/types/storage.rs +++ b/crates/router/src/types/storage.rs @@ -35,6 +35,7 @@ pub mod reverse_lookup; pub mod role; pub mod routing_algorithm; pub mod user; +pub mod user_authentication_method; pub mod user_role; use std::collections::HashMap; @@ -62,7 +63,7 @@ pub use self::{ file::*, fraud_check::*, gsm::*, locker_mock_up::*, mandate::*, merchant_account::*, merchant_connector_account::*, merchant_key_store::*, payment_link::*, payment_method::*, process_tracker::*, refund::*, reverse_lookup::*, role::*, routing_algorithm::*, user::*, - user_role::*, + user_authentication_method::*, user_role::*, }; use crate::types::api::routing; diff --git a/crates/router/src/types/storage/user_authentication_method.rs b/crates/router/src/types/storage/user_authentication_method.rs new file mode 100644 index 0000000000..87856f3805 --- /dev/null +++ b/crates/router/src/types/storage/user_authentication_method.rs @@ -0,0 +1 @@ +pub use diesel_models::user_authentication_method::*; diff --git a/crates/router/src/utils/user.rs b/crates/router/src/utils/user.rs index 3792da49e8..25c851549d 100644 --- a/crates/router/src/utils/user.rs +++ b/crates/router/src/utils/user.rs @@ -13,7 +13,10 @@ use crate::{ authentication::{AuthToken, UserFromToken}, authorization::roles::RoleInfo, }, - types::domain::{self, MerchantAccount, UserFromStorage}, + types::{ + domain::{self, MerchantAccount, UserFromStorage}, + transformers::ForeignFrom, + }, }; pub mod dashboard_metadata; @@ -200,3 +203,13 @@ pub fn get_redis_connection(state: &SessionState) -> UserResult for common_enums::UserAuthType { + fn foreign_from(from: user_api::AuthConfig) -> Self { + match from { + user_api::AuthConfig::OpenIdConnect { .. } => Self::OpenIdConnect, + user_api::AuthConfig::Password => Self::Password, + user_api::AuthConfig::MagicLink => Self::MagicLink, + } + } +} diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 51f762e371..3a7cb3ba2a 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -420,6 +420,12 @@ pub enum Flow { TerminateTwoFactorAuth, // Check 2FA status TwoFactorAuthStatus, + // Create user authentication method + CreateUserAuthenticationMethod, + // Update user authentication method + UpdateUserAuthenticationMethod, + // List user authentication methods + ListUserAuthenticationMethods, /// List initial webhook delivery attempts WebhookEventInitialDeliveryAttemptList, /// List delivery attempts for a webhook event diff --git a/crates/storage_impl/src/mock_db.rs b/crates/storage_impl/src/mock_db.rs index 0ada6513ff..3434bcb67d 100644 --- a/crates/storage_impl/src/mock_db.rs +++ b/crates/storage_impl/src/mock_db.rs @@ -58,6 +58,8 @@ pub struct MockDb { pub authentications: Arc>>, pub roles: Arc>>, pub user_key_store: Arc>>, + pub user_authentication_methods: + Arc>>, } impl MockDb { @@ -102,6 +104,7 @@ impl MockDb { authentications: Default::default(), roles: Default::default(), user_key_store: Default::default(), + user_authentication_methods: Default::default(), }) } } diff --git a/migrations/2024-06-10-084722_create_user_authentication_methods_table/down.sql b/migrations/2024-06-10-084722_create_user_authentication_methods_table/down.sql new file mode 100644 index 0000000000..e12da6d51a --- /dev/null +++ b/migrations/2024-06-10-084722_create_user_authentication_methods_table/down.sql @@ -0,0 +1,4 @@ +-- This file should undo anything in `up.sql` +DROP INDEX IF EXISTS auth_id_index; +DROP INDEX IF EXISTS owner_id_index; +DROP TABLE IF EXISTS user_authentication_methods; diff --git a/migrations/2024-06-10-084722_create_user_authentication_methods_table/up.sql b/migrations/2024-06-10-084722_create_user_authentication_methods_table/up.sql new file mode 100644 index 0000000000..4bdb835eeb --- /dev/null +++ b/migrations/2024-06-10-084722_create_user_authentication_methods_table/up.sql @@ -0,0 +1,16 @@ +-- Your SQL goes here +CREATE TABLE IF NOT EXISTS user_authentication_methods ( + id VARCHAR(64) PRIMARY KEY, + auth_id VARCHAR(64) NOT NULL, + owner_id VARCHAR(64) NOT NULL, + owner_type VARCHAR(64) NOT NULL, + auth_type VARCHAR(64) NOT NULL, + private_config bytea, + public_config JSONB, + allow_signup BOOLEAN NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + last_modified_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS auth_id_index ON user_authentication_methods (auth_id); +CREATE INDEX IF NOT EXISTS owner_id_index ON user_authentication_methods (owner_id);