代码 开放接口签名与防重放:appId、nonce、HMAC/RSA 和验签顺序

2026-10-03 21:31:38

开放接口签名与防重放: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:同一调用方同一随机数只能用一次。

服务端验签顺序

顺序很重要:

  1. 请求进入;
  2. 读取 appId;
  3. appId 是否存在且启用?否 → 拒绝;
  4. 检查 timestamp 时间窗口,是否过期或偏移太大?是 → 拒绝;
  5. 检查 nonce 是否已使用,nonce 重复?是 → 拒绝;
  6. 读取 keyVersion 对应密钥;
  7. 按规则重建签名串;
  8. 验签,签名是否正确?否 → 拒绝;
  9. 是 → 记录 nonce 并进入业务。

为什么先检查时间窗口?因为过期请求不需要耗费验签成本。

为什么 nonce 记录要谨慎?如果先记录 nonce 再验签,攻击者可以用错误签名占用 nonce,影响合法请求。实际落地时要结合幂等策略,可以在验签通过后记录 nonce,也可以用原子脚本处理“检查 + 记录”。

签名串如何构造

必须稳定。客户端和服务端任何一个空格、大小写、参数顺序不同,都会验签失败。

推荐规则:

  • HTTP 方法大写;
  • path 使用原始路径,不包含域名;
  • query 参数按 key 字典序排序;
  • body 使用 SHA-256 摘要;
  • header 中参与签名的字段明确列出;
  • 使用 \n 连接,不随意拼字符串。

示例:

POST\n/api/patient/upload\nappId=hospital-a&nonce=7b9e9a0f&timestamp=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 要高可用;
  • 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、错误原因
把防重放当幂等重试仍可能重复创建业务数据业务幂等号单独设计
复制全文 生成海报 接口对接 API 安全 签名 防重放 HMAC

推荐文章

程序员茄子在线接单