Skip to content
全部文章

正确验证 webhook 签名并处理重试

查尔斯·阿奇邦

, 联合创始人

对原始请求字节验证 V2 签名,拒绝重放窗口之外的时间戳,在执行任何工作之前,在唯一约束下记录事件 ID,并且只有在事件被持久存储后才返回 2xx。

标题“Webhook 签名和重试”,旁边配以带有尖括号的代码窗口插图,背景为深靛蓝色渐变。

要点

  • 对原始请求字节计算 HMAC,切勿对重新序列化的 JSON 进行计算。
  • 签名时间戳加上重放窗口可以防止旧的、有效签名的交付被重放。
  • 按事件 ID 去重,而不是按交付 ID 去重:手动重新发送是同一事件的新交付。
  • 只有在事件被持久化存储后才返回 2xx,然后再执行缓慢的工作。

Webhook接收器有三个任务,并且按固定顺序执行。证明请求来自 Toden 通过检查接收到的确切字节的签名来验证。通过将签名时间戳与重放窗口进行比较来证明它是最新的。然后,通过在唯一约束下记录事件 ID 来证明您尚未对其进行过任何操作。只有在完成这三项操作后,您才能返回。2xx只有在那之后,你才能真正开始工作。

顺序出错,每次失败都不会造成影响。解析前未验证,伪造的请求体就会到达业务逻辑。忽略时间戳,捕获的请求可能会无限期地重放。使用错误的键进行去重,会导致客户重复注册。本文将逐步介绍每个步骤。 Toden webhook (在新标签页中打开),带有一个可以自定义的接收器。

每次送货都会附带什么

每次送货都是 HTTP POST与一个 JSON 正文和一组标头。这些标头对于验证至关重要,详情请参阅webhook 身份验证文档 (在新标签页中打开):

标题

例子

该如何处理它

X-Myaza-Signature-V2

t=1786441200,v1=8df1…

解析时间戳和每个v1数值。请对照这些数值进行验证。

X-Myaza-Replay-Window

300

端点的重放窗口(以秒为单位)。未签名,因此切勿使其扩大您的检查范围。

X-Myaza-Event-Id

evt_…

用于去重的稳定密钥。

X-Myaza-Delivery

del_…

发货记录。仅用于追踪,不可用于去重。

X-Myaza-Event

verification.completed

用于路由。已签名的主体仍然具有权威性。

X-Myaza-Signature

sha256=…

仅对主体进行传统兼容性签名。

如果你建造时与旧式建筑风格背道而驰X-Myaza-Signature虽然标头仍然有效,但它会在不添加时间戳的情况下对请求体进行签名,这意味着它无法判断请求是否是最新的。请改用 V2 标头。

步骤 1:验证原始字节的签名

签名是小写十六进制 HMAC - SHA256 使用您的端点密钥作为密钥,通过字符串<t>.<rawBody>:标头中的时间戳,一个句号,然后是请求正文,原样返回。

“原样接收”是导致集成失败的原因。大多数 Web 框架都会解析这种做法。 JSON 在你的处理程序运行之前。如果你验证JSON.stringify(req.body)您正在对重新序列化进行签名:空格、密钥顺序和数字格式都可能与签名时的不同,并且验证会间歇性地失败,看起来像是一个错误的密钥。请配置路由以向您提供原始缓冲区。

使用恒定时间函数进行比较,并与每个函数进行比较。v1价值,而不仅仅是第一个。在秘密轮换期间 Toden 同时使用当前密钥和待退役密钥进行签名,因此请求头包含两个候选密钥。如果其中一个匹配,则接受请求;只有在控制面板中旧密钥的宽限期结束后,才将其移除。

步骤二:拒绝过期的时间戳

有效的签名只能证明是谁发送的请求,而不能证明发送时间。任何人(例如从代理日志中)截获了发送记录,都可以重新发送,并且仍然有效。签名时间戳弥补了这一缺陷:因为t如果该字符串位于签名字符串内部,攻击者无法在不破坏签名的情况下对其进行更改。

拒绝任何时间戳缺失、无法解析、早于重放窗口或日期过于遥远的投递。X-Myaza-Replay-Window它会告诉你端点配置的窗口大小,但这个头部信息位于签名字符串之外,因此任何重放请求的人都可以修改它。你可以用它来加强检查,但切勿扩大检查范围:将其限制在你为端点配置的窗口大小之内。确保你的服务器时钟已同步;时钟漂移会导致此检查变成随机拒绝。

返回401如果签名缺失、过期或无效,请勿退回。200停止重试:这告诉发送方事件已被接受。

步骤 3:对事件进行去重,而不是对交付进行去重。

Webhook 至少会发送一次。如果接收者超时、您响应后网络出现故障,或者队友在控制面板中点击“重新发送”,都会导致同一事件再次出现在您面前。

