TT Lab
开始
学习 学习路径 课程

幂等性 — 点两次也只扣一次款

签名有效的 Webhook 到达了两次

在 TT Lab 中继续学习

目标

把原始正文的签名、时间窗口和事件去重,连成同一条 Webhook 处理路径。

为什么重要

支付服务商没有收到响应,又把同一个事件发了一遍。服务器看到签名无误,就认为是正常请求,于是把销售额又加了一次。签名只能证明是谁发来的,并不能证明这是第一次处理的请求。允许重新发送的时间窗口,以及已保存的事件 id,必须分别检查。

步骤

  1. 在 /root/work/idem-webhook-lab/service.py 中,signed_bytes(timestamp, body) 只接受(不含 bool 的)int 类型的 timestamp 和 bytes 类型的 body,并返回 str(timestamp).encode()+b'.'+body。类型有误时抛出 ValueError。

先做一次准备。已有的文件不会被覆盖。

mkdir -p /root/work/idem-webhook-lab
test -e /root/work/idem-webhook-lab/service.py || cp /opt/fixtures/ten_labs/idem-webhook-lab/service.py /root/work/idem-webhook-lab/service.py
cd /root/work/idem-webhook-lab
  1. 在 /root/work/idem-webhook-lab/service.py 中,signature(secret, timestamp, body) 用 bytes 类型的 secret,对 signed_bytes 应用 HMAC-SHA256,得到 hex 字符串。

  2. 在 /root/work/idem-webhook-lab/service.py 中,verify(secret, timestamp, body, supplied) 仅在 supplied 是 str,且用 compare_digest 与计算出的签名相等时返回 True,否则返回 False。

  3. 在 /root/work/idem-webhook-lab/service.py 中,fresh(timestamp, now, tolerance=300) 在 timestamp 和 now 都是(不含 bool 的)int,且 abs(now-timestamp)<=tolerance 时返回 True,其余返回 False。tolerance 是由调用方给出的正 int。

  4. 在 /root/work/idem-webhook-lab/service.py 中,init_db(path) 创建 inbox(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL) 和 total(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL),并在 total 中不重复地插入 id=1,amount=0。

  5. 在 /root/work/idem-webhook-lab/service.py 中,apply(path, event, fault=lambda:None) 校验 event 的 id 是非空 str,amount 是(不含 bool 的)正 int。用规范化的 JSON 对 id 和 amount 计算指纹。相同的 id 与指纹返回 False,指纹不同抛出 ValueError,新事件则在依次执行插入 inbox → fault() → 合计增加之后返回 True。

  6. 在 /root/work/idem-webhook-lab/service.py 中,total(path) 返回 total 表中 id=1 那一行的 amount 整数。

  7. 在 /root/work/idem-webhook-lab/service.py 中,create_app(path, secret, clock) 在 POST /webhook 中读取原始 body、X-Timestamp 和 X-Signature。时间格式、时间窗口、签名校验失败返回 401,JSON 解析失败返回 400,apply 抛出的 ValueError 返回 409。正常情况返回 200 {accepted:True, duplicate:首次处理时为 False}。

参考

保留要签名的原始内容

在 /root/work/idem-webhook-lab/service.py 中,signed_bytes(timestamp, body) 只接受(不含 bool 的)int 类型的 timestamp 和 bytes 类型的 body,并返回 str(timestamp).encode()+b'.'+body。类型有误时抛出 ValueError。

先做一次准备。已有的文件不会被覆盖。

mkdir -p /root/work/idem-webhook-lab
test -e /root/work/idem-webhook-lab/service.py || cp /opt/fixtures/ten_labs/idem-webhook-lab/service.py /root/work/idem-webhook-lab/service.py
cd /root/work/idem-webhook-lab

把原始 body 解析成 JSON 再重新生成,会使签名对象发生变化。

保存后用 bash /opt/lab/checks/idem-webhook-lab/01-contract.sh 确认。

计算 HMAC

在 /root/work/idem-webhook-lab/service.py 中,signature(secret, timestamp, body) 用 bytes 类型的 secret,对 signed_bytes 应用 HMAC-SHA256,得到 hex 字符串。

使用标准的 HMAC,而不是把密钥拼接到普通哈希后面的做法。

保存后用 bash /opt/lab/checks/idem-webhook-lab/02-contract.sh 确认。

拒绝错误的签名

在 /root/work/idem-webhook-lab/service.py 中,verify(secret, timestamp, body, supplied) 仅在 supplied 是 str,且用 compare_digest 与计算出的签名相等时返回 True,否则返回 False。

“有没有签名”与“签名对不对”是两项不同的检查。

保存后用 bash /opt/lab/checks/idem-webhook-lab/03-contract.sh 确认。

限制过去和未来的重放

在 /root/work/idem-webhook-lab/service.py 中,fresh(timestamp, now, tolerance=300) 在 timestamp 和 now 都是(不含 bool 的)int,且 abs(now-timestamp)<=tolerance 时返回 True,其余返回 False。tolerance 是由调用方给出的正 int。

如果无条件允许未来的时间戳,攻击者就能延长有效期。

保存后用 bash /opt/lab/checks/idem-webhook-lab/04-contract.sh 确认。

为同时保存事件与效果做准备

在 /root/work/idem-webhook-lab/service.py 中,init_db(path) 创建 inbox(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL) 和 total(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL),并在 total 中不重复地插入 id=1,amount=0。

重复事件的记录与业务合计必须在同一个 DB 事务里。

保存后用 bash /opt/lab/checks/idem-webhook-lab/05-contract.sh 确认。

回滚记录与销售额之间的失败

在 /root/work/idem-webhook-lab/service.py 中,apply(path, event, fault=lambda:None) 校验 event 的 id 是非空 str,amount 是(不含 bool 的)正 int。用规范化的 JSON 对 id 和 amount 计算指纹。相同的 id 与指纹返回 False,指纹不同抛出 ValueError,新事件则在依次执行插入 inbox → fault() → 合计增加之后返回 True。

fault 中一旦抛出异常,inbox 和合计都不能留下。

保存后用 bash /opt/lab/checks/idem-webhook-lab/06-contract.sh 确认。

用单独的连接读取合计

在 /root/work/idem-webhook-lab/service.py 中,total(path) 返回 total 表中 id=1 那一行的 amount 整数。

不看回调被执行了几次,而是确认 DB 里实际留下的业务效果。

保存后用 bash /opt/lab/checks/idem-webhook-lab/07-contract.sh 确认。

处理真实的 Webhook 请求

在 /root/work/idem-webhook-lab/service.py 中,create_app(path, secret, clock) 在 POST /webhook 中读取原始 body、X-Timestamp 和 X-Signature。时间格式、时间窗口、签名校验失败返回 401,JSON 解析失败返回 400,apply 抛出的 ValueError 返回 409。正常情况返回 200 {accepted:True, duplicate:首次处理时为 False}。

先验证签名,再读取 JSON;重复的请求也以正常的确认响应返回。

保存后用 bash /opt/lab/checks/idem-webhook-lab/08-contract.sh 确认。