File: /home/xedaptot/be.naniguide.com/app/Http/Middleware/Ads/VerifyAdsWebhookSignature.php
<?php
namespace App\Http\Middleware\Ads;
use App\Services\Ads\AdCredentialResolver;
use App\Services\Ads\WebhookSignatureVerifier;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
/**
* Guards `/rui/ads/webhooks/{platform}/*` POST routes.
*
* 1. Compute HMAC-SHA256(body, app_secret) and constant-time compare against
* the platform's signature header.
* 2. On mismatch:
* - `ads.webhooks.enforce_signature = true` → 401
* - `ads.webhooks.enforce_signature = false` → log + allow through
* (observation mode for staging / Meta test events).
* 3. Always run replay-protection: same event id within
* `ads.webhooks.replay_protection_ttl_seconds` → return 204 No Content
* immediately, don't re-process.
*/
class VerifyAdsWebhookSignature
{
public function __construct(private WebhookSignatureVerifier $verifier) {}
public function handle(Request $request, Closure $next): Response
{
$platform = $this->platformFromRoute($request);
if ($platform === null) {
// No known platform in the route — reject outright to avoid
// routing malformed URLs into the webhook pipeline.
abort(404);
}
$body = $request->getContent();
$signatureHeader = $this->signatureHeader($request, $platform);
$appSecret = $this->appSecret($platform);
$valid = match ($platform) {
'meta' => $this->verifier->verifyMeta($body, $signatureHeader, $appSecret),
default => !(bool) config('ads.webhooks.enforce_signature', false),
};
if (!$valid) {
Log::channel((string) config('ads.observability.log_channel', 'stack'))->warning(
'ads.webhook.signature_rejected',
[
'platform' => $platform,
'ip' => $request->ip(),
'url' => $request->fullUrl(),
]
);
abort(401, 'Invalid webhook signature');
}
$payload = $request->json()->all() ?: [];
$eventId = match ($platform) {
'meta' => $this->verifier->metaEventId($payload, $body),
default => substr(hash('sha256', $body), 0, 32),
};
if (!$this->verifier->recordAndCheckFresh($platform, $eventId)) {
// Already seen — ACK without re-processing so the platform stops retrying.
return response()->noContent();
}
return $next($request);
}
private function platformFromRoute(Request $request): ?string
{
$route = $request->route();
if (!$route) {
return null;
}
$platform = $route->parameter('platform');
if (is_string($platform) && in_array($platform, ['meta', 'google', 'tiktok', 'linkedin'], true)) {
return $platform;
}
// Fall back to URI segment when the route param isn't named
$segments = $request->segments();
foreach (['meta', 'google', 'tiktok', 'linkedin'] as $candidate) {
if (in_array($candidate, $segments, true)) {
return $candidate;
}
}
return null;
}
private function signatureHeader(Request $request, string $platform): ?string
{
return match ($platform) {
'meta' => $request->header('X-Hub-Signature-256'),
'tiktok' => $request->header('X-Signature'),
default => null,
};
}
private function appSecret(string $platform): ?string
{
$secret = AdCredentialResolver::resolve($platform, 'app_secret');
if (is_string($secret) && $secret !== '') {
return $secret;
}
return null;
}
}