diff --git a/crates/api_models/src/enums.rs b/crates/api_models/src/enums.rs index 7b33097d2b..8c945a0303 100644 --- a/crates/api_models/src/enums.rs +++ b/crates/api_models/src/enums.rs @@ -599,3 +599,8 @@ pub fn convert_pm_auth_connector(connector_name: &str) -> Option Option { AuthenticationConnectors::from_str(connector_name).ok() } + +#[cfg(feature = "frm")] +pub fn convert_frm_connector(connector_name: &str) -> Option { + FrmConnectors::from_str(connector_name).ok() +} diff --git a/crates/api_models/src/webhooks.rs b/crates/api_models/src/webhooks.rs index df9b1249c7..b5428b6fca 100644 --- a/crates/api_models/src/webhooks.rs +++ b/crates/api_models/src/webhooks.rs @@ -39,6 +39,8 @@ pub enum IncomingWebhookEvent { MandateRevoked, EndpointVerification, ExternalAuthenticationARes, + FrmApproved, + FrmRejected, } pub enum WebhookFlow { @@ -50,6 +52,7 @@ pub enum WebhookFlow { BankTransfer, Mandate, ExternalAuthentication, + FraudCheck, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -119,6 +122,9 @@ impl From for WebhookFlow { IncomingWebhookEvent::SourceChargeable | IncomingWebhookEvent::SourceTransactionCreated => Self::BankTransfer, IncomingWebhookEvent::ExternalAuthenticationARes => Self::ExternalAuthentication, + IncomingWebhookEvent::FrmApproved | IncomingWebhookEvent::FrmRejected => { + Self::FraudCheck + } } } } diff --git a/crates/router/src/connector/riskified.rs b/crates/router/src/connector/riskified.rs index c7cb321f71..28678bbd93 100644 --- a/crates/router/src/connector/riskified.rs +++ b/crates/router/src/connector/riskified.rs @@ -2,19 +2,24 @@ pub mod transformers; use std::fmt::Debug; #[cfg(feature = "frm")] -use common_utils::request::RequestContent; -use error_stack::{report, ResultExt}; +use base64::Engine; +#[cfg(feature = "frm")] +use common_utils::{crypto, ext_traits::ByteSliceExt, request::RequestContent}; +#[cfg(feature = "frm")] +use error_stack::ResultExt; +#[cfg(feature = "frm")] use masking::{ExposeInterface, PeekInterface}; +#[cfg(feature = "frm")] use ring::hmac; +#[cfg(feature = "frm")] use transformers as riskified; #[cfg(feature = "frm")] -use super::utils::FrmTransactionRouterDataRequest; +use super::utils::{self as connector_utils, FrmTransactionRouterDataRequest}; use crate::{ configs::settings, core::errors::{self, CustomResult}, - headers, - services::{self, request, ConnectorIntegration, ConnectorValidation}, + services::{self, ConnectorIntegration, ConnectorValidation}, types::{ self, api::{self, ConnectorCommon, ConnectorCommonExt}, @@ -22,8 +27,13 @@ use crate::{ }; #[cfg(feature = "frm")] use crate::{ + consts, events::connector_api_logs::ConnectorEvent, - types::{api::fraud_check as frm_api, fraud_check as frm_types, ErrorResponse, Response}, + headers, + services::request, + types::{ + api::fraud_check as frm_api, domain, fraud_check as frm_types, ErrorResponse, Response, + }, utils::BytesExt, }; @@ -31,6 +41,7 @@ use crate::{ pub struct Riskified; impl Riskified { + #[cfg(feature = "frm")] pub fn generate_authorization_signature( &self, auth: &riskified::RiskifiedAuthType, @@ -53,6 +64,7 @@ impl ConnectorCommonExt for Ri where Self: ConnectorIntegration, { + #[cfg(feature = "frm")] fn build_headers( &self, req: &types::RouterData, @@ -122,7 +134,7 @@ impl ConnectorCommon for Riskified { Ok(ErrorResponse { status_code: res.status_code, attempt_status: None, - code: crate::consts::NO_ERROR_CODE.to_string(), + code: consts::NO_ERROR_CODE.to_string(), message: response.error.message.clone(), reason: None, connector_transaction_id: None, @@ -268,11 +280,7 @@ impl self.base_url(connectors), "/checkout_denied" )), - Some(true) => Ok(format!("{}{}", self.base_url(connectors), "/decision")), - None => Err(errors::ConnectorError::FlowNotSupported { - flow: "Transaction".to_owned(), - connector: req.connector.to_string(), - })?, + _ => Ok(format!("{}{}", self.base_url(connectors), "/decision")), } } @@ -286,14 +294,10 @@ impl let req_obj = riskified::TransactionFailedRequest::try_from(req)?; Ok(RequestContent::Json(Box::new(req_obj))) } - Some(true) => { + _ => { let req_obj = riskified::TransactionSuccessRequest::try_from(req)?; Ok(RequestContent::Json(Box::new(req_obj))) } - None => Err(errors::ConnectorError::FlowNotSupported { - flow: "Transaction".to_owned(), - connector: req.connector.to_owned(), - })?, } } @@ -545,26 +549,101 @@ impl frm_api::FraudCheckFulfillment for Riskified {} #[cfg(feature = "frm")] impl frm_api::FraudCheckRecordReturn for Riskified {} +#[cfg(feature = "frm")] #[async_trait::async_trait] impl api::IncomingWebhook for Riskified { - fn get_webhook_object_reference_id( + fn get_webhook_source_verification_algorithm( &self, _request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult, errors::ConnectorError> { + Ok(Box::new(crypto::HmacSha256)) + } + + fn get_webhook_source_verification_signature( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, + _connector_webhook_secrets: &api_models::webhooks::ConnectorWebhookSecrets, + ) -> CustomResult, errors::ConnectorError> { + let header_value = + connector_utils::get_header_key_value("x-riskified-hmac-sha256", request.headers)?; + Ok(header_value.as_bytes().to_vec()) + } + + async fn verify_webhook_source( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, + merchant_account: &domain::MerchantAccount, + merchant_connector_account: domain::MerchantConnectorAccount, + connector_label: &str, + ) -> CustomResult { + let connector_webhook_secrets = self + .get_webhook_source_verification_merchant_secret( + merchant_account, + connector_label, + merchant_connector_account, + ) + .await + .change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?; + + let signature = self + .get_webhook_source_verification_signature(request, &connector_webhook_secrets) + .change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?; + + let message = self + .get_webhook_source_verification_message( + request, + &merchant_account.merchant_id, + &connector_webhook_secrets, + ) + .change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?; + + let signing_key = hmac::Key::new(hmac::HMAC_SHA256, &connector_webhook_secrets.secret); + let signed_message = hmac::sign(&signing_key, &message); + let payload_sign = consts::BASE64_ENGINE.encode(signed_message.as_ref()); + Ok(payload_sign.as_bytes().eq(&signature)) + } + + fn get_webhook_source_verification_message( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, + _merchant_id: &str, + _connector_webhook_secrets: &api_models::webhooks::ConnectorWebhookSecrets, + ) -> CustomResult, errors::ConnectorError> { + Ok(request.body.to_vec()) + } + + fn get_webhook_object_reference_id( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, ) -> CustomResult { - Err(report!(errors::ConnectorError::WebhooksNotImplemented)) + let resource: riskified::RiskifiedWebhookBody = request + .body + .parse_struct("RiskifiedWebhookBody") + .change_context(errors::ConnectorError::WebhookReferenceIdNotFound)?; + Ok(api::webhooks::ObjectReferenceId::PaymentId( + api_models::payments::PaymentIdType::PaymentAttemptId(resource.id), + )) } fn get_webhook_event_type( &self, - _request: &api::IncomingWebhookRequestDetails<'_>, + request: &api::IncomingWebhookRequestDetails<'_>, ) -> CustomResult { - Err(report!(errors::ConnectorError::WebhooksNotImplemented)) + let resource: riskified::RiskifiedWebhookBody = request + .body + .parse_struct("RiskifiedWebhookBody") + .change_context(errors::ConnectorError::WebhookEventTypeNotFound)?; + Ok(api::IncomingWebhookEvent::from(resource.status)) } fn get_webhook_resource_object( &self, - _request: &api::IncomingWebhookRequestDetails<'_>, + request: &api::IncomingWebhookRequestDetails<'_>, ) -> CustomResult, errors::ConnectorError> { - Err(report!(errors::ConnectorError::WebhooksNotImplemented)) + let resource: riskified::RiskifiedWebhookBody = request + .body + .parse_struct("RiskifiedWebhookBody") + .change_context(errors::ConnectorError::WebhookResourceObjectNotFound)?; + Ok(Box::new(resource)) } } diff --git a/crates/router/src/connector/riskified/transformers/api.rs b/crates/router/src/connector/riskified/transformers/api.rs index f20b6103cc..f0d47274d1 100644 --- a/crates/router/src/connector/riskified/transformers/api.rs +++ b/crates/router/src/connector/riskified/transformers/api.rs @@ -11,7 +11,7 @@ use crate::{ }, core::{errors, fraud_check::types as core_types}, types::{ - self, api::Fulfillment, fraud_check as frm_types, storage::enums as storage_enums, + self, api, api::Fulfillment, fraud_check as frm_types, storage::enums as storage_enums, ResponseId, ResponseRouterData, }, }; @@ -142,8 +142,9 @@ impl TryFrom<&frm_types::FrmCheckoutRouterData> for RiskifiedPaymentsCheckoutReq field_name: "frm_metadata", })? .parse_value("Riskified Metadata") - .change_context(errors::ConnectorError::RequestEncodingFailed)?; - + .change_context(errors::ConnectorError::InvalidDataFormat { + field_name: "frm_metadata", + })?; let billing_address = payment_data.get_billing()?; let shipping_address = payment_data.get_shipping_address_with_phone_number()?; let address = payment_data.get_billing_address()?; @@ -606,3 +607,25 @@ fn get_fulfillment_status( core_types::FulfillmentStatus::PARTIAL | core_types::FulfillmentStatus::REPLACEMENT => None, } } + +#[derive(Debug, Clone, Deserialize, Serialize)] + +pub struct RiskifiedWebhookBody { + pub id: String, + pub status: RiskifiedWebhookStatus, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub enum RiskifiedWebhookStatus { + Approved, + Declined, +} + +impl From for api::IncomingWebhookEvent { + fn from(value: RiskifiedWebhookStatus) -> Self { + match value { + RiskifiedWebhookStatus::Declined => Self::FrmRejected, + RiskifiedWebhookStatus::Approved => Self::FrmApproved, + } + } +} diff --git a/crates/router/src/connector/signifyd.rs b/crates/router/src/connector/signifyd.rs index 1c045b8da0..9e591a336f 100644 --- a/crates/router/src/connector/signifyd.rs +++ b/crates/router/src/connector/signifyd.rs @@ -1,16 +1,23 @@ pub mod transformers; use std::fmt::Debug; +#[cfg(feature = "frm")] use base64::Engine; #[cfg(feature = "frm")] -use common_utils::request::RequestContent; -use error_stack::{report, ResultExt}; +use common_utils::{crypto, ext_traits::ByteSliceExt, request::RequestContent}; +#[cfg(feature = "frm")] +use error_stack::ResultExt; +#[cfg(feature = "frm")] use masking::PeekInterface; +#[cfg(feature = "frm")] +use ring::hmac; +#[cfg(feature = "frm")] use transformers as signifyd; +#[cfg(feature = "frm")] +use super::utils as connector_utils; use crate::{ configs::settings, - consts, core::errors::{self, CustomResult}, headers, services::{self, request, ConnectorIntegration, ConnectorValidation}, @@ -21,8 +28,11 @@ use crate::{ }; #[cfg(feature = "frm")] use crate::{ + consts, events::connector_api_logs::ConnectorEvent, - types::{api::fraud_check as frm_api, fraud_check as frm_types, ErrorResponse, Response}, + types::{ + api::fraud_check as frm_api, domain, fraud_check as frm_types, ErrorResponse, Response, + }, utils::BytesExt, }; @@ -61,6 +71,7 @@ impl ConnectorCommon for Signifyd { connectors.signifyd.base_url.as_ref() } + #[cfg(feature = "frm")] fn get_auth_header( &self, auth_type: &types::ConnectorAuthType, @@ -646,26 +657,101 @@ impl } } +#[cfg(feature = "frm")] #[async_trait::async_trait] impl api::IncomingWebhook for Signifyd { - fn get_webhook_object_reference_id( + fn get_webhook_source_verification_algorithm( &self, _request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult, errors::ConnectorError> { + Ok(Box::new(crypto::HmacSha256)) + } + + fn get_webhook_source_verification_signature( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, + _connector_webhook_secrets: &api_models::webhooks::ConnectorWebhookSecrets, + ) -> CustomResult, errors::ConnectorError> { + let header_value = + connector_utils::get_header_key_value("x-signifyd-sec-hmac-sha256", request.headers)?; + Ok(header_value.as_bytes().to_vec()) + } + + fn get_webhook_source_verification_message( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, + _merchant_id: &str, + _connector_webhook_secrets: &api_models::webhooks::ConnectorWebhookSecrets, + ) -> CustomResult, errors::ConnectorError> { + Ok(request.body.to_vec()) + } + + async fn verify_webhook_source( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, + merchant_account: &domain::MerchantAccount, + merchant_connector_account: domain::MerchantConnectorAccount, + connector_label: &str, + ) -> CustomResult { + let connector_webhook_secrets = self + .get_webhook_source_verification_merchant_secret( + merchant_account, + connector_label, + merchant_connector_account, + ) + .await + .change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?; + + let signature = self + .get_webhook_source_verification_signature(request, &connector_webhook_secrets) + .change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?; + + let message = self + .get_webhook_source_verification_message( + request, + &merchant_account.merchant_id, + &connector_webhook_secrets, + ) + .change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?; + + let signing_key = hmac::Key::new(hmac::HMAC_SHA256, &connector_webhook_secrets.secret); + let signed_message = hmac::sign(&signing_key, &message); + let payload_sign = consts::BASE64_ENGINE.encode(signed_message.as_ref()); + Ok(payload_sign.as_bytes().eq(&signature)) + } + + fn get_webhook_object_reference_id( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, ) -> CustomResult { - Err(report!(errors::ConnectorError::WebhooksNotImplemented)) + let resource: signifyd::SignifydWebhookBody = request + .body + .parse_struct("SignifydWebhookBody") + .change_context(errors::ConnectorError::WebhookReferenceIdNotFound)?; + Ok(api::webhooks::ObjectReferenceId::PaymentId( + api_models::payments::PaymentIdType::PaymentAttemptId(resource.order_id), + )) } fn get_webhook_event_type( &self, - _request: &api::IncomingWebhookRequestDetails<'_>, + request: &api::IncomingWebhookRequestDetails<'_>, ) -> CustomResult { - Err(report!(errors::ConnectorError::WebhooksNotImplemented)) + let resource: signifyd::SignifydWebhookBody = request + .body + .parse_struct("SignifydWebhookBody") + .change_context(errors::ConnectorError::WebhookEventTypeNotFound)?; + Ok(api::IncomingWebhookEvent::from(resource.review_disposition)) } fn get_webhook_resource_object( &self, - _request: &api::IncomingWebhookRequestDetails<'_>, + request: &api::IncomingWebhookRequestDetails<'_>, ) -> CustomResult, errors::ConnectorError> { - Err(report!(errors::ConnectorError::WebhooksNotImplemented)) + let resource: signifyd::SignifydWebhookBody = request + .body + .parse_struct("SignifydWebhookBody") + .change_context(errors::ConnectorError::WebhookResourceObjectNotFound)?; + Ok(Box::new(resource)) } } diff --git a/crates/router/src/connector/signifyd/transformers/api.rs b/crates/router/src/connector/signifyd/transformers/api.rs index 2f742b7157..8c487f904f 100644 --- a/crates/router/src/connector/signifyd/transformers/api.rs +++ b/crates/router/src/connector/signifyd/transformers/api.rs @@ -13,7 +13,7 @@ use crate::{ }, core::{errors, fraud_check::types as core_types}, types::{ - self, api::Fulfillment, fraud_check as frm_types, storage::enums as storage_enums, + self, api, api::Fulfillment, fraud_check as frm_types, storage::enums as storage_enums, ResponseId, ResponseRouterData, }, }; @@ -399,7 +399,9 @@ impl TryFrom<&frm_types::FrmCheckoutRouterData> for SignifydPaymentsCheckoutRequ field_name: "frm_metadata", })? .parse_value("Signifyd Frm Metadata") - .change_context(errors::ConnectorError::RequestEncodingFailed)?; + .change_context(errors::ConnectorError::InvalidDataFormat { + field_name: "frm_metadata", + })?; let ship_address = item.get_shipping_address()?; let street_addr = ship_address.get_line1()?; let city_addr = ship_address.get_city()?; @@ -705,3 +707,27 @@ impl TryFrom<&frm_types::FrmRecordReturnRouterData> for SignifydPaymentsRecordRe }) } } + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] + +pub struct SignifydWebhookBody { + pub order_id: String, + pub review_disposition: ReviewDisposition, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ReviewDisposition { + Fraudulent, + Good, +} + +impl From for api::IncomingWebhookEvent { + fn from(value: ReviewDisposition) -> Self { + match value { + ReviewDisposition::Fraudulent => Self::FrmRejected, + ReviewDisposition::Good => Self::FrmApproved, + } + } +} diff --git a/crates/router/src/core/webhooks.rs b/crates/router/src/core/webhooks.rs index 37393f0aab..7e29036c40 100644 --- a/crates/router/src/core/webhooks.rs +++ b/crates/router/src/core/webhooks.rs @@ -677,6 +677,118 @@ pub async fn mandates_incoming_webhook_flow( } } +#[allow(clippy::too_many_arguments)] +#[instrument(skip_all)] +pub(crate) async fn frm_incoming_webhook_flow( + state: AppState, + req_state: ReqState, + merchant_account: domain::MerchantAccount, + key_store: domain::MerchantKeyStore, + source_verified: bool, + event_type: webhooks::IncomingWebhookEvent, + object_ref_id: api::ObjectReferenceId, + business_profile: diesel_models::business_profile::BusinessProfile, +) -> CustomResult { + if source_verified { + let payment_attempt = + get_payment_attempt_from_object_reference_id(&state, object_ref_id, &merchant_account) + .await?; + let payment_response = match event_type { + webhooks::IncomingWebhookEvent::FrmApproved => { + Box::pin(payments::payments_core::< + api::Capture, + api::PaymentsResponse, + _, + _, + _, + >( + state.clone(), + req_state, + merchant_account.clone(), + key_store.clone(), + payments::PaymentApprove, + api::PaymentsCaptureRequest { + payment_id: payment_attempt.payment_id, + amount_to_capture: payment_attempt.amount_to_capture, + ..Default::default() + }, + services::api::AuthFlow::Merchant, + payments::CallConnectorAction::Trigger, + None, + HeaderPayload::default(), + )) + .await? + } + webhooks::IncomingWebhookEvent::FrmRejected => { + Box::pin(payments::payments_core::< + api::Void, + api::PaymentsResponse, + _, + _, + _, + >( + state.clone(), + req_state, + merchant_account.clone(), + key_store.clone(), + payments::PaymentReject, + api::PaymentsCancelRequest { + payment_id: payment_attempt.payment_id.clone(), + cancellation_reason: Some( + "Rejected by merchant based on FRM decision".to_string(), + ), + ..Default::default() + }, + services::api::AuthFlow::Merchant, + payments::CallConnectorAction::Trigger, + None, + HeaderPayload::default(), + )) + .await? + } + _ => Err(errors::ApiErrorResponse::EventNotFound)?, + }; + match payment_response { + services::ApplicationResponse::JsonWithHeaders((payments_response, _)) => { + let payment_id = payments_response + .payment_id + .clone() + .get_required_value("payment_id") + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("payment id not received from payments core")?; + let status = payments_response.status; + let event_type: Option = payments_response.status.foreign_into(); + if let Some(outgoing_event_type) = event_type { + let primary_object_created_at = payments_response.created; + create_event_and_trigger_outgoing_webhook( + state, + merchant_account, + business_profile, + &key_store, + outgoing_event_type, + enums::EventClass::Payments, + payment_id.clone(), + enums::EventObjectType::PaymentDetails, + api::OutgoingWebhookContent::PaymentDetails(payments_response), + primary_object_created_at, + ) + .await?; + }; + let response = WebhookResponseTracker::Payment { payment_id, status }; + Ok(response) + } + _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure).attach_printable( + "Did not get payment id as object reference id in webhook payments flow", + )?, + } + } else { + logger::error!("Webhook source verification failed for frm webhooks flow"); + Err(report!( + errors::ApiErrorResponse::WebhookAuthenticationFailed + )) + } +} + #[allow(clippy::too_many_arguments)] #[instrument(skip_all)] pub async fn disputes_incoming_webhook_flow( @@ -1828,6 +1940,18 @@ pub async fn webhooks_core( .await .attach_printable("Incoming webhook flow for external authentication failed")? } + api::WebhookFlow::FraudCheck => Box::pin(frm_incoming_webhook_flow( + state.clone(), + req_state, + merchant_account, + key_store, + source_verified, + event_type, + object_ref_id, + business_profile, + )) + .await + .attach_printable("Incoming webhook flow for fraud check failed")?, _ => Err(errors::ApiErrorResponse::InternalServerError) .attach_printable("Unsupported Flow Type received in incoming webhooks")?, @@ -1901,6 +2025,19 @@ fn get_connector_by_connector_name( ) -> CustomResult<(&'static (dyn api::Connector + Sync), String), errors::ApiErrorResponse> { let authentication_connector = api_models::enums::convert_authentication_connector(connector_name); + #[cfg(feature = "frm")] + { + let frm_connector = api_models::enums::convert_frm_connector(connector_name); + if frm_connector.is_some() { + let frm_connector_data = + api::FraudCheckConnectorData::get_connector_by_name(connector_name)?; + return Ok(( + *frm_connector_data.connector, + frm_connector_data.connector_name.to_string(), + )); + } + } + let (connector, connector_name) = if authentication_connector.is_some() { let authentication_connector_data = api::AuthenticationConnectorData::get_connector_by_name(connector_name)?;