登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  java教程

Java Webhook 签名校验实战:用时间戳和 HMAC 阻断重放请求

来源:17golang原创

时间:2026-07-26 14:53:14 215浏览 收藏

订单系统收到第三方 Webhook 后,最麻烦的情况不是接口返回 500,而是同一笔回调隔几分钟又来一次,业务却已经执行过。只把请求头里的密钥比对一下,无法区分“刚发来的请求”和“被截获后重新发送的旧请求”。

一个可落地的校验组合是:用原始请求体参与 HMAC-SHA256 签名,再把时间戳放进签名输入,并限制请求只能在短时间窗口内到达。验签通过后还要用业务事件 ID 做幂等处理。

要点速览

  • 签名必须基于原始请求体,不能基于解析后重新拼接的 JSON。
  • 时间戳负责缩短重放窗口,业务事件 ID 负责避免重复落库。
  • 比较签名时使用 MessageDigest.isEqual,避免普通字符串比较带来的时序差异。
  • 验收至少覆盖正常请求、过期请求和请求体被改动三种结果。

先做一个能验收的 Webhook 小服务

为了把边界讲清楚,示例只保留一个回调入口。请求方发送三个请求头:X-Webhook-TimestampX-Webhook-IdX-Webhook-Signature。签名原文采用 时间戳 + "." + 原始请求体,再用共享密钥计算 HMAC-SHA256。

String signingText = timestamp + "." + rawBody;
String signature = hmacSha256Hex(secret, signingText);

if (!sameSignature(signature, receivedSignature)) {
    return ResponseEntity.status(401).body("signature rejected");
}
return ResponseEntity.ok("accepted");

这里的 rawBody 是 HTTP 请求刚读出的字节转成的 UTF-8 文本。不要先把 JSON 反序列化成对象,再用对象重新序列化去签名;字段顺序、空格和转义方式稍有不同,合法请求也会验签失败。

Java Webhook 签名字段从原始请求体和时间戳组成 HMAC 后得到通过或拒绝结果

用 HMAC-SHA256 校验原始请求体

Java 标准库已经提供了 MacSecretKeySpec,不需要自己实现哈希算法。生产代码应从密钥管理系统读取密钥;为了方便本地运行,下面把密钥作为构造参数传入。

private static String hmacSha256Hex(String secret, String text) {
    try {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        byte[] digest = mac.doFinal(text.getBytes(StandardCharsets.UTF_8));
        StringBuilder hex = new StringBuilder(digest.length * 2);
        for (byte value : digest) {
            hex.append(String.format("%02x", value));
        }
        return hex.toString();
    } catch (GeneralSecurityException e) {
        throw new IllegalStateException("cannot build webhook signature", e);
    }
}

private static boolean sameSignature(String expected, String actual) {
    if (expected == null || actual == null) {
        return false;
    }
    return MessageDigest.isEqual(
        expected.getBytes(StandardCharsets.US_ASCII),
        actual.getBytes(StandardCharsets.US_ASCII));
}

验签函数只负责判断签名是否一致,不负责确认事件是否处理过。两件事拆开后,密钥轮换、错误统计和业务幂等都更容易测试。

再加时间窗口,拦住旧请求重放

攻击者即使拿到一份完整的合法请求,也不应该在很久以后再次提交。服务端读取时间戳后,先计算它与本机当前时间的差值。示例把允许窗口设为 300 秒;实际值要结合第三方的重试策略和网络延迟调整。

private static boolean withinWindow(String timestamp, long nowSeconds) {
    try {
        long sentAt = Long.parseLong(timestamp);
        return Math.abs(nowSeconds - sentAt) 

校验顺序建议是先检查时间戳格式和窗口,再计算签名,最后进入业务幂等判断。窗口过期时直接返回明确的 401 或 400,并记录事件 ID;不要把完整请求体写入普通日志。

Java Webhook 首次请求在时间窗口内通过,过期请求和篡改请求被拒绝

把事件 ID 接到幂等处理上

签名正确只说明请求来自持有密钥的一方,不代表它是第一次到达。收到 X-Webhook-Id 后,可以在数据库建立唯一索引,例如 webhook_event(event_id)。插入成功才执行业务;唯一键冲突时返回已处理状态。

CREATE TABLE webhook_event (
    event_id VARCHAR(80) PRIMARY KEY,
    received_at TIMESTAMP NOT NULL,
    payload_hash CHAR(64) NOT NULL,
    status VARCHAR(20) NOT NULL
);

这样即使第三方在网络超时后重试,服务也不会把同一个事件再次转成订单动作。若业务必须区分“处理中”和“已完成”,把状态更新放在同一事务边界内,并为长时间卡住的处理中记录准备人工或定时补偿路径。

用三组请求确认结果

本地验收时固定请求体和事件 ID,先生成签名,再分别改变时间戳或 JSON 内容。不要只测试一条成功请求,那只能证明代码能走通,不能证明边界有效。

body='{"eventId":"evt_1001","type":"order.paid"}'
ts=$(date +%s)
signature=$(./sign-webhook "$WEBHOOK_SECRET" "$ts.$body")

curl -i http://localhost:8080/webhook \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Timestamp: $ts" \
  -H "X-Webhook-Id: evt_1001" \
  -H "X-Webhook-Signature: $signature" \
  --data "$body"
  • 正常请求:返回 200,事件表新增 evt_1001
  • 重复请求:签名仍然正确,但唯一键命中,业务动作不再执行。
  • 过期请求:返回 401,日志只保留事件 ID、失败原因和时间差。
  • 篡改请求:保持旧签名但修改 type,返回 401。

常见问题

为什么不能只比较一个固定 Authorization?

固定密钥能证明请求方知道秘密,却不能证明请求是新鲜的。时间戳和请求体一起参与签名,才能让旧报文在窗口之外失效。

JSON 空格变化会导致验签失败吗?

会。签名针对的是原始字节序列,所以服务端应在解析 JSON 前完成验签。

时间窗口设得越短越安全吗?

不一定。窗口过短会误伤网络抖动和第三方重试;应根据真实延迟指标设置,并配合事件 ID 幂等。

验签通过后还需要做什么?

还要校验事件类型、字段范围、事件 ID 唯一性和业务状态。密码学通过不是业务数据可信的全部证明。

小结

这个小服务的核心不是某个框架注解,而是把四个判断按顺序固定下来:原始请求体是否完整、时间戳是否在窗口内、HMAC 是否匹配、事件 ID 是否已经处理。先用三组请求验收,再接入真实业务,排错时会比“回调偶尔重复”更容易定位。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>