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 72eea12..db53633 100644
--- a/MidrandBooksApi/MidrandBooksApi.csproj
+++ b/MidrandBooksApi/MidrandBooksApi.csproj
@@ -34,6 +34,7 @@
+
@@ -53,13 +54,13 @@
-
+
-
+
@@ -85,6 +86,7 @@
+
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 f4aa481..c83540f 100644
--- a/MidrandBooksApi/Payments/Endpoints/ConfirmationEndpoint.cs
+++ b/MidrandBooksApi/Payments/Endpoints/ConfirmationEndpoint.cs
@@ -1,7 +1,11 @@
-using LiteCharms.Features.Hasher;
+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.Models;
+using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.Quartz.Abstractions;
+using static LiteCharms.Features.Extensions.Api;
namespace MidrandBooksApi.Payments.Endpoints;
@@ -12,32 +16,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!, !hostEnvironment.IsProduction(), 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!,
+ allowLoopback: !hostEnvironment.IsProduction(), performBackgroundChecks: false); // Set to false because comprehensive checks are completed inline above
+
+ await jobOrchestrator.SendAsync(notification, cancellationToken);
activity?.SetStatus(ActivityStatusCode.Ok);
@@ -49,6 +72,27 @@ 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()
+ {
+ 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/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 9a5d63d..0000000
--- a/MidrandBooksApi/Setup.cs
+++ /dev/null
@@ -1,88 +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.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/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/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}}
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