一,为什么要给接口做签名验证?
1,app客户端在与服务端通信时,通常都是以接口的形式实现,
这种形式的安全方面有可能出现以下问题:
被非法访问(例如:发短信的接口通常会被利用来垃圾短信)
被重复访问 (例如:在提交订单时多点了几次提交按钮)
而客户端存在的弱点是:对接口站的地址不能轻易修改,
所以我们需要针对从app到接口的接口做签名验证,
接口不能随便app之外的应用访问
2,要注意的地方:
我们给app分配一个app_id和一个app_secret
app对app_secret的保存要做到不会被轻易的反编译出来,
否则安全就没有了保障
android平台建议保存到二进制的so文件中
说明:刘宏缔的架构森林是一个专注架构的博客,地址:https://www.cnblogs.com/architectforest
对应的源码可以访问这里获取: https://github.com/liuhongdi/
说明:作者:刘宏缔 邮箱: 371125307@qq.com
二,演示项目的相关信息
1,项目的地址
https://github.com/liuhongdi/apisign
2,项目的原理:
给客户端分发:appId,appSecret,version三个字串
appId:分配给客户端的id
appSecret:密钥字串,客户端要安全保存
version:服务端的接口版本
客户端在发送请求前,
用appId + appSecret + timestamp + nonce + version做md5,生成sign字串,
这个字串和appId/timestamp/nonce一起发送到服务端
服务端验证sign是否正确,
如果有误则拦截请求
3,项目的结构
如图:
三, java代码说明:
1,SignInterceptor.java
@Component public class SignInterceptor implements HandlerInterceptor { private static final String SIGN_KEY = "apisign_"; private static final Logger logger = LogManager.getLogger("bussniesslog"); @Resource private RedisStringUtil redisStringUtil; /* *@author:liuhongdi *@date:2020/7/1 下午4:00 *@description: * @param request:请求对象 * @param response:响应对象 * @param handler:处理对象:controller中的信息 * * *@return:true表示正常,false表示被拦截 */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //依次检查各变量是否存在? String appId = request.getHeader("appId"); if (StringUtils.isBlank(appId)) { ServletUtil.renderString(response, JSON.toJSONString(ResultUtil.error(ResponseCode.SIGN_NO_APPID))); return false; } String timestampStr = request.getHeader("timestamp"); if (StringUtils.isBlank(timestampStr)) { ServletUtil.renderString(response, JSON.toJSONString(ResultUtil.error(ResponseCode.SIGN_NO_TIMESTAMP))); return false; } String sign = request.getHeader("sign"); if (StringUtils.isBlank(sign)) { ServletUtil.renderString(response, JSON.toJSONString(ResultUtil.error(ResponseCode.SIGN_NO_SIGN))); return false; } String nonce = request.getHeader("nonce"); if (StringUtils.isBlank(nonce)) { ServletUtil.renderString(response, JSON.toJSONString(ResultUtil.error(ResponseCode.SIGN_NO_NONCE))); return false; } //得到正确的sign供检验用 String origin = appId + Constants.APP_SECRET + timestampStr + nonce + Constants.APP_API_VERSION; String signEcrypt = MD5Util.md5(origin); long timestamp = 0; try { timestamp = Long.parseLong(timestampStr); } catch (Exception e) { logger.error("发生异常",e); } //前端的时间戳与服务器当前时间戳相差如果大于180,判定当前请求的timestamp无效 if (Math.abs(timestamp - System.currentTimeMillis() / 1000) > 180) { ServletUtil.renderString(response, JSON.toJSONString(ResultUtil.error(ResponseCode.SIGN_TIMESTAMP_INVALID))); return false; } //nonce是否存在于redis中,检查当前请求是否是重复请求 boolean nonceExists = redisStringUtil.hasStringkey(SIGN_KEY+timestampStr+nonce); if (nonceExists) { ServletUtil.renderString(response, JSON.toJSONString(ResultUtil.error(ResponseCode.SIGN_DUPLICATION))); return false; } //后端MD5签名校验与前端签名sign值比对 if (!(sign.equalsIgnoreCase(signEcrypt))) { ServletUtil.renderString(response, JSON.toJSONString(ResultUtil.error(ResponseCode.SIGN_VERIFY_FAIL))); return false; } //将timestampstr+nonce存进redis redisStringUtil.setStringValue(SIGN_KEY+timestampStr+nonce, nonce, 180L); //sign校验无问题,放行 return true; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { } }
说明:如果客户端请求的数据缺少会被拦截
与服务端的appSecret等参数md5生成的sign不一致也会被拦截
时间超时/重复请求也会被拦截
2,DefaultMvcConfig.java
@Configuration @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) public class DefaultMvcConfig implements WebMvcConfigurer { @Resource private SignInterceptor signInterceptor; /** * 添加Interceptor
* liuhongdi */ @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(signInterceptor) .addPathPatterns("/**") //所有请求都需要进行报文签名sign .excludePathPatterns("/html/*","/js/*"); //排除html/js目录 } }
说明:用来添加interceptor
四,效果验证:
1,js代码实现:
说明:我们在这里使用js代码供仅演示使用,app_secret作为密钥不能使用js保存:
<body> <a href="javascript:login('right')">login(right)</a><br/> <a href="javascript:login('error')">login(error)</a><br/> <script> //vars var appId="wap"; var version="1.0"; //得到sign function getsign(appSecret,timestamp,nonce) { var origin = appId + appSecret + timestamp + nonce + version; console.log("origin:"+origin); var sign = hex_md5(origin); return sign; } //访问login这个api //说明:这里仅仅是举例子,在ios/android开发中,appSecret要以二进制的形式编译保存 function login(isright) { //right secret var appSecret_right="30c722c6acc64306a88dd93a814c9f0a"; //error secret var appSecret_error="aabbccdd"; var timestamp = parseInt((new Date()).getTime()/1000); var nonce = Math.floor(Math.random()*8999)+1000; var sign = ""; if (isright == 'right') { sign = getsign(appSecret_right,timestamp,nonce); } else { sign = getsign(appSecret_error,timestamp,nonce); }
var postdata = { username:"a", password:"b" } $.ajax({ type:"POST", url:"/user/login", data:postdata, //返回数据的格式 datatype: "json", //在请求之前调用的函数 beforeSend: function(request) { request.setRequestHeader("appId", appId); request.setRequestHeader("timestamp", timestamp); request.setRequestHeader("sign", sign); request.setRequestHeader("nonce", nonce); }, //成功返回之后调用的函数 success:function(data){ if (data.status == 0) { alert('success:'+data.msg); } else { alert("failed:"+data.msg); } }, //调用执行后调用的函数 complete: function(XMLHttpRequest, textStatus){ //complete }, //调用出错执行的函数 error: function(){ //请求出错处理 } }); } </script> </body>
如图:
说明:
login(right):使用正确的appSecret访问login这个接口
login(error):使用错误的appSecret访问login这个接口
2,查看效果:
成功时返回:
{"status":0,"msg":"操作成功","data":null}
报错时返回:
{"msg":"sign签名校验失败","status":10007}
五,查看spring boot的版本:
. ____ _ __ _ _ /\ / ___'_ __ _ _(_)_ __ __ _ ( ( )\___ | '_ | '_| | '_ / _` | \/ ___)| |_)| | | | | || (_| | ) ) ) ) ' |____| .__|_| |_|_| |_\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot :: (v2.3.1.RELEASE)