Skip to content

开放接口签名与防重放

开放接口安全不是“请求走 HTTPS 就完了”。HTTPS 保护传输链路,但开放 API 还要解决业务层面的几个问题:

  1. 谁在调用我。
  2. 请求内容有没有被改。
  3. 请求是不是过期请求。
  4. 同一个请求有没有被重复提交。
  5. 出问题后能不能审计和追踪。

这就是 appId、timestamp、nonce、签名、防重放、审计日志存在的原因。

学习目标

学完本章要能说清楚:

  1. HTTPS 和接口签名分别解决什么。
  2. appId、secret、timestamp、nonce、sign 各自作用。
  3. 为什么签名不能防重放,必须配合 timestamp 和 nonce。
  4. HMAC 签名和 RSA 签名怎么选。
  5. 签名串为什么要排序、规范化、包含请求体摘要。
  6. 服务端验签的完整顺序。
  7. 防重放如何用 Redis 落地。
  8. 签名失败、时间偏移、nonce 重复怎么排查。

接口签名解决什么

假设医院系统调用平台接口上传患者数据:

http
POST /api/patient/upload
Content-Type: application/json

{"patientId":"P1001","name":"张三"}

如果只靠普通参数,平台不知道:

  1. 请求是不是医院 A 发的。
  2. 报文中患者信息有没有被改。
  3. 请求是不是攻击者复制旧请求重放。

接口签名的核心是:调用方和服务方基于相同规则计算签名,服务端重新计算并比对。

mermaid
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密钥版本,支持轮换

请求示例:

http
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":"张三"}

签名为什么不能单独防重放

攻击者如果抓到完整请求:

text
body + appId + timestamp + nonce + sign

即使攻击者不知道 secret,也可以原封不动再发一次。因为签名仍然是正确的。

mermaid
flowchart TD
    A["合法请求"] --> B["攻击者抓包保存完整请求"]
    B --> C["稍后原样重放"]
    C --> D{"签名是否正确?"}
    D -->|"是"| E["如果没有防重放,会被接受"]

所以必须加:

  1. timestamp:请求只能在短时间窗口内有效。
  2. nonce:同一调用方同一随机数只能用一次。

服务端验签顺序

顺序很重要。推荐流程:

mermaid
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,也可以用原子脚本处理“检查 + 记录”。

签名串如何构造

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

推荐规则:

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

示例:

text
POST
/api/patient/upload
appId=hospital-a&nonce=7b9e9a0f&timestamp=1783238400000
bodyHash=39f0...

不要把 JSON 原文直接拼进签名串,因为 JSON 字段顺序、空格、换行可能不同。更稳妥的是对请求体原始字节做 SHA-256。

HMAC 签名 Demo

HMAC 适合内部系统或双方共享 secret 的开放接口。

java
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 签名适合调用方持有私钥,服务端保存调用方公钥。

mermaid
flowchart TD
    A["调用方私钥签名"] --> B["请求携带签名"]
    B --> C["服务端按 appId 找调用方公钥"]
    C --> D["公钥验签"]

优点:

  1. 服务端不保存调用方私钥。
  2. 调用方私钥泄露不会影响其他调用方。
  3. 适合外部合作方接入。

代价:

  1. 密钥对管理更复杂。
  2. 验签性能比 HMAC 低。
  3. 证书、公钥轮换流程要设计好。

Redis 防重放 Demo

核心思想:同一个 appId + nonce 在时间窗口内只能出现一次。

java
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("重复请求");
        }
    }
}

生产注意:

  1. Redis 要高可用。
  2. nonce key 要设置过期时间。
  3. 时间窗口不要太长,否则存储压力大。
  4. 时间窗口不要太短,否则客户端时钟偏差容易失败。
  5. 失败日志要记录 appId、nonce、timestamp、path。

完整验签伪代码

java
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。幂等通常基于业务幂等号,比如 requestIdorderNoeventId

例子:客户端网络超时后重试创建订单。如果每次重试都生成新 nonce,防重放不会拦截,但业务仍需要用 requestId 保证不会创建两笔订单。

商业场景:医疗数据上传

医院上传患者数据到平台:

  1. 使用 HTTPS 防止链路窃听。
  2. 使用 appId 识别医院。
  3. 使用 HMAC 或 RSA 签名防篡改和证明调用方身份。
  4. 使用 timestamp 限制请求有效期。
  5. 使用 nonce 防止抓包重放。
  6. 使用 batchNoeventId 做业务幂等。
  7. 对失败请求记录审计日志。
  8. 对敏感字段按数据等级加密或脱敏。

常见坑

后果正确做法
只做签名不做 nonce旧请求可被原样重放timestamp + nonce
JSON 直接拼签名空格和字段顺序导致验签不稳定对原始 body 做 hash
参数不排序客户端服务端签名串不一致字典序排序
secret 写在前端所有人都能伪造签名secret 只在服务端或可信客户端保存
时间窗口过长重放窗口变大常用 3-5 分钟,按业务调整
验签失败日志太少排查困难记录 appId、path、timestamp、nonce、错误原因
把防重放当幂等重试仍可能重复创建业务数据业务幂等号单独设计

面试标准回答

text
开放接口签名通常使用 appId 标识调用方,timestamp 限制请求时间窗口,nonce 防止同一请求重复提交,sign 防篡改和证明调用方身份。服务端根据 appId 找密钥,按约定规则重建签名串并验签,再检查 timestamp 和 nonce。签名本身不能防重放,因为攻击者可以原样重放完整请求,所以必须配合 timestamp 和 nonce。HMAC 适合共享密钥场景,RSA 适合外部合作方私钥签名、公钥验签。

关联知识点

  1. 加密基础
  2. HTTPS/TLS 全过程原理
  3. Spring Security JWT
  4. Spring Cloud Gateway
  5. Redis

本章小结

接口签名的核心不是某个算法,而是一整套协议:身份标识、时间窗口、随机数、签名串规范、验签顺序、防重放存储、幂等设计、错误日志和密钥轮换。少任何一环,都可能从“看起来安全”变成“线上不可用或可被绕过”。