Spring Boot 4.0 接口幂等、签名与防重放
前言
用户双击支付按钮、网关超时重试、攻击者截获请求后再次发送,最终都表现为“同一个业务动作被执行多次”。只靠数据库唯一索引能防重复数据,却不能保证重复请求得到相同响应。
本章为支付接口增加三层保护:HMAC 签名证明请求来源,时间窗与 nonce 阻止截获重放,Idempotency-Key 返回首次处理结果。
读者需要完成 JWT 与权限章节。完整代码位于 dvsusan/spring-boot4-demo,接口只在 chapter6 Profile 中启用。
最终结果
支付接口要求:
POST /api/v1/security/payments
Authorization: Bearer <jwt>
Idempotency-Key: <8-128 个字符>
X-Signature-Timestamp: <epoch seconds>
X-Signature-Nonce: <8-128 个字符>
X-Signature: <hex HMAC-SHA256>
图 1:签名、nonce 和幂等键解决的是三个不同问题,必须按顺序校验。
第 1 步:固定签名规范
签名原文必须形成双方一致的规范字符串:
HTTP_METHOD
REQUEST_PATH
TIMESTAMP
NONCE
SHA256(BODY)RequestSignatureService 实现如下:
String canonical = method + "\n"
+ path + "\n"
+ timestamp + "\n"
+ nonce + "\n"
+ bodySha256;
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
return HexFormat.of().formatHex(
mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))
);
} catch (Exception exception) {
throw new IllegalStateException("无法计算请求签名", exception);
}只签 Body 不够,攻击者可以修改路径;只签路径不够,攻击者可以替换金额。Method、Path、时间、nonce 和 Body 摘要都应进入规范字符串。
第 2 步:校验时间窗与 nonce
Instant requestTime = Instant.ofEpochSecond(timestamp);
Instant now = clock.instant();
Duration difference = Duration.between(requestTime, now).abs();
if (difference.compareTo(maxClockSkew) > 0) {
throw new IllegalArgumentException("请求已过期或时间偏差过大");
}
Instant expiresAt = requestTime.plus(maxClockSkew);
removeExpiredNonces(now);
if (usedNonces.putIfAbsent(nonce, expiresAt) != null) {
throw new IllegalArgumentException("nonce 已使用,拒绝重复请求");
}默认时间窗为 5 分钟。putIfAbsent 保证并发请求只有一个 nonce 成功。生产环境应把 nonce 放入 Redis,键保留到签名时间窗结束后。
第 3 步:让请求体可重复读取
过滤器读取 Body 后,Controller 还需要再次读取 JSON。如果直接消费原始 InputStream,后续 @RequestBody 会报解析失败。
项目使用 CachedBodyHttpServletRequest 缓存字节,并在 getInputStream() 时返回新的 ByteArrayInputStream。
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream input = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override
public int read() {
return input.read();
}
@Override
public boolean isFinished() {
return input.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener listener) {
throw new UnsupportedOperationException("当前请求使用同步读取");
}
};
}缓存上限必须与上传限制、网关限制一致,否则攻击者可以用超大 Body 消耗内存。
第 4 步:实现幂等响应缓存
IdempotencyFilter 以“用户 + 路径 + Idempotency-Key”作为作用域。第一次请求创建 Future,完成时保存状态码、Content-Type 和响应字节;后续请求等待并回放同一响应。
核心分支:
if (reservation.replay()) {
replay(response, reservation);
return;
}
ContentCachingResponseWrapper wrapped =
new ContentCachingResponseWrapper(response);
try {
filterChain.doFilter(request, wrapped);
store.complete(reservation, new StoredResponse(
wrapped.getStatus(),
wrapped.getContentType(),
wrapped.getContentAsByteArray()
));
wrapped.copyBodyToResponse();
} catch (Exception exception) {
store.abort(reservation);
throw exception;
}失败时删除占位,允许客户端修正后重试。成功响应保留 24 小时;生产环境应写入数据库,并记录请求摘要,防止同一幂等键被用于不同金额。
第 5 步:注册过滤器顺序
http.addFilterBefore(
signing,
BearerTokenAuthenticationFilter.class
)
.addFilterAfter(
tenantAccessFilter,
BearerTokenAuthenticationFilter.class
)
.addFilterAfter(
idempotencyFilter,
TenantAccessFilter.class
);签名过滤器先拒绝伪造请求;Bearer 过滤器完成 JWT 认证;租户过滤器校验身份;幂等过滤器最后缓存业务响应。
第 6 步:准备签名请求
先登录管理员:
curl -sS -X POST http://localhost:18106/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}'示例 Body:
{"orderId":"SO1001","amount":199.00}用 Node.js 计算签名:
const crypto = require('node:crypto')
const secret = 'chapter6-request-signing-secret'
const method = 'POST'
const path = '/api/v1/security/payments'
const timestamp = Math.floor(Date.now() / 1000).toString()
const nonce = crypto.randomUUID()
const body = '{"orderId":"SO1001","amount":199.00}'
const bodyHash = crypto.createHash('sha256').update(body).digest('hex')
const canonical = [method, path, timestamp, nonce, bodyHash].join('\n')
const signature = crypto
.createHmac('sha256', secret)
.update(canonical)
.digest('hex')
console.log({ timestamp, nonce, signature })把输出放入请求头,执行支付:
curl -i -X POST http://localhost:18106/api/v1/security/payments \
-H 'Authorization: Bearer <accessToken>' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: payment-SO1001-001' \
-H 'X-Signature-Timestamp: <timestamp>' \
-H 'X-Signature-Nonce: <nonce>' \
-H 'X-Signature: <signature>' \
-d '{"orderId":"SO1001","amount":199.00}'首次预期 HTTP 200:
{
"code": "0",
"data": {
"paymentId": "<uuid>",
"orderId": "SO1001",
"amount": 199.00
}
}第 7 步:验证重放和重复提交
使用相同 nonce 再次请求,预期 401:
{
"code": "INVALID_SIGNATURE",
"message": "nonce 已使用,拒绝重复请求"
}使用新的 nonce、相同 Idempotency-Key 和相同 Body 再请求,预期 200,但响应头包含:
X-Idempotent-Replay: true响应中的 paymentId 与首次完全一致。这表示请求被识别为重复提交,业务方法没有再次执行。
自动化测试会同时验证 nonce 重放为 401、幂等重放状态为 200 且完整响应字节一致:
mvn -pl demo-web -am \
-Dtest=Chapter6SecurityIntegrationTest \
-Dsurefire.failIfNoSpecifiedTests=false \
test三类机制不要混为一谈
| 机制 | 解决的问题 | 不能替代 |
|---|---|---|
| HMAC 签名 | 请求由持有密钥的一方生成且内容未篡改 | 用户身份、权限和数据权限 |
| 时间窗 + nonce | 截获请求不能无限重放 | 业务重复提交 |
| Idempotency-Key | 同一业务动作只执行一次 | 防御伪造请求 |
支付系统还需要交易表唯一键、支付渠道幂等号、金额一致性校验和人工对账。
常见问题排查
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 签名始终不匹配 | Header、路径或 Body 有隐藏换行 | 打印规范字符串逐项比较 |
| Controller 报 JSON 解析失败 | 过滤器消费了原始输入流 | 使用可重读 Body 包装 |
| 同一 nonce 偶发成功 | 内存 nonce 检查没有原子操作 | 使用 putIfAbsent 或 Redis SET NX |
| 重复请求返回不同交易号 | 没有保存首次响应 | 持久化幂等键和响应摘要 |
| 相同幂等键提交不同金额 | 只按 Key 去重 | 保存并比较请求 Body 的 SHA-256 |
练习题
为 Idempotency-Key 增加请求摘要校验:同 Key 但不同 Body 返回 409;再增加并发测试,保证 10 个相同请求只有一个执行 Controller。验收标准是响应一致、交易只创建一次、冲突请求有明确错误码。
总结
接口幂等、签名和防重放分别解决业务重复、来源可信和传输重放。三者组合后,写接口才具备可运营的安全边界。
完成第六章后,项目已经建立认证、会话、SSO、RBAC、数据权限和写接口防护的完整基础。
上一节:数据权限
相关阅读:Security 7 企业级配置
相关阅读:JWT 登录与刷新
返回课程目录
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
