Skip to content

Commit d535435

Browse files
committed
feat: add whitelist to shopify scope
All endpoints in the `/shopify` scope now have a whitelist check, if configured.
1 parent c370054 commit d535435

File tree

9 files changed

+148
-28
lines changed

9 files changed

+148
-28
lines changed

e2e/tests/cucumber/steps.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use tari_jwt::{
99
Ristretto256,
1010
Ristretto256SigningKey,
1111
};
12-
use tari_payment_engine::db_types::Role;
12+
use tari_payment_engine::db_types::{MicroTari, OrderId, Role};
1313
use tari_payment_server::auth::{build_jwt_signer, JwtClaims};
1414
use tokio::time::sleep;
1515

@@ -158,6 +158,23 @@ async fn expire_access_token(world: &mut TPGWorld) {
158158
world.access_token = Some(token);
159159
}
160160

161+
#[when(expr = "{word} places an order \"{word}\" for {int} XTR, memo = {string}")]
162+
async fn place_short_order(world: &mut TPGWorld, customer_id: String, order_id: String, amount: i64, memo: String) {
163+
let now = chrono::Utc::now();
164+
place_order(world, customer_id, order_id, amount, memo, now.to_rfc3339()).await;
165+
}
166+
167+
#[when(expr = "{word} places an order \"{word}\" for {int} XTR, memo = {string} at {string}")]
168+
async fn place_order(
169+
world: &mut TPGWorld,
170+
customer_id: String,
171+
order_id: String,
172+
amount: i64,
173+
memo: String,
174+
address: String,
175+
) {
176+
let order_id = OrderId(order_id);
177+
}
161178
fn modify_signature(token: String, value: &str) -> String {
162179
let mut parts = token.split('.').map(|s| s.to_owned()).collect::<Vec<_>>();
163180
let n = value.len();

e2e/tests/cucumber/world.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ impl Default for TPGWorld {
3939
shopify_api_key: String::default(),
4040
database_url: url.clone(),
4141
auth: AuthConfig::default(),
42+
shopify_whitelist: None,
43+
use_x_forwarded_for: false,
44+
use_forwarded: false,
4245
};
4346
Self {
4447
config,

e2e/tests/ignored_features/payments.feature.todo renamed to e2e/tests/features/payment_flow.feature

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1-
Feature: users can pay for orders with Tari
1+
Feature: Order flow
22
Background:
33
Given a blank slate
4-
Given Alice is an authenticated user
5-
Given a wallet listening for payments
6-
Given a shopify store with webhooks configured
74

8-
Scenario: Alice pays for an order with Tari
5+
Scenario: Standard order flow
96
When Alice places an order "alice001" on the store. Memo "": "Item A" for 100T, "Item B" for 200T
107
Then Alice's account has a balance of 300 Tari
118
Then Alice's order "alice001" is pending

tari_payment_engine/src/db_types.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,20 @@ impl NewOrder {
347347
}
348348
}
349349

350+
impl Display for NewOrder {
351+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352+
write!(
353+
f,
354+
"Order #{order_id} @ \"{customer_id}\". {total_price}{currency} ({created_at})",
355+
order_id = self.order_id,
356+
customer_id = self.customer_id,
357+
total_price = self.total_price,
358+
currency = self.currency,
359+
created_at = self.created_at
360+
)
361+
}
362+
}
363+
350364
//-------------------------------------- OrderUpdate ------------------------------------------------------
351365

352366
/// A struct representing the fields that are allowed to be updated on an order

tari_payment_server/src/config.rs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::{env, io::Write};
1+
use std::{env, io::Write, net::SocketAddr};
22

33
use log::*;
44
use rand::thread_rng;
@@ -26,6 +26,14 @@ pub struct ServerConfig {
2626
pub shopify_api_key: String,
2727
pub database_url: String,
2828
pub auth: AuthConfig,
29+
/// If supplied, requests against /shopify endpoints will be checked against a whitelist of Shopify IP addresses.
30+
pub shopify_whitelist: Option<Vec<SocketAddr>>,
31+
/// If true, the X-Forwarded-For header will be used to determine the client's IP address, rather than the
32+
/// connection's remote address.
33+
pub use_x_forwarded_for: bool,
34+
/// If true, the X-Forwarded-Proto header will be used to determine the client's protocol, rather than the
35+
/// connection's remote address.
36+
pub use_forwarded: bool,
2937
}
3038

