Files
zemailnator/app/Services/Webhooks/StandardWebhooks.php
idevakk 289baa1286 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.
2025-12-06 22:49:54 -08:00

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;
}
}