SpringBoot中接口签名防止接口重放

一、接口签名的作用与实现要点


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

  • 目的:防止请求伪造。
  • 伪造风险:第三方可模拟合法请求,执行敏感操作(如转账)。
  • 签名作用:确保请求来源真实、数据未被篡改。

2. 如何实现接口签名?

  • 共享密钥:客户端与服务端约定一个保密的 secretKey。
  • 生成签名:使用 secretKey + 请求体 + 其他参数(如 nonce、timestamp)通过安全算法(如 HMAC-MD5)生成签名。
  • 传输签名:将签名放入请求头中发送。
  • 服务端验证:服务端按相同规则重新计算签名,比对一致性。

3. 防止请求伪造

  • 原理:攻击者无法获取 secretKey,无法生成有效签名,请求被拒绝。

4. 防止请求重放

  • 定义:攻击者截获并重复发送旧请求,冒充合法用户。
  • 解决方法
    • 使用唯一随机值 nonce,服务端记录已使用的 nonce(如 Redis),防止重复使用。
    • 引入 timestamp,限定请求在时间窗口内有效(如 5 分钟)。

二、方案实战

1. AccountController账户控制类

@RestController
@Slf4j
public class AccountController {
    @RequestMapping("/account/transfer")
    public ResponseResult<String> transfer(@RequestBody TransferAccount request) {
        log.info("转账成功:{}", JSONUtil.toJsonStr(request));
        return ResponseResult.success("转账成功");
    }
}

2. 自定义request包装类:可重用的主体请求包装器

public class ReusableBodyRequestWrapper extends HttpServletRequestWrapper {

    /**
     * -- GETTER --
     *  获取请求体的字节数组
     *
     * @return 请求体的字节数组
     */
    //参数字节数组,用于存储请求体的字节数据
    @Getter
    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();
            }
        };
    }

    /**
     * 重写getReader方法,返回一个基于getInputStream的BufferedReader
     *
     * @return 包含请求体数据的BufferedReader对象
     * @throws IOException 如果读取请求体时发生IO错误
     */
    @Override
    public BufferedReader getReader() throws IOException {
        // 基于getInputStream创建BufferedReader
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }
}

3. 签名验证过滤器,用于校验请求的合法性

由于采用采用application/json传输参数时HttpServletRequest只能读取一次 body 中的内容。因为是读的字节流,读完就没了,因此需要需要做特殊处理。
为实现述多次读取 Request 中的 Body 内容,需继承HttpServletRequestWrapper 类,读取 Body 的内容,然后缓存到 byte[] 中,这样就可以实现多次读取 Body 的内容了。

@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, "参数错误");
            logger.error("参数错误");
            return false;
        }

        // timestamp 10分钟内有效
        long timestamp = Long.parseLong(timestampStr);
        long currentTimestamp = System.currentTimeMillis() / 1000;
        if (Math.abs(currentTimestamp - timestamp) > 600) {
            // 将服务器当前时间和前段传递的X-Timestamp对比,校验在10分钟内有效
            this.write(response, "请求已过期");
            logger.error("请求已过期");
            return false;
        }

        // 防止请求重放(即防止别人拿到接口数据再次执行),后端确保nonce只能用一次,放在redis中,有效期 20分钟
        String nonceKey = "SignatureVerificationFilter:nonce:" + nonce;
        if (Boolean.FALSE.equals(this.redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", 20, TimeUnit.MINUTES))) {
            this.write(response, "nonce无效");
            logger.error("nonce无效");
            return false;
        }
        // 通过X-Nonce和X-Timestamp的搭配使用,防止请求重放
        // 假如10分钟内请求被重放,由于nonc在redis中的有效时间为20分钟,在后端被拦截,发现已经被使用,则第二次请求会失败
        // 假如20分钟后请求被重放,由于timeStamp时间戳有效时间为10分钟,在后端发现时间戳已经过期,则则第二次请求会失败

        // 请求体
        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, "签名有误");
            logger.error("签名有误");
            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));
    }
}

4. 自定义生成签名的工具类

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算法计算签名
        return DigestUtil.md5Hex(data);
    }

    public static void main(String[] args) {
        // 示例参数
        String body = "{\n" + "  \"fromAccountId\": \"孙悟空\",\n" + "  \"toAccountId\": \"猪八戒\",\n"
            + "  \"transferPrice\": 1000\n" + "}";

        // 秘钥
        String secretKey = "test-hua8-83xj-dhd8-xdj8";
        // 随机数
        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);
    }
}

5. SignTest签名校验测试类

public class SignTest {
    @Test
    public void transferTest(){
        RestTemplate restTemplate = new RestTemplate();
        TransferAccount transferRequest = new TransferAccount("孙悟空", "猪八戒", new BigDecimal(1000));
        String body = JSONUtil.toJsonStr(transferRequest);
        // 秘钥
        String secretKey = "test-hua8-83xj-dhd8-xdj8";
        // 随机数
        String nonce = UUID.randomUUID().toString().replace("-", "");
        // 时间戳
        long timestamp = System.currentTimeMillis() / 1000;

        // 生成签名
        String sign = SignatureUtil.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);
        RequestEntity<String> request = RequestEntity.post(URI.create("http://localhost:8080/account/transfer"))
            .contentType(MediaType.APPLICATION_JSON)
            .header("X-Sign", sign)
            .header("X-Nonce", nonce)
            .header("X-Timestamp", String.valueOf(timestamp))
            .body(body);
        // 发送请求
        ResponseEntity<String> responseEntity = restTemplate.exchange(request, String.class);

        // 打印响应结果(用于调试)
        System.out.println("Response: " + responseEntity.getBody());
    }
}

三、方案验证

先启动主服务,然后启动单元测试类

1. 利用SignTest签名校验测试类验证

若secretKey正确时,发现接口能够正常进行签名校验。
在这里插入图片描述
若secretKey不正确时,发现接口不能够正常进行签名校验。
在这里插入图片描述

2. 利用postman验证

先利用SignatureUtil工具类生成X-Sign、X-Nonce、X-Timestamp,然后填充为header。
在这里插入图片描述
在这里插入图片描述

四、项目结构及源码下载

在这里插入图片描述
源码地址,欢迎Star: multi-datasource

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值