关于接口安全的设计有很多,比如token检验,对参数的安全性校验等等,但在一些安全性要求更高的业务要求中,常用的一些校验可能显得安全性不太够,所以就有了对api的验签。 简单来说就是在我们正常的访问请求下,再加一层对所有api进行校验的校验层。
我们最简易的一个流程大概就是如下,通过登录进行一个用户信息的校验,那用token去请求业务的系统进行业务操作。
但这种校验方式在很多安全性要求较高的情况下就不太适用。举个简单的例子,比如我们要转账100块给b,结果这个请求被'中间人'拦截,将b的信息改成c在重新去请求业务系统,在业务系统看来这个请求token也是对的,信息也没问题,最后就导致原本给b的钱转给了c。
这种情况,我们就需要对api接口进行额外的校验。
以上述案例,举一个简易的验签方案
首先我们要进行加密或者叫加签,由于是为了防止信息的篡改,所以可以选择对我们的信息进行一个加密,生成密文放在请求头中。加密的方式有很多,简单点可以进行一个md5的加密,更复杂可以进行非对称加密。这里我们可以先选择md5的方式进行加密。
验签就简单了,系统收到请求,根据信息进行一个相同md5函数的加密生成密文与请求头中的密文进行比较,相同就验签成功,不同则验签失败。看起来好像确实没什么问题了,也保护了参数不会被改变。
但是为了防止通过一些数据分析的方式,破解出加密的形式,所以可以选择在加密时多添加一个随机数,如果为了更安全还可以添加时间戳,每次请求验签成功时,将随机数和时间戳缓存,如果规定时间内有两次相同的请求则会之后的请求拦截,简易的防止重放的校验就完成了。最后的流程就可以变成这样。
代码示例如下:
java
@Slf4j
public class InterfaceInterceptor implements HandlerInterceptor {
private static final Integer SECONDS = 60 * 10;
private static final Integer TIME_ALIVE = 5;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String servletPath = request.getServletPath();
LogUtil.info(log, "InterfaceInterceptor.preHandle >> 进入拦截, {} ,{}", servletPath, handler.getClass().getName());
if (!(handler instanceof HandlerMethod)) {
return true;
}
if (!StringUtils.containsIgnoreCase(request.getContentType(), MediaType.APPLICATION_JSON_VALUE)) {
LogUtil.error(log, "InterfaceInterceptor.preHandle >> 错误,当前不是JSON数据, contentType={}", Strings.nullToEmpty(request.getContentType()));
//登录失效异常
WebUtil.sendResponse(response, BaseResponse.failure(ErrorCodeEnum.INTERFACE_INVALID));
return false;
}
String body = "{}";
try {
body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
} catch (Exception e) {
LogUtil.error(log, "InterfaceInterceptor.preHandle >> 对象解析失败 ,body={}", body, e);
// 对象解析失败
WebUtil.sendResponse(response, BaseResponse.failure(ErrorCodeEnum.INTERFACE_INVALID));
return false;
}
TreeMap<String, Object> sortedMap = Maps.newTreeMap();
//请求头中取出签名、时间戳和随机字符串
final String signature = ServletUtil.getHeaderIgnoreCase(request, CommonConstant.SIGN);
final String timestamp = ServletUtil.getHeaderIgnoreCase(request, CommonConstant.TIMESTAMP);
final String nonce = ServletUtil.getHeaderIgnoreCase(request, CommonConstant.NONCE);
String signMd5 = this.getMd5Sign(servletPath, body, sortedMap, nonce, timestamp);
if (!signMd5.equalsIgnoreCase(signature)) {
LogUtil.warn(log, "InterfaceInterceptor.preHandle >> 接口预校验 >> signature error, expected signature is {}", signMd5);
WebUtil.sendResponse(response, BaseResponse.failure(ErrorCodeEnum.INTERFACE_INVALID));
return false;
}
//判断时间是否是60s内
long nowTime = DateUtil.currentSeconds();
long reqTime = Long.parseLong(timestamp);
long abs = Math.abs(nowTime - reqTime);
if (abs > SECONDS) {
LogUtil.warn(log, "InterfaceInterceptor.preHandle >> 接口预校验 >> 时间校验, 时间差值超过10分钟, 检查手机系统时间, nowTime={}, reqTime={} ", nowTime, reqTime);
WebUtil.sendResponse(response, BaseResponse.failure(ErrorCodeEnum.INTERFACE_INVALID, "请校验手机系统时间是否准确"));
return false;
}
RedissonClient redissonClient = SpringUtil.getBean(RedissonClient.class);
RBucket<String> bucket = redissonClient.getBucket(StrUtil.format(RedisPrefixConstant.INTERFACE_NONCE_REDIS_KEY, nonce));
if (bucket.isExists()) {
LogUtil.warn(log, "InterfaceInterceptor.preHandle >> 缓存存在 , nonce={}", nonce);
WebUtil.sendResponse(response, BaseResponse.failure(ErrorCodeEnum.INTERFACE_INVALID));
return false;
}
//存入缓存
bucket.set(nonce, TIME_ALIVE, TimeUnit.MINUTES);
return true;
}
private String getMd5Sign(String servletPath, String body, TreeMap<String, Object> sortedMap, String nonce, String timestamp) {
....md5校验
}
总而言之,API接口验签主要点在于加密或者叫加签的过程,根据不同的业务需求可以对不同的参数进行加签,如果是为了防止参数修改就可以对参数进行一个加签,如果是别的情况就可以选择别的加签对象。至于加签的算法,就仁者见仁智者见智了,每个人都有自己的看法😀。