Zur Dokumentation springen
PHP

Eine Zustellung verifizieren

`OpenEmail::verifyWebhookSignature`: in konstanter Zeit, mit einem Replay-Fenster und dem geparsten Event zurück.

In einem Request-Handler

Eine Webhook-URL ist öffentlich. Alles im Internet kann JSON in der richtigen Form per POST dorthin schicken. Ein Handler, der den type des Events liest, ohne die Signatur zu prüfen, ist daher eine offene Schreib-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);

Übergeben Sie den ROHEN Body als String. Erneutes Decodieren und Encodieren ändert die Reihenfolge der Schlüssel und den Leerraum, und die Signatur passt dann nicht mehr. Deshalb ist die Payload als string typisiert: Ein Array, etwa das Ergebnis von json_decode() oder die geparste Eingabe eines Frameworks, ergibt einen TypeError, statt geprüft zu werden. Die Header können ein Array mit dem Header-Namen in beliebiger Schreibweise sein, wie es getallheaders() zurückgibt, $_SERVER, in dem der Header als HTTP_X_OPENEMAIL_SIGNATURE ankommt, jedes Iterable wie ein HeaderBag aus Symfony oder Laravel oder ein PSR-7-Request. Ein Wert, der ein Array ist, wird aus seinem ersten Element gelesen.

Zwei Dinge, die das hier erledigt und die eine selbstgebaute Prüfung meist nicht leistet: Es vergleicht den MAC mit hash_equals() in konstanter Zeit, sodass sich das korrekte Präfix nicht über die Laufzeit rekonstruieren lässt, und es weist eine Zustellung zurück, die in eine der beiden Richtungen mehr als toleranceSeconds: alt ist, fünf Minuten, sofern Sie nichts anderes angeben, sodass eine mitgeschnittene Anfrage nicht für immer wiederverwendbar ist. toleranceSeconds: 0 schaltet die Replay-Prüfung ab. Beide Fehler sind still. Ein Handler mit einem davon besteht jeden Test, auf den Sie kämen.

Es wirft bei jedem Fehlschlag OpenEmail\Exception\WebhookSignatureException: bei einem fehlenden X-OpenEmail-Signature-Header, bei einem, der nicht die Form t=<seconds>,v1=<hex> hat, bei einem Zeitstempel außerhalb des Fensters, bei einer Signatur, die nicht passt, oder bei einem signierten Body, der kein JSON-Objekt ist. Bei Erfolg gibt es den Body decodiert als Array mit camelCase-Schlüsseln zurück, mit id, type, createdAt und data, es gibt also kein zweites json_decode(), das man falsch machen könnte.

Ein leeres Secret wirft stattdessen OpenEmail\Exception\InvalidArgumentException, weil das ein Fehler in Ihrer Konfiguration ist und keine schlechte Zustellung, und das (string) vor getenv() macht aus einer nicht gesetzten Variablen genau das. Fangen Sie nur WebhookSignatureException ab und antworten Sie mit 400. Ein fehlendes Secret beendet das Skript dann mit einer nicht abgefangenen Exception, auf die PHP in Produktion mit 500 antwortet, und die Zustellung wird erneut versucht, sobald Sie das behoben haben, statt dass jedes Event als gefälscht abgewiesen wird.

Die Prüfung läuft auf hash_hmac() und hash_equals(), die jeder PHP-Build mitbringt, und braucht daher keine Erweiterung. Das Beispiel verwendet return, statt exit aufzurufen, sodass dieselben Zeilen auch in einer Funktion oder einem Front-Controller funktionieren.

In einer PSR-7-App

Bei einem PSR-7-Request, wie Slim und Mezzio ihn Ihnen übergeben, übergeben Sie den Request selbst als Header und seinen Body als String.

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

Antworten Sie schnell: Eine Zustellung, die innerhalb von 5 Sekunden keine Antwort bekommt, gilt als fehlgeschlagen und wird später erneut gesendet. Übergeben Sie die Arbeit daher an eine Queue und antworten Sie mit einem 2xx. Die Controller für Laravel und Symfony, samt Übergabe an die Queue, stehen auf der Seite zu den Frameworks.

OpenEmail\Constants\WebhookSignatureHeaders nennt die drei Header, die eine Zustellung trägt: X-OpenEmail-Signature, X-OpenEmail-Event mit dem Typ des Events und X-OpenEmail-Delivery mit seiner id, derselben id wie im Body. Diese id bleibt bei jeder Wiederholung und jedem erneuten Abspielen des Events gleich. Sie ist daher die, die Sie speichern, wenn Sie bereits bearbeitete Events überspringen.

Während das Secret rotiert

webhooks->rotateSecret hat kein Überlappungsfenster: Zustellungen werden ab dem Moment, in dem es zurückkehrt, mit dem neuen Secret signiert. Rollen Sie zuerst einen Empfänger aus, der beide Secrets akzeptiert, rotieren Sie, speichern Sie das neue Secret dort, wo der Empfänger es liest, und entfernen Sie dann das alte.

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 sendet ein signiertes synthetisches Event. Rufen Sie es nach der Rotation auf, um zu beweisen, dass das neue Secret verifiziert, bevor Sie das alte entfernen.