2025-04-09-拦截器校验签名实现应用接入
1、场景分析
当外部第三方应用需要接入自身系统时,我们使用数据签名来校验第三方数据的正确性,保证第三方传输的数据不会在中途被篡改。同时还需要防止重放攻击、国产化签名方式(SM3)。
2、实现思路
实现签名的前提时提供应用接入的管理方案,例如为每个第三方应用颁布appId和appSecret,appId相当于账号,appSecret相当于密码,appSecret会被用于数据签名。
-
第三方接口发送请求时,需要在
header
携带参数:-
appId
应用id,用于识别身份 -
timestamp
时间戳 -
signature
签名
-
-
构成签名的数据如下:
appId
+timestamp
+urlParams
+bodyAsString
-
appId
应用id -
timestamp
时间戳 -
urlParams
为url上携带的参数 -
bodyAsString
为body携带的参数
-
-
拦截器的执行步骤:
- 检查必要的请求头参数:
appId
、timestamp
、signature
- 校验时间戳,防止重放攻击:接入应用的请求时间与当前服务时间不能超过5分钟
- 获取请求参数(需要注意reqeust获取流只能获取一次的问题1),计算签名
- 校验签名
- 请求完成后清空
ThreadLocal
- 检查必要的请求头参数:
-
使用
ThreadLocal
在业务代码中随时获取当前请求的应用信息
3、源码
1.ThreadLocal存储当前请求的应用信息
import com.alibaba.ttl.TransmittableThreadLocal;
import com.hymake.biz.modular.product.entity.ProductApp;
import lombok.experimental.UtilityClass;
import java.util.Objects;
/**
* 应用信息上下文线程变量
*
* @author huanghwh
* @date 2025/04/03 09:16
*/
@UtilityClass
public final class AppContextLocal {
private static final ThreadLocal<ProductApp> THREAD_LOCAL = new TransmittableThreadLocal<>();
/**
* 获取
*/
public static ProductApp get(){
ProductApp productApp = THREAD_LOCAL.get();
if (Objects.isNull(productApp)){
productApp = new ProductApp();
THREAD_LOCAL.set(productApp);
}
return productApp;
}
/**
* 设置
*
* @author huanghwh
* @date 2025/04/03 09:13
*/
public static void set(ProductApp productApp) {
THREAD_LOCAL.set(productApp);
}
/**
* 清除
*/
public static void clear() {
THREAD_LOCAL.remove();
}
}
2.SM3签名工具
-
引入工具包
<!-- sm3国密 --> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk18on</artifactId> <version>1.76</version> </dependency>
-
工具类
import org.bouncycastle.crypto.digests.SM3Digest; import org.bouncycastle.crypto.macs.HMac; import org.bouncycastle.crypto.params.KeyParameter; import java.nio.charset.StandardCharsets; public class SM3Util { /** * 计算 SM3 HMAC 签名 * * @param message 需要签名的内容 * @param secretKey 签名密钥 * @return 签名后的字符串 */ public static String hmacSM3(String message, String secretKey) { HMac hmac = new HMac(new SM3Digest()); hmac.init(new KeyParameter(secretKey.getBytes(StandardCharsets.UTF_8))); byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8); hmac.update(messageBytes, 0, messageBytes.length); byte[] result = new byte[hmac.getMacSize()]; hmac.doFinal(result, 0); return bytesToHex(result); } private static String bytesToHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02x", b)); } return sb.toString(); } }
3.拦截器实现
import com.hymake.biz.modular.product.entity.ProductApp;
import com.hymake.biz.modular.product.service.ProductService;
import com.hymake.biz.modular.util.AppContextLocal;
import com.hymake.core.util.SM3Util;
import jakarta.annotation.Resource;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
/**
* 全局客户端接口拦截器、签名校验
*
* @author huanghwh
* @date 2025/04/02 15:45
*/
@Slf4j
@Component
public class GlobalClientInterceptor implements HandlerInterceptor {
// 允许的时间戳误差(5分钟)
private static final long TIME_DIFF_LIMIT = 5 * 60 * 1000;
@Resource
private ProductService productService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String appId = request.getHeader("app-id");
String timestamp = request.getHeader("timestamp");
String signature = request.getHeader("signature");
// 检查必要的请求头参数
if (!StringUtils.hasText(appId) || !StringUtils.hasText(timestamp) || !StringUtils.hasText(signature)) {
log.error("验签失败:参数缺失,appId:{}", appId);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Missing authentication headers");
return false;
}
// 校验时间戳,防止重放攻击
long requestTime;
try {
requestTime = Long.parseLong(timestamp);
} catch (NumberFormatException e) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Invalid timestamp format");
return false;
}
if (Math.abs(System.currentTimeMillis() - requestTime) > TIME_DIFF_LIMIT) {
log.error("验签失败:服务器时间不一致,appId:{}", appId);
log.error(String.valueOf(System.currentTimeMillis()));
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Request expired");
return false;
}
// **获取url参数**
String urlParams = getSortedQueryParams(request);
// **获取body参数**
ServletInputStream inputStream = request.getInputStream();
String bodyAsString = new String(inputStream.readAllBytes());
// 构造需要签名的数据
String dataToSign = appId + timestamp + urlParams + bodyAsString;
ProductApp productApp = productService.getAppInfoByAppId(appId);
String serverSignature = SM3Util.hmacSM3(dataToSign, productApp.getAppSecret());
// 校验签名
if (!serverSignature.equals(signature)) {
log.error("验签失败:签名错误,appId:{}", appId);
log.error(serverSignature);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Invalid signature");
return false;
}
// 初始化上下文变量
productApp.setAppSecret(null);
AppContextLocal.set(productApp);
//log.info("签名校验成功,appId:{}", appId);
return true;
}
/**
* 获取url参数,排序输出
*
* @author huanghwh
* @date 2025/04/02 16:53
*/
public static String getSortedQueryParams(HttpServletRequest request) {
Map<String, String[]> paramMap = request.getParameterMap();
if (paramMap == null || paramMap.isEmpty()) {
// **空格占位,确保签名计算一致**
return " ";
}
TreeMap<String, String> sortedParams = new TreeMap<>();
for (Map.Entry<String, String[]> entry : paramMap.entrySet()) {
String key = entry.getKey();
// 多值用逗号拼接
String value = String.join(",", entry.getValue());
sortedParams.put(key, value);
}
return sortedParams.entrySet().stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(Collectors.joining("&"));
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// **请求完成后清理 ThreadLocal**
AppContextLocal.clear();
}
}
4.注册拦截器
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Resource
private GlobalClientInterceptor globalClientInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(globalClientInterceptor)
.addPathPatterns(
"/c/**",
).excludePathPatterns("/c/biz/memberOrder/callback/notify");
}
}
4、第三方应用接入
1.请求时统一生成签名
- 工具类同SM3签名工具2
-
请求工具类(基于hutool的http请求工具)
import cn.hutool.core.bean.BeanUtil; import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpResponse; import cn.hutool.http.Method; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.fasterxml.jackson.databind.ObjectMapper; import com.hymake.common.cache.CommonCacheOperator; import com.hymake.common.exception.CommonException; import jakarta.annotation.Resource; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.text.SimpleDateFormat; import java.util.List; import java.util.Map; import java.util.TreeMap; /** * 会员服务请求工具 * * @author huanghwh * @date 2025/04/03 10:06 */ @Component public class MemberServiceRequest { private static String host; private static String appId; private static String appSecret; public static final String CACHE_MEMBER_TOKEN = "member:service:token:"; @Value("${member-service.host}") public void setHost(String host) { MemberServiceRequest.host = host; } @Value("${member-service.app-id}") public void setAppId(String appId) { MemberServiceRequest.appId = appId; } @Value("${member-service.app-secret}") public void setAppSecret(String appSecret) { MemberServiceRequest.appSecret = appSecret; } @Resource private CommonCacheOperator commonCacheOperator; /** * 发送请求 * * @param url 请求 URL * @param method 请求方法(GET、POST) * @param params GET 请求参数 或者 POST 表单参数 * @param jsonBody POST JSON 参数 * @return 响应字符串 */ public String sendRequest(String url, Method method, Map<String, Object> params, String jsonBody) { long timestamp = System.currentTimeMillis(); String signature = generateSignature(appId, timestamp, params, jsonBody); HttpRequest request = HttpRequest.of(host + url) .method(method) .header("app-id", appId) .header("timestamp", String.valueOf(timestamp)) .header("signature", signature); // 如果 token 不为空,则补充到 Header if (StpClientUtil.isLogin()) { Object token = commonCacheOperator.get(CACHE_MEMBER_TOKEN + StpClientUtil.getLoginIdAsString()); request.header("Token", token.toString()); } if (method == Method.GET) { request.form(params); } else if (method == Method.POST) { if (jsonBody != null) { request.body(jsonBody).header("Content-Type", "application/json"); } else { request.form(params); } } try (HttpResponse response = request.execute()) { int status = response.getStatus(); String responseBody = response.body(); // 处理不同状态码 switch (status) { case 200: // 解析JSON并提取 data 字段 JSONObject jsonResponse = JSONUtil.parseObj(responseBody); String code = jsonResponse.getStr("code"); if (!StringUtils.equals("200", code)) { throw new CommonException(jsonResponse.getStr("msg")); } if (jsonResponse.containsKey("data")) { return jsonResponse.getStr("data"); // 直接返回 data 字段内容 } else { return responseBody; // 没有 data 字段就返回整个响应 } case 401: throw new CommonException("Unauthorized: 签名校验失败"); case 403: throw new CommonException("Forbidden: 无访问权限"); case 429: throw new CommonException("Too Many Requests: 请求过多,请稍后再试"); case 500: throw new CommonException("Server Error: 服务器内部错误"); default: throw new CommonException("Unexpected Error: HTTP " + status + ",响应内容:" + responseBody); } } catch (Exception e) { throw new CommonException("请求失败:" + e.getMessage(), e); } } /** * 生成签名 * * @param appId 应用ID * @param timestamp 时间戳 * @param params 请求参数 * @param jsonBody JSON 请求体 * @return 签名 */ private String generateSignature(String appId, long timestamp, Map<String, Object> params, String jsonBody) { // 排序参数 TreeMap<String, String> sortedParams = new TreeMap<>(); if (params != null) { params.forEach((key, value) -> { if (value != null && !String.valueOf(value).isEmpty()) { // 忽略 null 和 空字符串 sortedParams.put(key, String.valueOf(value)); } }); } String sortedParamsString = sortedParams.entrySet().stream() .map(entry -> entry.getKey() + "=" + entry.getValue()) .reduce((a, b) -> a + "&" + b) .orElse(" "); // 计算签名数据串 String dataToSign = appId + timestamp + sortedParamsString + (jsonBody != null ? jsonBody : ""); return SM3HmacUtil.hmac(dataToSign, appSecret); } /** * 发送 GET 请求 */ public String get(String url, Map<String, Object> params) { return sendRequest(url, Method.GET, params, null); } /** * 发送 GET 请求 */ public String get(String url, Object paramObject) { Map<String, Object> params = BeanUtil.beanToMap(paramObject); return sendRequest(url, Method.GET, params, null); } /** * 发送 GET 请求,返回 List<T> * * @author huanghwh * @date 2025/04/03 13:51 */ public <T> List<T> getList(String url, Map<String, Object> params, Class<T> responseType) { String jsonResponse = sendRequest(url, Method.GET, params, null); try { return JSONUtil.parseArray(jsonResponse).toList(responseType); } catch (Exception e) { throw new CommonException("Failed to parse response:" + e.getMessage(), e); } } /** * 发送 GET 请求,返回指定类型 * * @author huanghwh * @date 2025/04/03 13:51 */ public <T> T get(String url, Map<String, Object> params, Class<T> responseType) { String jsonResponse = sendRequest(url, Method.GET, params, null); try { ObjectMapper objectMapper = new ObjectMapper().setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); return objectMapper.readValue(jsonResponse, responseType); } catch (Exception e) { throw new CommonException("Failed to parse response:" + e.getMessage(), e); } } /** * 发送 POST JSON 请求 */ public String postJson(String url, String jsonBody) { return sendRequest(url, Method.POST, null, jsonBody); } /** * 发送 POST JSON 请求,支持对象自动转换为 JSON */ public String postJson(String url, Object body) { String jsonBody = JSONUtil.toJsonStr(body); return sendRequest(url, Method.POST, null, jsonBody); } /** * 发送 POST JSON 请求,返回指定类型 * * @author huanghwh * @date 2025/04/03 13:53 */ public <T> T postJson(String url, Object body, Class<T> responseType) { String jsonBody = JSONUtil.toJsonStr(body); String jsonResponse = sendRequest(url, Method.POST, null, jsonBody); try { ObjectMapper objectMapper = new ObjectMapper().setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); return objectMapper.readValue(jsonResponse, responseType); } catch (Exception e) { throw new CommonException("Failed to parse response:" + e.getMessage(), e); } } /** * 发送 POST 表单请求 */ public String postForm(String url, Map<String, Object> params) { return sendRequest(url, Method.POST, params, null); } /** * 发送 POST JSON 请求,返回指定类型 * * @author huanghwh * @date 2025/04/03 13:53 */ public <T> T postForm(String url, Map<String, Object> params, Class<T> responseType) { String jsonResponse = sendRequest(url, Method.POST, params, null);; try { ObjectMapper objectMapper = new ObjectMapper().setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); return objectMapper.readValue(jsonResponse, responseType); } catch (Exception e) { throw new CommonException("Failed to parse response:" + e.getMessage(), e); } } }
-
# 2025-04-02-解决servlet流只能读取一次的问题(拦截器验签)
1、问题场景分析
使用拦截器对接口传来的参数进行签名验证,校验数据完整性,为获取请求参数,需要调用
HttpServletRequest
的getInputStream
方法获取请求的入参,在拦截器执行完毕,需要进入controller时,发现无法正确接收参数。2、原因分析
经过查看spring源码可知,
request
的getInputStream
方法只允许调用一次,原因是因为底层获取参数时是使用下标记录当前的位置,若全部读取完毕后,下标无法回到原来的位置,request
使用usingReader
字段来记录是否读取过数据。public ServletInputStream getInputStream() throws IOException { if (this.usingReader) { throw new IllegalStateException(sm.getString("coyoteRequest.getInputStream.ise")); } else { this.usingInputStream = true; if (this.inputStream == null) { this.inputStream = new CoyoteInputStream(this.inputBuffer); } return this.inputStream; } }
3、问题解决
- 第一步:自定义实现
ServletRequestWrapper
包装类,缓存HttpServletRequest
的流数据 -
第二步:使用过滤器将后续传递的
HttpServletRequest
都替换为自定义包装类,后续拦截器可实现重复读取注意:
-
后续传递的实际都是
ServletRequestWrapper
,它是HttpServletRequest
的子类,所以可以正常使用HttpServletRequest
的所有方法,并且可以调用MyHttpServletRequestWrapper
自己扩展的方法。 -
在 Spring MVC 控制器方法中,
@RequestMapping
等注解的方法如果接收HttpServletRequest
参数,得到的也是MyHttpServletRequestWrapper
,因为整个请求链(过滤器 -> 拦截器 -> 控制器)中传递的都是这个包装后的对象。
-
1、实现
ServletRequestWrapper
无需修改,可直接复制使用
MyHttpServletRequestWrapper
缓存了HttpServletRequest
的流数据,之后直接从缓存中读取,可以做到无限读取。public class MyHttpServletRequestWrapper extends HttpServletRequestWrapper { /** * 缓存流 */ private byte[] cacheBody; /** * 构造方法 * * @param request * @throws IOException */ public MyHttpServletRequestWrapper(HttpServletRequest request) { super(request); } /** * 获取缓冲流 * * @return */ @Override public BufferedReader getReader() throws IOException { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } /** * 获取流 * * @return */ @Override public ServletInputStream getInputStream() throws IOException { // 1. 初始化缓存 if (cacheBody == null) { cacheBody = StreamUtils.copyToByteArray(super.getInputStream()); } // 3. 从缓存中返回流 final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(cacheBody); return new ServletInputStream() { @Override public boolean isFinished() { return false; } @Override public boolean isReady() { return false; } @Override public void setReadListener(ReadListener readListener) { } @Override public int read() throws IOException { return byteArrayInputStream.read(); } }; } }
2、定义过滤器
使用
MyHttpServletRequestWrapper
替代HttpServletRequest
传递下去@Component public class MyHttpServletRequestWrapperFilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { } /** * 对 request 进行包装 * * @param request * @param response * @param chain * @throws IOException * @throws ServletException */ @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // 使用MyHttpServletRequestWrapper替代ServletRequest传递下去 MyHttpServletRequestWrapper requestWrapper = new MyHttpServletRequestWrapper((HttpServletRequest) request); chain.doFilter(requestWrapper, response); } @Override public void destroy() { } }
4、拦截器获取流数据
直接调用
ServletInputStream inputStream = request.getInputStream();
获取数据@Slf4j @Component public class GlobalClientInterceptor implements HandlerInterceptor { // 允许的时间戳误差(5分钟) private static final long TIME_DIFF_LIMIT = 5 * 60 * 1000; @Resource private ProductService productService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String appId = request.getHeader("app-id"); String timestamp = request.getHeader("timestamp"); String signature = request.getHeader("signature"); // 检查必要的请求头参数 if (!StringUtils.hasText(appId) || !StringUtils.hasText(timestamp) || !StringUtils.hasText(signature)) { log.error("验签失败:参数缺失,appId:{}", appId); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Missing authentication headers"); return false; } // 校验时间戳,防止重放攻击 long requestTime; try { requestTime = Long.parseLong(timestamp); } catch (NumberFormatException e) { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.getWriter().write("Invalid timestamp format"); return false; } if (Math.abs(System.currentTimeMillis() - requestTime) > TIME_DIFF_LIMIT) { log.error("验签失败:服务器时间不一致,appId:{}", appId); log.error(String.valueOf(System.currentTimeMillis())); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Request expired"); return false; } // **计算 URL 参数哈希** String urlParams = getSortedQueryParams(request); // **计算 Body 哈希** ServletInputStream inputStream = request.getInputStream(); String bodyAsString = new String(inputStream.readAllBytes()); // 构造需要签名的数据 String dataToSign = appId + timestamp + urlParams + bodyAsString; ProductApp productApp = productService.getAppInfoByAppId(appId); String serverSignature = SM3Util.hmacSM3(dataToSign, productApp.getAppSecret()); // 校验签名 if (!serverSignature.equals(signature)) { log.error("验签失败:签名错误,appId:{}", appId); log.error(serverSignature); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Invalid signature"); return false; } // 初始化上下文变量 productApp.setAppSecret(null); AppContextLocal.set(productApp); //log.info("签名校验成功,appId:{}", appId); return true; } /** * 获取url参数,排序输出 * * @author huanghwh * @date 2025/04/02 16:53 */ public static String getSortedQueryParams(HttpServletRequest request) { Map<String, String[]> paramMap = request.getParameterMap(); if (paramMap == null || paramMap.isEmpty()) { // **空格占位,确保签名计算一致** return " "; } TreeMap<String, String> sortedParams = new TreeMap<>(); for (Map.Entry<String, String[]> entry : paramMap.entrySet()) { String key = entry.getKey(); // 多值用逗号拼接 String value = String.join(",", entry.getValue()); sortedParams.put(key, value); } return sortedParams.entrySet().stream() .map(entry -> entry.getKey() + "=" + entry.getValue()) .collect(Collectors.joining("&")); } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // **请求完成后清理 ThreadLocal** AppContextLocal.clear(); } }
拦截器注册
@Configuration public class WebConfig implements WebMvcConfigurer { @Resource private GlobalClientInterceptor globalClientInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(globalClientInterceptor) .addPathPatterns( "/c/**", "/client/c/**", "/auth/c/**", "/client/c/**", "/client/route" ).excludePathPatterns("/c/biz/memberOrder/callback/notify"); } }
↩
- 第一步:自定义实现
-
## 2.SM3签名工具 ↩