From 8eedf16a494ef2d9f7ed57117a60b36ca83a487e Mon Sep 17 00:00:00 2001 From: Khwezi Mngoma Date: Wed, 3 Jun 2026 00:11:27 +0200 Subject: [PATCH 1/2] Refactored endpoint to use new payment verification process --- MidrandBooksApi/MidrandBooksApi.csproj | 5 +- .../Endpoints/ConfirmationEndpoint.cs | 72 ++++++++++++++----- MidrandBooksApi/Setup.cs | 2 + MidrandBooksApi/appsettings.json | 9 +++ midrandbooksapi-uat.yml | 7 ++ 5 files changed, 77 insertions(+), 18 deletions(-) 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 -- 2.47.3 From 8be8eb52bcc8a44565f0340c9c35fab67019e10b Mon Sep 17 00:00:00 2001 From: Khwezi Mngoma Date: Wed, 3 Jun 2026 00:47:54 +0200 Subject: [PATCH 2/2] Used shared components Built loopbackip check override based on environment --- MidrandBooksApi/ApiVersionTargetAttribute.cs | 7 -- MidrandBooksApi/EndpointTags.cs | 7 -- MidrandBooksApi/IEndpoint.cs | 6 -- MidrandBooksApi/MidrandBooksApi.csproj | 5 +- .../OpenApiBearerSecuritySchemeTransformer.cs | 16 ---- .../Endpoints/ConfirmationEndpoint.cs | 12 ++- MidrandBooksApi/Program.cs | 3 - MidrandBooksApi/Setup.cs | 90 ------------------- MidrandBooksApi/http/app.http | 8 -- 9 files changed, 11 insertions(+), 143 deletions(-) delete mode 100644 MidrandBooksApi/ApiVersionTargetAttribute.cs delete mode 100644 MidrandBooksApi/EndpointTags.cs delete mode 100644 MidrandBooksApi/IEndpoint.cs delete mode 100644 MidrandBooksApi/OpenApiBearerSecuritySchemeTransformer.cs delete mode 100644 MidrandBooksApi/Setup.cs delete mode 100644 MidrandBooksApi/http/app.http diff --git a/MidrandBooksApi/ApiVersionTargetAttribute.cs b/MidrandBooksApi/ApiVersionTargetAttribute.cs deleted file mode 100644 index d02fb28..0000000 --- a/MidrandBooksApi/ApiVersionTargetAttribute.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace MidrandBooksApi; - -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] -public sealed class ApiVersionTargetAttribute(int majorVersion) : Attribute -{ - public int MajorVersion { get; } = majorVersion; -} diff --git a/MidrandBooksApi/EndpointTags.cs b/MidrandBooksApi/EndpointTags.cs deleted file mode 100644 index af64953..0000000 --- a/MidrandBooksApi/EndpointTags.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace MidrandBooksApi; - -public static class EndpointTags -{ - public const string Books = nameof(Books); - public const string Payments = nameof(Payments); -} diff --git a/MidrandBooksApi/IEndpoint.cs b/MidrandBooksApi/IEndpoint.cs deleted file mode 100644 index e2a2c05..0000000 --- a/MidrandBooksApi/IEndpoint.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace MidrandBooksApi; - -public interface IEndpoint -{ - void Map(IEndpointRouteBuilder builder); -} diff --git a/MidrandBooksApi/MidrandBooksApi.csproj b/MidrandBooksApi/MidrandBooksApi.csproj index b6be5dd..db53633 100644 --- a/MidrandBooksApi/MidrandBooksApi.csproj +++ b/MidrandBooksApi/MidrandBooksApi.csproj @@ -34,6 +34,7 @@ + @@ -53,13 +54,13 @@ - + - + diff --git a/MidrandBooksApi/OpenApiBearerSecuritySchemeTransformer.cs b/MidrandBooksApi/OpenApiBearerSecuritySchemeTransformer.cs deleted file mode 100644 index de99e93..0000000 --- a/MidrandBooksApi/OpenApiBearerSecuritySchemeTransformer.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace MidrandBooksApi; - -public sealed class OpenApiBearerSecuritySchemeTransformer : IOpenApiDocumentTransformer -{ - public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) - { - var bearerScheme = new OpenApiSecurityScheme - { - Type = SecuritySchemeType.Http, - Scheme = "bearer", - Description = "JWT Authorization header using the Bearer scheme. Example: \"Bearer {token}\"" - }; - - document.AddComponent("Bearer", bearerScheme); - } -} diff --git a/MidrandBooksApi/Payments/Endpoints/ConfirmationEndpoint.cs b/MidrandBooksApi/Payments/Endpoints/ConfirmationEndpoint.cs index 5417e65..c83540f 100644 --- a/MidrandBooksApi/Payments/Endpoints/ConfirmationEndpoint.cs +++ b/MidrandBooksApi/Payments/Endpoints/ConfirmationEndpoint.cs @@ -1,7 +1,11 @@ -using LiteCharms.Features.MidrandBooks.Payments; +using LiteCharms.Features.Abstractions; +using LiteCharms.Features.Api; +using LiteCharms.Features.Extensions; +using LiteCharms.Features.MidrandBooks.Payments; using LiteCharms.Features.MidrandBooks.Payments.Events; using LiteCharms.Features.MidrandBooks.Payments.Models; using LiteCharms.Features.Quartz.Abstractions; +using static LiteCharms.Features.Extensions.Api; namespace MidrandBooksApi.Payments.Endpoints; @@ -22,7 +26,7 @@ public sealed class ConfirmationEndpoint : IEndpoint string? remoteIp = request.HttpContext.Connection.RemoteIpAddress?.ToString(); - var ipValidation = await payfastService.ValidateReferrerIpAsync(remoteIp!, cancellationToken); + var ipValidation = await payfastService.ValidateReferrerIpAsync(remoteIp!, !hostEnvironment.IsProduction(), cancellationToken); if (ipValidation.IsFailed || !ipValidation.Value) return Results.Unauthorized(); @@ -54,7 +58,7 @@ public sealed class ConfirmationEndpoint : IEndpoint return Results.Unauthorized(); var notification = PayfastPaymentConfirmationReceivedEvent.Create(payload, payload.MerchantPaymentId!, - performBackgroundChecks: false); // Set to false because comprehensive checks are completed inline above + allowLoopback: !hostEnvironment.IsProduction(), performBackgroundChecks: false); // Set to false because comprehensive checks are completed inline above await jobOrchestrator.SendAsync(notification, cancellationToken); @@ -68,7 +72,7 @@ public sealed class ConfirmationEndpoint : IEndpoint .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) - .WithTags(EndpointTags.Payments); + .WithTags(Api.Payments); } private static PayfastWebhookPayload ParseForm(IFormCollection formCollection, string incomingSignature) => new() diff --git a/MidrandBooksApi/Program.cs b/MidrandBooksApi/Program.cs index 2e801d0..5df7539 100644 --- a/MidrandBooksApi/Program.cs +++ b/MidrandBooksApi/Program.cs @@ -1,9 +1,6 @@ -using Asp.Versioning.Builder; -using k8s.Models; using LiteCharms.Features.Extensions; using LiteCharms.Features.Mediator; using LiteCharms.Features.MidrandBooks.Extensions; -using MidrandBooksApi; using static LiteCharms.Features.Extensions.Quartz; var builder = WebApplication.CreateBuilder(args); diff --git a/MidrandBooksApi/Setup.cs b/MidrandBooksApi/Setup.cs deleted file mode 100644 index a34a8a7..0000000 --- a/MidrandBooksApi/Setup.cs +++ /dev/null @@ -1,90 +0,0 @@ -namespace MidrandBooksApi; - -public static class Setup -{ - public static IApplicationBuilder MapEndpoints(this WebApplication app, Dictionary versionGroups) - { - var endpoints = app.Services.GetRequiredService>(); - - foreach (var endpoint in endpoints) - { - var versionAttributes = endpoint.GetType().GetCustomAttributes().ToList(); - - if (versionAttributes.Count != 0) - { - foreach (var attr in versionAttributes) - if (versionGroups.TryGetValue(attr.MajorVersion, out var targetGroup)) - endpoint.Map(targetGroup); - } - else - endpoint.Map(app); - } - - return app; - } - - public static IServiceCollection AddEndpoints(this IServiceCollection services, Assembly assembly) - { - ServiceDescriptor[] discriptors = [.. assembly.DefinedTypes - .Where(t => t is { IsInterface: false, IsAbstract: false }) - .Where(t => t.IsAssignableTo(typeof(IEndpoint))) - .Select(t => ServiceDescriptor.Transient(typeof(IEndpoint), t))]; - - services.TryAddEnumerable(discriptors); - - return services; - } - - public static string ToEndpointName(this Type target, string? annotation = "") => - $"{target.Name.Replace("Endpoint", string.Empty)}{annotation}".ToLower(); - - public static IServiceCollection AddApiServices(this IServiceCollection services, IConfiguration configuration) - { - services.AddHttpClient(); - - services.AddApiVersioning(options => - { - options.DefaultApiVersion = new ApiVersion(1); - options.ReportApiVersions = true; - options.AssumeDefaultVersionWhenUnspecified = true; - options.ApiVersionReader = ApiVersionReader.Combine(new UrlSegmentApiVersionReader(), - new QueryStringApiVersionReader("version"), - new QueryStringApiVersionReader("version"), - new MediaTypeApiVersionReader("version")); - }) - .AddApiExplorer(options => - { - options.GroupNameFormat = "'v'VVV"; - options.SubstituteApiVersionInUrl = true; - }); - - var urls = configuration["ASPNETCORE_URLS"] ?? configuration["Urls"]; - var healthUrl = "http://localhost:8080/health"; - - if (!string.IsNullOrWhiteSpace(urls)) - { - string firstUrl = urls.Split(';').FirstOrDefault(s => s.Contains("http://"))! - .Replace("*", "localhost").Replace("+", "localhost"); - - healthUrl = $"{firstUrl.TrimEnd('/')}/health"; - } - - services.AddHealthChecksUI(setup => - { - setup.SetNotifyUnHealthyOneTimeUntilChange(); - setup.AddHealthCheckEndpoint("primary, heal", healthUrl); - setup.SetHeaderText("Midrand Books"); - }) - .AddInMemoryStorage(); - - services.AddOutputCache(options => - { - options.AddBasePolicy(builder => builder.Cache()); - options.DefaultExpirationTimeSpan = TimeSpan.FromSeconds(10); - }); - - services.AddOpenApi(options => options.AddDocumentTransformer()); - - return services; - } -} diff --git a/MidrandBooksApi/http/app.http b/MidrandBooksApi/http/app.http deleted file mode 100644 index 7297a16..0000000 --- a/MidrandBooksApi/http/app.http +++ /dev/null @@ -1,8 +0,0 @@ -## Payfast Payment Confirmation -# This endpoint is used by Payfast to confirm the payment status of a transaction. -# It receives a POST request with the payment details and updates the order status accordingly. - -POST {{baseUrl}}/v1/payments/payfast/confirm -Content-Type: application/x-www-form-urlencoded - -amount={{amount}}&item_name={{item_name}}&m_payment_id={{paymentId}}&signature={{signature}} -- 2.47.3