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