SpringBoot接口安全设计:接口限流、防重放攻击与签名验证实战(附源码)

图片

在对外开放接口的过程中,如何确保接口不被恶意调用、数据不被篡改、请求不被重放,是每一个后端开发者都绕不过去的安全难题。尤其是在企业级对接中,签名校验机制已经成为“标配”。

今天我们就以一个 Spring Boot 项目为例,带大家深入了解如何设计并实现一套安全、通用、可落地的接口签名校验方案。

需要源码的,文末有源码的获取方式。

图片

,时长03:28

图片

一、为什么需要签名机制?

签名机制的本质是“你说你是你,那你得证明一下”。常见的安全风险包括:

  • 接口被恶意刷爆:攻击者伪造请求,不断调用接口,拖垮服务器;

  • 请求参数被篡改:中间人修改了请求内容;

  • 重放攻击:别人截获了一次有效请求,不断重发造成数据污染;

  • 敏感参数泄露:接口参数暴露,系统安全边界丧失。

通过签名校验,可以有效阻止上述行为,做到:

  • 鉴别调用者身份;

  • 验证数据完整性;

  • 阻止重复请求。

二、签名方案设计思路

签名机制核心是“对一组参数 + 密钥进行加密,服务器验签判断合法性”。

签名参数设计

  • appId:调用方身份标识(如客户编号)

  • timestamp:请求时间戳(防止重放)

  • nonce:随机字符串(防止重放)

  • sign:签名结果

签名算法流程

  1. 客户端发起请求时,将业务参数 + 公共参数(appId、timestamp、nonce)组成有序 Map;

  2. 将参数按 key 排序,拼接为 key=value 的形式;

  3. 在结尾追加 appSecret(只存于服务端)

  4. 对拼接结果进行 MD5 加密,生成 sign

  5. 服务器端收到请求后,从头信息中读取 appId 获取对应的 appSecret,按相同规则生成 serverSign

  6. 比对 sign 和 serverSign,一致则合法。

三、为什么不能在前端生成签名?

许多开发者会问:我能不能提供一个生成签名的接口给前端?前端先调签名接口拿 sign,再调业务接口?

这是非常危险的做法。

原因如下:

  • appSecret 会被泄露:任何放在前端的内容都不能称为“安全”,一旦暴露,就等于失去了身份认证的依据;

  • 签名服务被滥用:黑客可以利用签名接口批量获取签名,实施攻击;

  • 信任边界下沉:本该可信的“后端”签名逻辑被搬到前端,失去了安全控制力。

✅ 正确的做法是:

让对接方的 后端 负责签名,前端不参与签名流程。

四、重放攻击怎么防?

仅靠签名不能完全防止重放攻击,因此我们还需引入:

  • timestamp + 有效时间窗口(如5分钟):超时则拒绝;

  • nonce 去重机制:服务器端记录历史 nonce,若重复则拒绝;

对于单机部署,可以使用 Set<String> 缓存最近请求的 (appId + nonce) 组合,防止重复。

六、完整调用流程梳理

图片

  • 你对外提供接口文档 + 签名规则;

  • 客户的后端根据规则实现签名逻辑;

  • 客户前端调用自家后端,后端代为签名并调用你的接口;

  • 你服务器验签、返回结果。

七、如何实现这套机制?

项目使用 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@Componentpublic 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;
@Slf4jpublic 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 内容,实现对请求体的重复读取。

✍️ 实现步骤如下:
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();    }
    @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()));    }}

2. 添加过滤器将请求包装为 CachedBodyHttpServletRequest

@Beanpublic 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实现接口限流、防重放攻击与签名验证功能就完成了,需要源码的可以关注本公众号,回复“接口安全”获取。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值