1. 背景
出海的业务当中我们会对接很多的商户,有时候我们会提供一个接口给第三方OTA,比如提供接口给第三方OTA用于对方给我们推送可以结算的订单,那如何保证我们接口的安全性是一个核心的问题
2. 一个例子说明保证接口安全的必要性和重要性
现在有个充值的接口,调用后可以给用户增加对应的余额, http://localhost/api/user/recharge?user_id=1001&amount=10
会带来的可能问题:
-
如果非法用户通过抓包获取到接口参数后,修改user_id 或 amount的值就可以实现给任意账户添加余额的目的
-
非法用户获取到这个请求的信息之后什么也不改,,直接拿着接口的参数
重复请求这个充值的接口。
基于上面的理解就是两个概念:防篡改和防重放
3. 解决方案
3.1 https协议传输
http协议是无状态的协议,明文传输的, 服务端无法确定客户端发送的请求是否合法,也不了解请求中的参数是否正确
采用https协议可以将传输的明文进行加密,但是黑客仍然可以截获传输的数据包,进一步伪造请求进行重放攻击。如 果黑客使用特殊手段让请求方设备使用了伪造的证书进行通信,那么https加密的内容也会被解密。防证书伪造可以采用:客户端加签, 服务端验签
3.1 加签验签
整体流程:
- 服务方提供一组AppId和appSecret,由客户端保存
- 将timestamp、nonce、appId与请求参数一起按照字典排序,使用url键值对的格式拼接成字符串strA
- 在strA的最后拼接上appSecret,得到strB
- 使用摘要算法对strB进行加密,得到签名sign,和其他参数一起发给服务端
- 服务端收到请求后,对接口进行校验
- appId 参与本地加密和网络传输,appSecret仅仅作为本地加密使用,不参与网络传输,服务端拿到appId后,从存储介质中获取appSecret,然后采用与客户端相同的签名规则生成服务端签名,最后比较客户端和服务端的签名是否一致
服务端校验的步骤:
- 请求时间校验: 判断请求时间是否已经超过允许范围
- nonce校验: 判断nonce请求是否已经处理过,一般借助redis实现
- 签名校验: 服务端收到请求,根据约定的规则重新签名得到sign
4.完整的代码 Talk is cheap. Show me the code
服务端代码: sourcecode: test_springboot, branch: 接口安全性校验
主要filter:
public class SignFilter implements Filter {
@Resource
private RedisUtil redisUtil;
//从fitler配置中获取sign过期时间
private Long signMaxTime;
private static final String NONCE_KEY = "x-nonce-";
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
System.out.println(httpRequest.getRequestURI());
HttpServletRequestWrapper requestWrapper = new SignRequestWrapper(httpRequest);
//构建请求头
ClientRequestHeader clientRequestHeader = new ClientRequestHeader();
clientRequestHeader.setNonce(httpRequest.getHeader("x-Nonce"));
clientRequestHeader.setSign(httpRequest.getHeader("X-Sign"));
String header = httpRequest.getHeader("X-Time");
if (StringUtils.isEmpty(header)) {
responseFail(httpResponse, ReturnCode.ILLEGAL_HEADER);
return;
}
clientRequestHeader.setTimestamp(Long.parseLong(header));
//验证请求头是否存在
if (StringUtils.isEmpty(clientRequestHeader.getSign()) || ObjectUtils.isEmpty(clientRequestHeader.getTimestamp()) || StringUtils.isEmpty(clientRequestHeader.getNonce())) {
responseFail(httpResponse, ReturnCode.ILLEGAL_HEADER);
return;
}
/*
* 1.重放验证
* 判断timestamp时间戳与当前时间是否超过60s(过期时间根据业务情况设置),如果超过了就提示签名过期。
*/
long now = System.currentTimeMillis() / 1000;
if (now - clientRequestHeader.getTimestamp() > signMaxTime) {
responseFail(httpResponse, ReturnCode.REPLAY_ERROR);
return;
}
//2. 判断nonce
boolean nonceExists = redisUtil.hasKey(NONCE_KEY + clientRequestHeader.getNonce());
if (nonceExists) {
//请求重复
responseFail(httpResponse, ReturnCode.REPLAY_ERROR);
return;
} else {
redisUtil.set(NONCE_KEY + clientRequestHeader.getNonce(), clientRequestHeader.getNonce(), signMaxTime);
}
boolean accept;
SortedMap<String, String> paramMap;
switch (httpRequest.getMethod()) {
case "GET":
paramMap = HttpDataUtil.getUrlParams(requestWrapper);
accept = SignUtil.verifySign(paramMap, clientRequestHeader);
break;
case "POST":
paramMap = HttpDataUtil.getBodyParams(requestWrapper);
accept = SignUtil.verifySign(paramMap, clientRequestHeader);
break;
default:
accept = true;
break;
}
if (accept) {
filterChain.doFilter(requestWrapper, servletResponse);
} else {
responseFail(httpResponse, ReturnCode.ARGUMENT_ERROR);
return;
}
}
private void responseFail(HttpServletResponse httpResponse, ReturnCode returnCode) {
ResultData<Object> resultData = ResultData.fail(returnCode.getCode(), returnCode.getMessage());
WebUtils.writeJson(httpResponse, resultData);
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
String signTime = filterConfig.getInitParameter("signMaxTime");
signMaxTime = Long.parseLong(signTime);
}
}
辅助的bean
public class ClientRequestHeader {
/**
* 客户端传来的签名密钥
*/
private String sign;
/**
* 客户端的时间戳
*/
private Long timestamp;
/**
* 随机的字符串,nonce的意思是仅一次有效的随机字符串,要求每次请求时该参数要保证不同。实际使用用户信息+时间戳+随机数等信息做个哈希之后,作为nonce参数。
*/
private String nonce;
public String getSign() {
return sign;
}
public void setSign(String sign) {
this.sign = sign;
}
public Long getTimestamp() {
return timestamp;
}
public void setTimestamp(Long timestamp) {
this.timestamp = timestamp;
}
public String getNonce() {
return nonce;
}
public void setNonce(String nonce) {
this.nonce = nonce;
}
}
@Configuration
public class SignFilterConfiguration {
@Value("${sign.maxTime}")
private String signMaxTime;
//filter中的初始化参数
private Map<String, String> initParametersMap = new HashMap<>();
@Bean
public FilterRegistrationBean contextFilterRegistrationBean() {
initParametersMap.put("signMaxTime", signMaxTime);
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(signFilter());
registration.setInitParameters(initParametersMap);
registration.addUrlPatterns("/*");
registration.setName("SignFilter");
// 设置过滤器被调用的顺序
registration.setOrder(1);
return registration;
}
@Bean
public Filter signFilter() {
return new SignFilter();
}
}