开放接口签名与防重放:appId、nonce、HMAC/RSA 和验签顺序
开放接口安全不是“请求走 HTTPS 就完了”。HTTPS 保护传输链路,但开放 API 还要解决业务层面:谁在调用我;请求内容有没有被改;请求是不是过期请求;同一个请求有没有被重复提交;出问题后能不能审计和追踪。这就是 appId、timestamp、nonce、签名、防重放、审计日志存在的原因。
接口签名解决什么
假设医院系统调用平台接口上传患者数据:
POST /api/patient/upload
{"patientId":"P1001","name":"张三"}
如果只靠普通参数,平台不知道:
- 请求是不是医院 A 发的;
- 报文中患者信息有没有被改;
- 请求是不是攻击者复制旧请求重放。
接口签名的核心是:调用方和服务方基于相同规则计算签名,服务端重新计算并比对。
核心字段:
| 字段 | 作用 |
|---|---|
appId | 标识调用方是谁 |
timestamp | 请求发起时间,用于限制有效窗口 |
nonce | 随机数,同一时间窗口内只能用一次 |
bodyHash | 请求体摘要,避免大报文直接参与签名 |
sign | 签名值,证明请求未被篡改 |
keyVersion | 密钥版本,支持轮换 |
请求示例 header:
X-App-Id: hospital-a
X-Timestamp: 1783238400000
X-Nonce: 7b9e9a0f
X-Key-Version: v2
X-Sign: MEUCIQ...
签名为什么不能单独防重放
攻击者如果抓到完整请求 body + appId + timestamp + nonce + sign,即使不知道 secret,也可以原封不动再发一次,因为签名仍然是正确的。
所以必须加:
timestamp:请求只能在短时间窗口内有效;nonce:同一调用方同一随机数只能用一次。
服务端验签顺序
顺序很重要:
- 请求进入;
- 读取
appId; appId是否存在且启用?否 → 拒绝;- 检查
timestamp时间窗口,是否过期或偏移太大?是 → 拒绝; - 检查
nonce是否已使用,nonce重复?是 → 拒绝; - 读取
keyVersion对应密钥; - 按规则重建签名串;
- 验签,签名是否正确?否 → 拒绝;
- 是 → 记录
nonce并进入业务。
为什么先检查时间窗口?因为过期请求不需要耗费验签成本。
为什么 nonce 记录要谨慎?如果先记录 nonce 再验签,攻击者可以用错误签名占用 nonce,影响合法请求。实际落地时要结合幂等策略,可以在验签通过后记录 nonce,也可以用原子脚本处理“检查 + 记录”。
签名串如何构造
必须稳定。客户端和服务端任何一个空格、大小写、参数顺序不同,都会验签失败。
推荐规则:
- HTTP 方法大写;
- path 使用原始路径,不包含域名;
- query 参数按 key 字典序排序;
- body 使用 SHA-256 摘要;
- header 中参与签名的字段明确列出;
- 使用
\n连接,不随意拼字符串。
示例:
POST\n/api/patient/upload\nappId=hospital-a&nonce=7b9e9a0f×tamp=1783238400000\nbodyHash=39f0...
不要把 JSON 原文直接拼进签名串,因为 JSON 字段顺序、空格、换行可能不同。更稳妥的是对请求体原始字节做 SHA-256。
HMAC 签名 Demo
适合内部系统或双方共享 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 签名适合什么
适合调用方持有私钥,服务端保存调用方公钥。
流程:调用方私钥签名 → 请求携带签名 → 服务端按 appId 找调用方公钥 → 公钥验签。
优点:
- 服务端不保存调用方私钥;
- 调用方私钥泄露不影响其他调用方;
- 适合外部合作方接入。
代价:
- 密钥对管理更复杂;
- 验签性能比 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 要高可用;
noncekey 要设置过期时间;- 时间窗口不要太长,否则存储压力大;
- 时间窗口不要太短,否则客户端时钟偏差容易失败;
- 失败日志要记录
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、错误原因 |
| 把防重放当幂等 | 重试仍可能重复创建业务数据 | 业务幂等号单独设计 |