文章目录
背景
对外开放查询接口时,调用方未必能使用系统原有的登录会话。我们可以给调用方分配一把共享密钥,要求每次请求携带签名,再用时间戳和随机数限制请求重放。本文以一个 Spring Boot 列表接口为例,说明这种方案解决什么问题、怎样实现,以及它仍有哪些边界。
一、接口签名的作用
先用一个比喻理解签名
想象你去银行保险柜取东西。银行不会只凭你报出"我要取 3 号柜"就给你开门,它要求你出示一把钥匙------这把钥匙只有你和银行各有一把。你转动钥匙时,锁芯会验证齿形是否吻合;吻合就开,不吻合就拒。密钥就是这把钥匙,签名就是"转动钥匙"这个动作在服务端留下的验证结果。
但钥匙本身也有个问题:如果有人在你开门时偷偷配了一把,或者把你已经签好字的取件单原样复印一份再递进去,银行还是会照办。所以现实中还会加两道限制:
- 时间戳相当于取件单上写着"本单仅在今天 9:00--9:05 有效",过了这个时间窗口,单子作废;
- nonce相当于每张取件单都有一个唯一编号,银行柜台登记过的编号不能再用第二次,哪怕单子内容一模一样。
把这套逻辑搬到 HTTP 接口上,就得到本文要讲的方案:HMAC 证明"你持有密钥",时间戳把可重放的时间窗口收窄,nonce 阻止窗口内的重复提交。
签名具体解决什么
- 证明请求来源:签名能回答"请求是否由持有共享密钥的调用方生成"。服务端用相同密钥重新计算摘要,与请求携带的签名比较,结果一致即说明对方持有密钥。
- 保证参数完整:签名同时回答"参与签名的参数在传输过程中是否被改动"。任何字段被篡改,重算出的摘要都会不同,比较即失败。
- 为什么用 HMAC-SHA256:相比直接计算普通 SHA-256,HMAC 引入了密钥。没有密钥的一方即使拿到全部请求参数,也无法生成有效签名,而普通摘要谁都能算。
- 签名不加密内容:请求体仍可被读取,因此对外调用还需要 HTTPS 保护传输机密性。
- 签名不能单独防重放:攻击者即使不知道密钥,仍可能截获并原样重发一份已签好的请求。时间戳用来缩短可重放的时间窗口,nonce 用来阻止有效窗口内的重复提交,两者配合才能限制重放。
二、协议与校验流程
示例接口 POST /open/api/v1/resource/query。请求体只开放分页和关键词:
json
{"nowPage":1,"pageSize":8,"keyWord":"123"}
客户端每次请求生成 Unix 毫秒时间戳和新的 nonce,并按以下顺序用真正的换行符 \n 拼接待签名文本:
text
POST
/open/api/v1/resource/query
{X-Timestamp}
{X-Nonce}
{nowPage}
{pageSize}
{keyWord}
双方都用 UTF-8 编码文本和密钥,HMAC-SHA256 的结果转成十六进制,放在 X-Signature 请求头中。
密钥即使由十六进制字符组成,也作为 UTF-8 文本使用,不能先做十六进制解码。
服务端依次执行以下检查:
- 确认已配置密钥,请求带有时间戳、nonce 和签名,nonce 符合
[A-Za-z0-9_-]{16,128}。 - 时间戳与服务器时间相差不超过 5 分钟;用相同参数重算 HMAC,做恒定时间比较。
- 在 Redis 中执行
SET nonceKey 1 NX EX 600。首次写入成功;键已存在说明这个 nonce 已使用。 - 以上检查都通过后,执行实际的列表查询。签名错误和重复 nonce 返回 401;密钥未配置或 Redis 不可用返回 503。
Redis 写入必须同时包含 NX 和过期时间,不能拆成先查询、再写入的两个操作,否则并发请求可能同时通过。
因为时间戳可比服务器时间早或晚 5 分钟,同一时间戳理论上可能跨越 10 分钟的可接受窗口;当前 600 秒留存刚好覆盖这个窗口。
三、Spring Boot 实现
为了不让示例代码和具体业务 DTO 绑死,下面把"签名协议"抽象成两个自包含的接口/类:一个负责从请求中提取参与签名的字段(SignPayload),一个负责签名与验签(RequestSigner)。业务 DTO 只要实现 SignPayload 即可复用整套逻辑,换一个接口也不用改签名器。
请求 DTO 提供分页默认值,同时用 Bean Validation 阻止非正数,并声明自己参与签名的字段:
java
public interface SignPayload {
/** 参与签名的字段,按声明顺序逐个拼接;null 视为空串 */
List<String> signFields();
}
@Data
public class ResourceQueryPageDTO implements SignPayload {
@Min(1)
private int nowPage = 1;
@Min(1)
private int pageSize = 8;
private String keyWord;
@Override
public List<String> signFields() {
return List.of(String.valueOf(nowPage), String.valueOf(pageSize),
keyWord == null ? "" : keyWord);
}
}
签名器只依赖 JDK 标准库,构造时传入密钥、接口路径和时钟偏移上限,方法层面不再关心业务:
java
public final class RequestSigner {
private final byte[] secret;
private final String path;
private final long maxClockSkewMillis;
public RequestSigner(String secret, String path, Duration maxClockSkew) {
this.secret = secret.getBytes(StandardCharsets.UTF_8);
this.path = path;
this.maxClockSkewMillis = maxClockSkew.toMillis();
}
/** 供客户端使用:生成签名 */
public String sign(String method, String timestamp, String nonce, SignPayload payload) {
return hmacHex(buildPayload(method, timestamp, nonce, payload));
}
/** 供服务端使用:验签 */
public boolean verify(String method, String timestamp, String nonce,
String signature, SignPayload payload, long nowMillis) {
if (timestamp == null || nonce == null || signature == null
|| !nonce.matches("[A-Za-z0-9_-]{16,128}")) {
return false;
}
long requestTime;
try {
requestTime = Long.parseLong(timestamp);
} catch (NumberFormatException e) {
return false;
}
if (Math.abs(requestTime - nowMillis) > maxClockSkewMillis) {
return false;
}
byte[] expected = hmacHex(buildPayload(method, timestamp, nonce, payload))
.getBytes(StandardCharsets.US_ASCII);
byte[] actual = signature.toLowerCase(Locale.ROOT)
.getBytes(StandardCharsets.US_ASCII);
return MessageDigest.isEqual(expected, actual);
}
private String buildPayload(String method, String timestamp, String nonce,
SignPayload payload) {
StringBuilder sb = new StringBuilder();
sb.append(method).append('\n').append(path).append('\n')
.append(timestamp).append('\n').append(nonce);
for (String field : payload.signFields()) {
sb.append('\n').append(field == null ? "" : field);
}
return sb.toString();
}
private String hmacHex(String text) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
byte[] digest = mac.doFinal(text.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(digest.length * 2);
for (byte b : digest) {
hex.append(Character.forDigit((b >> 4) & 0xf, 16))
.append(Character.forDigit(b & 0xf, 16));
}
return hex.toString();
} catch (GeneralSecurityException e) {
throw new IllegalStateException("HMAC 计算失败", e);
}
}
}
再封装一个"一次性 nonce 登记器"接口,把 Redis 的具体细节隔离掉,控制器只依赖抽象:
java
public interface NonceRegistry {
/** 首次登记返回 true;已存在返回 false */
boolean register(String nonce, Duration ttl);
}
@Component
public class RedisNonceRegistry implements NonceRegistry {
private final RedisTemplate<String, byte[]> redisTemplate;
public RedisNonceRegistry(RedisTemplate<String, byte[]> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public boolean register(String nonce, Duration ttl) {
String key = "signature:nonce:" + nonce;
Boolean first = redisTemplate.execute((RedisCallback<Boolean>) connection ->
connection.set(key.getBytes(StandardCharsets.UTF_8), new byte[]{1},
Expiration.milliseconds(ttl.toMillis()), SetOption.ifAbsent()));
return Boolean.TRUE.equals(first);
}
}
控制器代码:只负责参数绑定、调用签名器和 nonce 登记器,以及把结果映射成 HTTP 状态码:
java
@Value("${RESOURCE_QUERY_SIGNATURE_SECRET:}")
private String signatureSecret;
@Autowired
private NonceRegistry nonceRegistry;
private static final String SIGNED_PATH = "/open/api/v1/resource/query";
private static final Duration NONCE_TTL = Duration.ofSeconds(660);
private static final Duration MAX_SKEW = Duration.ofMinutes(5);
@PostMapping("/open/api/v1/resource/query")
public Result<PageResult<ResourceDTO>> query(
@Valid @RequestBody ResourceQueryPageDTO page,
@RequestHeader(value = "X-Timestamp", required = false) String timestamp,
@RequestHeader(value = "X-Nonce", required = false) String nonce,
@RequestHeader(value = "X-Signature", required = false) String signature,
HttpServletResponse response) {
if (StringUtils.isBlank(signatureSecret)) {
response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
return Result.error("接口签名密钥未配置");
}
RequestSigner signer = new RequestSigner(signatureSecret, SIGNED_PATH, MAX_SKEW);
if (!signer.verify("POST", timestamp, nonce, signature, page, System.currentTimeMillis())) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return Result.error("接口签名无效");
}
try {
if (!nonceRegistry.register(nonce, NONCE_TTL)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return Result.error("接口签名已使用");
}
} catch (DataAccessException e) {
response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
return Result.error("接口签名校验不可用");
}
return resourceQueryService.listByCondition(page);
}
- 客户端应对每次请求重新生成时间戳、nonce 和签名。
- 修改
nowPage、pageSize或keyWord后必须重算签名;重发同一组请求头会因 nonce 已使用而失败。 - 把签名逻辑抽成独立类还有一个好处:将来新增接口,只要实现一个
SignPayload,再 new 一个RequestSigner传入对应路径即可,不必复制粘贴验签代码。
四、当前实现的不足
- 只签了三个业务字段。 目前参与签名的是
nowPage、pageSize和keyWord,不是整个 JSON。以后若新增会影响查询结果的字段,却忘记加入signFields(),持有旧签名的请求就可能更改新字段。扩展协议时应明确签名字段清单,或对规范化后的完整请求体做摘要。 - 600 秒处于理论边界。 在服务器时间恰好处于允许范围端点时,nonce 键到期和时间戳失效可能同时发生。把 nonce TTL 增至略大于 600 秒,例如上面代码里的 660 秒,可留出时钟和执行耗时余量。
- nonce 在业务执行前就被占用。 即使业务查询随后失败,原请求也不能原样重试。客户端需要生成新的时间戳、nonce 和签名;若扩展到写接口,还需另外设计业务幂等键,不能把 nonce 当作完整的幂等方案。
- 共享密钥缺少身份标识和轮换机制。 单一密钥无法区分不同调用方,也不便于逐个撤销。正式的多方接入可增加
keyId,由服务端按调用方选取密钥,并支持新旧密钥的过渡期。已泄露或发到公开渠道的密钥应轮换。 - 格式规则需要严格同步。 签名基于解析后的 DTO 值,而不是原始 JSON;关键词为
null时按空串计算,业务查询又可能对关键词执行trim()。客户端和服务端对换行、路径、字符编码、默认值及空白字符的理解必须一致。当前代码没有校验签名字符串的长度和十六进制格式,只在最终比较时判定是否一致。 - 免登录不等于整个调用链无鉴权。 主接口验签后还会访问字典等下游服务。若下游仍要求 token,这些调用可能返回 403;需要逐一审视下游鉴权策略,并限制免登录路径的暴露范围。
这套方案适合少量、明确的服务间只读调用。
它提供调用方持有密钥的证明、参与签名参数的完整性校验和时间窗口内的重放限制;HTTPS、密钥管理、下游权限和业务幂等仍需分别处理。