Webhooks 詳解:簽名載荷與重試邏輯
你已經建立了一個 API,現在需要在事件發生時通知其他服務。你決定實作 webhooks:將資料推送到訂閱者 URL 的 HTTP 回呼。但很快你面臨兩個關鍵問題:如何確保載荷未被篡改?以及當接收伺服器宕機或傳回錯誤時該怎麼辦?如果沒有適當的保護措施,webhooks 可能成為安全漏洞和可靠性噩夢。
在本指南中,我們將探討如何透過簽名載荷保護 webhooks 以及實作穩健的重試邏輯。你將學習如何產生和驗證 HMAC 簽名、設計冪等處理常式,並建立一個能優雅處理失敗的重試機制。最終,你將獲得一套可用於生產環境的 webhooks 方法。
為什麼 Webhooks 需要安全性和可靠性
Webhooks 本質上是由事件觸發的伺服器對伺服器 HTTP 請求。與由客戶端發起的 API 呼叫不同,webhooks 是從你的伺服器推送到訂閱者的端點。這種推送模型帶來兩個主要挑戰:
- 安全性: 載荷透過網際網路傳輸,可能被攔截或偽造。訂閱者必須驗證請求確實來自你的服務。
- 可靠性: 訂閱者的伺服器可能暫時不可用、回應緩慢或傳回錯誤。你需要一種策略來重試失敗的傳遞,同時避免重複處理。
忽略這些挑戰可能導致資料外洩、事件遺失或系統間狀態不一致。讓我們逐一解決這些問題。
使用簽名載荷保護 Webhooks
簽名載荷使用加密雜湊來證明請求的完整性和真實性。最常見的方法是使用共享密鑰的 HMAC(基於雜湊的訊息驗證碼)。運作方式如下:
- 共享密鑰: 發送方(你的服務)和接收方(訂閱者)共享一個密鑰。此密鑰在訂閱者註冊其 webhook 端點時產生。
- 簽名產生: 對於每個 webhook 請求,發送方使用密鑰計算請求主體(有時還包括時間戳等標頭)的 HMAC。簽名透過標頭傳送,例如
X-Signature。 - 簽名驗證: 接收方使用相同的密鑰重新計算 HMAC,並與收到的簽名進行比較。如果匹配,則載荷是真實且未經修改的。
讓我們看一個 Node.js 中產生和驗證簽名的程式碼範例。
const crypto = require('crypto');
// 發送方:產生簽名
function generateSignature(payload, secret) {
return crypto.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
}
// 接收方:驗證簽名
function verifySignature(payload, secret, receivedSignature) {
const expectedSignature = generateSignature(payload, secret);
// 使用 timingSafeEqual 防止時序攻擊
return crypto.timingSafeEqual(
Buffer.from(expectedSignature, 'hex'),
Buffer.from(receivedSignature, 'hex')
);
}
重要: 始終使用如 timingSafeEqual 的常數時間比較函數來避免時序攻擊。此外,在簽名資料中包含時間戳以防止重放攻擊。例如,簽署 timestamp + '.' + payload 並拒絕超過幾分鐘的請求。
簽名載荷的最佳實踐
- 使用 HMAC-SHA256 或更強的演算法。
- 在簽名中包含時間戳並強制最大年齡(例如 5 分鐘)。
- 切勿在請求中傳輸密鑰;它僅用於簽名。
- 定期輪換密鑰,並在輪換期間支援多個活躍密鑰。
實作可靠傳遞的重試邏輯
即使有了安全措施,webhooks 仍可能因網路問題、伺服器錯誤或逾時而失敗。穩健的重試機制確保事件最終被傳遞。以下是逐步方法:
- 設定逾時: 為 webhook 請求定義合理的逾時(例如 10 秒)。如果訂閱者未及時回應,則視為失敗。
- 分類回應: 將 2xx 狀態碼視為成功。對於 4xx 錯誤(429 除外),重試通常沒有意義,因為請求無效。對於 5xx 錯誤和逾時,使用指數退避進行重試。
- 帶抖動的指數退避: 以遞增的間隔安排重試(例如 1 秒、5 秒、30 秒、5 分鐘、30 分鐘),並添加隨機抖動以避免驚群效應。
- 限制重試次數: 設定最大嘗試次數(例如 5 次),然後將傳遞標記為失敗。可選地,透過電子郵件或儀表板通知訂閱者。
- 冪等性: 由於重試可能導致重複傳遞,在載荷和標頭中包含唯一的事件 ID。訂閱者應使用此 ID 來去重處理。
以下是使用 requests 和 time 的 Python 簡化重試佇列實作:
import requests
import time
import random
def deliver_webhook(url, payload, secret, max_retries=5):
signature = generate_signature(payload, secret)
headers = {'X-Signature': signature, 'Content-Type': 'application/json'}
for attempt in range(max_retries):
try:
response = requests.post(url, json=payload, headers=headers, timeout=10)
if 200 <= response.status_code < 300:
return True
elif response.status_code == 429 or 500 <= response.status_code < 600:
# 可重試錯誤
pass
else:
# 不可重試錯誤
return False
except requests.exceptions.RequestException:
# 網路錯誤,重試
pass
# 帶抖動的指數退避
delay = min(30, 2 ** attempt) + random.uniform(0, 1)
time.sleep(delay)
return False
冪等性:處理重複傳遞
由於重試可能導致同一事件被多次傳遞,訂閱者必須實作冪等處理。標準方法是:在載荷中包含唯一的事件 ID(例如 UUID),並讓接收方在一段時間內儲存已處理的 ID。如果收到重複的 ID,接收方確認但不重新處理。
以下是 webhook 處理常式中簡單的冪等性檢查:
processed_events = set()
def handle_webhook(event):
event_id = event['id']
if event_id in processed_events:
return 'already processed'
# 處理事件
processed_events.add(event_id)
return 'success'
在生產環境中,使用如 Redis 或帶 TTL 的資料庫等持久化儲存來追蹤已處理的 ID。
比較:簽名載荷與其他安全措施
| 方法 | 優點 | 缺點 |
|---|---|---|
| HMAC 簽名載荷 | 強大的完整性和真實性;不傳輸密鑰 | 需要共享密鑰管理 |
| 基本驗證 | 易於實作 | 每個請求都傳送憑證;安全性較低 |
| IP 允許清單 | 封鎖未經授權的來源 | IP 可能變更;不適合動態環境 |
| OAuth2 客戶端憑證 | 標準化;基於權杖 | 對簡單 webhooks 來說過於複雜;權杖管理開銷 |
對於大多數 webhook 使用案例,HMAC 簽名載荷在安全性和簡單性之間取得了良好的平衡。
整合所有內容
要建立可靠且安全的 webhook 系統:
- 為每個訂閱者產生唯一密鑰,並使用 HMAC 簽署每個載荷。
- 在簽名資料中包含時間戳和事件 ID。
- 實作帶指數退避和抖動的重試,以處理暫時性失敗。
- 使用事件 ID 確保接收方的冪等性。
- 監控傳遞成功率並在失敗時發出警報。
在除錯 webhook 傳遞時,檢查原始 HTTP 請求和回應是非常有價值的。如果你正在處理 Nginx 日誌以追蹤傳入的 webhook 請求,Nginx 日誌分析器 可以幫助你解析和過濾日誌條目,以識別失敗的傳遞或可疑模式。
常見問題
什麼是簽名 webhook 載荷?
簽名 webhook 載荷包含加密簽名(通常是 HMAC),允許接收方驗證請求的真實性和完整性。發送方使用共享密鑰計算簽名並將其包含在標頭中;接收方重新計算並進行比較。
我應該重試失敗的 webhook 多少次?
常見做法是進行 3 到 5 次重試,並使用指數退避。之後,將傳遞標記為失敗並通知訂閱者。具體次數取決於你的 SLA 和事件的關鍵程度。
為什麼冪等性對 webhooks 很重要?
重試可能導致重複傳遞。冪等性確保多次處理同一事件與處理一次的效果相同,防止資料損壞或重複操作。