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