SpringBoot接口安全设计-接口限流、防重放攻击与签名验证实战_springboot security接口签名
一、为什么需要签名机制?
签名机制的本质是“你说你是你,那你得证明一下”。常见的安全风险包括:
-
接口被恶意刷爆:攻击者伪造请求,不断调用接口,拖垮服务器;
-
请求参数被篡改:中间人修改了请求内容;
-
重放攻击:别人截获了一次有效请求,不断重发造成数据污染;
-
敏感参数泄露:接口参数暴露,系统安全边界丧失。
通过签名校验,可以有效阻止上述行为,做到:
-
鉴别调用者身份;
-
验证数据完整性;
-
阻止重复请求。
二、签名方案设计思路
签名机制核心是“对一组参数 + 密钥进行加密,服务器验签判断合法性”。
签名参数设计
-
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 缓存最近请求的 (appId + nonce) 组合,防止重复。
五、完整调用流程梳理
-
你对外提供接口文档 + 签名规则;
-
客户的后端根据规则实现签名逻辑;
-
客户前端调用自家后端,后端代为签名并调用你的接口;
-
你服务器验签、返回结果。
六、如何实现这套机制?
项目使用 Spring Boot + 拦截器 + 注解 的组合方式实现,核心功能包括:
-
自动拦截指定接口;
-
校验头参数合法性;
-
校验签名;
-
防止重放;
-
支持
@RequestBodyJSON 参数读取并参与签名。
七、部分核心代码
拦截器
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@Componentpublic class ApiSecurityInterceptor implements HandlerInterceptor {@Autowiredprivate AppKeyService appKeyService;private final ObjectMapper objectMapper = new ObjectMapper();@Autowiredprivate RedisTemplate redisTemplate;@Autowiredprivate 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 自动过期机制@Overridepublic 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 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 collectAllParams(CachedBodyHttpServletRequest request) {Map map = new HashMap ();try {// 获取请求体内容String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);if (StringUtils.hasText(body)) {if (StringUtils.hasText(body)) {try {Map json = objectMapper.readValue(body, Map.class);for (Map.Entry 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 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;@Slf4jpublic class SignUtils {public static String sign(Map params, String secret) {TreeMap sorted = new TreeMap (params);StringBuilder sb = new StringBuilder();for (Map.Entry 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 内容,实现对请求体的重复读取。
✍️ 实现步骤如下:
1. 创建 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();}@Overridepublic 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) {}};}@Overridepublic BufferedReader getReader() {return new BufferedReader(new InputStreamReader(this.getInputStream()));}}
2. 添加过滤器将请求包装为 CachedBodyHttpServletRequest
@Bean
public Filter requestWrapperFilter() {return new Filter() {@Overridepublic 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 collectAllParams(CachedBodyHttpServletRequest request) {Map map = new HashMap ();try {// 获取请求体内容String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);if (StringUtils.hasText(body)) {if (StringUtils.hasText(body)) {try {Map json = objectMapper.readValue(body, Map.class);for (Map.Entry 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 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实现接口限流、防重放攻击与签名验证功能就完成了。


