Spring Boot 接口签名实践

文章目录

背景

对外开放查询接口时,调用方未必能使用系统原有的登录会话。我们可以给调用方分配一把共享密钥,要求每次请求携带签名,再用时间戳和随机数限制请求重放。本文以一个 Spring Boot 列表接口为例,说明这种方案解决什么问题、怎样实现,以及它仍有哪些边界。

一、接口签名的作用

先用一个比喻理解签名

想象你去银行保险柜取东西。银行不会只凭你报出"我要取 3 号柜"就给你开门,它要求你出示一把钥匙------这把钥匙只有你和银行各有一把。你转动钥匙时,锁芯会验证齿形是否吻合;吻合就开,不吻合就拒。密钥就是这把钥匙,签名就是"转动钥匙"这个动作在服务端留下的验证结果。

但钥匙本身也有个问题:如果有人在你开门时偷偷配了一把,或者把你已经签好字的取件单原样复印一份再递进去,银行还是会照办。所以现实中还会加两道限制:

  • 时间戳相当于取件单上写着"本单仅在今天 9:00--9:05 有效",过了这个时间窗口,单子作废;
  • nonce相当于每张取件单都有一个唯一编号,银行柜台登记过的编号不能再用第二次,哪怕单子内容一模一样。

把这套逻辑搬到 HTTP 接口上,就得到本文要讲的方案:HMAC 证明"你持有密钥",时间戳把可重放的时间窗口收窄,nonce 阻止窗口内的重复提交。

签名具体解决什么

  1. 证明请求来源:签名能回答"请求是否由持有共享密钥的调用方生成"。服务端用相同密钥重新计算摘要,与请求携带的签名比较,结果一致即说明对方持有密钥。
  2. 保证参数完整:签名同时回答"参与签名的参数在传输过程中是否被改动"。任何字段被篡改,重算出的摘要都会不同,比较即失败。
  3. 为什么用 HMAC-SHA256:相比直接计算普通 SHA-256,HMAC 引入了密钥。没有密钥的一方即使拿到全部请求参数,也无法生成有效签名,而普通摘要谁都能算。
  4. 签名不加密内容:请求体仍可被读取,因此对外调用还需要 HTTPS 保护传输机密性。
  5. 签名不能单独防重放:攻击者即使不知道密钥,仍可能截获并原样重发一份已签好的请求。时间戳用来缩短可重放的时间窗口,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 文本使用,不能先做十六进制解码。

服务端依次执行以下检查:

  1. 确认已配置密钥,请求带有时间戳、nonce 和签名,nonce 符合 [A-Za-z0-9_-]{16,128}。
  2. 时间戳与服务器时间相差不超过 5 分钟;用相同参数重算 HMAC,做恒定时间比较。
  3. 在 Redis 中执行 SET nonceKey 1 NX EX 600。首次写入成功;键已存在说明这个 nonce 已使用。
  4. 以上检查都通过后,执行实际的列表查询。签名错误和重复 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 传入对应路径即可,不必复制粘贴验签代码。

四、当前实现的不足

  1. 只签了三个业务字段。 目前参与签名的是 nowPage、pageSize 和 keyWord,不是整个 JSON。以后若新增会影响查询结果的字段,却忘记加入 signFields(),持有旧签名的请求就可能更改新字段。扩展协议时应明确签名字段清单,或对规范化后的完整请求体做摘要。
  2. 600 秒处于理论边界。 在服务器时间恰好处于允许范围端点时,nonce 键到期和时间戳失效可能同时发生。把 nonce TTL 增至略大于 600 秒,例如上面代码里的 660 秒,可留出时钟和执行耗时余量。
  3. nonce 在业务执行前就被占用。 即使业务查询随后失败,原请求也不能原样重试。客户端需要生成新的时间戳、nonce 和签名;若扩展到写接口,还需另外设计业务幂等键,不能把 nonce 当作完整的幂等方案。
  4. 共享密钥缺少身份标识和轮换机制。 单一密钥无法区分不同调用方,也不便于逐个撤销。正式的多方接入可增加 keyId,由服务端按调用方选取密钥,并支持新旧密钥的过渡期。已泄露或发到公开渠道的密钥应轮换。
  5. 格式规则需要严格同步。 签名基于解析后的 DTO 值,而不是原始 JSON;关键词为 null 时按空串计算,业务查询又可能对关键词执行 trim()。客户端和服务端对换行、路径、字符编码、默认值及空白字符的理解必须一致。当前代码没有校验签名字符串的长度和十六进制格式,只在最终比较时判定是否一致。
  6. 免登录不等于整个调用链无鉴权。 主接口验签后还会访问字典等下游服务。若下游仍要求 token,这些调用可能返回 403;需要逐一审视下游鉴权策略,并限制免登录路径的暴露范围。

这套方案适合少量、明确的服务间只读调用。

它提供调用方持有密钥的证明、参与签名参数的完整性校验和时间窗口内的重放限制;HTTPS、密钥管理、下游权限和业务幂等仍需分别处理。

相关推荐
denlaku1 小时前
JDK 22 新特性详解
java·开发语言
余槐i1 小时前
开虚拟线程后接口反而更慢:JDK 21 synchronized 锁住载体线程的排查
java·spring boot·redis·mysql·性能优化
RuoyiOffice2 小时前
Flowable 状态通知设计:单体用本地事件,微服务如何回写业务状态?
spring boot·微服务·vue3·flowable·ruoyi office·redis stream·流程状态通知
Sirens.2 小时前
网络通信基础:IP、端口、协议分层与数据封装
java·网络·网络协议·tcp/ip·java-ee·1024程序员节
CRMEB系统商城2 小时前
服装连锁的导购分销与企微私域
java·大数据·开发语言·人工智能·小程序·php·1024程序员节
若亦_2 小时前
Java泛型入门:类型安全与上界约束
java·1024程序员节
编程的一拳超人2 小时前
【元宝搜索开发者征文大赛】四语实战:Java/Python/Go/Node.js 构建 AI 搜索应用全攻略
java·python·golang·node.js·agent
wuminyu2 小时前
JVM规范学习总结-Java源码经过javac编译后产物
java·linux·c语言·jvm·c++
ly76893 小时前
Spring Security 过滤器链定制:认证、CORS 与 CSRF 的先后顺序
java·spring boot·spring·spring security·csrf·cors·过滤器链