You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Boot中实现API的HMAC Token安全认证相关问题问询

Spring Boot 实现HMAC Token接口安全认证完整方案

HMAC(基于哈希的消息认证码)认证是一种无状态、高安全性的接口认证方案,核心逻辑是客户端与服务端共享密钥,通过比对请求内容的哈希签名实现身份校验与防篡改,不需要在请求中传输密钥,安全性远高于普通的固定Token认证。

核心认证流程

  • 服务端提前为每个合法客户端分配唯一标识accessKey与共享密钥secretKey,secretKey仅存储在两端本地,永远不通过网络传输
  • 客户端发起请求时,按约定规则拼接请求要素(请求方法、请求路径、请求参数/请求体、时间戳、随机串、accessKey),用secretKey对拼接后的字符串做HMAC哈希运算(通常用HMAC-SHA256算法)生成签名signature
  • 客户端将accessKey、时间戳timestamp、随机串nonce、signature四个参数放入请求头,发送请求到服务端
  • 服务端接收请求后按如下逻辑校验:
    1. 校验请求头是否包含所有必填的HMAC参数,缺失直接返回401未认证
    2. 校验timestamp与当前服务器时间差是否在允许范围内(通常设置为5分钟),超出则判定为过期请求直接拒绝,防范重放攻击
    3. 校验nonce是否在有效期内已经被使用过,已使用过直接拒绝,进一步防范重放攻击
    4. 根据accessKey查询对应的secretKey,按照与客户端完全一致的规则拼接请求要素,生成服务端签名
    5. 比对服务端生成的签名与客户端传入的签名,一致则放行请求,不一致直接返回401

具体实现步骤

1. 核心依赖

仅需要Spring Boot Web基础依赖即可,HMAC运算使用JDK自带的javax.crypto包,无需引入额外第三方依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 若需要分布式防重放,引入Redis依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2. 定义HMAC工具类

封装HMAC签名生成逻辑:

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;

public class HmacSignatureUtil {
    // 统一使用HMAC-SHA256算法
    private static final String HMAC_ALGORITHM = "HmacSHA256";

    /**
     * 生成HMAC签名
     * @param secretKey 共享密钥
     * @param content 待签名拼接字符串
     * @return 十六进制格式的小写签名
     */
    public static String generateSignature(String secretKey, String content) throws Exception {
        SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM);
        Mac mac = Mac.getInstance(HMAC_ALGORITHM);
        mac.init(keySpec);
        byte[] hashBytes = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
        // JDK16及以下可替换为org.apache.commons.codec.binary.Hex.encodeHexString(hashBytes).toLowerCase()
        return HexFormat.of().formatHex(hashBytes).toLowerCase();
    }
}

3. 定义请求头常量与全局异常

定义统一的HMAC请求头Key,避免硬编码:

public class HmacConstants {
    public static final String HEADER_ACCESS_KEY = "X-HMAC-Access-Key";
    public static final String HEADER_TIMESTAMP = "X-HMAC-Timestamp";
    public static final String HEADER_NONCE = "X-HMAC-Nonce";
    public static final String HEADER_SIGNATURE = "X-HMAC-Signature";
    // 签名有效期5分钟,单位毫秒
    public static final long SIGN_EXPIRE_TIME = 5 * 60 * 1000;
}

4. 解决请求体重复读取问题

请求的输入流默认只能读取一次,拦截器读取请求体后Controller无法获取,需要自定义请求包装类缓存请求体:

import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import org.springframework.util.StreamUtils;
import java.io.*;

public class CachedHttpServletRequestWrapper extends HttpServletRequestWrapper {
    private final byte[] cachedBody;

    public CachedHttpServletRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        return new CachedServletInputStream(new ByteArrayInputStream(cachedBody));
    }

    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }

    public byte[] getCachedBody() {
        return cachedBody;
    }
}
// 配套实现CachedServletInputStream即可,该类为Servlet输入流通用缓存实现

新增过滤器将所有请求替换为包装后的请求:

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import java.io.IOException;

@Component
public class CachedBodyFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        if (request instanceof HttpServletRequest httpServletRequest) {
            chain.doFilter(new CachedHttpServletRequestWrapper(httpServletRequest), response);
        } else {
            chain.doFilter(request, response);
        }
    }
}

5. 实现HMAC认证拦截器

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;

public class HmacAuthInterceptor implements HandlerInterceptor {
    private final StringRedisTemplate stringRedisTemplate;
    // 模拟accessKey与secretKey的映射关系,生产环境从数据库/配置中心读取
    private static final String VALID_ACCESS_KEY = "test_access_key";
    private static final String VALID_SECRET_KEY = "test_secret_key_123456";