3139
impl Default for ServerConfig {
@@ -36,6 +44,9 @@ impl Default for ServerConfig {
3644
shopify_api_key: String::default(),
3745
database_url: String::default(),
3846
auth: AuthConfig::default(),
47+
shopify_whitelist: None,
48+
use_x_forwarded_for: false,
49+
use_forwarded: false,
3950
}
4051
}
4152
}
@@ -65,7 +76,23 @@ impl ServerConfig {
6576
error!("TPG_DATABASE_URL is not set. Please set it to the URL for the TPG database.");
6677
String::default()
6778
});
68-
Self { host, port, shopify_api_key, auth, database_url }
79+
let shopify_whitelist = env::var("TPG_SHOPIFY_IP_WHITELIST")
80+
.map(|s| {
81+
s.split(',')
82+
.filter_map(|s| {
83+
s.parse()
84+
.map_err(|e| {
85+
warn!("Ignoring invalid IP address ({s}) in TPG_SHOPIFY_IP_WHITELIST: {e}");
86+
None::<SocketAddr>
87+
})
88+
.ok()
89+
})
90+
.collect::<Vec<_>>()
91+
})
92+
.ok();
93+
let use_x_forwarded_for = env::var("TPG_USE_X_FORWARDED_FOR").map(|s| &s == "1" || &s == "true").is_ok();
94+
let use_forwarded = env::var("TPG_USE_FORWARDED").map(|s| &s == "1" || &s == "true").is_ok();
95+
Self { host, port, shopify_api_key, auth, database_url, shopify_whitelist, use_forwarded, use_x_forwarded_for }
6996
}
7097
}
7198

