返回功能模块设计实现
功能模块设计实现 / 发布 2025-08-24 11:08 / 更新 2026-06-03 00:00

接口验签

基于当前项目实现,记录一次接口验签方案从 RSA 长文本加密到 RSA 传密钥 + HMAC-SHA256 签名的落地过程。

接口安全Spring AOPHMAC

为什么要做接口验签

登录、播放源、支付这类接口都有一个共同点:参数本身很敏感,不能只依赖“接口能访问”来判断请求可信。即使后端还有登录态、权限校验和 HTTPS,接口入参仍然可能在客户端侧被篡改,或者被拿到后短时间重复请求。

这个项目里的接口验签主要解决两个问题:

  • 请求参数完整性:后端确认收到的业务参数就是前端参与签名的那一份。
  • 基础防重放:签名密钥中带毫秒时间戳,后端只接受短时间窗口内的请求。

一开始我想过把排序后的 JSON 直接 RSA 分段加密后放进请求头,后端解密再对比参数。但这个方式很快暴露出两个问题:RSA 不适合处理长文本,前后端也要反复做 JSON 序列化和分段加解密。最终实现改成了现在的方案:RSA 只负责传递签名密钥,真正的参数完整性校验交给 HMAC。

最终采用的方案

当前项目的验签方案可以概括成一句话:

前端把业务参数按固定规则拼成字符串,使用 HmacSHA256 生成签名;签名密钥是 FF + 毫秒时间戳,再通过 RSA 公钥加密后放到请求头。后端用 RSA 私钥解出签名密钥,校验时间戳,再按同样规则重算 HMAC。

请求头里只需要两个字段:

请求头 作用
EncryptedSignKey RSA 公钥加密后的签名密钥
Signature 使用签名密钥计算出的 HMAC-SHA256 签名

这里有一个容易踩坑的点:当前代码真正读取的是 EncryptedSignKey,不是 Encrypted-Sign-Key。如果前端按短横线风格传 Header,后端会直接报缺少加密签名密钥。

签名密钥格式如下:

signKey = "FF" + timestampMillis

其中 FF 是当前后端硬编码的固定前缀,timestampMillis 是客户端生成签名时的毫秒时间戳。后端会校验这个时间戳和当前服务器时间的差值,超过 15 秒就拒绝请求。

整体流程

flowchart TD A[前端准备业务参数] --> B[参数按 key 字典序排序] B --> C[URL 编码后拼接 key=value] C --> D[生成 signKey = FF + 毫秒时间戳] D --> E[把参数串和 timestamp 拼成待签名字符串] E --> F[使用 HmacSHA256 计算 Signature] D --> G[RSA 公钥加密 signKey] F --> H[请求头放入 Signature] G --> I[请求头放入 EncryptedSignKey] H --> J[请求进入后端] I --> J J --> K{接口是否需要请求体解密} K -- 是 --> L[DecryptFilter 解密 data] K -- 否 --> M[直接读取原始参数] L --> N[SignatureDecryptAspect 验签] M --> N N --> O[RSA 私钥解出 signKey] O --> P[校验 timestamp 是否过期] P --> Q[后端按同样规则重建参数串] Q --> R[后端重新计算 HMAC] R --> S{签名一致} S -- 是 --> T[放行业务方法] S -- 否 --> U[拒绝请求]

登录接口会同时用到请求体解密、接口验签和响应加密,整体时序大概是这样:

sequenceDiagram participant C as 前端 participant G as draft-gateway participant F as DecryptFilter participant S as SignatureDecryptAspect participant A as AuthController participant R as Redis/RBAC C->>C: 基于明文业务参数生成 HMAC 签名 C->>C: RSA 公钥加密 signKey C->>C: AES 加密登录参数为 data C->>G: POST /draft-auth/auth/login G->>F: 转发到 /auth/login F->>F: 发现 @DecryptParams 并解密 data F->>S: 进入 @DecryptSignature 切面 S->>S: 解密 signKey、校验时间戳、重算签名 S->>A: 验签通过后执行登录逻辑 A->>R: 校验验证码、账号密码、角色权限 R-->>A: 返回认证结果 A-->>C: @EncryptResponse 加密成功响应 data

