التحقق من تسليم
`OpenEmail.verify_webhook_signature`: بزمن ثابت، ومع نافذة لمنع إعادة التشغيل، ويعيد الحدث محلَّلًا.
داخل معالج طلب
عنوان URL الخاص بـ webhook عام. فأي شيء على الإنترنت يستطيع أن يرسل إليه بـ POST بنية JSON بالشكل الصحيح، ولذلك فإن معالجًا يقرأ type الحدث دون التحقق من التوقيع هو API كتابة مفتوح للجميع.
class OpenEmailWebhooksController < ActionController::API def create event = OpenEmail.verify_webhook_signature( payload: request.raw_post, headers: request.headers, secret: ENV.fetch("OPENEMAIL_WEBHOOK_SECRET"), tolerance_seconds: 300 ) Rails.logger.info("#{event[:type]} #{event[:data]}") head :no_content rescue OpenEmail::WebhookSignatureError head :bad_request endendمرّر المتن الخام، في صورة String. فالتحليل ثم إعادة التسلسل يغيّران ترتيب المفاتيح والمسافات، ولن يتطابق التوقيع، ولهذا يرفع الـ Hash، مثل params في Rails، الخطأ ArgumentError بدل أن يُفحص. ويأخذ headers: قيمة Hash فيها اسم الترويسة بأي حالة أحرف، أو بيئة Rack، حيث تصل الترويسة بوصفها HTTP_X_OPENEMAIL_SIGNATURE، أو request.headers في Rails. والقيمة التي تكون Array تُقرأ من عنصرها الأول.
أمران يعالجهما هذا ولا يعالجهما عادةً فحص مكتوب يدويًا: فهو يقارن الـ MAC بزمن ثابت، فلا يمكن استخراج البادئة الصحيحة بقياس الزمن، وهو يرفض تسليمًا أقدم من tolerance_seconds: في أي من الاتجاهين، أي خمس دقائق ما لم تقل غير ذلك، فلا يبقى طلب ملتقَط قابلًا لإعادة التشغيل إلى الأبد. والقيمة tolerance_seconds: 0 تعطّل فحص إعادة التشغيل. وكلا الخللين صامت. والمعالج المصاب بأي منهما يجتاز كل اختبار قد يخطر لك أن تكتبه.
يرفع OpenEmail::WebhookSignatureError عند كل إخفاق: ترويسة X-OpenEmail-Signature مفقودة، أو ترويسة ليست على الصيغة t=<seconds>,v1=<hex>، أو طابع وقت خارج النافذة، أو توقيع لا يتطابق. وعند النجاح يعيد المتن محلَّلًا إلى Hash بمفاتيح من نوع Symbol، هي id وtype وcreatedAt وdata، فلا يوجد JSON.parse ثانٍ يمكن أن تخطئ فيه.
أما secret: المفقود أو الفارغ فيرفع ArgumentError بدلًا من ذلك، لأنه خطأ في إعداداتك لا تسليم سيئ. التقط OpenEmail::WebhookSignatureError وحده وأجب بـ 400، فيجيب الخادم ذو الإعداد الخاطئ بـ 500 ويُعاد التسليم بعد أن تصلحه.
يستخدم OpenSSL من المكتبة القياسية، التي يحمّلها الـ gem أصلًا، فلا يحتاج إلى شيء آخر.
في تطبيق Rack أو Sinatra
خارج Rails، مرّر بيئة Rack نفسها بوصفها headers: واقرأ المتن الخام من الطلب.
require "openemail"require "rack" webhook = lambda do |env| event = OpenEmail.verify_webhook_signature( payload: Rack::Request.new(env).body.read, headers: env, secret: ENV.fetch("OPENEMAIL_WEBHOOK_SECRET") ) warn "#{event[:type]} #{event[:id]}" [204, {}, []]rescue OpenEmail::WebhookSignatureError [400, {"content-type" => "text/plain"}, ["bad signature"]]end run webhookنسخة Rack ملف config.ru كامل. أجب سريعًا في كلتيهما: فالتسليم الذي لا يتلقى ردًا خلال 5 ثوانٍ يُعدّ فاشلًا ويُرسَل مجددًا لاحقًا، فسلّم العمل إلى مهمة وأجب بـ 2xx.
يسمّي OpenEmail::WEBHOOK_SIGNATURE_HEADERS الترويسات الثلاث التي يحملها التسليم: X-OpenEmail-Signature، وX-OpenEmail-Event مع نوع الحدث، وX-OpenEmail-Delivery مع معرّفه، وهو id نفسه الموجود في المتن. ويبقى ذلك المعرّف نفسه في كل إعادة محاولة وإعادة تشغيل للحدث، فهو الذي تخزّنه حين تتخطى الأحداث التي عالجتها من قبل.
أثناء تدوير السر
ليس لـ webhooks.rotate_secret نافذة تداخل: فالتسليمات تُوقَّع بالسر الجديد منذ لحظة عودته. انشر أولًا مستقبِلًا يقبل أيًّا من السرين، ثم دوّر، ثم خزّن السر الجديد حيث يقرؤه المستقبِل، ثم تخلّص من القديم.
def verify_delivery(payload, headers) secrets = [ENV.fetch("OPENEMAIL_WEBHOOK_SECRET"), ENV["OPENEMAIL_WEBHOOK_SECRET_NEXT"]].compact secrets.each_with_index do |secret, index| return OpenEmail.verify_webhook_signature(payload:, headers:, secret:) rescue OpenEmail::WebhookSignatureError raise if index == secrets.size - 1 endendيرسل webhooks.test حدثًا اصطناعيًا موقَّعًا، فاستدعه بعد التدوير لتثبت أن السر الجديد يجتاز التحقق قبل أن تزيل القديم.