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