Webhook 详解:签名负载与重试机制
你刚刚集成了一个第三方支付提供商。文档上写着:“支付成功时我们会发送一个 webhook。”听起来很简单,对吧?但随后你意识到:你怎么知道请求真的来自他们?如果他们发送时你的服务器宕机了怎么办?如果他们重复发送同一个事件呢?
Webhook 很强大,但需要谨慎处理。本指南涵盖基本要点:对负载进行签名以验证真实性、实现重试以处理失败,以及确保幂等性以避免重复处理。
什么是 Webhook?
Webhook 是一种 HTTP 回调:当事件发生时,提供商向你指定的 URL 发送 HTTP POST 请求,而不是让你的应用轮询 API 来获取变更。这使得集成高效且近乎实时。
常见的例子包括支付通知(Stripe、PayPal)、CI/CD 触发器(GitHub、GitLab)和消息平台(Slack、Discord)。
为什么用签名保护 Webhook?
你的 webhook 端点可公开访问。任何人都可以向它发送 POST 请求。如果不进行验证,攻击者可以伪造事件——例如,将未支付的订单标记为已支付。
使用共享密钥对负载进行签名,可以让你验证请求确实来自预期的发送方,并且负载未被篡改。
HMAC 签名的工作原理
提供商和你共享一个密钥。对于每个 webhook,提供商使用该密钥计算负载的 HMAC(基于哈希的消息认证码),并将签名包含在请求头中(例如 X-Signature)。你在自己这边重新计算 HMAC 并进行比较。
HMAC 确保完整性和真实性,因为只有拥有密钥的人才能生成有效的签名。
实现签名负载
让我们通过 Node.js(Express)和 Python(Flask)中的典型实现来逐步了解。这些原则适用于任何语言。
步骤 1:获取并存储密钥
注册 webhook 时,提供商会给你一个密钥。请安全存储——使用环境变量或密钥管理器——切勿存储在代码中。
步骤 2:捕获原始请求体
签名验证需要精确的原始请求体字节。如果你的框架在验证之前解析了 JSON,重新序列化可能会改变字节并破坏验证。
在 Express 中,为 webhook 路由使用 express.raw({ type: 'application/json' })。在 Flask 中,在解析之前访问 request.get_data()。
步骤 3:计算并比较签名
使用恒定时间比较函数以防止时序攻击。
// Node.js (Express)
const crypto = require('crypto');
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const expected = crypto.createHmac('sha256', secret).update(req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
// Process event
const event = JSON.parse(req.body);
// ... handle event
res.status(200).send('OK');
});
在 Python 中,使用 hmac.compare_digest:
# Python (Flask)
import hmac, hashlib, os
from flask import request, abort
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Signature')
secret = os.environ['WEBHOOK_SECRET'].encode()
expected = hmac.new(secret, request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json()
# ... handle event
return 'OK', 200
步骤 4:处理时间戳(可选但推荐)
一些提供商在签名中包含时间戳以防止重放攻击。例如,Stripe 的方案对 timestamp.payload 进行签名。你应该拒绝时间戳过于久远(例如超过 5 分钟)的请求。
实现重试
网络会失败。你的服务器可能宕机,或者请求可能超时。可靠的 webhook 系统会重试失败的传递。
在提供商侧
如果你正在构建 webhook 发送方,请实现带指数退避的重试。常见模式:1 分钟后重试,然后 5 分钟,然后 30 分钟,以此类推,直到达到最大尝试次数(例如 5 次)。在负载中包含唯一的事件 ID,以便接收方可以去重。
在接收方侧
作为接收方,你应该:
- 快速响应(在几秒内)并返回 2xx 状态以确认收到。将繁重的处理异步进行。
- 对于需要重试的错误(例如 500)返回非 2xx。但要小心:如果你返回 4xx,提供商可能不会重试。
- 使你的端点具有幂等性,以便重复传递不会导致问题。
幂等性:处理重复事件
重试可能导致重复事件。使用事件 ID(大多数提供商都会包含)来跟踪已处理的事件。在处理之前,检查该 ID 是否已经出现过;如果是,则跳过。
将已处理的 ID 存储在具有 TTL 的数据库中,或在业务逻辑中使用幂等键。
在本地测试 Webhook
在开发过程中,你的本地服务器无法公开访问。ngrok 或 localtunnel 等工具会创建一个公共 URL,将请求隧道传输到你的机器。或者,使用捕获请求并允许你重放的 webhook 测试服务。
常见陷阱和最佳实践
- 在验证之前不要信任负载。 始终先验证签名。
- 使用 HTTPS。 Webhook 携带敏感数据;在传输过程中加密。
- 记录一切。 记录原始请求(请求头和请求体)以便调试,但需脱敏敏感信息。
- 监控失败。 为重复的 webhook 失败设置警报。
- 对事件进行版本控制。 在负载中包含版本,以便在不破坏接收方的情况下进行演进。
比较:签名与未签名 Webhook
| 方面 | 签名 | 未签名 |
|---|---|---|
| 安全性 | 高:验证来源和完整性 | 低:任何人都可以伪造 |
| 实现工作量 | 中等:计算和比较 HMAC | 最小 |
| 推荐用于 | 所有生产系统 | 仅限内部可信网络 |
常见问题
如何验证 webhook 签名?
使用共享密钥计算原始请求体的 HMAC,然后使用恒定时间函数将其与签名头进行比较。始终使用原始请求体,而不是解析后的 JSON。
我的 webhook 端点应该返回什么?
快速返回 2xx 状态码以确认收到。如果处理失败,返回 5xx 以触发重试,但确保你的端点具有幂等性以处理重复。
Webhook 会被重试多少次?
这取决于提供商。大多数会在数小时或数天内使用指数退避重试多次。请查看提供商的文档以了解具体信息。
在调试 webhook 传递时,通常需要检查服务器日志。如果你使用的是 Nginx,Nginx 日志分析器可以帮助你解析和分析访问日志,以发现失败的请求或异常模式。