开放接口签名与防重放
开放接口安全不是“请求走 HTTPS 就完了”。HTTPS 保护传输链路,但开放 API 还要解决业务层面的几个问题:
- 谁在调用我。
- 请求内容有没有被改。
- 请求是不是过期请求。
- 同一个请求有没有被重复提交。
- 出问题后能不能审计和追踪。
这就是 appId、timestamp、nonce、签名、防重放、审计日志存在的原因。
学习目标
学完本章要能说清楚:
- HTTPS 和接口签名分别解决什么。
- appId、secret、timestamp、nonce、sign 各自作用。
- 为什么签名不能防重放,必须配合 timestamp 和 nonce。
- HMAC 签名和 RSA 签名怎么选。
- 签名串为什么要排序、规范化、包含请求体摘要。
- 服务端验签的完整顺序。
- 防重放如何用 Redis 落地。
- 签名失败、时间偏移、nonce 重复怎么排查。
接口签名解决什么
假设医院系统调用平台接口上传患者数据:
POST /api/patient/upload
Content-Type: application/json
{"patientId":"P1001","name":"张三"}如果只靠普通参数,平台不知道:
- 请求是不是医院 A 发的。
- 报文中患者信息有没有被改。
- 请求是不是攻击者复制旧请求重放。
接口签名的核心是:调用方和服务方基于相同规则计算签名,服务端重新计算并比对。
flowchart TD
A["客户端按规则拼签名串"] --> B["用 secret 或私钥计算签名"]
B --> C["请求携带 appId、timestamp、nonce、sign"]
C --> D["服务端查 appId 对应密钥"]
D --> E["服务端按同样规则重算签名"]
E --> F{"签名是否一致?"}
F -->|"是"| G["请求可信,继续业务"]
F -->|"否"| H["拒绝请求"]核心字段
| 字段 | 作用 |
|---|---|
appId | 标识调用方是谁 |
timestamp | 请求发起时间,用于限制有效窗口 |
nonce | 随机数,同一时间窗口内只能用一次 |
bodyHash | 请求体摘要,避免大报文直接参与签名 |
sign | 签名值,证明请求未被篡改 |
keyVersion | 密钥版本,支持轮换 |
请求示例:
POST /api/patient/upload
X-App-Id: hospital-a
X-Timestamp: 1783238400000
X-Nonce: 7b9e9a0f
X-Key-Version: v2
X-Sign: MEUCIQ...
Content-Type: application/json
{"patientId":"P1001","name":"张三"}签名为什么不能单独防重放
攻击者如果抓到完整请求:
body + appId + timestamp + nonce + sign即使攻击者不知道 secret,也可以原封不动再发一次。因为签名仍然是正确的。
flowchart TD
A["合法请求"] --> B["攻击者抓包保存完整请求"]
B --> C["稍后原样重放"]
C --> D{"签名是否正确?"}
D -->|"是"| E["如果没有防重放,会被接受"]所以必须加:
timestamp:请求只能在短时间窗口内有效。nonce:同一调用方同一随机数只能用一次。
服务端验签顺序
顺序很重要。推荐流程:
flowchart TD
A["请求进入"] --> B["读取 appId"]
B --> C{"appId 是否存在且启用?"}
C -->|"否"| D["拒绝"]
C -->|"是"| E["检查 timestamp 时间窗口"]
E --> F{"是否过期或偏移太大?"}
F -->|"是"| D
F -->|"否"| G["检查 nonce 是否已使用"]
G --> H{"nonce 重复?"}
H -->|"是"| D
H -->|"否"| I["读取 keyVersion 对应密钥"]
I --> J["按规则重建签名串"]
J --> K["验签"]
K --> L{"签名是否正确?"}
L -->|"否"| D
L -->|"是"| M["记录 nonce 并进入业务"]为什么先检查时间窗口?因为过期请求不需要耗费验签成本。
为什么 nonce 记录要谨慎?如果先记录 nonce 再验签,攻击者可以用错误签名占用 nonce,影响合法请求。实际落地时要结合幂等策略,可以在验签通过后记录 nonce,也可以用原子脚本处理“检查 + 记录”。
签名串如何构造
签名串必须稳定。客户端和服务端任何一个空格、大小写、参数顺序不同,都会验签失败。
推荐规则:
- HTTP 方法大写。
- path 使用原始路径,不包含域名。
- query 参数按 key 字典序排序。
- body 使用 SHA-256 摘要。
- header 中参与签名的字段明确列出。
- 使用
\n连接,不随意拼字符串。
示例:
POST
/api/patient/upload
appId=hospital-a&nonce=7b9e9a0f×tamp=1783238400000
bodyHash=39f0...不要把 JSON 原文直接拼进签名串,因为 JSON 字段顺序、空格、换行可能不同。更稳妥的是对请求体原始字节做 SHA-256。
HMAC 签名 Demo
HMAC 适合内部系统或双方共享 secret 的开放接口。
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
public class HmacSigner {
public static String sign(String data, String secret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] bytes = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(bytes);
} catch (Exception e) {
throw new IllegalStateException("HMAC签名失败", e);
}
}
public static boolean verify(String data, String secret, String sign) {
String expected = sign(data, secret);
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
sign.getBytes(StandardCharsets.UTF_8)
);
}
}注意使用常量时间比较,避免简单字符串比较带来的时序攻击风险。
HMAC 的问题:调用方和服务端都保存同一个 secret,任何一方泄露都能伪造请求。
RSA 签名适合什么
RSA 签名适合调用方持有私钥,服务端保存调用方公钥。
flowchart TD
A["调用方私钥签名"] --> B["请求携带签名"]
B --> C["服务端按 appId 找调用方公钥"]
C --> D["公钥验签"]优点:
- 服务端不保存调用方私钥。
- 调用方私钥泄露不会影响其他调用方。
- 适合外部合作方接入。
代价:
- 密钥对管理更复杂。
- 验签性能比 HMAC 低。
- 证书、公钥轮换流程要设计好。
Redis 防重放 Demo
核心思想:同一个 appId + nonce 在时间窗口内只能出现一次。
public class ReplayProtector {
private final StringRedisTemplate redisTemplate;
public ReplayProtector(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void checkAndSave(String appId, String nonce, long windowSeconds) {
String key = "api:nonce:" + appId + ":" + nonce;
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, "1", Duration.ofSeconds(windowSeconds));
if (!Boolean.TRUE.equals(success)) {
throw new IllegalStateException("重复请求");
}
}
}生产注意:
- Redis 要高可用。
- nonce key 要设置过期时间。
- 时间窗口不要太长,否则存储压力大。
- 时间窗口不要太短,否则客户端时钟偏差容易失败。
- 失败日志要记录 appId、nonce、timestamp、path。
完整验签伪代码
public void verify(ApiRequest request) {
App app = appRepository.findEnabled(request.appId())
.orElseThrow(() -> new SecurityException("非法 appId"));
long now = System.currentTimeMillis();
long diff = Math.abs(now - request.timestamp());
if (diff > TimeUnit.MINUTES.toMillis(5)) {
throw new SecurityException("请求已过期");
}
String bodyHash = sha256(request.bodyBytes());
String signText = SignTextBuilder.builder()
.method(request.method())
.path(request.path())
.query(request.sortedQuery())
.bodyHash(bodyHash)
.timestamp(request.timestamp())
.nonce(request.nonce())
.build();
String secret = keyService.getSecret(request.appId(), request.keyVersion());
if (!HmacSigner.verify(signText, secret, request.sign())) {
throw new SecurityException("签名错误");
}
replayProtector.checkAndSave(request.appId(), request.nonce(), 300);
}幂等和防重放不是一回事
| 概念 | 解决什么 |
|---|---|
| 防重放 | 防止同一个已签名请求被重复提交 |
| 幂等 | 同一个业务请求重复到达时,业务结果只生效一次 |
防重放通常基于 nonce。幂等通常基于业务幂等号,比如 requestId、orderNo、eventId。
例子:客户端网络超时后重试创建订单。如果每次重试都生成新 nonce,防重放不会拦截,但业务仍需要用 requestId 保证不会创建两笔订单。
商业场景:医疗数据上传
医院上传患者数据到平台:
- 使用 HTTPS 防止链路窃听。
- 使用
appId识别医院。 - 使用 HMAC 或 RSA 签名防篡改和证明调用方身份。
- 使用 timestamp 限制请求有效期。
- 使用 nonce 防止抓包重放。
- 使用
batchNo或eventId做业务幂等。 - 对失败请求记录审计日志。
- 对敏感字段按数据等级加密或脱敏。
常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 只做签名不做 nonce | 旧请求可被原样重放 | timestamp + nonce |
| JSON 直接拼签名 | 空格和字段顺序导致验签不稳定 | 对原始 body 做 hash |
| 参数不排序 | 客户端服务端签名串不一致 | 字典序排序 |
| secret 写在前端 | 所有人都能伪造签名 | secret 只在服务端或可信客户端保存 |
| 时间窗口过长 | 重放窗口变大 | 常用 3-5 分钟,按业务调整 |
| 验签失败日志太少 | 排查困难 | 记录 appId、path、timestamp、nonce、错误原因 |
| 把防重放当幂等 | 重试仍可能重复创建业务数据 | 业务幂等号单独设计 |
面试标准回答
开放接口签名通常使用 appId 标识调用方,timestamp 限制请求时间窗口,nonce 防止同一请求重复提交,sign 防篡改和证明调用方身份。服务端根据 appId 找密钥,按约定规则重建签名串并验签,再检查 timestamp 和 nonce。签名本身不能防重放,因为攻击者可以原样重放完整请求,所以必须配合 timestamp 和 nonce。HMAC 适合共享密钥场景,RSA 适合外部合作方私钥签名、公钥验签。关联知识点
本章小结
接口签名的核心不是某个算法,而是一整套协议:身份标识、时间窗口、随机数、签名串规范、验签顺序、防重放存储、幂等设计、错误日志和密钥轮换。少任何一环,都可能从“看起来安全”变成“线上不可用或可被绕过”。
