feat(payments): implement standard webhooks validation system
Add comprehensive webhook validation and processing system with Polar.sh integration: - Create built-in Standard Webhooks package following official specification - Implement HMAC-SHA256 signature validation with base64 encoding - Add webhook factory for multi-provider support (Polar, Stripe, generic) - Replace custom Polar webhook validation with Standard Webhooks implementation - Add proper exception handling with custom WebhookVerificationException - Support sandbox mode bypass for development environments - Update Polar provider to use database-driven configuration - Enhance webhook test suite with proper Standard Webhooks format - Add PaymentProvider model HasFactory trait for testing - Implement timestamp tolerance checking (±5 minutes) for replay protection - Support multiple signature versions and proper header validation This provides a secure, reusable webhook validation system that can be extended to other payment providers while maintaining full compliance with Standard Webhooks specification. BREAKING CHANGE: Polar webhook validation now uses Standard Webhooks format with headers 'webhook-id', 'webhook-timestamp', 'webhook-signature' instead of previous Polar-specific headers.
This commit is contained in:
135
app/Services/Webhooks/StandardWebhooks.php
Normal file
135
app/Services/Webhooks/StandardWebhooks.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Webhooks;
|
||||
|
||||
class StandardWebhooks
|
||||
{
|
||||
private const SECRET_PREFIX = 'whsec_';
|
||||
|
||||
private const TOLERANCE = 5 * 60; // 5 minutes
|
||||
|
||||
private $secret;
|
||||
|
||||
public function __construct(string $secret)
|
||||
{
|
||||
if (substr($secret, 0, strlen(self::SECRET_PREFIX)) === self::SECRET_PREFIX) {
|
||||
$secret = substr($secret, strlen(self::SECRET_PREFIX));
|
||||
}
|
||||
|
||||
// The secret should be base64 decoded for HMAC operations
|
||||
$this->secret = base64_decode($secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a webhook instance from a raw secret (no base64 decoding)
|
||||
*/
|
||||
public static function fromRaw(string $secret): self
|
||||
{
|
||||
$obj = new self('');
|
||||
$obj->secret = $secret;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify webhook signature and return decoded payload
|
||||
*/
|
||||
public function verify(string $payload, array $headers): array
|
||||
{
|
||||
// Check for required headers
|
||||
if (
|
||||
! isset($headers['webhook-id']) &&
|
||||
! isset($headers['webhook-timestamp']) &&
|
||||
! isset($headers['webhook-signature'])
|
||||
) {
|
||||
throw new WebhookVerificationException('Missing required headers');
|
||||
}
|
||||
|
||||
$msgId = $headers['webhook-id'] ?? null;
|
||||
$msgTimestamp = $headers['webhook-timestamp'] ?? null;
|
||||
$msgSignature = $headers['webhook-signature'] ?? null;
|
||||
|
||||
if (! $msgId || ! $msgTimestamp || ! $msgSignature) {
|
||||
throw new WebhookVerificationException('Missing required headers');
|
||||
}
|
||||
|
||||
// Verify timestamp is within tolerance
|
||||
$timestamp = $this->verifyTimestamp($msgTimestamp);
|
||||
|
||||
// Generate expected signature
|
||||
$signature = $this->sign($msgId, $timestamp, $payload);
|
||||
$expectedSignature = explode(',', $signature, 2)[1];
|
||||
|
||||
// Check all provided signatures
|
||||
$passedSignatures = explode(' ', $msgSignature);
|
||||
foreach ($passedSignatures as $versionedSignature) {
|
||||
$sigParts = explode(',', $versionedSignature, 2);
|
||||
if (count($sigParts) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $sigParts[0];
|
||||
$passedSignature = $sigParts[1];
|
||||
|
||||
if (strcmp($version, 'v1') !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hash_equals($expectedSignature, $passedSignature)) {
|
||||
return json_decode($payload, true) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
throw new WebhookVerificationException('No matching signature found');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate webhook signature
|
||||
*/
|
||||
public function sign(string $msgId, int $timestamp, string $payload): string
|
||||
{
|
||||
$timestamp = (string) $timestamp;
|
||||
|
||||
if (! $this->isPositiveInteger($timestamp)) {
|
||||
throw new WebhookSigningException('Invalid timestamp');
|
||||
}
|
||||
|
||||
$toSign = "{$msgId}.{$timestamp}.{$payload}";
|
||||
$hexHash = hash_hmac('sha256', $toSign, $this->secret);
|
||||
$signature = base64_encode(pack('H*', $hexHash));
|
||||
|
||||
return "v1,{$signature}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify timestamp is within acceptable tolerance
|
||||
*/
|
||||
private function verifyTimestamp(string $timestampHeader): int
|
||||
{
|
||||
$now = time();
|
||||
|
||||
try {
|
||||
$timestamp = intval($timestampHeader, 10);
|
||||
} catch (\Exception $e) {
|
||||
throw new WebhookVerificationException('Invalid Signature Headers');
|
||||
}
|
||||
|
||||
if ($timestamp < ($now - self::TOLERANCE)) {
|
||||
throw new WebhookVerificationException('Message timestamp too old');
|
||||
}
|
||||
|
||||
if ($timestamp > ($now + self::TOLERANCE)) {
|
||||
throw new WebhookVerificationException('Message timestamp too new');
|
||||
}
|
||||
|
||||
return $timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value is a positive integer
|
||||
*/
|
||||
private function isPositiveInteger(string $value): bool
|
||||
{
|
||||
return is_numeric($value) && ! is_float($value + 0) && (int) $value == $value && (int) $value > 0;
|
||||
}
|
||||
}
|
||||
32
app/Services/Webhooks/WebhookFactory.php
Normal file
32
app/Services/Webhooks/WebhookFactory.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Webhooks;
|
||||
|
||||
class WebhookFactory
|
||||
{
|
||||
/**
|
||||
* Create a Standard Webhooks validator for Polar
|
||||
*/
|
||||
public static function createPolar(string $secret): StandardWebhooks
|
||||
{
|
||||
// Polar uses raw secret, so we use fromRaw() method
|
||||
return StandardWebhooks::fromRaw($secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Standard Webhooks validator for Stripe
|
||||
*/
|
||||
public static function createStripe(string $secret): StandardWebhooks
|
||||
{
|
||||
// Stripe typically uses whsec_ prefix
|
||||
return new StandardWebhooks($secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Standard Webhooks validator for generic providers
|
||||
*/
|
||||
public static function create(string $secret, bool $isRaw = false): StandardWebhooks
|
||||
{
|
||||
return $isRaw ? StandardWebhooks::fromRaw($secret) : new StandardWebhooks($secret);
|
||||
}
|
||||
}
|
||||
13
app/Services/Webhooks/WebhookSigningException.php
Normal file
13
app/Services/Webhooks/WebhookSigningException.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Webhooks;
|
||||
|
||||
use Exception;
|
||||
|
||||
class WebhookSigningException extends Exception
|
||||
{
|
||||
public function __construct($message, $code = 0, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
13
app/Services/Webhooks/WebhookVerificationException.php
Normal file
13
app/Services/Webhooks/WebhookVerificationException.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Webhooks;
|
||||
|
||||
use Exception;
|
||||
|
||||
class WebhookVerificationException extends Exception
|
||||
{
|
||||
public function __construct($message, $code = 0, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user