一、为什么需要签名机制?
签名机制的本质是“你说你是你,那你得证明一下”。常见的安全风险包括:
接口被恶意刷爆:攻击者伪造请求,不断调用接口,拖垮服务器;
请求参数被篡改:中间人修改了请求内容;
重放攻击:别人截获了一次有效请求,不断重发造成数据污染;
敏感参数泄露:接口参数暴露,系统安全边界丧失。
通过签名校验,可以有效阻止上述行为,做到:
鉴别调用者身份;
验证数据完整性;
阻止重复请求。
二、签名方案设计思路
签名机制核心是“对一组参数 + 密钥进行加密,服务器验签判断合法性”。
签名参数设计
appId:调用方身份标识(如客户编号)
timestamp:请求时间戳(防止重放)
nonce:随机字符串(防止重放)
sign:签名结果
签名算法流程
客户端发起请求时,将业务参数 + 公共参数(appId、timestamp、nonce)组成有序 Map;
将参数按 key 排序,拼接为 key=value 的形式;
在结尾追加 appSecret(只存于服务端);
对拼接结果进行 MD5 加密,生成 sign;
服务器端收到请求后,从头信息中读取 appId 获取对应的 appSecret,按相同规则生成 serverSign;
比对 sign 和 serverSign,一致则合法。
三、为什么不能在前端生成签名?
许多开发者会问:我能不能提供一个生成签名的接口给前端?前端先调签名接口拿 sign,再调业务接口?
这是非常危险的做法。
原因如下:
appSecret 会被泄露:任何放在前端的内容都不能称为“安全”,一旦暴露,就等于失去了身份认证的依据;
签名服务被滥用:黑客可以利用签名接口批量获取签名,实施攻击;
信任边界下沉:本该可信的“后端”签名逻辑被搬到前端,失去了安全控制力。
✅ 正确的做法是:
让对接方的 后端 负责签名,前端不参与签名流程。
四、重放攻击怎么防?
仅靠签名不能完全防止重放攻击,因此我们还需引入:
timestamp + 有效时间窗口(如5分钟):超时则拒绝;
nonce 去重机制:服务器端记录历史 nonce,若重复则拒绝;
对于单机部署,可以使用 Set
六、完整调用流程梳理
你对外提供接口文档 + 签名规则;
客户的后端根据规则实现签名逻辑;
客户前端调用自家后端,后端代为签名并调用你的接口;
你服务器验签、返回结果。
七、如何实现这套机制?
项目使用 Spring Boot + 拦截器 + 注解 的组合方式实现,核心功能包括:
自动拦截指定接口;
校验头参数合法性;
校验签名;
防止重放;
支持 @RequestBody JSON 参数读取并参与签名。
八、部分核心代码
拦截器
package com.dream.interceptor;
import com.dream.annotation.OnlyQuery;
import com.dream.exception.ApiException;
import com.dream.service.AppKeyService;
import com.dream.utils.SignUtils;
import com.dream.wrapper.CachedBodyHttpServletRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.dream.enums.ErrorEnums.*;
@Slf4j
@Component
public class ApiSecurityInterceptor implements HandlerInterceptor {
@Autowired
private AppKeyService appKeyService;
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private RedisTemplate < String, Object > redisTemplate;
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 接口请求有效期
*/
private static final long EXPIRATION_TIME_MILLIS = 5 * 60 * 1000;
/**
* 限流请求时间,单位秒
* 默认10秒
*/
private static final long RATE_LIMIT_TIME_MILLIS = 10;
/**
* 单位时间内允许的请求次数
*/
private static final int RATE_LIMIT_COUNT = 5;
// 删除原有的 init 方法,使用 Redis 自动过期机制
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
CachedBodyHttpServletRequest cachedRequest = (CachedBodyHttpServletRequest) request;
// 获取并规范化请求路径
String requestURI = request.getRequestURI();
String normalizedUri = normalizePath(requestURI);
// 接口限流校验
String repeatKey = "rate_limit:" + normalizedUri + ":" + getClientIP(request);
Long count = stringRedisTemplate.opsForValue().increment(repeatKey);
if (count != null && count == 1) {
stringRedisTemplate.expire(repeatKey, RATE_LIMIT_TIME_MILLIS, TimeUnit.SECONDS);
}
if (count != null && count > RATE_LIMIT_COUNT) {
throw new ApiException(LIMIT_ERROR.getCode(), LIMIT_ERROR.getMessage());
}
// 检查处理器方法是否带有 OnlyQuery 注解,如果带有该注解,则不进行签名校验
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
OnlyQuery onlyQuery = handlerMethod.getMethodAnnotation(OnlyQuery.class);
if (onlyQuery != null) {
return true;
}
}
String appId = request.getHeader("appId");
String timestamp = request.getHeader("timestamp");
String nonce = request.getHeader("nonce");
String sign = request.getHeader("sign");
// 接口参数校验
if (!StringUtils.hasText(appId) || !StringUtils.hasText(timestamp) ||
!StringUtils.hasText(nonce) || !StringUtils.hasText(sign)) {
throw new ApiException(SING_MISS_PARAM_ERROR.getCode(), SING_MISS_PARAM_ERROR.getMessage());
}
// appId校验
String secret = appKeyService.getSecretByAppId(appId);
if (secret == null) {
throw new ApiException(SING_APPID_ERROR.getCode(), SING_APPID_ERROR.getMessage());
}
// 请求时间过期校验
long now = System.currentTimeMillis();
long ts = Long.parseLong(timestamp);
if (Math.abs(now - ts) > EXPIRATION_TIME_MILLIS) {
throw new ApiException(EXPIRATION_TIME_ERROR.getCode(), EXPIRATION_TIME_ERROR.getMessage());
}
Map < String, String > params = collectAllParams(cachedRequest);
// 重复请求校验
String key = appId + ":" + nonce + ":" + SignUtils.md5(params.toString());
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, now, EXPIRATION_TIME_MILLIS,
TimeUnit.MILLISECONDS);
if (result == null || !result) {
throw new ApiException(REPEAT_ERROR.getCode(), REPEAT_ERROR.getMessage());
}
params.put("appId", appId);
params.put("timestamp", timestamp);
params.put("nonce", nonce);
// 签名校验
String serverSign = SignUtils.sign(params, secret);
if (!serverSign.equalsIgnoreCase(sign)) {
throw new ApiException(SIGN_ERROR.getCode(), SIGN_ERROR.getMessage());
}
return true;
}
private Map < String, String > collectAllParams(CachedBodyHttpServletRequest request) {
Map < String, String > map = new HashMap < > ();
try {
// 获取请求体内容
String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
if (StringUtils.hasText(body)) {
if (StringUtils.hasText(body)) {
try {
Map < String, Object > json = objectMapper.readValue(body, Map.class);
for (Map.Entry < String, Object > entry: json.entrySet()) {
map.put(entry.getKey(), entry.getValue() == null ? "" : entry.getValue().toString());
}
} catch (Exception e) {
log.error("Failed to parse request body: {}", body, e);
throw new ApiException(REQUEST_BODY_ERROR.getCode(), REQUEST_BODY_ERROR.getMessage());
}
}
} else {
// 如果没有请求体,则获取URL参数
Enumeration < String > parameterNames = request.getParameterNames();
while (parameterNames.hasMoreElements()) {
String name = parameterNames.nextElement();
String value = request.getParameter(name);
map.put(name, value);
}
}
} catch (Exception e) {
log.error("Failed to read request body", e);
throw new ApiException(REQUEST_BODY_ERROR.getCode(), REQUEST_BODY_ERROR.getMessage());
}
return map;
}
private String getClientIP(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
return (ip == null || ip.isEmpty()) ? request.getRemoteAddr() : ip.split(",")[0];
}
private String normalizePath(String path) {
// 移除重复的斜杠
String normalized = path.replaceAll("/+", "/");
// 确保路径以单个斜杠开头
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
// 移除末尾的斜杠(除非是根路径)
if (normalized.length() > 1 && normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
}
验签工具类
package com.dream.utils;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.TreeMap;
@Slf4j
public class SignUtils {
public static String sign(Map < String, String > params, String secret) {
TreeMap < String, String > sorted = new TreeMap < > (params);
StringBuilder sb = new StringBuilder();
for (Map.Entry < String, String > entry: sorted.entrySet()) {
if (entry.getValue() != null) {
sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
}
}
sb.append("appSecret=").append(secret);
log.info("sign:{}", sb.toString());
return md5(sb.toString());
}
public static String md5(String data) {
try {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
byte[] array = md.digest(data.getBytes("UTF-8"));
StringBuilder sb = new StringBuilder();
for (byte b: array) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException("MD5 error", e);
}
}
}
九、支持@RequestParam与@RequestBody两种方式统一参与签名
在实际开发中,你的接口参数可能会有两种常见的接收方式:
方式一:通过 @RequestParam 接收 URL 或 form 表单参数
方式二:通过 @RequestBody 接收 JSON 参数体
为了保证签名逻辑的统一性,我们需要同时收集两种参数用于签名,并且保持前后端拼接顺序一致。
🧩 问题:RequestBody 流只能读取一次
Spring 中的 HttpServletRequest.getInputStream() 默认只能读取一次,如果你在拦截器中读取了 body 内容用于签名校验,那么后续 Controller 将无法再次读取,会报类似如下错误:
Required request body is missing: public com.dream.wrap.R com.dream.controller.DemoController.submitBody(com.dream.model.SubmitBody)]
✅ 解决方案:使用 CachedBodyHttpServletRequest 包装请求
通过自定义过滤器,在请求进入 Spring 容器前缓存 body 内容,实现对请求体的重复读取。
✍️ 实现步骤如下:
- 创建 CachedBodyHttpServletRequest
package com.dream.wrapper;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.*;
public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
private final byte[] cachedBody;
public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
InputStream requestInputStream = request.getInputStream();
this.cachedBody = requestInputStream.readAllBytes();
}
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody);
return new ServletInputStream() {
public int read() {
return byteArrayInputStream.read();
}
public boolean isFinished() {
return byteArrayInputStream.available() == 0;
}
public boolean isReady() {
return true;
}
public void setReadListener(ReadListener readListener) {}
};
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
}
- 添加过滤器将请求包装为 CachedBodyHttpServletRequest
@Bean
public Filter requestWrapperFilter() {
return new Filter() {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException,
ServletException {
if (request instanceof HttpServletRequest) {
CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest((
HttpServletRequest) request);
chain.doFilter(cachedRequest, response);
} else {
chain.doFilter(request, response);
}
}
};
}
3. 签名参数获取方式
private Map < String, String > collectAllParams(CachedBodyHttpServletRequest request) {
Map < String, String > map = new HashMap < > ();
try {
// 获取请求体内容
String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
if (StringUtils.hasText(body)) {
if (StringUtils.hasText(body)) {
try {
Map < String, Object > json = objectMapper.readValue(body, Map.class);
for (Map.Entry < String, Object > entry: json.entrySet()) {
map.put(entry.getKey(), entry.getValue() == null ? "" : entry.getValue().toString());
}
} catch (Exception e) {
log.error("Failed to parse request body: {}", body, e);
throw new ApiException(REQUEST_BODY_ERROR.getCode(), REQUEST_BODY_ERROR.getMessage());
}
}
} else {
// 如果没有请求体,则获取URL参数
Enumeration < String > parameterNames = request.getParameterNames();
while (parameterNames.hasMoreElements()) {
String name = parameterNames.nextElement();
String value = request.getParameter(name);
map.put(name, value);
}
}
} catch (Exception e) {
log.error("Failed to read request body", e);
throw new ApiException(REQUEST_BODY_ERROR.getCode(), REQUEST_BODY_ERROR.getMessage());
}
return map;
}
最终将 map 排序并拼接用于签名,即可支持 param 和 body 混合的接口请求。
至此,SpringBoot实现接口限流、防重放攻击与签名验证功能就完成了,需要源码的可以关注本公众号,回复“接口安全”获取。