Webhooks 详解:签名负载与重试策略
你已经构建了一个 API,现在需要在事件发生时通知其他服务。轮询效率低下,因此你实现了 Webhook。但很快你面临两个常见问题:如何确保负载未被篡改,以及当接收端点宕机时该怎么办。本文介绍如何使用签名负载和重试策略,使你的 Webhook 安全可靠。
为什么 Webhook 需要签名和重试
Webhook 是由事件触发的 HTTP 回调。它们实现简单,但引入了安全和可靠性挑战。没有签名,攻击者可以伪造请求到你的端点,导致数据损坏或未经授权的操作。没有重试,瞬时故障(如网络抖动或临时服务器错误)会导致事件丢失。
签名负载解决了真实性:发送方用密钥对负载签名,接收方进行验证。重试解决了可靠性:发送方以退避方式重试失败的交付。
签名负载的工作原理
签名负载包含一个由请求体和共享密钥生成的加密签名。最常见的方法是使用 SHA-256 的 HMAC(基于哈希的消息认证码)。
生成签名
发送方使用密钥计算原始请求体的 HMAC。签名通常放在请求头中发送,并常附带时间戳以防止重放攻击。
// Node.js 示例:生成 HMAC 签名
const crypto = require('crypto');
function signPayload(payload, secret) {
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
const signature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return { timestamp, signature };
}
将时间戳和签名包含在请求头中,例如 X-Webhook-Timestamp 和 X-Webhook-Signature。
验证签名
接收方必须使用收到的时间戳和原始请求体重建签名负载,然后计算 HMAC 并进行安全比较。
// Node.js 示例:验证签名
function verifySignature(rawBody, timestamp, receivedSignature, secret) {
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// 使用恒定时间比较
return crypto.timingSafeEqual(
Buffer.from(expectedSignature, 'hex'),
Buffer.from(receivedSignature, 'hex')
);
}
始终使用恒定时间比较以防止时序攻击。同时,检查时间戳是否较新(例如在 5 分钟内),以减轻重放攻击。
设计重试策略
重试对于可靠的 Webhook 交付至关重要。但简单的重试可能压垮接收方或导致重复处理。一个好的重试策略包括:
- 指数退避: 每次重试之间等待更长时间(例如 1s、2s、4s、8s...)。
- 最大重试次数: 限制尝试次数(例如 5 次)以避免无限循环。
- 死信队列: 达到最大重试次数后,存储事件以供手动检查。
- 幂等性: 包含唯一的事件 ID,以便接收方去重。
以下是伪代码中的简单重试循环:
function deliverWithRetry(url, payload, maxRetries = 5) {
let attempt = 0;
while (attempt < maxRetries) {
try {
const response = await fetch(url, { method: 'POST', body: payload });
if (response.ok) return;
} catch (error) {
// 记录错误
}
attempt++;
const delay = Math.pow(2, attempt) * 1000; // 指数退避
await new Promise(resolve => setTimeout(resolve, delay));
}
// 移至死信队列
}
考虑在退避中加入抖动,以避免惊群问题。
比较:轮询 vs 带重试的 Webhook
| 方面 | 轮询 | 带重试的 Webhook |
|---|---|---|
| 延迟 | 高(取决于间隔) | 低(近实时) |
| 服务器负载 | 恒定(即使无事件) | 事件驱动 |
| 可靠性 | 高(接收方控制) | 取决于重试策略 |
| 安全性 | 标准 API 认证 | 需要签名负载 |
分步指南:实现带签名和重试的 Webhook
- 为每个 Webhook 端点生成一个密钥,并安全地共享它。
- 事件发生时,构造负载(JSON)并使用 HMAC-SHA256 和密钥及时间戳计算签名。
- 发送 POST 请求 到端点,请求头包括:
Content-Type: application/json、X-Webhook-Timestamp和X-Webhook-Signature。 - 在接收方,使用原始请求体和时间戳验证签名。如果无效或时间戳太旧,则拒绝。
- 如果交付失败(非 2xx 响应或网络错误),使用指数退避重试,直到达到最大尝试次数。
- 达到最大重试次数后,记录失败并可选地发出警报;将事件存储在死信队列中以便手动重放。
- 确保接收方的幂等性,使用事件 ID 忽略重复。
常见陷阱和最佳实践
- 不使用原始请求体: 许多框架解析 JSON 并修改请求体。使用原始请求体进行签名验证。
- 忽略时间戳: 没有时间戳验证,可能发生重放攻击。
- 没有幂等性: 重试可能导致重复处理。使用幂等键。
- 无限重试: 始终设置最大重试限制和死信队列。
- 不记录日志: 记录交付尝试和失败以便调试。
常见问题
什么是签名的 Webhook 负载?
签名的 Webhook 负载包含一个加密签名(例如 HMAC),允许接收方验证请求来自预期的发送方且未被篡改。
失败的 Webhook 应该重试多少次?
常见做法是使用指数退避进行 3–5 次重试。具体次数取决于你的 SLA 和事件的关键性。始终为超过重试限制的事件使用死信队列。
我可以不使用签名吗?
不建议。没有签名,你的端点容易受到伪造请求的攻击。始终使用签名(如 HMAC)并验证时间戳以防止重放攻击。
调试 Webhook 交付时,检查服务器日志至关重要。如果你使用 Nginx,Nginx 日志分析器 可以帮助你解析和分析访问日志,以追踪 Webhook 请求并识别失败。