前言
最近在做的集团SaaS平台的派车模块,因实际使用中司机无法操作电脑端,所以又开发了派车小程序以方便司机角色去接单、派车和送货签收操作。小程序端直接调用的是后台的派车模块的接口,这就涉及到了前后端分离中的一个痛点-接口的文档维护和接口的联调测试问题。幸好,在这个全民脱贫、码农翻身把歌唱的时代,我们有了比postman更好用的接口管理工具-Apifox
官方👉[点我直达]给出的介绍:
Apifox 是接口管理、开发、测试全流程集成工具,定位 Postman + Swagger + Mock + JMeter。通过一套系统、一份数据,解决多个系统之间的数据同步问题。只要定义好接口文档,接口调试、数据 Mock、接口测试就可以直接使用,无需再次定义;接口文档和接口开发调试使用同一个工具,接口调试完成后即可保证和接口文档定义完全一致。高效、及时、准确!
Apifox在项目中的实践应用
一、后端接口服务的签名验证规则
-
调用 JSON 格式为:
{ "accessKey":, //访问key(由系统分配给用户) "reqSign":xxxxxxxxxxxxxxxxxxxxxxxxxx, //用一定规则生成的签名 "timestamp":2022-01-20 13: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
权限过滤器:
package com.jieguan.filter;
import com.jieguan.entity.ParamDTO;
import com.jieguan.utils.ServiceLicKit;
import com.yorma.constant.RspCode;
import io.zbus.rpc.RpcFilter;
import io.zbus.rpc.annotation.FilterDef;
import io.zbus.transport.Message;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
/**
* 权限过滤器
*
* @author ZHANGCHAO
* @date 2022/3/31 16:56
*/
@Slf4j
@Component("authFilter1")
@FilterDef("jieguanAuthFilter")
public class AuthFilter implements RpcFilter {
@Override
public boolean doFilter(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());
return false;
}
//校验 NONCE 防重放
if (!ServiceLicKit.verifyNonce(param.getTimeStamp(), param.getNonce())) {
response.setStatus(RspCode.UNAUTH);
response.setHeaders(new HashMap<>());
response.setBody("校验NONCE未通过,请求拒绝!");
//校验 URI访问控制
} else if (!ServiceLicKit.verifyUri(request, param.getLic())) {
response.setStatus(RspCode.UNAUTH);
response.setBody("访问受限!");
//校验 请求签名 防篡改
} else if (!ServiceLicKit.verifySign(param, request)) {
response.setStatus(RspCode.UNAUTH);
response.setBody("非法请求!");
} else {
auth = true;
}
return auth;
}
}
权限验证处理类:
package com.jieguan.utils;
import cn.hutool.core.date.DateUnit;
import cn.hutool.core.date.DateUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.jieguan.config.SpringUtil;
import com.jieguan.entity.Nonce;
import com.jieguan.entity.ParamDTO;
import com.jieguan.entity.ServiceLic;
import com.jieguan.entity.ServiceLicUrl;
import com.jieguan.mapper.NonceMapper;
import com.jieguan.mapper.ServiceLicMapper;
import com.jieguan.mapper.ServiceLicUrlMapper;
import com.yorma.util.FileKit;
import com.yorma.util.MD5Util;
import com.yorma.util.StringUtil;
import io.zbus.transport.Message;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
import java.util.*;
import static cn.hutool.core.util.ObjectUtil.isEmpty;
import static cn.hutool.core.util.ObjectUtil.isNotEmpty;
import static cn.hutool.core.util.StrUtil.isBlank;
import static cn.hutool.core.util.StrUtil.isNotBlank;
/**
* AppKeyKit
*
* @author 张杰 2021/11/8 10:39
* @version 1.0
* @apiNote <pre>
* 类简介
* </pre>
*/
@Slf4j
public class ServiceLicKit {
public static final String TIMESTAMP = "timestamp";
public static final String NONCE = "nonce";
public static final String MD5 = "MD5"; //摘要算法: SM3/MD5
public static final String SM3 = "SM3"; //摘要算法: SM3/MD5
public static final int MAX_DELAY = 5; // NONCE间隔时间
private static final String ACCESS_KEY = "accessKey";
private static final String SECRET_KEY = "secretKey";
private static final String SIGN = "reqSign";
private static final String BODY_HASH = "bodyHash";
/**
* 提前验证参数
*
* @param request 请求
* @return com.jieguan.entity.ParamDTO
* @author ZHANGCHAO
* @date 2022/1/17 22:56
*/
public static ParamDTO checkParams(Message request) {
ParamDTO param = new ParamDTO();
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
*/
public static ServiceLic getLicByAccessKey(String accessKey) {
ServiceLicMapper licMapper = (ServiceLicMapper) SpringUtil.getBean("serviceLicMapper");
ServiceLic serviceLic = licMapper.selectOne(new QueryWrapper<ServiceLic>().lambda()
.eq(ServiceLic::getAccessKey, accessKey)
.eq(ServiceLic::getIsWhite, true));
if (isEmpty(serviceLic)) {
return null;
}
ServiceLicUrlMapper licUrlMapper = (ServiceLicUrlMapper) SpringUtil.getBean("serviceLicUrlMapper");
List<ServiceLicUrl> serviceLicUrls = licUrlMapper.selectList(new QueryWrapper<ServiceLicUrl>().lambda()
.eq(ServiceLicUrl::getLicId, serviceLic.getId()));
Set<String> urlSet = new HashSet<>();
if (isNotEmpty(serviceLicUrls)) {
for (ServiceLicUrl url : serviceLicUrls) {
urlSet.add(url.getLicUrl());
}
}
serviceLic.setUrlSet(urlSet);
return serviceLic;
}
/**
* 取参数或头的属性值(参数优先)
*
* @param key
* @param msg
* @return
*/
public static String getKey(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
*/
public static boolean verifyNonce(String timeStamp, long nonce) {
long betweenTime = DateUtil.between(DateUtil.parseDateTime(timeStamp), new Date(), DateUnit.MINUTE, false);
// 超出5分钟时间范围?
if (betweenTime > MAX_DELAY || betweenTime < 0) {
log.info("[校验NONCE]超出时间范围,请求拒绝!");
return false;
}
NonceMapper nonceMapper = (NonceMapper) SpringUtil.getBean("nonceMapper");
Nonce nonceRecord = nonceMapper.selectOne(new QueryWrapper<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);
return false;
}
Nonce nonceNew = new Nonce();
nonceNew.setNonce(nonce).setReqTime(DateUtil.parseDateTime(timeStamp));
nonceMapper.insert(nonceNew);
return true;
}
/**
* 访问权限验证
*
* @param msg
* @param lic
* @return
*/
public static boolean verifyUri(Message msg, ServiceLic lic) {
String uri = msg.getUrl();
String queryStr = msg.getQueryString();
uri = uri.replace("?" + queryStr, "");
return isNotEmpty(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
*/
public static boolean verifySign(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();
}
// nonce
if (!src.contains(NONCE + "=")) {
src += "&" + param.getNonce();
}
// bodyHash
if (param.getBodyHash() != null && !src.contains(BODY_HASH + "=")) {
src += "&" + param.getBodyHash();
}
src += "&" + param.getLic().getSecretKey();
// MD5 16byte, SM3 32byte
String 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);
} else if (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
*/
public static String signature(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
*/
public static String getSortJson(Object json) {
if (json instanceof JSONArray || json instanceof JSONObject) {
return JSONObject.toJSONString(getSortMap(json));
} else if (json instanceof String) {
JSONObject jsonObject;
try {
jsonObject = JSONObject.parseObject((String) json);
} catch (Exception e) {
throw new RuntimeException("不是 JSON 对象: " + json);
}
return JSONObject.toJSONString(getSortMap(jsonObject));
} else {
throw new RuntimeException("不是 JSON 对象: " + json);
}
}
public static Object getSortMap(Object json) {
SortedMap map = new TreeMap();
if (json instanceof JSONArray && !((JSONArray) json).isEmpty()
&& ((JSONArray) json).get(0) instanceof JSONObject) {
JSONArray va = (JSONArray) json;
for (int i = 0; i < va.size(); i++) {
va.set(i, getSortMap(va.get(i)));
}
return json;
} else if (json instanceof JSONObject) {
Iterator<String> iteratorKeys = ((JSONObject) json).keySet().iterator();
while (iteratorKeys.hasNext()) {
String key = iteratorKeys.next();
Object value = ((JSONObject) json).get(key);
if (value instanceof JSONObject || value instanceof JSONArray) {
map.put(key, getSortMap(value));
} else if (value != null) {
map.put(key, value);
}
}
return map;
} else {
return json;
}
}
}
三、Apifox编写公共脚本用于前置操作,设置接口请求签名sign
公共脚本主要用途是实现脚本复用
,避免多处重复编写相同功能的脚本
。
可以将多处都会用到的相同功能的脚本
或者通用的类、方法
,放到公共脚本里,然后所有接口直接引用公共脚本即可使用。
在项目设置里新建一个公共脚本,请求前根据一定规则生成公共的请求头,编写生成签名的代码,可参考官方使用文档,讲的都很详细👉接口签名如何处理:
脚本代码:
// 设置请求头timestamp
var moment = require("moment");
var timestamp = moment().format('YYYY-MM-DD HH:mm:ss')
console.log(timestamp)
/**
* 6位随机数
*/
function getNonce() {
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;
// }
});
// 取 key
let 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“智能化”不少,但是很烦网络上铺天盖地的广告软文,只有凭自己的实力赢得口碑才是硬道理!文章来源:https://www.toymoban.com/news/detail-469854.html
以上文章来源地址https://www.toymoban.com/news/detail-469854.html
到了这里,关于接口管理工具Apifox在前后端分离项目中的实践的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!