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.
136 lines
3.9 KiB
PHP
136 lines
3.9 KiB
PHP
<?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;
|
|
}
|
|
}
|