diff --git a/MidrandBooksApi/MidrandBooksApi.csproj b/MidrandBooksApi/MidrandBooksApi.csproj
index 72eea12..b6be5dd 100644
--- a/MidrandBooksApi/MidrandBooksApi.csproj
+++ b/MidrandBooksApi/MidrandBooksApi.csproj
@@ -53,13 +53,13 @@
-
+
-
+
@@ -85,6 +85,7 @@
+
diff --git a/MidrandBooksApi/Payments/Endpoints/ConfirmationEndpoint.cs b/MidrandBooksApi/Payments/Endpoints/ConfirmationEndpoint.cs
index f4aa481..5417e65 100644
--- a/MidrandBooksApi/Payments/Endpoints/ConfirmationEndpoint.cs
+++ b/MidrandBooksApi/Payments/Endpoints/ConfirmationEndpoint.cs
@@ -1,6 +1,6 @@
-using LiteCharms.Features.Hasher;
+using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Events;
-using LiteCharms.Features.Models;
+using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.Quartz.Abstractions;
namespace MidrandBooksApi.Payments.Endpoints;
@@ -12,32 +12,51 @@ public sealed class ConfirmationEndpoint : IEndpoint
public void Map(IEndpointRouteBuilder builder)
{
- builder.MapPost("payments/payfast/confirm", async (HttpRequest request, HashService hashService,
- IJobOrchestrator jobOrchestrator, CancellationToken cancellationToken) =>
+ builder.MapPost("payments/payfast/confirm", async (HttpRequest request, PayfastService payfastService,
+ IJobOrchestrator jobOrchestrator, IConfiguration configuration, IHostEnvironment hostEnvironment, CancellationToken cancellationToken) =>
{
using Activity? activity = PaymentActivitySource.StartActivity("ReceivePayfastWebhook", ActivityKind.Server);
+
activity?.SetTag("messaging.system", "payfast");
activity?.SetTag("messaging.destination.name", "payments/confirm");
+ string? remoteIp = request.HttpContext.Connection.RemoteIpAddress?.ToString();
+
+ var ipValidation = await payfastService.ValidateReferrerIpAsync(remoteIp!, cancellationToken);
+
+ if (ipValidation.IsFailed || !ipValidation.Value) return Results.Unauthorized();
+
var formCollection = await request.ReadFormAsync(cancellationToken);
- if (!formCollection.TryGetValue("signature", out var signatureValues) || string.IsNullOrWhiteSpace(signatureValues.ToString()))
+ if (!formCollection.TryGetValue("signature", out var signatureValues) || string.IsNullOrWhiteSpace(signatureValues.ToString()))
return Results.BadRequest("Missing Payfast validation signature.");
- string incomingSignature = signatureValues.ToString();
+ string incomingSignature = signatureValues.ToString().Trim();
+ var payload = ParseForm(formCollection, incomingSignature);
- var payload = new PayfastWebhookPayload
- {
- Amount = formCollection.TryGetValue("amount", out var amountValues) ? amountValues.ToString() : null,
- ItemName = formCollection.TryGetValue("item_name", out var itemValues) ? itemValues.ToString() : null,
- MPaymentId = formCollection.TryGetValue("m_payment_id", out var paymentIdValues) ? paymentIdValues.ToString() : null
- };
+ var paramDictionary = payload.ToParamDictionary();
+ string? passphrase = configuration["HasherSettings:PayfastPassphrase"];
- var validationResult = hashService.VerifyPayfastWebhookSignature(payload, incomingSignature);
+ var signatureCheck = PayfastService.GenerateSignature(paramDictionary, passphrase);
- if (validationResult.IsFailed || !validationResult.Value) return Results.Unauthorized();
+ if (signatureCheck.IsFailed || !string.Equals(signatureCheck.Value, incomingSignature, StringComparison.OrdinalIgnoreCase))
+ return Results.Unauthorized();
- await jobOrchestrator.SendAsync(PayfastPaymentConfirmationReceivedEvent.Create(payload, payload.MPaymentId!), cancellationToken);
+ var formPairs = formCollection.Select(kvp => $"{kvp.Key}={HttpUtility.UrlEncode(kvp.Value.ToString())}");
+
+ string rawQueryParamString = string.Join("&", formPairs);
+
+ bool isSandbox = !hostEnvironment.IsProduction();
+
+ var serverConfirmation = await payfastService.ValidateServerConfirmationAsync(rawQueryParamString, isSandbox, cancellationToken);
+
+ if (serverConfirmation.IsFailed || !serverConfirmation.Value)
+ return Results.Unauthorized();
+
+ var notification = PayfastPaymentConfirmationReceivedEvent.Create(payload, payload.MerchantPaymentId!,
+ performBackgroundChecks: false); // Set to false because comprehensive checks are completed inline above
+
+ await jobOrchestrator.SendAsync(notification, cancellationToken);
activity?.SetStatus(ActivityStatusCode.Ok);
@@ -51,4 +70,25 @@ public sealed class ConfirmationEndpoint : IEndpoint
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Payments);
}
-}
+
+ private static PayfastWebhookPayload ParseForm(IFormCollection formCollection, string incomingSignature) => new()
+ {
+ MerchantId = formCollection.TryGetValue("merchant_id", out var mId) ? mId.ToString() : null,
+ MerchantKey = formCollection.TryGetValue("merchant_key", out var mKey) ? mKey.ToString() : null,
+ Signature = incomingSignature,
+ MerchantPaymentId = formCollection.TryGetValue("m_payment_id", out var mPayId) ? mPayId.ToString() : null,
+ PaymentId = formCollection.TryGetValue("pf_payment_id", out var pfPayId) ? pfPayId.ToString() : null,
+ PaymentStatus = formCollection.TryGetValue("payment_status", out var status) ? status.ToString() : null,
+ ItemName = formCollection.TryGetValue("item_name", out var item) ? item.ToString() : null,
+ ItemDescription = formCollection.TryGetValue("item_description", out var desc) ? desc.ToString() : null,
+ AmountGross = formCollection.TryGetValue("amount_gross", out var gross) ? gross.ToString() : null,
+ AmountFee = formCollection.TryGetValue("amount_fee", out var fee) ? fee.ToString() : null,
+ AmountNet = formCollection.TryGetValue("amount_net", out var net) ? net.ToString() : null,
+ NameFirst = formCollection.TryGetValue("name_first", out var first) ? first.ToString() : null,
+ NameLast = formCollection.TryGetValue("name_last", out var last) ? last.ToString() : null,
+ EmailAddress = formCollection.TryGetValue("email_address", out var email) ? email.ToString() : null,
+ CustomStr1 = formCollection.TryGetValue("custom_str1", out var cStr1) ? cStr1.ToString() : null,
+ CustomInt1 = formCollection.TryGetValue("custom_int1", out var cInt1) ? cInt1.ToString() : null,
+ Token = formCollection.TryGetValue("token", out var tok) ? tok.ToString() : null
+ };
+}
\ No newline at end of file
diff --git a/MidrandBooksApi/Setup.cs b/MidrandBooksApi/Setup.cs
index 9a5d63d..a34a8a7 100644
--- a/MidrandBooksApi/Setup.cs
+++ b/MidrandBooksApi/Setup.cs
@@ -40,6 +40,8 @@ public static class Setup
public static IServiceCollection AddApiServices(this IServiceCollection services, IConfiguration configuration)
{
+ services.AddHttpClient();
+
services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1);
diff --git a/MidrandBooksApi/appsettings.json b/MidrandBooksApi/appsettings.json
index 085e7d6..b872baf 100644
--- a/MidrandBooksApi/appsettings.json
+++ b/MidrandBooksApi/appsettings.json
@@ -1,4 +1,13 @@
{
+ "ValidPayfastHosts": [
+ "www.payfast.co.za",
+ "sandbox.payfast.co.za",
+ "w1w.payfast.co.za",
+ "w2w.payfast.co.za",
+ "ips.payfast.co.za",
+ "api.payfast.co.za",
+ "payment.payfast.io"
+ ],
"HasherSettings": {
"MinHashLength": 11
},
diff --git a/midrandbooksapi-uat.yml b/midrandbooksapi-uat.yml
index 434a28a..50e5033 100644
--- a/midrandbooksapi-uat.yml
+++ b/midrandbooksapi-uat.yml
@@ -19,6 +19,13 @@ data:
BookshopS3Settings__Region: "garage"
BookshopS3Settings__BucketName: "bookshop"
BookshopS3Settings__CdnBaseUrl: "https://bookshop.cdn.khongisa.co.za"
+ ValidPayfastHosts__0: "www.payfast.co.za"
+ ValidPayfastHosts__1: "sandbox.payfast.co.za"
+ ValidPayfastHosts__2: "w1w.payfast.co.za"
+ ValidPayfastHosts__3: "w2w.payfast.co.za"
+ ValidPayfastHosts__4: "ips.payfast.co.za"
+ ValidPayfastHosts__5: "api.payfast.co.za"
+ ValidPayfastHosts__6: "payment.payfast.io"
---
apiVersion: v1
kind: Secret