前言
最近在做的集团SaaS平台的派车模块,因实际使用中司机无法操作电脑端,所以又开发了派车小程序以方便司机角色去接单、派车和送货签收操作。小程序端直接调用的是后台的派车模块的接口,这就涉及到了前后端分离中的一个痛点-接口的文档维护和接口的联调测试问题。幸好,在这个全民脱贫、码农翻身把歌唱的时代,我们有了比postman更好用的接口管理工具-Apifox
官方👉[点我直达]给出的介绍:
Apifox 是接口管理、开发、测试全流程集成工具,定位 Postman + Swagger + Mock + JMeter。通过一套系统、一份数据,解决多个系统之间的数据同步问题。只要定义好接口文档,接口调试、数据 Mock、接口测试就可以直接使用,无需再次定义;接口文档和接口开发调试使用同一个工具,接口调试完成后即可保证和接口文档定义完全一致。高效、及时、准确!
Apifox在项目中的实践应用
一、后端接口服务的签名验证规则
- 调用 JSON 格式为:
{"accessKey":,//访问key(由系统分配给用户)"reqSign":xxxxxxxxxxxxxxxxxxxxxxxxxx,//用一定规则生成的签名"timestamp":2022-01-2013:15:15,//请求时间记录"nonce":123456,//小于6位的随机数,用来标识每个被签名的请求// "data":{} //查询参数}
- Signature****参数签名生成规则:① 按照请求参数名的字母升序排列非空请求参数(包含
AccessKey
),使用URL键值对的格式(即key1=value1&key2=value2…
)拼接成字符串stringA
;② 在stringA
最后拼接上用户密钥(32位UUID)得到字符串stringSignTemp
; 对stringSignTemp
进行MD5
运算,并将得到的字符串所有字符转换为大写,得到Signature
值。返回 JSON 格式为:{"ok":true,//查询是否成功"errorCode":null,//错误码"errors":null,//错误信息“data”:{}//查询结果数据}
具体的调用参数和返回结果中 data 的内容各个功能详细描述。验证失败的返回结果是:{"ok":false,"errorCode":-1"errors":”用户验证失败”"data":null}
另外错误返回可能还包括:-2:服务过期-3: 未购买指定的服务-4: 内部错误
二、后端权限过滤器AuthFilter
权限过滤器:
packagecom.jieguan.filter;importcom.jieguan.entity.ParamDTO;importcom.jieguan.utils.ServiceLicKit;importcom.yorma.constant.RspCode;importio.zbus.rpc.RpcFilter;importio.zbus.rpc.annotation.FilterDef;importio.zbus.transport.Message;importlombok.extern.slf4j.Slf4j;importorg.springframework.stereotype.Component;importjava.util.HashMap;/**
* 权限过滤器
*
* @author ZHANGCHAO
* @date 2022/3/31 16:56
*/@Slf4j@Component("authFilter1")@FilterDef("jieguanAuthFilter")publicclassAuthFilterimplementsRpcFilter{@OverridepublicbooleandoFilter(Message request,Message response,Throwable exception){boolean auth =false;ParamDTO param =ServiceLicKit.checkParams(request);if(!param.isOk()){
response.setStatus(RspCode.REQ_ERR);
response.setBody(param.getErrorMsg());returnfalse;}//校验 NONCE 防重放if(!ServiceLicKit.verifyNonce(param.getTimeStamp(), param.getNonce())){
response.setStatus(RspCode.UNAUTH);
response.setHeaders(newHashMap<>());
response.setBody("校验NONCE未通过,请求拒绝!");//校验 URI访问控制}elseif(!ServiceLicKit.verifyUri(request, param.getLic())){
response.setStatus(RspCode.UNAUTH);
response.setBody("访问受限!");//校验 请求签名 防篡改}elseif(!ServiceLicKit.verifySign(param, request)){
response.setStatus(RspCode.UNAUTH);
response.setBody("非法请求!");}else{
auth =true;}return auth;}}
权限验证处理类:
packagecom.jieguan.utils;importcn.hutool.core.date.DateUnit;importcn.hutool.core.date.DateUtil;importcom.alibaba.fastjson.JSON;importcom.alibaba.fastjson.JSONArray;importcom.alibaba.fastjson.JSONObject;importcom.baomidou.mybatisplus.core.conditions.query.QueryWrapper;importcom.jieguan.config.SpringUtil;importcom.jieguan.entity.Nonce;importcom.jieguan.entity.ParamDTO;importcom.jieguan.entity.ServiceLic;importcom.jieguan.entity.ServiceLicUrl;importcom.jieguan.mapper.NonceMapper;importcom.jieguan.mapper.ServiceLicMapper;importcom.jieguan.mapper.ServiceLicUrlMapper;importcom.yorma.util.FileKit;importcom.yorma.util.MD5Util;importcom.yorma.util.StringUtil;importio.zbus.transport.Message;importlombok.extern.slf4j.Slf4j;importjava.io.File;importjava.util.*;importstaticcn.hutool.core.util.ObjectUtil.isEmpty;importstaticcn.hutool.core.util.ObjectUtil.isNotEmpty;importstaticcn.hutool.core.util.StrUtil.isBlank;importstaticcn.hutool.core.util.StrUtil.isNotBlank;/**
* AppKeyKit
*
* @author 张杰 2021/11/8 10:39
* @version 1.0
* @apiNote <pre>
* 类简介
* </pre>
*/@Slf4jpublicclassServiceLicKit{publicstaticfinalString TIMESTAMP ="timestamp";publicstaticfinalString NONCE ="nonce";publicstaticfinalString MD5 ="MD5";//摘要算法: SM3/MD5publicstaticfinalString SM3 ="SM3";//摘要算法: SM3/MD5publicstaticfinalint MAX_DELAY =5;// NONCE间隔时间privatestaticfinalString ACCESS_KEY ="accessKey";privatestaticfinalString SECRET_KEY ="secretKey";privatestaticfinalString SIGN ="reqSign";privatestaticfinalString BODY_HASH ="bodyHash";/**
* 提前验证参数
*
* @param request 请求
* @return com.jieguan.entity.ParamDTO
* @author ZHANGCHAO
* @date 2022/1/17 22:56
*/publicstaticParamDTOcheckParams(Message request){ParamDTO param =newParamDTO();String accessKey =ServiceLicKit.getKey(ACCESS_KEY, request);String sign =ServiceLicKit.getKey(SIGN, request);String bodyHash =ServiceLicKit.getKey(BODY_HASH, request);ServiceLic lic =ServiceLicKit.getLicByAccessKey(accessKey);String timeStamp =ServiceLicKit.getKey(ServiceLicKit.TIMESTAMP, request);Long nonce;try{
nonce =Long.valueOf(ServiceLicKit.getKey(ServiceLicKit.NONCE, request));}catch(Exception e){
param.setErrorMsg("防重放标识nonce不存在或格式错误!");return param;}if(isBlank(accessKey)){
param.setErrorMsg("未获取到用户标识AccessKey!");return param;}if(isBlank(sign)){
param.setErrorMsg("未获取到参数签名sign!");return param;}if(isBlank(timeStamp)){
param.setErrorMsg("未获取到请求时间戳timestamp!");return param;}if(isEmpty(nonce)){
param.setErrorMsg("未获取到防重放标识nonce!");return param;}if(isEmpty(lic)){
param.setErrorMsg("未获取到此用户标识的许可信息!");return param;}
param.setOk(true).setAccessKey(accessKey).setSign(sign).setBodyHash(bodyHash).setTimeStamp(timeStamp).setNonce(nonce).setLic(lic);
log.info("[参数检查]最终的Param:"+ param);return param;}/**
* 查询许可, 根据accessKey
*
* @param accessKey
* @return
*/publicstaticServiceLicgetLicByAccessKey(String accessKey){ServiceLicMapper licMapper =(ServiceLicMapper)SpringUtil.getBean("serviceLicMapper");ServiceLic serviceLic = licMapper.selectOne(newQueryWrapper<ServiceLic>().lambda().eq(ServiceLic::getAccessKey, accessKey).eq(ServiceLic::getIsWhite,true));if(isEmpty(serviceLic)){returnnull;}ServiceLicUrlMapper licUrlMapper =(ServiceLicUrlMapper)SpringUtil.getBean("serviceLicUrlMapper");List<ServiceLicUrl> serviceLicUrls = licUrlMapper.selectList(newQueryWrapper<ServiceLicUrl>().lambda().eq(ServiceLicUrl::getLicId, serviceLic.getId()));Set<String> urlSet =newHashSet<>();if(isNotEmpty(serviceLicUrls)){for(ServiceLicUrl url : serviceLicUrls){
urlSet.add(url.getLicUrl());}}
serviceLic.setUrlSet(urlSet);return serviceLic;}/**
* 取参数或头的属性值(参数优先)
*
* @param key
* @param msg
* @return
*/publicstaticStringgetKey(String key,Message msg){String val =null;if(StringUtil.isNotEmpty(key)&& msg !=null){
val = msg.getParam(key)==null? msg.getHeader(key): msg.getParam(key,String.class);}return val;}/**
* 校验 NONCE
*
* @param timeStamp
* @param nonce
* @return
*/publicstaticbooleanverifyNonce(String timeStamp,long nonce){long betweenTime =DateUtil.between(DateUtil.parseDateTime(timeStamp),newDate(),DateUnit.MINUTE,false);// 超出5分钟时间范围?if(betweenTime > MAX_DELAY || betweenTime <0){
log.info("[校验NONCE]超出时间范围,请求拒绝!");returnfalse;}NonceMapper nonceMapper =(NonceMapper)SpringUtil.getBean("nonceMapper");Nonce nonceRecord = nonceMapper.selectOne(newQueryWrapper<Nonce>().lambda().eq(Nonce::getNonce, nonce));if(isNotEmpty(nonceRecord)){
log.info("[校验NONCE]已存在的NONCE,请求拒绝!");
nonceRecord.setAttackTimes(isNotEmpty(nonceRecord.getAttackTimes())? nonceRecord.getAttackTimes()+1:1);
nonceMapper.updateById(nonceRecord);returnfalse;}Nonce nonceNew =newNonce();
nonceNew.setNonce(nonce).setReqTime(DateUtil.parseDateTime(timeStamp));
nonceMapper.insert(nonceNew);returntrue;}/**
* 访问权限验证
*
* @param msg
* @param lic
* @return
*/publicstaticbooleanverifyUri(Message msg,ServiceLic lic){String uri = msg.getUrl();String queryStr = msg.getQueryString();
uri = uri.replace("?"+ queryStr,"");returnisNotEmpty(lic.getUrlSet())&& lic.getUrlSet().contains(uri);}/**
* 验证请求签名
* 原文= paramStr[&timeValue][&nonceValue][&bodyHashHEXValue]&secretKeyValue]
* paramStr: 请求参数原文(不含‘?’,保持顺序)
* bodyHashHEXValue:POST/PUT需要计算 bodyHash值,算法 MD5/SM3, 格式 HEX
* secretKeyValue: 根据 accessKey 获取服务端记录的 secretKeyValue
*
* @param param
* @param req
* @return
*/publicstaticbooleanverifySign(ParamDTO param,Message req){boolean rt =false;String src =isBlank(req.getQueryString())?"": req.getQueryString().replaceFirst("&?"+ SIGN +"=[0-9,a-f,A-F]+","");String sign = param.getSign();// 时间戳if(!src.contains(TIMESTAMP +"=")){
src +="&"+ param.getTimeStamp();}// nonceif(!src.contains(NONCE +"=")){
src +="&"+ param.getNonce();}// bodyHashif(param.getBodyHash()!=null&&!src.contains(BODY_HASH +"=")){
src +="&"+ param.getBodyHash();}
src +="&"+ param.getLic().getSecretKey();// MD5 16byte, SM3 32byteString alg = sign.length()==64? SM3 : MD5;//param.getLic().getAlgorithm();//String localSign =signature(src, alg);String localBodyHash ="";if(isNotBlank(param.getBodyHash())){String sourtJson =getSortJson(req.getBody());
log.info("sortJson:"+ sourtJson);
localBodyHash =signature(sourtJson, alg);}
log.info("[src]:"+ src);if(!localSign.equalsIgnoreCase(sign)){
log.info("[src]:"+ src);
log.info("[sign]:"+ sign);
log.info("[localSign]:"+ localSign);}elseif(isNotBlank(param.getBodyHash())&&!localBodyHash.equalsIgnoreCase(param.getBodyHash())){
log.info("[bodyHash]:"+ param.getBodyHash());
log.info("[localBodyHash]:"+ localBodyHash);}else{
rt =true;}return rt;}/**
* 对请求签名
* <p>
* MD5{参数串|body串|key}
*
* @param src
* @param alg
* @return
*/publicstaticStringsignature(String src,String alg){// FIXME 原文结构待定String sign =null;if(StringUtil.isNotEmpty(alg)){switch(alg.toUpperCase()){case MD5:
sign =MD5Util.MD5Encode(src,"UTF-8");break;case SM3:
sign =SM3Digest.hashHex(src,"UTF-8");break;default://不支持的算法
log.info("不支持算法:"+ alg);}}return sign;}/**
* 对单层json进行key字母排序
*
* @param json
* @return
*/publicstaticStringgetSortJson(Object json){if(json instanceofJSONArray|| json instanceofJSONObject){returnJSONObject.toJSONString(getSortMap(json));}elseif(json instanceofString){JSONObject jsonObject;try{
jsonObject =JSONObject.parseObject((String) json);}catch(Exception e){thrownewRuntimeException("不是 JSON 对象: "+ json);}returnJSONObject.toJSONString(getSortMap(jsonObject));}else{thrownewRuntimeException("不是 JSON 对象: "+ json);}}publicstaticObjectgetSortMap(Object json){SortedMap map =newTreeMap();if(json instanceofJSONArray&&!((JSONArray) json).isEmpty()&&((JSONArray) json).get(0)instanceofJSONObject){JSONArray va =(JSONArray) json;for(int i =0; i < va.size(); i++){
va.set(i,getSortMap(va.get(i)));}return json;}elseif(json instanceofJSONObject){Iterator<String> iteratorKeys =((JSONObject) json).keySet().iterator();while(iteratorKeys.hasNext()){String key = iteratorKeys.next();Object value =((JSONObject) json).get(key);if(value instanceofJSONObject|| value instanceofJSONArray){
map.put(key,getSortMap(value));}elseif(value !=null){
map.put(key, value);}}return map;}else{return json;}}}
三、Apifox编写公共脚本用于前置操作,设置接口请求签名sign
公共脚本主要用途是实现
脚本复用
,避免多处重复编写
相同功能的脚本
。
可以将多处都会用到的
相同功能的脚本
或者
通用的类、方法
,放到公共脚本里,然后所有接口直接引用公共脚本即可使用。
在项目设置里新建一个公共脚本,请求前根据一定规则生成公共的请求头,编写生成签名的代码,可参考官方使用文档,讲的都很详细👉接口签名如何处理:
脚本代码:
// 设置请求头timestampvar moment =require("moment");var timestamp =moment().format('YYYY-MM-DD HH:mm:ss')
console.log(timestamp)/**
* 6位随机数
*/functiongetNonce(){let nonce = Math.random().toString().slice(-6);if(nonce.startsWith("0")){
nonce =getNonce();}return nonce;}var nonce =getNonce();
console.log(nonce)// // 获取 Header 参数对象// var headers = pm.request.headers;// // 获取 key 为 field1 的 header 参数的值// var accessKey = pm.variables.replaceIn(headers.get("accessKey"));// console.log(accessKey)// 存放所有需要用来签名的参数let param ={};// 加入 query 参数let queryParams = pm.request.url.query;
queryParams.each(item=>{// if (item.value !== '') { // 非空参数值的参数才参与签名
param[item.key]= item.value;// }});// 取 keylet keys =[];for(let key in param){// 注意这里,要剔除掉 sign 参数本身if(key !=='sign'){
keys.push(key);}}// 转成键值对let paramPair =[];for(let i =0, len = keys.length; i < len; i++){let k = keys[i];
paramPair.push(k +'='+encodeURIComponent(param[k]))// urlencode 编码}
paramPair.push(timestamp);
paramPair.push(nonce);
paramPair.push("cjf9hbd4rln75a58o3tc");// 最后加上 key// paramPair.push("key=" + key);// 拼接let stringSignTemp = paramPair.join('&');if(queryParams ==null|| queryParams ==''){
stringSignTemp ="&"+ stringSignTemp;}
console.log(stringSignTemp);let sign = CryptoJS.MD5(stringSignTemp).toString();
console.log(sign);// 方案一:直接修改接口请求的 query 参数,注入 sign,无需使用环境变量。// 参考文档:https://www.apifox.cn/help/app/scripts/examples/request-handle/// queryParams.upsert({// key: 'sign',// value: sign,// });// 方案二:写入环境变量,此方案需要在接口里设置参数引用环境变量// 设置全局变量
pm.globals.set("reqSign", sign);
pm.globals.set("timestamp", timestamp);
pm.globals.set("nonce", nonce);
四、调用接口,验证权限
通过Apifox调用接口,成功返回数据,可以在控制台查看调用时发送的参数等信息:
另外分享一个MD5加密的脚本:
let password = pm.request.url.query.get('password');
console.log('原密码:'+ password);let newPwd = CryptoJS.MD5(password).toString();
console.log('MD5加密后:'+ newPwd);
pm.request.url.query.upsert({key:"password",value: newPwd,});
总结
作为一款国人开发的工具,Apifox已经很优秀了,起码比postman“智能化”不少,但是很烦网络上铺天盖地的广告软文,只有凭自己的实力赢得口碑才是硬道理!
以上
版权归原作者 dearmrzhang 所有, 如有侵权,请联系我们删除。