Webhook详解:签名负载与重试机制
你已经构建了一个Webhook端点来接收来自第三方服务的事件。在测试中一切正常,但在生产环境中你注意到了重复事件、遗漏通知以及偶尔对伪造请求的安全担忧。这些是在没有适当保护措施的情况下实现Webhook时的常见陷阱。
Webhook是HTTP回调,允许服务在发生某些事情时通知你的应用程序。与轮询不同,它们提供实时更新。但要可靠地使用它们,你需要解决两个关键方面:真实性(确保请求来自预期的发送方)和交付(处理失败和重试)。本文通过实际代码示例解释这两点。
Webhook如何工作
Webhook是用户定义的HTTP POST端点。当事件发生时,源服务向该URL发送一个请求,其中包含描述事件的负载。例如,支付处理器可能在扣款成功时发送Webhook。
流程很简单:
- 你在服务中注册一个Webhook URL。
- 当事件触发时,服务向该URL发送HTTP POST请求。
- 你的端点处理负载并返回2xx状态以确认收到。
如果没有适当的验证,任何人都可以向你的端点发送请求。如果没有重试处理,暂时的故障可能导致事件丢失。让我们逐一解决。
使用签名负载保护Webhook安全
为了确保Webhook源自预期的发送方,服务使用共享密钥对负载进行签名。签名通常是请求体的HMAC(基于哈希的消息认证码),通过类似X-Signature的头部发送。
HMAC签名的工作原理
发送方使用密钥和原始请求体计算HMAC,然后将十六进制编码的签名包含在头部中。你的端点使用相同的密钥重新计算HMAC,并将其与接收到的签名进行比较。如果匹配,则请求是真实的。
以下是使用Express的Node.js最小示例:
const crypto = require('crypto');
const express = require('express');
const app = express();
// 使用原始请求体进行签名验证
app.use(express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const payload = req.body;
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
// 处理事件
const event = JSON.parse(payload);
// ... 处理事件
res.status(200).send('OK');
});
关键点:
- 始终使用原始请求体进行验证,而不是解析后的对象。解析可能会改变负载(例如空格),从而破坏签名。
- 使用时间安全的比较以防止时序攻击。
- 安全地存储密钥(环境变量、密钥管理器),并定期轮换。
额外的安全措施
- 时间戳验证:在签名中包含时间戳(例如
t=timestamp,v1=signature),并拒绝超过几分钟的请求以防止重放攻击。 - IP允许列表:如果提供方发布了IP范围,将你的端点限制为这些IP。
- HTTPS:始终使用HTTPS以保护传输中的负载。
实现可靠的重试
Webhook交付不是保证的。网络故障、你的服务器可能宕机,或者你可能返回非2xx状态。为了处理这种情况,发送方通常使用指数退避进行重试。作为接收方,你必须设计端点具有幂等性并快速响应。
在发送方(如果你发送Webhook)
如果你正在构建发送Webhook的服务,请实现带退避的重试。常见策略:
- 在网络错误或5xx响应时重试。
- 不要在4xx响应时重试(除了429 Too Many Requests)。
- 使用带抖动的指数退避:等待1秒、2秒、4秒、8秒等,直到最大值。
- 设置最大重试次数(例如5次),并考虑为失败使用死信队列。
Python中的重试逻辑示例:
import time
import requests
from requests.exceptions import RequestException
def send_webhook(url, payload, max_retries=5):
for attempt in range(max_retries):
try:
response = requests.post(url, json=payload, timeout=5)
if 200 <= response.status_code < 300:
return True
elif 400 <= response.status_code < 500 and response.status_code != 429:
# 客户端错误,不重试
return False
except RequestException:
pass
# 带抖动的指数退避
delay = (2 ** attempt) + random.uniform(0, 1)
time.sleep(delay)
return False
在接收方
你的端点应该:
- 快速确认:在几秒内返回2xx。如果处理时间较长,将事件入队并异步处理。
- 幂等:使用负载中的事件ID进行去重。存储已处理的ID并忽略重复。
- 处理并发:使用数据库事务或锁来避免并发处理同一事件时的竞态条件。
使用数据库唯一约束的幂等示例:
CREATE TABLE processed_events (
event_id VARCHAR(255) PRIMARY KEY,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
在处理之前,尝试插入事件ID。如果违反主键约束,则跳过处理。
本地测试Webhook
在开发过程中,你需要一个公共URL以便服务能够访问你的本地服务器。ngrok或localtunnel等工具可以暴露你的localhost。始终在开发中使用测试密钥并验证签名,以便尽早发现问题。
比较:轮询 vs Webhook
| 方面 | 轮询 | Webhook |
|---|---|---|
| 延迟 | 取决于间隔 | 近实时 |
| 服务器负载 | 高(频繁请求) | 低(仅事件发生时) |
| 复杂性 | 实现简单 | 需要签名验证和重试处理 |
| 可靠性 | 你控制重试 | 取决于发送方的重试策略 |
常见问题
什么是签名的Webhook负载?
签名负载包含使用共享密钥生成的加密签名(通常是HMAC)。它允许接收方验证请求来自预期的发送方且未被篡改。
如何处理重复的Webhook交付?
通过使用负载中的唯一事件ID使端点具有幂等性。存储已处理的ID并忽略重复。这确保重试或网络问题不会导致重复处理。
我应该重试失败的Webhook交付吗?
是的,但仅针对暂时性错误(网络问题、5xx)。使用带抖动的指数退避并设置最大重试限制。避免在4xx错误(除429外)时重试,因为它们表示请求本身有问题。
在调试Webhook负载时,你通常需要检查JSON数据。JSON格式化工具可以帮助你快速美化打印和验证JSON负载。