Перейти к документации
PHP

Проверка доставки

`OpenEmail::verifyWebhookSignature`: за постоянное время, с окном защиты от повторов и с разобранным событием в результате.

В обработчике запроса

URL вебхука публичен. Кто угодно в интернете может отправить на него POST с JSON правильной формы, поэтому обработчик, который читает type события, не проверив подпись, является открытым API для записи.

webhook.php
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, передайте сам запрос в качестве заголовков, а его тело в виде строки.

webhook_handler.php
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 нет окна перекрытия: доставки подписываются новым секретом с момента возврата из вызова. Сначала разверните приёмник, который принимает любой из двух секретов, затем выполните ротацию, сохраните новый секрет там, откуда его читает приёмник, и после этого уберите старый.

two_secrets.php
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 отправляет подписанное синтетическое событие, поэтому вызовите его после ротации, чтобы убедиться, что новый секрет проходит проверку, прежде чем убирать старый.