前端如何生成签名

前端参与签名的不是外层请求体,而是真正的业务参数。比如登录接口如果业务参数是:

{
  "username": "zhang san",
  "password": "12@#&=!"
}

先按 key 排序,再按 Java URLEncoder 的规则编码并拼接:

password=12%40%23%26%3D%21&username=zhang+san

然后把签名密钥中的时间戳追加进去:

password=12%40%23%26%3D%21&username=zhang+san&timestamp=1724476800000

最后用 signKey 做 HMAC:

const timestamp = Date.now().toString()
const signKey = `FF${timestamp}`
const paramString = buildSortedParamString(params)
const signString = `${paramString}&timestamp=${timestamp}`

const signature = hmacSha256Hex(signString, signKey)
const encryptedSignKey = rsaEncryptWithPublicKeyB(signKey)

request.post({
  url: '/draft-auth/auth/login',
  data: encryptBodyIfNeeded(params),
  headers: {
    EncryptedSignKey: encryptedSignKey,
    Signature: signature
  }
})

前端最容易出错的是 URL 编码规则。Java 的 URLEncoder 会把空格编码成 +,而很多前端实现会编码成 %20。只要这个细节不一致,后端重算出来的签名就一定不同。

后端如何完成验签

后端的入口是 @DecryptSignature 注解。控制器方法只要标了这个注解,就会被 SignatureDecryptAspect 拦截。

当前已经接入的典型接口有两个:

  • 登录接口:POST /draft-auth/auth/login
  • QQ 音乐播放源接口:GET /draft-auth/api/qqmusic/url

切面里的核心处理顺序是固定的:

  1. 从请求头读取 EncryptedSignKeySignature
  2. 使用 PrivateKey_B.pem 解密 EncryptedSignKey,拿到 signKey
  3. signKey 中截出时间戳,并判断是否超过 15 秒有效期。
  4. 从请求参数和 @RequestBody 参数里提取业务字段。
  5. 使用 TreeMap 按 key 字典序排序。
  6. 使用 URLEncoder.encode(value, "UTF-8") 编码 key 和 value。
  7. 拼接 &timestamp=请求时间戳,使用 HmacSHA256 重算签名。
  8. 和请求头里的 Signature 做精确比较。

其中参数提取有一个实现细节:如果接口参数里有 @RequestBody,后端会把这个对象转换成 Map,只处理顶层字段,字段值通过 toString() 参与签名,null 会转成空字符串。也就是说,复杂嵌套对象和数组并不是当前方案的强项,最好在接入验签时优先使用顶层简单字段。

和请求加密、响应加密的配合

这个项目里还有两个相关注解:

注解 作用
@DecryptParams 请求体只有 data 字段时,先用 AES 解密出真实业务 JSON
@EncryptResponse 成功响应且 data 不为空时,对响应数据做 AES 加密

它们和验签的关系可以这样理解:

前端签名时,永远基于明文业务参数签名;如果接口还要求请求体加密,再把这份明文参数加密成 data。后端则先通过 DecryptFilterdata 解开,再由 SignatureDecryptAspect 基于解密后的参数验签。

这个顺序很重要。如果前端拿外层 data 密文参与签名,后端拿解密后的业务参数参与签名,结果一定对不上。

当前设计的缺陷

这套方案已经比“直接 RSA 加密完整 JSON”更轻量,但它仍然不是完整的接口安全方案。它更适合提高参数篡改成本、统一敏感接口的校验流程,而不是作为身份认证或强防重放机制。

签名密钥不是服务端私有密钥

当前 signKey 的格式是 FF + timestampMillis。这意味着签名密钥并不是服务端独有的 secret,而是前端可以直接生成的字符串。只要别人知道这个规则,也拿到了 RSA 公钥,就可以自己构造一个新的 signKey,再生成一组合法的 EncryptedSignKeySignature

所以这套设计能校验“请求参数和签名是否匹配”,但不能证明“请求一定来自可信客户端”。如果要做真正的服务端私有签名校验,需要引入只有服务端和可信调用方知道的密钥,或者使用更标准的 AK/SK、OAuth、JWT 绑定签名等机制。

RSA 加密密钥不等于数字签名

