SpringBoot中,接口签名,通用方案,以确保接口的安全性

1. 为什么需要接口签名?

  • 接口签名目的:防止第三方伪造请求。
  • 请求伪造:未经授权的第三方构造合法用户的请求来执行不希望的操作。
  • 转账接口示例:展示了如果接口没有安全措施,第三方可以轻易伪造请求,例如将资金从一个账户转移到另一个账户。

2. 如何实现接口签名?

  • 引入密钥:接口调用方和服务提供方之间共享一个密钥(secretKey),此密钥必须保密。
  • 签名算法:使用密钥和请求体的内容通过MD5算法生成签名。
  • 携带签名:客户端在请求头中附加生成的签名。
  • 服务端校验:服务器接收到请求后,使用同样的算法和密钥重新计算签名并与请求中提供的签名比较,如果不一致,则拒绝请求。

3. 防止请求伪造

  • 请求伪造解决办法:通过接口签名机制,第三方不知道密钥,因此无法正确生成匹配的签名,请求会被服务器拒绝。

4. 防止请求重放

  • 请求重放定义:攻击者截获合法请求后重新发送以达到重复执行的效果。
  • 解决请求重放的办法:引入随机字符串(nonce)和时间戳(timestamp)。nonce用来确保每个请求只能被使用一次,存储在Redis中并在一段时间后过期;时间戳用来限制请求的有效时间范围。





具体实现

1 整合springboot+redis环境

2 pom.xml

复制代码
<dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.2</version>
</dependency>

3 yml配置

复制代码
//redis 相关的配置省略了

//秘钥,要保密
secret-key: b0e8668b-bcf2-4d73-abd4-893bbc1c6079

4 类ReusableBodyRequestWrapper,该类用于包装HttpServletRequest,以便在读取请求体后仍可重复读取

复制代码
import org.apache.tomcat.util.http.fileupload.IOUtils;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.*;

/**
 * 该类用于包装HttpServletRequest,以便在读取请求体后仍可重复读取
 */
public class ReusableBodyRequestWrapper extends HttpServletRequestWrapper {

    //参数字节数组,用于存储请求体的字节数据
    private byte[] requestBody;

    //Http请求对象
    private HttpServletRequest request;
    
    /**
     * 构造函数,初始化包装类
     * @param request 原始HttpServletRequest对象
     * @throws IOException 如果读取请求体时发生IO错误
     */
    public ReusableBodyRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        this.request = request;
    }
    
    /**
     * 重写getInputStream方法,实现请求体的重复读取
     * @return 包含请求体数据的ServletInputStream对象
     * @throws IOException 如果读取请求体时发生IO错误
     */
    @Override
    public ServletInputStream getInputStream() throws IOException {
        /**
         * 每次调用此方法时将数据流中的数据读取出来,然后再回填到InputStream之中
         * 解决通过@RequestBody和@RequestParam(POST方式)读取一次后控制器拿不到参数问题
         */
        //仅当requestBody未初始化时,从请求中读取并存储到requestBody
        if (null == this.requestBody) {
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            IOUtils.copy(request.getInputStream(), baos);
            this.requestBody = baos.toByteArray();
        }
        //创建一个 ByteArrayInputStream 对象,用于重复读取requestBody
        final ByteArrayInputStream bais = new ByteArrayInputStream(requestBody);
        return new ServletInputStream() {

            @Override
            public boolean isFinished() {
                //始终返回false,表示数据流未完成
                return false;
            }

            @Override
            public boolean isReady() {
                //始终返回false,表示数据流未准备好
                return false;
            }

            @Override
            public void setReadListener(ReadListener listener) {
                //不执行任何操作,因为该数据流不支持异步操作
            }

            @Override
            public int read() {
                //从ByteArrayInputStream中读取数据
                return bais.read();
            }
        };
    }
    
    /**
     * 获取请求体的字节数组
     * @return 请求体的字节数组
     */
    public byte[] getRequestBody() {
        return requestBody;
    }
    
    /**
     * 重写getReader方法,返回一个基于getInputStream的BufferedReader
     * @return 包含请求体数据的BufferedReader对象
     * @throws IOException 如果读取请求体时发生IO错误
     */
    @Override
    public BufferedReader getReader() throws IOException {
        //基于getInputStream创建BufferedReader
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }
}

5 SignatureVerificationFilter类,签名验证过滤器,用于校验请求的合法性

复制代码
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.json.JSONUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;

/**
 * 签名验证过滤器,用于校验请求的合法性
 */
