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

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

签名有效的 Webhook 到达了两次:设计原理

在 TT Lab 中继续学习

一句话总结

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

为什么需要它

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

工作原理

签名的对象是时间戳字符串和原始 body 字节。如果把 JSON 重新序列化之后再比较签名,空白或键顺序就会不同,从而把正常请求拒之门外。使用 HMAC-SHA256 和 compare_digest,并对时间差做双向检查。有效的事件要把 id 和正文指纹记录到 inbox,并与销售额合计在同一个事务中提交。中途出现异常时,两次写入都要回滚。

원문+시각 → 서명·시간 검사 → inbox id+본문지문 → 합계 반영 → 동일 트랜잭션

阅读契约并预测失败的工作表

下面并不是要求你把实现整个背下来的答案,而是逐步进行的代码评审。每个改动片段都有意破坏了契约。要注意,改动之后正常用例仍然可能通过。执行之前,先预测观测哪些输入、异常、状态能让差异显现出来;实现之后,再拿这个预测与实际结果对比。

1. 保留要签名的原始内容

signed_bytes(timestamp, body) 只接受(不含 bool 的)int 类型的 timestamp 和 bytes 类型的 body,并返回 str(timestamp).encode()+b'.'+body。类型有误时抛出 ValueError。

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

需要评审的有问题的改动片段:

+ b":" + body

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

2. 计算 HMAC

signature(secret, timestamp, body) 用 bytes 类型的 secret,对 signed_bytes 应用 HMAC-SHA256,得到 hex 字符串。

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

需要评审的有问题的改动片段:

body, hashlib.sha256

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

3. 拒绝错误的签名

verify(secret, timestamp, body, supplied) 仅在 supplied 是 str,且用 compare_digest 与计算出的签名相等时返回 True,否则返回 False。

判断依据:“有没有签名”与“签名对不对”是两项不同的检查。

需要评审的有问题的改动片段:

signature(secret,timestamp,body), signature(secret,timestamp,body)

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

4. 限制过去和未来的重放

fresh(timestamp, now, tolerance=300) 在 timestamp 和 now 都是(不含 bool 的)int,且 abs(now-timestamp)<=tolerance 时返回 True,其余返回 False。tolerance 是由调用方给出的正 int。

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

需要评审的有问题的改动片段:

(now-timestamp)

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

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

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 事务里。

需要评审的有问题的改动片段:

VALUES (1,1)

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

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

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

判断依据:fault 中一旦抛出异常,inbox 和合计都不能留下。

需要评审的有问题的改动片段:

db.commit()
        fault()

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

7. 用单独的连接读取合计

total(path) 返回 total 表中 id=1 那一行的 amount 整数。

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

需要评审的有问题的改动片段:

SELECT id FROM total WHERE id=1

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

8. 处理真实的 Webhook 请求

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

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

需要评审的有问题的改动片段:

"duplicate":False

将它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就选择本应被拒绝的输入,或失败之后的状态作为观测对象。

在现场相遇的样子

这种签名格式是教学用的协议,并不能取代真实支付服务商的规范。只使用学习用的 secret。服务器时钟是否可靠、密钥轮换、允许的正文大小以及永久保存的期限,是另外的运维课题。去重适用于相同的事件 id 加相同的正文;如果用不同的正文复用同一个 id,就是冲突。

下一项实验要做什么

八个步骤会连成一个可运行的成果。保留要签名的原始内容 → 计算 HMAC → 拒绝错误的签名 → 限制过去和未来的重放 → 为同时保存事件与效果做准备 → 回滚记录与销售额之间的失败 → 用单独的连接读取合计 → 处理真实的 Webhook 请求。

每个步骤检查的不是函数或文件是否存在,而是实际的返回值、异常和状态变化。看过正确答案之后,故意改动边界比较或清理代码,确认哪些测试会失败。说明为什么前面的测试在后面的步骤中仍然成立,并写出一个本实验不保证的运维条件。