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