前言

最近在做的集团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

* 类简介

*

*/

@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().lambda()

.eq(ServiceLic::getAccessKey, accessKey)

.eq(ServiceLic::getIsWhite, true));

if (isEmpty(serviceLic)) {

return null;

}

ServiceLicUrlMapper licUrlMapper = (ServiceLicUrlMapper) SpringUtil.getBean("serviceLicUrlMapper");

List serviceLicUrls = licUrlMapper.selectList(new QueryWrapper().lambda()

.eq(ServiceLicUrl::getLicId, serviceLic.getId()));

Set 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().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;

}

/**

* 对请求签名

*

* 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 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“智能化”不少,但是很烦网络上铺天盖地的广告软文,只有凭自己的实力赢得口碑才是硬道理!

以上

相关阅读

评论可见,请评论后查看内容,谢谢!!!
 您阅读本篇文章共花了: