Проверка доставки
`OpenEmail::verifyWebhookSignature`: за постоянное время, с окном защиты от повторов и с разобранным событием в результате.
В обработчике запроса
URL вебхука публичен. Кто угодно в интернете может отправить на него POST с JSON правильной формы, поэтому обработчик, который читает type события, не проверив подпись, является открытым API для записи.
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; $payload = (string) file_get_contents('php://input'); try { $event = OpenEmail::verifyWebhookSignature( $payload, $_SERVER, (string) getenv('OPENEMAIL_WEBHOOK_SECRET'), toleranceSeconds: 300, );} catch (WebhookSignatureException) { http_response_code(400); return;} error_log($event['type'] . ' ' . $event['id']);http_response_code(204);Передавайте ИСХОДНОЕ тело в виде строки. Декодирование и повторное кодирование меняют порядок ключей и пробелы, и подпись не совпадёт, поэтому полезная нагрузка объявлена с типом string: массив, например результат json_decode() или разобранный фреймворком ввод, даёт TypeError, а не проверяется. Заголовки можно передать массивом с именем заголовка в любом регистре, какой возвращает getallheaders(), как $_SERVER, где заголовок приходит как HTTP_X_OPENEMAIL_SIGNATURE, как любой iterable, например HeaderBag из Symfony или Laravel, или как запрос PSR-7. Значение-массив читается по первому элементу.
Две вещи, которые эта функция учитывает, а самодельная проверка обычно нет: она сравнивает MAC за постоянное время через hash_equals(), поэтому правильный префикс нельзя восстановить по времени ответа, и она отклоняет доставку, расхождение которой по времени в любую сторону больше toleranceSeconds: (пять минут, если не указано иное), поэтому перехваченный запрос нельзя воспроизводить вечно. toleranceSeconds: 0 отключает проверку на повтор. Обе ошибки незаметны. Обработчик с любой из них проходит все тесты, которые придут вам в голову.
Она выбрасывает OpenEmail\Exception\WebhookSignatureException при любом сбое: отсутствующий заголовок X-OpenEmail-Signature, заголовок не в форме t=<seconds>,v1=<hex>, метка времени вне окна, несовпадающая подпись или подписанное тело, которое не является JSON-объектом. При успехе возвращает тело, декодированное в массив с ключами в camelCase: id, type, createdAt и data, поэтому второго json_decode(), в котором можно ошибиться, нет.
Пустой секрет вместо этого выбрасывает OpenEmail\Exception\InvalidArgumentException, потому что это ошибка в вашей конфигурации, а не плохая доставка, а (string) перед getenv() превращает незаданную переменную именно в такой секрет. Перехватывайте только WebhookSignatureException и отвечайте 400. Тогда отсутствующий секрет завершит скрипт неперехваченным исключением, на которое PHP в продакшене отвечает 500, и доставка будет повторена, когда вы это исправите, а не каждое событие будет отклонено как поддельное.
Проверка работает на hash_hmac() и hash_equals(), которые есть в любой сборке PHP, поэтому никакое расширение не нужно. Пример делает return, а не вызывает exit, поэтому те же строки работают и внутри функции или фронт-контроллера.
В приложении PSR-7
С запросом PSR-7, какой дают Slim и Mezzio, передайте сам запрос в качестве заголовков, а его тело в виде строки.
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail;use Psr\Http\Message\ResponseFactoryInterface;use Psr\Http\Message\ResponseInterface;use Psr\Http\Message\ServerRequestInterface; function receiveOpenEmail(ServerRequestInterface $request, ResponseFactoryInterface $responses): ResponseInterface{ try { $event = OpenEmail::verifyWebhookSignature( (string) $request->getBody(), $request, (string) getenv('OPENEMAIL_WEBHOOK_SECRET'), ); } catch (WebhookSignatureException) { return $responses->createResponse(400); } error_log($event['type'] . ' ' . $event['id']); return $responses->createResponse(204);}Отвечайте быстро: доставка, не получившая ответа в течение 5 секунд, считается неудачной и позже отправляется снова, поэтому передайте работу в очередь и ответьте 2xx. Контроллеры для Laravel и Symfony вместе с передачей в очередь приведены на странице о фреймворках.
OpenEmail\Constants\WebhookSignatureHeaders называет три заголовка, которые несёт доставка: X-OpenEmail-Signature, X-OpenEmail-Event с типом события и X-OpenEmail-Delivery с его идентификатором, тем же id, что и в теле. Этот идентификатор одинаков при каждом повторе и повторной отправке события, поэтому именно его стоит хранить, чтобы пропускать уже обработанные события.
Во время ротации секрета
У webhooks->rotateSecret нет окна перекрытия: доставки подписываются новым секретом с момента возврата из вызова. Сначала разверните приёмник, который принимает любой из двух секретов, затем выполните ротацию, сохраните новый секрет там, откуда его читает приёмник, и после этого уберите старый.
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; function verifyDelivery(string $payload, mixed $headers): array{ $next = getenv('OPENEMAIL_WEBHOOK_SECRET_NEXT'); try { return OpenEmail::verifyWebhookSignature($payload, $headers, (string) getenv('OPENEMAIL_WEBHOOK_SECRET')); } catch (WebhookSignatureException $error) { if (!is_string($next) || $next === '') { throw $error; } return OpenEmail::verifyWebhookSignature($payload, $headers, $next); }}webhooks->test отправляет подписанное синтетическое событие, поэтому вызовите его после ротации, чтобы убедиться, что новый секрет проходит проверку, прежде чем убирать старый.