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

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

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

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

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

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

鉴别调用者身份;

验证数据完整性;

阻止重复请求。

二、签名方案设计思路

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

签名参数设计
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 + 拦截器 + 注解 的组合方式实现,核心功能包括:

自动拦截指定接口;

校验头参数合法性;

校验签名;

防止重放;

支持 @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 内容,实现对请求体的重复读取。

✍️ 实现步骤如下:

  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()));
    }
}
  1. 添加过滤器将请求包装为 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实现接口限流、防重放攻击与签名验证功能就完成了,需要源码的可以关注本公众号,回复“接口安全”获取。

文档更新时间: 2025-05-31 08:17   作者:admin