这里 RSA 的作用是把 signKey 加密传给后端。它解决的是“传递短密钥”的问题,不是“证明请求来源”的问题。

真正的数字签名通常是私钥签名、公钥验签;当前实现是前端用公钥加密、后端用私钥解密。两者解决的问题不同,不能因为用了 RSA 就认为它已经具备数字签名能力。

15 秒窗口不能彻底防重放

当前后端只校验时间戳是否在 15 秒内,没有记录 nonce、请求流水号或签名摘要。因此同一个请求只要还在有效时间窗口内,理论上仍然可以被重复发送。

如果接口需要更严格的防重放,比较直接的做法是让前端额外传 nonce,后端把 nonce 或签名摘要写入 Redis,并设置一个短 TTL。第一次请求通过后,同一个 nonce 再出现就直接拒绝。

参数规范化规则不够通用

当前后端把 @RequestBody 转成 Map 后,只处理顶层字段,并使用 value.toString() 参与签名。这个规则对登录这类简单对象够用,但遇到嵌套对象、数组、数字精度、日期格式、空数组、字段顺序时,就容易出现前后端生成的签名字符串不一致。

如果后续要扩大验签范围,需要先定义一套稳定的 JSON 规范化规则,比如明确字段排序、空值保留策略、数组处理方式、数字和日期格式,再让前后端共用同一套规则。

普通字符串比较存在侧信道风险

当前签名比较使用普通的字符串 equals。在一般业务里问题不大,但如果按更严格的安全标准看,签名比较应该使用常量时间比较,避免理论上的时序侧信道风险。

日志会暴露敏感中间值

当前切面里会打印 signKeytimestampStr、参数拼接字符串和待签名字符串。联调时这些日志很有用,但生产环境不应该长期保留。因为一旦日志被拿到,攻击者可以看到签名密钥格式、业务参数和签名原文,排查价值和泄漏风险需要权衡。

更合理的做法是生产环境只保留必要的错误类型、请求标识和摘要信息,不直接打印完整签名密钥和业务参数。

固定 AES 密钥只能算传输混淆

@DecryptParams@EncryptResponse 当前依赖代码里的固定 AES 密钥。它能避免明文参数直接出现在请求体里,但只要前端和后端都内置同一把密钥,这把密钥就很难真正保密。

因此这里的 AES 更适合作为业务层面的传输混淆,不能替代 HTTPS,也不能当成完整的数据安全边界。生产环境如果确实需要请求/响应加密,密钥管理、轮换、客户端暴露风险都要单独设计。

踩坑与注意点

RSA 不要直接加密长参数

RSA 更适合加密短数据,比如临时密钥。把完整 JSON 参数分段 RSA 加密虽然能做,但实现复杂、性能差,而且请求参数越多越容易出问题。这里把 RSA 限定在“加密签名密钥”这个职责上,整体会简单很多。

Header 名要和代码完全一致

日志里有地方写的是 Encrypted-Sign-Key,但代码实际读取 EncryptedSignKey。联调时以代码读取的 Header 为准。

前后端编码规则必须完全一致

签名最怕“看起来一样”。比如空格是 + 还是 %20,中文是否 UTF-8 编码,空值是否保留,都会影响最终签名。建议联调阶段同时打印前端 signString 和后端日志里的待签名字符串,直接对比。

响应加密不是验签的一部分

@EncryptResponse 只负责返回值加密,它不参与请求验签。如果接口返回异常,或者响应 data 为空,也不会走成功响应数据加密。

总结

这套接口验签方案的核心思路是让 RSA、HMAC、时间戳各做自己擅长的事情:RSA 传递短密钥,HMAC 校验参数完整性,时间戳限制请求有效期。相比直接 RSA 加密完整参数,它更轻量,也更适合放到 Spring AOP 里做通用能力。

但它的定位要说清楚:当前方案适合提高参数篡改成本和统一接口校验流程,不能当作高安全身份认证或完整防重放机制。后续如果要继续增强,可以把签名密钥改成真正的服务端 secret、增加 nonce 一次性校验、对 HMAC 比较改成常量时间比较,并为复杂 JSON 参数设计稳定的序列化规范。