接口管理工具Apifox在前后端分离项目中的实践

前言

最近在做的集团SaaS平台的派车模块,因实际使用中司机无法操作电脑端,所以又开发了派车小程序以方便司机角色去接单、派车和送货签收操作。小程序端直接调用的是后台的派车模块的接口,这就涉及到了前后端分离中的一个痛点-接口的文档维护和接口的联调测试问题。幸好,在这个全民脱贫、码农翻身把歌唱的时代,我们有了比postman更好用的接口管理工具-Apifox

官方👉[点我直达]给出的介绍:

Apifox 是接口管理、开发、测试全流程集成工具,定位 Postman + Swagger + Mock + JMeter。通过一套系统、一份数据,解决多个系统之间的数据同步问题。只要定义好接口文档,接口调试、数据 Mock、接口测试就可以直接使用,无需再次定义;接口文档和接口开发调试使用同一个工具,接口调试完成后即可保证和接口文档定义完全一致。高效、及时、准确!

Apifox在项目中的实践应用

一、后端接口服务的签名验证规则
  1. 调用 JSON 格式为:

    {
    "accessKey":, //访问key(由系统分配给用户)
    "reqSign":xxxxxxxxxxxxxxxxxxxxxxxxxx, //用一定规则生成的签名
    "timestamp":2022-01-20 13:15:15, //请求时间记录
    "nonce":123456, //小于6位的随机数,用来标识每个被签名的请求
    // "data":{} //查询参数
    }
    
  2. 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“智能化”不少,但是很烦网络上铺天盖地的广告软文,只有凭自己的实力赢得口碑才是硬道理!

以上

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值