129 lines
5.6 KiB
C#
129 lines
5.6 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|