当前位置: 首页 > news >正文

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

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

  • 一、接口签名的作用与实现要点
    • 1. 为什么需要接口签名?
    • 2. 如何实现接口签名?
    • 3. 防止请求伪造
    • 4. 防止请求重放
  • 二、方案实战
    • 1. AccountController账户控制类
    • 2. 自定义request包装类:可重用的主体请求包装器
    • 3. 签名验证过滤器,用于校验请求的合法性
    • 4. 自定义生成签名的工具类
    • 5. SignTest签名校验测试类
  • 三、方案验证
    • 1. 利用SignTest签名校验测试类验证
    • 2. 利用postman验证
  • 四、项目结构及源码下载

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


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 请求体的字节数组*///参数字节数组,用于存储请求体的字节数据@Getterprivate 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错误*/@Overridepublic ServletInputStream getInputStream() throws IOException {/*** 每次调用此方法时将数据流中的数据读取出来,然后再回填到InputStream之中* 解决通过@RequestBody和@RequestParam(POST方式)读取一次后控制器拿不到参数问题*/// 仅当requestBody未初始化时,从请求中读取并存储到requestBodyif (null == this.requestBody) {ByteArrayOutputStream baos = new ByteArrayOutputStream();IOUtils.copy(request.getInputStream(), baos);this.requestBody = baos.toByteArray();}// 创建一个 ByteArrayInputStream 对象,用于重复读取requestBodyfinal ByteArrayInputStream bais = new ByteArrayInputStream(requestBody);return new ServletInputStream() {@Overridepublic boolean isFinished() {// 始终返回false,表示数据流未完成return false;}@Overridepublic boolean isReady() {// 始终返回false,表示数据流未准备好return false;}@Overridepublic void setReadListener(ReadListener listener) {// 不执行任何操作,因为该数据流不支持异步操作}@Overridepublic int read() {//从ByteArrayInputStream中读取数据return bais.read();}};}/*** 重写getReader方法,返回一个基于getInputStream的BufferedReader** @return 包含请求体数据的BufferedReader对象* @throws IOException 如果读取请求体时发生IO错误*/@Overridepublic BufferedReader getReader() throws IOException {// 基于getInputStream创建BufferedReaderreturn 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);@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)throws ServletException, IOException {// 对request进行包装,支持重复读取bodyReusableBodyRequestWrapper requestWrapper = new ReusableBodyRequestWrapper(request);// 校验签名if (this.verifySignature(requestWrapper, response)) {filterChain.doFilter(requestWrapper, response);}}@Autowiredprivate 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 {@Testpublic 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

相关文章:

  • 前端面经-VUE3篇(三)--vue Router(二)导航守卫、路由元信息、路由懒加载、动态路由
  • Java后端开发day40--异常File
  • 【QT】QT中http协议和json数据的解析-http获取天气预报
  • express 怎么搭建 WebSocket 服务器
  • Linux | 了解Linux中的任务调度---at与crontab 命令
  • 调试Cortex-M85 MCU启动汇编和链接命令文件 - 解题一则
  • 基于多策略混合改进哈里斯鹰算法的混合神经网络多输入单输出回归预测模型HPHHO-CNN-LSTM-Attention
  • 【AI提示词】黑天鹅模型专家
  • 如何提高情商?(优化版)
  • 【转载】【翻译】图解智能体到智能体 (A2A) 协议
  • org.apache.poi——将 office的各种类型文件(word等文件类型)转为 pdf
  • 14.Excel:排序和筛选
  • 19、权限控制:分院帽系统——React 19 RBAC实现
  • Kubernetes 安装 minikube
  • Day3:设置页面全局渐变线性渐变背景色uniapp壁纸实战
  • qmt下载的数据放在了哪里了?
  • 利用flask设计接口
  • Linux 的网络卡
  • 黑马点评day01(基于Redis)
  • C++ 多态:原理、实现与应用
  • 我驻旧金山总领事馆:黄石公园车祸中受伤同胞伤情稳定
  • 上海成五一国内最热门的入境游目的地,国际消费明显提升
  • 巴菲特股东大会十大金句:未来五年内可能有投资机会,快乐的人活得更久
  • 社区来电催生?多地回应:系为居民提供卫生健康服务
  • 贵州赤水一处岩体崩塌致4车受损,连夜抢修后已恢复通车
  • 南京大屠杀幸存者刘贵祥去世,享年95岁