    public HmacAuthInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 提取请求头参数
        String accessKey = request.getHeader(HmacConstants.HEADER_ACCESS_KEY);
        String timestampStr = request.getHeader(HmacConstants.HEADER_TIMESTAMP);
        String nonce = request.getHeader(HmacConstants.HEADER_NONCE);
        String clientSignature = request.getHeader(HmacConstants.HEADER_SIGNATURE);

        if (accessKey == null || timestampStr == null || nonce == null || clientSignature == null) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }

        // 2. 校验时间戳有效期
        long timestamp = Long.parseLong(timestampStr);
        long now = System.currentTimeMillis();
        if (Math.abs(now - timestamp) > HmacConstants.SIGN_EXPIRE_TIME) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }

        // 3. 校验nonce是否已使用,Redis key格式 hmac:nonce:{nonce}
        String nonceKey = "hmac:nonce:" + nonce;
        if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(nonceKey))) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }
        // 存储nonce,有效期和签名有效期一致
        stringRedisTemplate.opsForValue().set(nonceKey, "1", HmacConstants.SIGN_EXPIRE_TIME, TimeUnit.MILLISECONDS);

        // 4. 校验accessKey合法性,获取对应secretKey
        if (!VALID_ACCESS_KEY.equals(accessKey)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }
        String secretKey = VALID_SECRET_KEY;

        // 5. 拼接待签名字符串,规则必须和客户端完全一致:请求方法\n请求URI\n请求体\n时间戳\n随机串\naccessKey
        String method = request.getMethod().toUpperCase();
        String requestUri = request.getRequestURI();
        String body = "";
        if (request instanceof CachedHttpServletRequestWrapper cachedRequest) {
            body = new String(cachedRequest.getCachedBody(), StandardCharsets.UTF_8);
        }
        String signContent = String.join("\n",
                method,
                requestUri,
                body,
                timestampStr,
                nonce,
                accessKey
        );

        // 6. 生成服务端签名比对
        String serverSignature = HmacSignatureUtil.generateSignature(secretKey, signContent);
        if (!serverSignature.equals(clientSignature)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }

        return true;
    }
}

6. 注册拦截器

配置拦截器生效的路径:

import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final StringRedisTemplate stringRedisTemplate;

    public WebConfig(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new HmacAuthInterceptor(stringRedisTemplate))
                // 拦截所有需要认证的接口
                .addPathPatterns("/api/**")
                // 排除不需要认证的路径,比如健康检查、开放接口等
                .excludePathPatterns("/actuator/**", "/open/**");
    }
}

客户端对接示例

客户端发起请求时按照同样的规则生成签名即可,Java示例代码:

import okhttp3.*;
import java.util.UUID;

public class HmacClientDemo {
    private static final String ACCESS_KEY = "test_access_key";
    private static final String SECRET_KEY = "test_secret_key_123456";
    private static final OkHttpClient client = new OkHttpClient();

    public static void main(String[] args) throws Exception {
        // 构造请求参数
        String method = "POST";
        String uri = "/api/test";
        String body = "{\"name\":\"test\"}";
        String timestamp = String.valueOf(System.currentTimeMillis());
        String nonce = UUID.randomUUID().toString().replace("-", "");

        // 生成签名
        String signContent = String.join("\n", method, uri, body, timestamp, nonce, ACCESS_KEY);
        String signature = HmacSignatureUtil.generateSignature(SECRET_KEY, signContent);

        // 构造请求
        Request request = new Request.Builder()
                .url("http://localhost:8080" + uri)
                .addHeader(HmacConstants.HEADER_ACCESS_KEY, ACCESS_KEY)
                .addHeader(HmacConstants.HEADER_TIMESTAMP, timestamp)
                .addHeader(HmacConstants.HEADER_NONCE, nonce)
                .addHeader(HmacConstants.HEADER_SIGNATURE, signature)
                .post(RequestBody.create(body.getBytes(), MediaType.parse("application/json")))
                .build();

        // 发起请求
        try (Response response = client.newCall(request).execute()) {
            System.out.println(response.code());
            System.out.println(response.body().string());
        }
    }
}

注意事项

  • 签名拼接规则必须与客户端100%对齐,包括换行符、参数顺序、编码格式、大小写要求,否则会出现签名校验不通过的问题
  • 如果是GET请求,将请求参数按字典序排序后拼接成key1=value1&key2=value2的格式,替换签名内容中的body部分即可
  • 生产环境中accessKey与secretKey的映射关系建议存储在数据库,可针对不同客户端分配不同的接口访问权限
  • 不要使用过于简单的secretKey,建议长度不少于16位,包含大小写字母、数字与特殊字符

内容的提问来源于stack exchange,提问作者KumariPriti Singh

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.02 18:06:02