@Order(Ordered.HIGHEST_PRECEDENCE)
@WebFilter(urlPatterns = "/**", filterName = "SignatureVerificationFilter")
@Component
public class SignatureVerificationFilter extends OncePerRequestFilter {
    public static Logger logger = LoggerFactory.getLogger(SignatureVerificationFilter.class);
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 对request进行包装,支持重复读取body
        ReusableBodyRequestWrapper requestWrapper = new ReusableBodyRequestWrapper(request);
        // 校验签名
        if (this.verifySignature(requestWrapper, response)) {
            filterChain.doFilter(requestWrapper, response);
        }
    }
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    // 签名秘钥
    @Value("${secret-key}")
    private String secretKey;
    
    /**
     * 校验签名
     *
     * @param request  HTTP请求
     * @param response HTTP响应
     * @return 签名验证结果
     * @throws IOException 如果读取请求体失败
     */
    public boolean verifySignature(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 签名
        String sign = request.getHeader("X-Sign");
        // 随机数
        String nonce = request.getHeader("X-Nonce");
        // 时间戳
        String timestampStr = request.getHeader("X-Timestamp");
        if (!StringUtils.hasText(sign) || !StringUtils.hasText(nonce) || !StringUtils.hasText(timestampStr)) {
            this.write(response, "参数错误");
            return false;
        }
        
        // timestamp 10分钟内有效
        long timestamp = Long.parseLong(timestampStr);
        long currentTimestamp = System.currentTimeMillis() / 1000;
        if (Math.abs(currentTimestamp - timestamp) > 600) {
            this.write(response, "请求已过期");
            return false;
        }
        
        // 防止请求重放,nonce只能用一次,放在redis中,有效期 20分钟
        String nonceKey = "SignatureVerificationFilter:nonce:" + nonce;
        if (!this.redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", 20, TimeUnit.MINUTES)) {
            this.write(response, "nonce无效");
            return false;
        }
        
        // 请求体
        String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
        // 需要签名的数据:secretKey+noce+timestampStr+body
        // 校验签名
        String data = String.format("%s%s%s%s", this.secretKey, nonce, timestampStr, body);
        if (!DigestUtil.md5Hex(data).equals(sign)) {
            write(response, "签名有误");
            return false;
        }
        return true;
    }
    
    /**
     * 向客户端写入响应信息
     *
     * @param response HTTP响应
     * @param msg      响应信息
     * @throws IOException 如果写入失败
     */
    private void write(HttpServletResponse response, String msg) throws IOException {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.getWriter().write(JSONUtil.toJsonStr(msg));
    }
}

6 自己写的一个生成签名的工具类,可选项,因为在实现中,应该是前台传参或代码里写的,这是只是方便测试调度调试

复制代码
import cn.hutool.crypto.digest.DigestUtil;
import org.springframework.util.StringUtils;
import java.util.UUID;

public class SignatureUtil {
    
    /**
     * 生成签名
     *
     * @param body      请求体
     * @param secretKey 密钥
     * @param nonce     随机数
     * @param timestamp 时间戳
     * @return 签名
     */
    public static String generateSignature(String body, String secretKey, String nonce, String timestamp) {
        if (!StringUtils.hasText(body) || !StringUtils.hasText(secretKey) || !StringUtils.hasText(nonce) || !StringUtils.hasText(timestamp)) {
            throw new IllegalArgumentException("参数不能为空");
        }
        
        // 按照 secretKey + nonce + timestamp + body 的顺序拼接字符串
        String data = String.format("%s%s%s%s", secretKey, nonce, timestamp, body);
        System.out.println("data = " + data);
        
        // 使用MD5算法计算签名
        String sign = DigestUtil.md5Hex(data);
        
        return sign;
    }
    
    public static void main(String[] args) {
        // 示例参数
        String body = "{\n" +
                "  \"fromAccountId\": \"张三\",\n" +
                "  \"toAccountId\": \"李四\",\n" +
                "  \"transferPrice\": 100\n" +
                "}";
        
        //秘钥
        String secretKey = "b0e8668b-bcf2-4d73-abd4-893bbc1c6079";
        // 随机数
        String nonce = UUID.randomUUID().toString().replace("-", "");
        // 时间戳
        long timestamp = System.currentTimeMillis() / 1000;
        
        // 生成签名
        String sign = generateSignature(body, secretKey, nonce, String.valueOf(timestamp));
        
        // 输出生成的签名
        System.out.println("X-Sign: " + sign);
        System.out.println("X-Nonce: " + nonce);
        System.out.println("X-Timestamp: " + timestamp);
    }
}

7 写一个接口,用于调用

复制代码
import lombok.*;
import java.math.BigDecimal;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TransferRequest {
    //付款人账户id
    private String fromAccountId;
    //收款人账号id
    private String toAccountId;
    //转账金额
    private BigDecimal transferPrice;
}

///

import cn.hutool.json.JSONUtil;
import com.example.demo_26.dto.TransferRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Slf4j
public class AccountController {
    @RequestMapping("/account/transfer")
    public Object transfer(@RequestBody TransferRequest request) {
        log.info("转账成功:{}", JSONUtil.toJsonStr(request));
        return "转账成功";
    }
}

8 测试
9 最后的效果,只能发一次请求,重复发送请求,就会失败,需要用新的随机数,时间戳,生成新的签名才可以

相关推荐
程序员黑豆7 小时前
AI全栈开发 - Java:基本数据类型 vs 引用数据类型的内存存储
java·前端·ai编程
道友可好7 小时前
AI 测试全绿,代码却是错的
前端·人工智能·后端
布朗克1687 小时前
34 JVM深入理解
java·jvm
Flittly7 小时前
【AgentScope Java新手村系列】(4)结构化输出
java·spring boot·spring·ai
techdashen7 小时前
Rust 基础设施团队 2025 Q4 回顾与 2026 Q1 计划
开发语言·后端·rust
何以解忧,唯有..7 小时前
Python 中的继承机制:从基础到高级用法详解
java·开发语言·python
Yiyaoshujuku8 小时前
化合物数据集API接口(数据结构及样例)
java·网络·数据结构
神奇小汤圆8 小时前
互联网大厂精选面试八股文(附2026最新Java+AI高频题)| 建议收藏
后端
春天花会开1318 小时前
影像上传前置机网络架构设计模板(含VPN)
后端·架构
程序员cxuan8 小时前
Fable 5 的系统提示词被人扒出来了,精彩,太精彩了。
人工智能·后端·程序员