Initial commit
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Receives platform-level Stripe webhook events (subscription lifecycle, checkout completion, etc.)
|
||||
/// from the Stripe platform account. This is distinct from <c>PaymentController.ConnectWebhook</c>
|
||||
/// which handles Stripe Connect account-level events (payments, refunds, disputes on tenant
|
||||
/// accounts). Raw body reading is required — Stripe's signature verification computes an HMAC over
|
||||
/// the exact bytes received, so any middleware that re-encodes the body (e.g. JSON middleware)
|
||||
/// would break signature validation. The request body size limit is 5 MB; Stripe events are always
|
||||
/// much smaller, but this cap prevents memory exhaustion from malformed oversized POST requests.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[IgnoreAntiforgeryToken]
|
||||
[RequestSizeLimit(5 * 1024 * 1024)] // 5 MB — Stripe events are never larger than this
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Webhook)]
|
||||
public class StripeWebhookController : Controller
|
||||
{
|
||||
private readonly IStripeService _stripeService;
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly ILogger<StripeWebhookController> _logger;
|
||||
|
||||
public StripeWebhookController(
|
||||
IStripeService stripeService,
|
||||
ApplicationDbContext db,
|
||||
ILogger<StripeWebhookController> logger)
|
||||
{
|
||||
_stripeService = stripeService;
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Receives, verifies, and processes a Stripe webhook event at <c>POST /stripe/webhook</c>.
|
||||
/// The body is read as raw text rather than via model binding so the HMAC signature computed
|
||||
/// by Stripe over the exact byte sequence remains valid. The event ID and type are parsed
|
||||
/// structurally (before signature check) purely for logging — this pre-parse does not trust the
|
||||
/// payload; the authoritative verification happens inside
|
||||
/// <c>IStripeService.HandleWebhookAsync</c>. The <c>StripeWebhookEvent</c> audit record is
|
||||
/// written AFTER signature verification to prevent unauthenticated callers from flooding the
|
||||
/// events table. On a processing error after verification, the event is still recorded with
|
||||
/// <c>Status = Failed</c> so SuperAdmins can investigate in <c>StripeEventsController</c>.
|
||||
/// Returns HTTP 500 on processing failure — Stripe will retry the event up to its retry policy
|
||||
/// limit, which is the desired behaviour for transient DB or service errors.
|
||||
/// </summary>
|
||||
[HttpPost("/stripe/webhook")]
|
||||
public async Task<IActionResult> Webhook()
|
||||
{
|
||||
// Read raw body
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var json = await reader.ReadToEndAsync();
|
||||
|
||||
var stripeSignature = Request.Headers["Stripe-Signature"].ToString();
|
||||
if (string.IsNullOrEmpty(stripeSignature))
|
||||
{
|
||||
_logger.LogWarning("Stripe webhook received without signature header");
|
||||
return BadRequest("Missing Stripe-Signature header");
|
||||
}
|
||||
|
||||
// Parse event id/type for logging (no signature check yet — just structural parse)
|
||||
string eventId = string.Empty;
|
||||
string eventType = string.Empty;
|
||||
try
|
||||
{
|
||||
var evt = Stripe.EventUtility.ParseEvent(json);
|
||||
eventId = evt.Id ?? string.Empty;
|
||||
eventType = evt.Type ?? string.Empty;
|
||||
}
|
||||
catch { /* invalid JSON — HandleWebhookAsync will reject it below */ }
|
||||
|
||||
// Process first (signature verification happens inside HandleWebhookAsync).
|
||||
// Only persist a log record after we know the request is authentic — this prevents
|
||||
// unauthenticated callers from flooding the StripeWebhookEvents table.
|
||||
try
|
||||
{
|
||||
await _stripeService.HandleWebhookAsync(json, stripeSignature);
|
||||
|
||||
// Signature verified and event processed — now record it
|
||||
var logEntry = new StripeWebhookEvent
|
||||
{
|
||||
EventId = eventId,
|
||||
EventType = eventType,
|
||||
RawJson = json,
|
||||
Status = StripeWebhookEventStatus.Processed,
|
||||
ReceivedAt = DateTime.UtcNow,
|
||||
ProcessedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.StripeWebhookEvents.Add(logEntry);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
catch (Stripe.StripeException ex)
|
||||
{
|
||||
// Signature invalid or event malformed — log warning, do NOT save to DB
|
||||
_logger.LogWarning("Stripe webhook rejected (invalid signature or payload): {Message}", ex.Message);
|
||||
return BadRequest("Invalid Stripe signature");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing Stripe webhook {EventType} {EventId}", eventType, eventId);
|
||||
|
||||
// Processing failed after signature was verified — record it for debugging
|
||||
var logEntry = new StripeWebhookEvent
|
||||
{
|
||||
EventId = eventId,
|
||||
EventType = eventType,
|
||||
RawJson = json,
|
||||
Status = StripeWebhookEventStatus.Failed,
|
||||
ReceivedAt = DateTime.UtcNow,
|
||||
ProcessedAt = DateTime.UtcNow,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
_db.StripeWebhookEvents.Add(logEntry);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return StatusCode(500, "Webhook processing error");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user