تخطَّ إلى المستندات
PHP

التحقق من تسليم

`OpenEmail::verifyWebhookSignature`: بزمن ثابت، ومع نافذة لمنع إعادة التشغيل، ويعيد الحدث محلَّلًا.

داخل معالج طلب

عنوان URL الخاص بـ webhook عام. فأي شيء على الإنترنت يستطيع أن يرسل إليه بـ 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، أو أي كائن قابل للتكرار مثل 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 حدثًا اصطناعيًا موقَّعًا، فاستدعه بعد التدوير لتثبت أن السر الجديد يجتاز التحقق قبل أن تزيل القديم.