tari_payment_server/src/endpoint_tests/mocks.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use mockall::mock;
22
use tari_common_types::tari_address::TariAddress;
33
use tari_payment_engine::{
4-
order_objects::OrderQueryFilter,
54
db_types::{Order, OrderId, Payment, Role, UserAccount},
5+
order_objects::OrderQueryFilter,
66
AccountManagement,
77
AuthApiError,
88
AuthManagement,

tari_payment_server/src/errors.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ impl ResponseError for ServerError {
5151
AuthError::ValidationError(_) => StatusCode::UNAUTHORIZED,
5252
AuthError::PoorlyFormattedToken(_) => StatusCode::BAD_REQUEST,
5353
AuthError::AccountNotFound => StatusCode::FORBIDDEN,
54+
AuthError::ForbiddenPeer => StatusCode::FORBIDDEN,
5455
},
5556
Self::InitializeError(_) => StatusCode::INTERNAL_SERVER_ERROR,
5657
Self::BackendError(_) => StatusCode::INTERNAL_SERVER_ERROR,
@@ -88,6 +89,8 @@ pub enum AuthError {
8889
PoorlyFormattedToken(String),
8990
#[error("User account not found.")]
9091
AccountNotFound,
92+
#[error("Request was made from a forbidden peer")]
93+
ForbiddenPeer,
9194
}
9295

9396
impl From<AuthApiError> for ServerError {

tari_payment_server/src/routes.rs

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,19 @@
2323
//! ```
2424
use std::{marker::PhantomData, str::FromStr};
2525

26-
use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder};
26+
use actix_web::{get, web, HttpRequest, HttpResponse, Responder};
2727
use log::*;
2828
use paste::paste;
2929
use tari_common_types::tari_address::TariAddress;
3030
use tari_payment_engine::{
31-
db_types::{OrderId, Role, SerializedTariAddress},
31+
db_types::{NewOrder, OrderId, Role, SerializedTariAddress},
3232
order_objects::OrderQueryFilter,
3333
AccountApi,
3434
AccountManagement,
3535
AuthApi,
3636
AuthManagement,
37+
OrderFlowApi,
38+
PaymentGatewayDatabase,
3739
};
3840

3941
use crate::{
@@ -353,19 +355,26 @@ where B: AccountManagement {
353355

354356
//---------------------------------------------- Checkout ----------------------------------------------------
355357

356-
#[post("/webhook/checkout_create")]
357-
pub async fn shopify_webhook(req: HttpRequest, body: web::Bytes) -> Result<HttpResponse, ServerError> {
358+
route!(shopify_webhook => Post "webhook/checkout_create" impl PaymentGatewayDatabase);
359+
pub async fn shopify_webhook<B: PaymentGatewayDatabase>(
360+
req: HttpRequest,
361+
body: web::Json<ShopifyOrder>,
362+
api: web::Data<OrderFlowApi<B>>,
363+
) -> Result<HttpResponse, ServerError> {
358364
trace!("💻️ Received webhook request: {}", req.uri());
359-
let payload = std::str::from_utf8(body.as_ref()).map_err(|e| ServerError::InvalidRequestBody(e.to_string()))?;
360-
trace!("💻️ Decoded payload body. {} bytes", payload.bytes().len());
361-
let _order: ShopifyOrder = serde_json::from_str(payload).map_err(|e| {
362-
error!("💻️ Could not deserialize order payload. {e}");
363-
debug!("💻️ JSON payload: {payload}");
364-
ServerError::CouldNotDeserializePayload
365-
})?;
366-
// let new_order = ShopifyOrder::try_from(order)?;
367-
// TODO - Send the new order to payment engine
368-
365+
let order = body.into_inner();
366+
let new_order = NewOrder::try_from(order)?;
367+
match api.process_new_order(new_order.clone()).await {
368+
Ok(orders) => {
369+
info!("💻️ Order {} processed successfully.", new_order.order_id);
370+
let ids = orders.iter().map(|o| o.order_id.as_str()).collect::<Vec<_>>().join(", ");
371+
info!("💻️ {} orders were paid. {}", orders.len(), ids);
372+
},
373+
Err(e) => {
374+
warn!("💻️ Could not process order {}. {e}", new_order.order_id);
375+
debug!("💻️ Failed order: {new_order}");
376+
},
377+
}
369378
Ok(HttpResponse::Ok().finish())
370379
}
371380

tari_payment_server/src/server.rs

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,29 @@
1-
use std::time::Duration;
1+
use std::{future::ready, net::SocketAddr, str::FromStr, time::Duration};
22

33
use actix_jwt_auth_middleware::use_jwt::UseJWTOnApp;
4-
use actix_web::{dev::Server, http::KeepAlive, middleware::Logger, web, App, HttpServer};
4+
use actix_web::{
5+
dev::{Server, Service},
6+
http::KeepAlive,
7+
middleware::Logger,
8+
web,
9+
App,
10+
Error,
11+
HttpResponse,
12+
HttpServer,
13+
};
14+
use futures::{
15+
future::{ok, Either, LocalBoxFuture},
16+
FutureExt,
17+
};
18+
use log::{info, warn};
519
use tari_payment_engine::{AccountApi, AuthApi, OrderFlowApi, SqliteDatabase};
620

721
use crate::{
822
auth::{build_tps_authority, TokenIssuer},
923
config::ServerConfig,
10-
errors::ServerError,
24+
errors::{AuthError, ServerError, ServerError::AuthenticationError},
1125
routes::{
1226
health,
13-
shopify_webhook,
1427
AccountRoute,
1528
AuthRoute,
1629
CheckTokenRoute,
@@ -21,6 +34,7 @@ use crate::{
2134
OrdersRoute,
2235
OrdersSearchRoute,
2336
PaymentsRoute,
37+
ShopifyWebhookRoute,
2438
UpdateRolesRoute,
2539
},
2640
};
@@ -58,10 +72,46 @@ pub fn create_server_instance(config: ServerConfig, db: SqliteDatabase) -> Resul
5872
.service(PaymentsRoute::<SqliteDatabase>::new())
5973
.service(OrdersSearchRoute::<SqliteDatabase>::new())
6074
.service(CheckTokenRoute::new());
75+
let use_x_forwarded_for = config.use_x_forwarded_for;
76+
let use_forwarded = config.use_forwarded;
77+
let shopify_whitelist = config.shopify_whitelist.clone();
78+
let shopify_scope = web::scope("/shopify")
79+
.wrap_fn(move |req, srv| {
80+
// Collect peer IP from x-forwarded-for, or forwarded headers _if_ `use_nnn` has been set to true
81+
// in the configuration. Otherwise, use the peer address from the connection info.
82+
let peer_addr = req.connection_info().peer_addr().map(|a| a.to_string());
83+
84+
let peer_ip = req
85+
.headers()
86+
.get("X-Forwarded-For")
87+
.and_then(|v| use_x_forwarded_for.then(|| v.to_str().ok()).flatten())
88+
.or_else(|| {
89+
req.headers().get("X-Real-IP").and_then(|v| use_forwarded.then(|| v.to_str().ok()).flatten())
90+
})
91+
.or_else(|| peer_addr.as_ref().map(|s| s.as_str()))
92+
.and_then(|s| SocketAddr::from_str(s).ok());
93+
let whitelisted = match (peer_ip, &shopify_whitelist) {
94+
(Some(ip), Some(whitelist)) => {
95+
info!("Shopify webhook from {ip}");
96+
whitelist.contains(&ip)
97+
},
98+
(_, None) => true,
99+
(None, Some(_)) => {
100+
warn!("No IP address found in shopify remote peer request, denying access.");
101+
false
102+
},
103+
};
104+
if whitelisted {
105+
srv.call(req)
106+
} else {
107+
ok(req.error_response(AuthenticationError(AuthError::ForbiddenPeer))).boxed_local()
108+
}
109+
})
110+
.service(ShopifyWebhookRoute::<SqliteDatabase>::new());
61111
app.use_jwt(authority.clone(), auth_scope)
62112
.service(health)
63113
.service(AuthRoute::<SqliteDatabase>::new())
64-
.service(web::scope("/shopify").service(shopify_webhook))
114+
.service(shopify_scope)
65115
})
66116
.keep_alive(KeepAlive::Timeout(Duration::from_secs(600)))
67117
.bind((config.host.as_str(), config.port))?

0 commit comments

Comments
 (0)