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 最后的效果,只能发一次请求,重复发送请求,就会失败,需要用新的随机数,时间戳,生成新的签名才可以

相关推荐
山山而川粤1 小时前
母婴用品系统|Java|SSM|JSP|
java·开发语言·后端·学习·mysql
m0_748235072 小时前
SpringBoot集成kafka
spring boot·kafka·linq
字节流动3 小时前
Android Java 版本的 MSAA OpenGL ES 多重采样
android·java·opengles
玉红7773 小时前
R语言的数据类型
开发语言·后端·golang
呜呼~225144 小时前
前后端数据交互
java·vue.js·spring boot·前端框架·intellij-idea·交互·css3
飞的肖4 小时前
从测试服务器手动热部署到生产环境的实现
java·服务器·系统架构
周伯通*4 小时前
策略模式以及优化
java·前端·策略模式
两点王爷4 小时前
Java读取csv文件内容,保存到sqlite数据库中
java·数据库·sqlite·csv
lvbu_2024war015 小时前
MATLAB语言的网络编程
开发语言·后端·golang
问道飞鱼5 小时前
【Springboot知识】Springboot进阶-实现CAS完整流程
java·spring boot·后端·cas