问题在于哪个标识符表示“同一事物”。有两个候选标识符,它们的行为方式不同:

  • 事件ID (X-Myaza-Event-Id, 和id(在信封中)标识业务事件。它在自动重试和手动重新发送时保持不变。

  • 配送ID (X-Myaza-Delivery)标识一条发货记录。手动重新发送会创建另一条发货记录,因此会获得一个新的发货 ID。

以事件 ID 为键进行去重。Webhook测试指南中 (在新标签页中打开)明确指出这一点,并建议插入一个在发生冲突时不执行任何操作的语句:

insert into received_myaza_events (event_id, event_type, payload)
values (:id, :type, :payload)
on conflict (event_id) do nothing;

有一件事需要注意。verification.status_updated平铺包装寄送,不进行包装。{ id, event, createdAt, data }包裹其他事件使用的,因此主体没有id字段。从事件头中获取事件 ID 适用于所有事件类型,这是一条更简单的规则。

第四步:快速确认,然后开展工作

返回2xx事件必须先被永久存储,而不是在此之前。然后在请求之外进行处理:更新客户记录、通知申请人、创建内部工单。在请求内部缓慢处理会​​导致超时,超时将被视为失败并重试。

未知事件类型应被存储或忽略,但仍需确认。新事件会随时间推移添加到目录中;拒绝一个无法识别的事件会导致重试循环,而这原本并非您打算处理的事件。

一个能完成所有四项任务的接收器

改编自文档中的 Node.js 示例,限制了回放窗口,并添加了去重插入功能。saveEventOnce和queue充当自己的仓库管理员和工作跑腿员。

import crypto from "node:crypto";
import express from "express";

const app = express();

app.post("/webhooks/myaza", express.raw({ type: "application/json" }), async (req, res) => {
  const parts = String(req.header("X-Myaza-Signature-V2") || "").split(",");
  const timestamp = parts.find((p) => p.startsWith("t="))?.slice(2);
  const candidates = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
  const MAX_WINDOW = 300; // the replay window configured for this endpoint
  const windowSeconds = Math.min(Number(req.header("X-Myaza-Replay-Window")) || MAX_WINDOW, MAX_WINDOW);

  if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > windowSeconds) {
    return res.status(401).send("Expired signature");
  }

  const expected = crypto
    .createHmac("sha256", process.env.MYAZA_WEBHOOK_SECRET)
    .update(`${timestamp}.`)
    .update(req.body)
    .digest("hex");

  const valid = candidates.some((c) =>
    c.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(c), Buffer.from(expected))
  );
  if (!valid) return res.status(401).send("Invalid signature");

  const eventId = req.header("X-Myaza-Event-Id");
  const payload = JSON.parse(req.body.toString("utf8"));
  const isNew = await saveEventOnce(eventId, req.header("X-Myaza-Event"), payload);

  res.status(200).send("OK");
  if (isNew) queue.add("myaza-event", { eventId });
});

由于事件是在响应之前存储的,因此响应和事件之间可能会发生崩溃。queue.add不会有任何损失:让一个小型清扫器收集那些从未被处理的已存储事件。在轮换过程中,保留两个秘密并分别计算它们的期望值;如果任何候选值与任何一个秘密匹配,则检查通过。

重试机制如何运作,以及这对你意味着什么

未收到的货物2xx如果超时,则会使用退避机制重试:

试图

上次尝试后的延迟

1

30秒

2

5分钟

3

30分钟

4

2小时

5

24小时

五次投递失败后,投递状态被标记为“已投递失败”。FAILED并且可以从控制面板手动重试。这将导致两个后果。

首先,如果您的服务器出现一两天的故障,只要在最后一次尝试之前修复故障,并在恢复后以幂等方式处理大量重试,就可以在不丢失数据的情况下恢复。对重复的投递失败发出警报,这样您就可以从监控系统中而不是从客户那里得知故障情况。

其次,交付会独立重试,因此可能会出现乱序到达的情况。假设您的工作流程将一项验证请求路由到审核,合规官在一小时后批准了该请求:两个verification.status_updated事件,每个事件都有自己的重试计划。如果第一次投递失败,重试成功,您可能会收到approved前in_review有据可查的规则是,应将最新的changedAt将其作为当前状态保存,并忽略比当前状态更旧的更新。在关键时刻,从当前状态读取数据。 API 行动之前。

生产前先进行测试

创建一个沙盒端点,并在该端点页面上使用模拟器,通过实际的传输路径发送正确签名的示例事件。然后故意重现各种失败情况:

  1. 发送模拟事件并确认接收方验证了 V2 签名并返回2xx迅速地。

  2. 更改捕获到的物体的一个字节并确认401。

  3. 窗口期过后重放捕获的请求并确认401。

  4. 从控制面板重新发送一次订单,并确认副作用是否发生一次。

  5. 返回非2xx暂时进行尝试,并确认已安排重试。

  6. 轮换使用密钥,并在宽限期内确认两个签名均被接受。

一个接球手如果能传球六次,那么在出现问题的那一天,他就能表现得正确,而这才是真正重要的。

参考来源

查尔斯·阿奇邦

关于作者

Charles Archibong

联合创始人

Charles Archibong是Toden的联合创始人。他撰写有关身份验证、金融科技以及构建可信数字服务的实际工作的文章。

  • 开发者

    围绕异步验证结果设计您的产品

    将提交视为状态机的开始,而非请求的结束。显示一个基于验证 ID 的待处理状态,通过 Webhook 更新该状态,并允许状态多次更改。

  • 开发者

    可公开密钥和私钥:保护客户端免受验证数据的影响。

    将可发布密钥放在您的应用程序中,并将私钥保存在您的服务器上。可发布密钥可以启动验证并读取最少的状态信息,但绝不会读取身份数据,因此请做好密钥泄露的准备,并尽可能降低泄露造成的损失。

  • Product Updates

    在不发布应用版本的情况下更改验证流程

    使用工作流 ID 挂载 SDK,每次验证时都会从服务器获取流程配置。重新发布会更改下次验证的步骤、检查、副本和决策规则,而无需发布应用程序。

打造您的产品。其余交给我们。

端到端身份验证与合规服务,遵循全球标准,为创业者提供合理定价。