Spring Boot中实现API的HMAC Token安全认证相关问题问询
Spring Boot 实现HMAC Token接口安全认证完整方案
HMAC(基于哈希的消息认证码)认证是一种无状态、高安全性的接口认证方案,核心逻辑是客户端与服务端共享密钥,通过比对请求内容的哈希签名实现身份校验与防篡改,不需要在请求中传输密钥,安全性远高于普通的固定Token认证。
核心认证流程
- 服务端提前为每个合法客户端分配唯一标识
accessKey与共享密钥secretKey,secretKey仅存储在两端本地,永远不通过网络传输 - 客户端发起请求时,按约定规则拼接请求要素(请求方法、请求路径、请求参数/请求体、时间戳、随机串、accessKey),用
secretKey对拼接后的字符串做HMAC哈希运算(通常用HMAC-SHA256算法)生成签名signature - 客户端将
accessKey、时间戳timestamp、随机串nonce、signature四个参数放入请求头,发送请求到服务端 - 服务端接收请求后按如下逻辑校验:
- 校验请求头是否包含所有必填的HMAC参数,缺失直接返回401未认证
- 校验
timestamp与当前服务器时间差是否在允许范围内(通常设置为5分钟),超出则判定为过期请求直接拒绝,防范重放攻击 - 校验
nonce是否在有效期内已经被使用过,已使用过直接拒绝,进一步防范重放攻击 - 根据
accessKey查询对应的secretKey,按照与客户端完全一致的规则拼接请求要素,生成服务端签名 - 比对服务端生成的签名与客户端传入的签名,一致则放行请求,不一致直接返回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
相关产品推荐
相关产品推荐

