0


微信小程序服务端API安全鉴权&统一调用封装

目录

一、序言

做过小程序开发的朋友都知道,微信开放平台的接口提供了通信鉴权体系,通过数据加密与签名的机制,可以防止数据泄漏与篡改。

开发者可在小程序管理后台API安全模块,为应用配置密钥与公钥,以此来保障开发者应用和微信开放平台交互的安全性。

在小程序管理后台开启api加密后,开发者需要对原API的请求内容加密与签名,同时API的回包内容需要开发者验签与解密。支持的api可参考支持的接口调用。

今天我们一起来写个简单、易用的微信API网关接口调用封装,涉及到API的加解密、加验签等,让我们专心关注业务开发。


二、前置准备

开始前,我们需要先在管理后台开启API安全模块,具体步骤可参考:安全鉴权模式介绍。

1、获取小程序AppID和AppSecret

2、下载对称加密密钥

同时我们需要获取对称加密秘钥,这里对称加密密钥类型,我们选择

AES256

用于数据加解密。
在这里插入图片描述

3、下载加签私钥

这里的非对称加密密钥类型选择RSA,这里的私钥主要是用来对请求数据加签的。
在这里插入图片描述

4、下载验签证书

这里我们需要下载开放平台证书和密钥编号,用于响应数据的验签,如下:
在这里插入图片描述


三、加解密封装

做好前置准备后,我们开始进行封装,具体我们可以参考:微信小程序api签名指南。

1、相关基础类

(1) WxApiGatewayRequest (加密请求数据体)

@DatapublicclassWxApiGatewayRequest{/**
     * 初始向量,为16字节base64字符串(解码后为12字节随机字符串)
     */privateString iv;/**
     * 加密后的密文,使用base64编码
     */privateString data;/**
     * GCM模式输出的认证信息,使用base64编码
     */privateString authtag;}

(2) WxApiGatewayResponse(加密响应数据体)

@DatapublicclassWxApiGatewayResponse{/**
     * 初始向量,为16字节base64字符串(解码后为12字节随机字符串)
     */privateString iv;/**
     * 加密后的密文,使用base64编码
     */privateString data;/**
     * GCM模式输出的认证信息,使用base64编码
     */privateString authtag;}

备注:微信API网关请求和响应数据体的字段都是一样的。

2、加解密工具类

该工具类是根据微信服务端api的签名指南进行封装的,这里我们加密算法选择熟悉的

AES256_GCM

,签名算法选择

RSAwithSHA256

里面共包含了AES加解密RSA加验签4个核心方法。

importcom.xlyj.common.dto.WxApiGatewayRequest;importcom.xlyj.common.vo.WxApiGatewayResponse;importorg.apache.commons.lang3.StringUtils;importjavax.crypto.Cipher;importjavax.crypto.spec.GCMParameterSpec;importjavax.crypto.spec.SecretKeySpec;importjava.io.ByteArrayInputStream;importjava.nio.charset.StandardCharsets;importjava.security.KeyFactory;importjava.security.SecureRandom;importjava.security.Signature;importjava.security.cert.CertificateFactory;importjava.security.cert.X509Certificate;importjava.security.interfaces.RSAPrivateKey;importjava.security.spec.MGF1ParameterSpec;importjava.security.spec.PKCS8EncodedKeySpec;importjava.security.spec.PSSParameterSpec;importjava.util.Arrays;importjava.util.Base64;/**
 * 微信API请求和响应加解密、加验签工具类
 * @author Nick Liu
 * @date 2024/7/3
 */publicabstractclassWxApiCryptoUtils{privatestaticfinalString AES_ALGORITHM ="AES";privatestaticfinalString AES_TRANSFORMATION ="AES/GCM/NoPadding";privatestaticfinalint GCM_TAG_LENGTH =128;privatestaticfinalString RSA_ALGORITHM ="RSA";privatestaticfinalString SIGNATURE_ALGORITHM ="RSASSA-PSS";privatestaticfinalString HASH_ALGORITHM ="SHA-256";privatestaticfinalString MFG_ALGORITHM ="MGF1";privatestaticfinalString CERTIFICATE_TYPE ="X.509";privatestaticfinalBase64.Decoder DECODER =Base64.getDecoder();privatestaticfinalBase64.Encoder ENCODER =Base64.getEncoder();/**
     * AES256_GCM 数据加密
     * @param base64AesKey Base64编码AES密钥
     * @param iv           向量IV
     * @param aad          AAD (url_path + app_id + req_timestamp + sn), 中间竖线分隔
     * @param plainText    明文字符串
     * @return 加密后的请求数据
     */publicstaticWxApiGatewayRequestencryptByAES(String base64AesKey,String iv,String aad,String plainText)throwsException{byte[] keyAsBytes = DECODER.decode(base64AesKey);byte[] ivAsBytes = DECODER.decode(iv);byte[] aadAsBytes = aad.getBytes(StandardCharsets.UTF_8);byte[] plainTextAsBytes = plainText.getBytes(StandardCharsets.UTF_8);// AES256_GCM加密Cipher cipher =Cipher.getInstance(AES_TRANSFORMATION);SecretKeySpec keySpec =newSecretKeySpec(keyAsBytes, AES_ALGORITHM);GCMParameterSpec parameterSpec =newGCMParameterSpec(GCM_TAG_LENGTH, ivAsBytes);
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, parameterSpec);
        cipher.updateAAD(aadAsBytes);// 前16字节为加密数据,后16字节为授权标识byte[] cipherTextAsBytes = cipher.doFinal(plainTextAsBytes);byte[] encryptedData =Arrays.copyOfRange(cipherTextAsBytes,0, cipherTextAsBytes.length -16);byte[] authTag =Arrays.copyOfRange(cipherTextAsBytes, cipherTextAsBytes.length -16, cipherTextAsBytes.length);WxApiGatewayRequest baseRequest =newWxApiGatewayRequest();
        baseRequest.setIv(iv);
        baseRequest.setData(ENCODER.encodeToString(encryptedData));
        baseRequest.setAuthtag(ENCODER.encodeToString(authTag));return baseRequest;}/**
     * AES256_GCM 数据解密
     * @param base64AesKey Base64编码AES密钥
     * @param aad AAD (url_path + app_id + resp_timestamp + sn), 中间竖线分隔
     * @param response 来自微信API网关的响应
     * @return 解密后的请求明文字符串
     * @throws Exception
     */publicstaticStringdecryptByAES(String base64AesKey,String aad,WxApiGatewayResponse response)throwsException{byte[] keyAsBytes = DECODER.decode(base64AesKey);byte[] aadAsBytes = aad.getBytes(StandardCharsets.UTF_8);byte[] ivAsBytes = DECODER.decode(response.getIv());byte[] truncateTextAsBytes = DECODER.decode(response.getData());byte[] authTagAsBytes = DECODER.decode(response.getAuthtag());byte[] cipherTextAsBytes =newbyte[truncateTextAsBytes.length + authTagAsBytes.length];// 需要将截断的字节和authTag的字节部分重新组装System.arraycopy(truncateTextAsBytes,0, cipherTextAsBytes,0, truncateTextAsBytes.length);System.arraycopy(authTagAsBytes,0, cipherTextAsBytes, truncateTextAsBytes.length, authTagAsBytes.length);Cipher cipher =Cipher.getInstance(AES_TRANSFORMATION);SecretKeySpec keySpec =newSecretKeySpec(keyAsBytes, AES_ALGORITHM);GCMParameterSpec parameterSpec =newGCMParameterSpec(GCM_TAG_LENGTH, ivAsBytes);
        cipher.init(Cipher.DECRYPT_MODE, keySpec, parameterSpec);
        cipher.updateAAD(aadAsBytes);byte[] plainTextAsBytes = cipher.doFinal(cipherTextAsBytes);returnnewString(plainTextAsBytes,StandardCharsets.UTF_8);}/**
     * RSA with SHA256请求参数加签
     * @param base64PrivateKey Base64编码RSA加签私钥
     * @param payload          请求负载(url_path + app_id + req_timestamp + req_data), 中间换行符分隔
     * @return 签名后的字符串
     */publicstaticStringsignByRSAWithSHA256(String base64PrivateKey,String payload)throwsException{byte[] privateKeyAsBytes = DECODER.decode(base64PrivateKey);PKCS8EncodedKeySpec keySpec =newPKCS8EncodedKeySpec(privateKeyAsBytes);RSAPrivateKey privateKey =(RSAPrivateKey)KeyFactory.getInstance(RSA_ALGORITHM).generatePrivate(keySpec);Signature signature =Signature.getInstance(SIGNATURE_ALGORITHM);PSSParameterSpec parameterSpec =newPSSParameterSpec(HASH_ALGORITHM, MFG_ALGORITHM,MGF1ParameterSpec.SHA256,32,1);
        signature.setParameter(parameterSpec);
        signature.initSign(privateKey);
        signature.update(payload.getBytes(StandardCharsets.UTF_8));byte[] signatureAsBytes = signature.sign();return ENCODER.encodeToString(signatureAsBytes);}/**
     * RSA with SHA256响应内容验签
     * @param payload 响应负载(url_path + app_id + resp_timestamp + resp_data)
     * @param base64Certificate 验签证书(Base64编码)
     * @param signature 请求签名
     * @return 是否验签通过
     * @throws Exception
     */publicstaticbooleanverifySignature(String payload,String base64Certificate,String signature)throwsException{CertificateFactory certificateFactory =CertificateFactory.getInstance(CERTIFICATE_TYPE);ByteArrayInputStream inputStream =newByteArrayInputStream(DECODER.decode(base64Certificate));X509Certificate x509Certificate =(X509Certificate) certificateFactory.generateCertificate(inputStream);Signature verifier =Signature.getInstance(SIGNATURE_ALGORITHM);PSSParameterSpec parameterSpec =newPSSParameterSpec(HASH_ALGORITHM, MFG_ALGORITHM,MGF1ParameterSpec.SHA256,32,1);
        verifier.setParameter(parameterSpec);
        verifier.initVerify(x509Certificate);
        verifier.update(payload.getBytes(StandardCharsets.UTF_8));byte[] signatureInBytes = DECODER.decode(signature);return verifier.verify(signatureInBytes);}/**
     * 生成Base64随机IV
     * @return
     */publicstaticStringgenerateRandomIV(){byte[] bytes =newbyte[12];newSecureRandom().nextBytes(bytes);return ENCODER.encodeToString(bytes);}publicstaticStringgenerateNonce(){byte[] bytes =newbyte[16];newSecureRandom().nextBytes(bytes);return ENCODER.encodeToString(bytes).replace("=",StringUtils.EMPTY);}}

四、HTTP调用封装

(1) HttpClientProperties

importlombok.Data;importorg.springframework.boot.context.properties.ConfigurationProperties;importjava.time.Duration;/**
 * @author 刘亚楼
 * @date 2022/5/10
 */@Data@ConfigurationProperties(prefix ="http.client")publicclassHttpClientProperties{/**
     * 连接最大空闲时间
     */privateDuration maxIdleTime =Duration.ofSeconds(5);/**
     * 与服务端建立连接超时时间
     */privateDuration connectionTimeout =Duration.ofSeconds(5);/**
     * 客户端从服务器读取数据超时时间
     */privateDuration socketTimeout =Duration.ofSeconds(10);/**
     * 从连接池获取连接超时时间
     */privateDuration connectionRequestTimeout =Duration.ofSeconds(3);/**
     * 连接池最大连接数
     */privateint maxTotal =500;/**
     * 每个路由(即ip+端口)最大连接数
     */privateint defaultMaxPerRoute =50;}

(2) HttpClientManager

这个类包含了http请求的封装,如下:

importorg.apache.commons.lang3.builder.ToStringBuilder;importorg.apache.commons.lang3.builder.ToStringStyle;importorg.apache.http.Consts;importorg.apache.http.Header;importorg.apache.http.HttpEntity;importorg.apache.http.HttpStatus;importorg.apache.http.NameValuePair;importorg.apache.http.client.HttpClient;importorg.apache.http.client.entity.UrlEncodedFormEntity;importorg.apache.http.client.methods.CloseableHttpResponse;importorg.apache.http.client.methods.HttpGet;importorg.apache.http.client.methods.HttpPost;importorg.apache.http.client.methods.HttpRequestBase;importorg.apache.http.client.protocol.HttpClientContext;importorg.apache.http.client.utils.URIBuilder;importorg.apache.http.entity.ContentType;importorg.apache.http.entity.StringEntity;importorg.apache.http.message.AbstractHttpMessage;importorg.apache.http.message.BasicNameValuePair;importorg.apache.http.util.EntityUtils;importorg.springframework.util.CollectionUtils;importjava.io.IOException;importjava.util.ArrayList;importjava.util.Collections;importjava.util.HashMap;importjava.util.List;importjava.util.Map;/**
 * Convenient class for http invocation.
 * @author 刘亚楼
 * @date 2022/5/10
 */publicclassHttpClientManager{privatefinalHttpClient httpClient;publicHttpClientManager(HttpClient httpClient){this.httpClient = httpClient;}publicHttpClientRespget(String url)throwsException{returnthis.get(url,Collections.emptyMap(),Collections.emptyMap());}/**
     * 发送get请求
     * @param url 资源地址
     * @param headers
     * @param params 请求参数
     * @return
     * @throws Exception
     */publicHttpClientRespget(String url,Map<String,Object> headers,Map<String,Object> params)throwsException{URIBuilder uriBuilder =newURIBuilder(url);if(!CollectionUtils.isEmpty(params)){for(Map.Entry<String,Object> param : params.entrySet()){
                uriBuilder.setParameter(param.getKey(),String.valueOf(param.getValue()));}}HttpGet httpGet =newHttpGet(uriBuilder.build());setHeaders(httpGet, headers);returngetResponse(httpGet);}/**
     * 模拟表单发送post请求
     * @param url 资源地址
     *
     * @param params 请求参数
     * @return
     * @throws IOException
     */publicHttpClientResppostInHtmlForm(String url,Map<String,Object> params)throwsIOException{HttpPost httpPost =newHttpPost(url);if(!CollectionUtils.isEmpty(params)){List<NameValuePair> formParams =newArrayList<>();for(Map.Entry<String,Object> param : params.entrySet()){
                formParams.add(newBasicNameValuePair(param.getKey(),String.valueOf(param.getValue())));}
            httpPost.setEntity(newUrlEncodedFormEntity(formParams,Consts.UTF_8));}returngetResponse(httpPost);}publicHttpClientResppostInJson(String url,String jsonStr)throwsIOException{returnthis.postInJson(url,Collections.emptyMap(), jsonStr);}/**
     * 发送post请求,请求参数格式为json
     * @param url 资源地址
     * @param headers 请求头信息
     * @param jsonStr 请求参数json字符串
     * @return
     * @throws IOException
     */publicHttpClientResppostInJson(String url,Map<String,Object> headers,String jsonStr)throwsIOException{HttpPost httpPost =newHttpPost(url);setHeaders(httpPost, headers);
        httpPost.setEntity(newStringEntity(jsonStr,ContentType.APPLICATION_JSON));returngetResponse(httpPost);}publicstaticvoidsetHeaders(AbstractHttpMessage message,Map<String,Object> headers){if(!CollectionUtils.isEmpty(headers)){for(Map.Entry<String,Object> header : headers.entrySet()){
                message.setHeader(header.getKey(),String.valueOf(header.getValue()));}}}privateHttpClientRespgetResponse(HttpRequestBase request)throwsIOException{try(CloseableHttpResponse response =(CloseableHttpResponse) httpClient.execute(request,HttpClientContext.create())){HttpClientResp resp =newHttpClientResp();int statusCode = response.getStatusLine().getStatusCode();if(statusCode >=HttpStatus.SC_OK && statusCode <HttpStatus.SC_MULTIPLE_CHOICES){Map<String,String> headers =newHashMap<>();for(Header header : response.getAllHeaders()){
                    headers.put(header.getName(), header.getValue());}HttpEntity httpEntity = response.getEntity();
                resp.setSuccessful(true);
                resp.setHeaders(headers);
                resp.setContentType(httpEntity.getContentType().getValue());
                resp.setContentLength(httpEntity.getContentLength());
                resp.setRespContent(EntityUtils.toString(httpEntity,Consts.UTF_8));if(httpEntity.getContentEncoding()!=null){
                    resp.setContentEncoding(httpEntity.getContentEncoding().getValue());}}return resp;}}publicstaticclassHttpClientResp{privateString respContent;privatelong contentLength;privateString contentType;privateString contentEncoding;privateMap<String,String> headers;privateboolean successful;publicStringgetRespContent(){return respContent;}publicvoidsetRespContent(String respContent){this.respContent = respContent;}publiclonggetContentLength(){return contentLength;}publicvoidsetContentLength(long contentLength){this.contentLength = contentLength;}publicStringgetContentType(){return contentType;}publicvoidsetContentType(String contentType){this.contentType = contentType;}publicStringgetContentEncoding(){return contentEncoding;}publicvoidsetContentEncoding(String contentEncoding){this.contentEncoding = contentEncoding;}publicMap<String,String>getHeaders(){return headers;}publicvoidsetHeaders(Map<String,String> headers){this.headers = headers;}publicbooleanisSuccessful(){return successful;}publicvoidsetSuccessful(boolean successful){this.successful = successful;}@OverridepublicStringtoString(){returnToStringBuilder.reflectionToString(this,ToStringStyle.SHORT_PREFIX_STYLE);}}}

五、微信服务端API网关调用封装

1、基础类

(1) WxApiGatewayBaseDTO

该类为请求业务JSON参数基类,里面包含了

_n

_appid

_timestamp

三个安全字段。

importcom.alibaba.fastjson.annotation.JSONField;importlombok.Data;/**
 * @author Nick Liu
 * @date 2024/7/3
 */@DatapublicclassWxApiGatewayBaseDTO{/**
     * 安全字段:nonce随机值
     */@JSONField(name ="_n")privateString nonce;/**
     * 安全字段:app id
     */@JSONField(name ="_appid")privateString appid;/**
     * 安全字段:时间戳
     */@JSONField(name ="_timestamp")privateLong timestamp;}

(2) WxApiGatewayUrlParamBaseDTO

这里是微信API网关URL参数的基类,这里只定义,没有具体参数。

importlombok.Data;/**
 * 微信API网关URL参数DTO
 * @author Nick Liu
 * @date 2024/7/27
 */@DatapublicclassWxApiGatewayUrlParamBaseDTO{}

(3) GenericUrlParamsDTO

@Data@Builder@AllArgsConstructor@NoArgsConstructorpublicclassGenericUrlParamsDTOextendsWxApiGatewayUrlParamBaseDTO{@JSONField(name ="access_token")privateString accessToken;}

(4) WxApiGatewayErrorMsgVO

这个类包含了微信API网关返回的错误信息,如下:

importcom.alibaba.fastjson.annotation.JSONField;importlombok.Data;/**
 * @author Nick Liu
 * @date 2024/8/6
 */@DatapublicclassWxApiGatewayErrorMsgVO{@JSONField(name ="errcode")privateInteger errorCode;@JSONField(name ="errmsg")privateString errorMsg;}

(4) WxApiGatewayBaseVO

这里是微信API网关返回的响应内容基类,当碰到异常时,会返回

WxApiGatewayErrorMsgVO

类里的错误信息,正常调用会返回该类的信息,如下:

importcom.alibaba.fastjson.annotation.JSONField;importlombok.Data;/**
 * @author Nick Liu
 * @date 2024/7/3
 */@DatapublicclassWxApiGatewayBaseVOextendsWxApiGatewayErrorMsgVO{/**
     * 安全字段:nonce随机值
     */@JSONField(name ="_n")privateString nonce;/**
     * 安全字段:app id
     */@JSONField(name ="_appid")privateString appid;/**
     * 安全字段:时间戳
     */@JSONField(name ="_timestamp")privatelong timestamp;}

2、属性类和工具类

(1) WxApiGatewayProperties

@Data@Component@ConfigurationProperties(prefix ="wx.gateway")publicclassWxApiGatewayProperties{/**
     * 微信网关调用host
     */privateString host;/**
     * 小程序APP ID
     */privateString appId;/**
     * 小程序APP Secret
     */privateString appSecret;/**
     * 对称密钥编号
     */privateString symmetricSn;/**
     * 对称密钥编号
     */privateString asymmetricSn;/**
     * 小程序加密密钥
     */privateString aesKey;/**
     * 小程序加密私钥
     */privateString privateKey;/**
     * 小程序通信验签证书
     */privateString certificate;}

(2) FastJsonUtils

/**
 * json字符串与java bean转换工具类
 * @author: liuyalou
 * @date: 2019年10月29日
 */publicclassFastJsonUtils{publicstaticStringtoJsonString(Object obj){returntoJsonString(obj,null,false,false);}publicstaticStringtoJsonString(Object obj,SerializeFilter... filters){returntoJsonString(obj,null,false,false, filters);}publicstaticStringtoJsonStringWithNullValue(Object obj,SerializeFilter... filters){returntoJsonString(obj,null,true,false, filters);}publicstaticStringtoPrettyJsonString(Object obj,SerializeFilter... filters){returntoJsonString(obj,null,false,true, filters);}publicstaticStringtoPrettyJsonStringWithNullValue(Object obj,SerializeFilter... filters){returntoJsonString(obj,null,true,true, filters);}publicstaticStringtoJsonStringWithDateFormat(Object obj,String dateFormat,SerializeFilter... filters){returntoJsonString(obj, dateFormat,false,false, filters);}publicstaticStringtoJsonStringWithDateFormatAndNullValue(Object obj,String dateFormat,SerializeFilter... filters){returntoJsonString(obj, dateFormat,true,false, filters);}publicstaticStringtoPrettyJsonStringWithDateFormat(Object obj,String dateFormat,SerializeFilter... filters){returntoJsonString(obj, dateFormat,false,true, filters);}publicstaticStringtoPrettyJsonStringWithDateFormatAndNullValue(Object obj,String dateFormat,SerializeFilter... filters){returntoJsonString(obj, dateFormat,true,true, filters);}publicstaticStringtoJsonString(Object obj,String dateFormat,boolean writeNullValue,boolean prettyFormat,SerializeFilter... filters){if(obj ==null){returnnull;}int defaultFeature = JSON.DEFAULT_GENERATE_FEATURE;if(writeNullValue){return prettyFormat ?
                JSON.toJSONString(obj,SerializeConfig.globalInstance, filters, dateFormat, defaultFeature,SerializerFeature.WriteMapNullValue,SerializerFeature.PrettyFormat):
                JSON.toJSONString(obj,SerializeConfig.globalInstance, filters, dateFormat, defaultFeature,SerializerFeature.WriteMapNullValue);}return prettyFormat ?
            JSON.toJSONString(obj,SerializeConfig.globalInstance, filters, dateFormat, defaultFeature,SerializerFeature.PrettyFormat):
            JSON.toJSONString(obj,SerializeConfig.globalInstance, filters, dateFormat, defaultFeature);}publicstatic<T>TtoJavaBean(String jsonStr,Class<T> clazz){if(StringUtils.isBlank(jsonStr)){returnnull;}return JSON.parseObject(jsonStr, clazz);}publicstatic<T>List<T>toList(String jsonStr,Class<T> clazz){if(StringUtils.isBlank(jsonStr)){returnnull;}return JSON.parseArray(jsonStr, clazz);}publicstaticMap<String,Object>toMap(String jsonStr){if(StringUtils.isBlank(jsonStr)){returnnull;}return JSON.parseObject(jsonStr,newTypeReference<Map<String,Object>>(){});}publicstaticMap<String,Integer>toIntegerValMap(String jsonStr){if(StringUtils.isBlank(jsonStr)){returnnull;}return JSON.parseObject(jsonStr,newTypeReference<Map<String,Integer>>(){});}publicstaticMap<String,String>toStringValMap(String jsonStr){if(StringUtils.isBlank(jsonStr)){returnnull;}return JSON.parseObject(jsonStr,newTypeReference<Map<String,String>>(){});}publicstaticMap<String,Object>beanToMap(Object obj){if(Objects.isNull(obj)){returnnull;}returntoMap(toJsonString(obj));}publicstatic<T>TmapToJavaBean(Map<String,?extendsObject> map,Class<T> clazz){if(CollectionUtils.isEmpty(map)){returnnull;}String jsonStr = JSON.toJSONString(map);return JSON.parseObject(jsonStr, clazz);}/**
     *
     * 对象所有的key,包括嵌套对象的key都会按照自然顺序排序
     * @param obj
     * @return
     */publicstaticStringtoKeyOrderedJsonString(Object obj){returntoJsonString(beanToTreeMap(obj));}/**
     * 对象所有的key按原始顺序排序
     * @param obj
     * @return
     */publicstaticStringtoKeyLinkedJsonString(Object obj){returntoJsonString(beanToLinkedHashMap(obj));}publicstaticMap<String,Object>beanToTreeMap(Object obj){if(Objects.isNull(obj)){returnnull;}returntoTreeMap(toJsonString(obj));}publicstaticMap<String,Object>beanToLinkedHashMap(Object obj){if(Objects.isNull(obj)){returnnull;}Map<String,Object> linkHashMap =newLinkedHashMap<>();Field[] fields = obj.getClass().getDeclaredFields();for(Field field : fields){
            field.setAccessible(true);
            linkHashMap.put(field.getName(),ReflectionUtils.getField(field, obj));}return linkHashMap;}publicstaticMap<String,Object>toTreeMap(String jsonStr){if(StringUtils.isBlank(jsonStr)){returnnull;}JSONObject jsonObject = JSON.parseObject(jsonStr);returnconvertJsonObjectToMap(jsonObject,TreeMap::new);}privatestaticMap<String,Object>convertJsonObjectToMap(JSONObject jsonObject,Supplier<Map<String,Object>> supplier){Map<String,Object> map = supplier.get();
        jsonObject.forEach((key, value)->{if(value instanceofJSONObject){// 如果是JSON对象则递归遍历
                map.put(key,convertJsonObjectToMap((JSONObject) value, supplier));}elseif(value instanceofJSONArray){// 如果是数组则对数组中的元素重新排序List<Object> list =newArrayList<>();JSONArray jsonArray =(JSONArray) value;
                jsonArray.forEach(obj ->{
                    list.add((obj instanceofJSONObject)?convertJsonObjectToMap((JSONObject) obj, supplier): obj);});
                map.put(key, list);}else{// 如果是普通类型则直接赋值
                map.put(key, value);}});return map;}}

3、枚举类

(1) WxApiHeaderEnum

/**
 * Wx API网关调用Header
 * @author Nick Liu
 * @date 2024/7/27
 */@GetterpublicenumWxApiHeaderEnum{APP_ID("Wechatmp-Appid","当前小程序的Appid"),TIMESTAMP("Wechatmp-TimeStamp","时间戳"),SERIAL("Wechatmp-Serial","平台证书编号,在MP管理页面获取,非证书内序列号"),SIGNATURE("Wechatmp-Signature","平台证书签名数据,使用base64编码"),;privatefinalString value;privatefinalString desc;WxApiHeaderEnum(String value,String desc){this.value = value;this.desc = desc;}}

(2) WxApiMsgTypeEnum

/**
 * @author Nick Liu
 * @date 2024/7/24
 */@GetterpublicenumWxApiMsgTypeEnum{/**
     * 获取稳定版接口调用凭据
     */GET_ACCESS_TOKEN("/cgi-bin/stable_token",HttpMethod.POST,false),/**
     * 查询每日调用接口的额度,调用次数,频率限制
     */GET_API_QUOTA("/cgi-bin/openapi/quota/get",HttpMethod.POST,false),/**
     * 查询小程序域名配置信息
     */GET_DOMAIN_INFO("/wxa/getwxadevinfo",HttpMethod.POST,true),/**
     * 小程序登录
     */LOGIN("/cgi-bin/stable_token",HttpMethod.GET,false);;/**
     * URL路径
     */privatefinalString urlPath;/**
     * 支持的HTTP请求方式
     */privatefinalHttpMethod httpMethod;/**
     * 是否支持安全鉴权,可鉴权的API参考:<a href=https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc>微信Open API</a>
     */privatefinalboolean supportSecurityAuth;WxApiMsgTypeEnum(String urlPath,HttpMethod httpMethod,boolean supportSecurityAuth){this.urlPath = urlPath;this.httpMethod = httpMethod;this.supportSecurityAuth = supportSecurityAuth;}publicstaticWxApiMsgTypeEnumfromUrl(String urlPath){returnArrays.stream(WxApiMsgTypeEnum.values()).filter(e -> e.urlPath.equals(urlPath)).findAny().orElse(null);}}

(3) BizExceptionEnum

@GetterpublicenumBizExceptionEnum{INVALID_PARAMS("A0101","Invalid request params"),SYSTEM_ERROR("B0001","System exception, please concat customer service"),WX_GATEWAY_SYSTEM_ERROR("wx_5000","WX gateway invocation system error"),WX_GATEWAY_BIZ_ERROR("wx_5001","WX gateway invocation biz error"),;privatefinalString code;privatefinalString message;BizExceptionEnum(String code,String message){this.code = code;this.message = message;}publicstaticBizExceptionEnumfromCode(String code){returnArrays.stream(BizExceptionEnum.values()).filter(bizExceptionEnum -> bizExceptionEnum.code.equals(code)).findAny().orElse(null);}}

4、网关核心调用抽象类

/**
 * 微信API网关调用封装,包括安全鉴权(加解密,加验签),数据转换等。<br/>
 * 安全鉴权需要在小程序管理后台开启
 * @author Nick Liu
 * @date 2024/7/24
 */@Slf4jpublicabstractclassAbstractWxApiGatewayInvocationService{privatestaticfinalString VERTICAL_LINE_SEPARATOR ="|";privatestaticfinalString NEW_LINE_SEPARATOR ="\n";@AutowiredprivateWxApiGatewayProperties wxApiGatewayProperties;@AutowiredprivateHttpClientManager httpClientManager;/**
     * 预处理请求负载,填充安全字段
     * @param payload
     * @param <T>
     */private<TextendsWxApiGatewayBaseDTO>voidpreProcess(T payload){
        payload.setAppid(wxApiGatewayProperties.getAppId());
        payload.setNonce(WxApiCryptoUtils.generateNonce());
        payload.setTimestamp(DateTimeUtils.getUnixTimestamp());}/**
     * 请求数据加密
     * @param requestUrl 当前请求API的URL,不包括URL参数(URL Query),需要带HTTP协议头
     * @param payload 请求负载
     * @return 响应内容
     * @param <T> 响应内容参数泛型
     * @throws Exception
     */private<TextendsWxApiGatewayBaseDTO>WxApiGatewayRequestencryptRequest(String requestUrl,T payload)throwsException{String appId = wxApiGatewayProperties.getAppId();String sn = wxApiGatewayProperties.getSymmetricSn();String secretKey = wxApiGatewayProperties.getAesKey();long timeStamp = payload.getTimestamp();List<String> aadParamList =Arrays.asList(requestUrl, appId,String.valueOf(timeStamp), sn);String aad =StringUtils.join(aadParamList, VERTICAL_LINE_SEPARATOR);String iv =WxApiCryptoUtils.generateRandomIV();String plainText =FastJsonUtils.toJsonString(payload);returnWxApiCryptoUtils.encryptByAES(secretKey, iv, aad, plainText);}/**
     * 请求签名
     * @param requestUrl 请求URL
      * @param plainPayload 明文请求负载
     * @param cipherPayload 密文请求负载
     * @return Base64签名字符串
     * @param <T> 请求参数泛型
     * @throws Exception
     */private<TextendsWxApiGatewayBaseDTO>Stringsign(String requestUrl,T plainPayload,String cipherPayload)throwsException{String appId = wxApiGatewayProperties.getAppId();String privateKey = wxApiGatewayProperties.getPrivateKey();long timestamp = plainPayload.getTimestamp();List<String> signDataList =Arrays.asList(requestUrl, appId,String.valueOf(timestamp), cipherPayload);String signData =StringUtils.join(signDataList, NEW_LINE_SEPARATOR);returnWxApiCryptoUtils.signByRSAWithSHA256(privateKey, signData);}/**
     * 响应解密
     * @param requestUrl 请求url
     * @param respHeaders 响应头
     * @param resp 加密响应数据
     * @return 解密后的响应报文
     * @throws Exception
     */privateStringdecryptResp(String requestUrl,Map<String,String> respHeaders,WxApiGatewayResponse resp)throwsException{String respTimestamp = respHeaders.get(WxApiHeaderEnum.TIMESTAMP.getValue());String appId = wxApiGatewayProperties.getAppId();String sn = wxApiGatewayProperties.getSymmetricSn();String secretKey = wxApiGatewayProperties.getAesKey();List<String> aadParamList =Arrays.asList(requestUrl, appId, respTimestamp, sn);String aad =StringUtils.join(aadParamList, VERTICAL_LINE_SEPARATOR);returnWxApiCryptoUtils.decryptByAES(secretKey, aad, resp);}/**
     * 响应验签
     * @param requestUrl 请求url
     * @param respHeaders 响应头
     * @param resp 加密后的响应数据
     * @return 是否验签通过
     * @throws Exception
     */privatebooleanverifySignature(String requestUrl,Map<String,String> respHeaders,WxApiGatewayResponse resp)throwsException{String appId = wxApiGatewayProperties.getAppId();String certificate = wxApiGatewayProperties.getCertificate();String respTimestamp = respHeaders.get(WxApiHeaderEnum.TIMESTAMP.getValue());String respDataStr =FastJsonUtils.toJsonString(resp);String signature = respHeaders.get(WxApiHeaderEnum.SIGNATURE.getValue());List<String> aadParamList =Arrays.asList(requestUrl, appId, respTimestamp, respDataStr);String payload =StringUtils.join(aadParamList, NEW_LINE_SEPARATOR);returnWxApiCryptoUtils.verifySignature(payload, certificate, signature);}protectedabstractBizExceptionprocessInvocationException(Exception e);/**
     *  发送GET请求到微信API网关
     * @param msgType 消息类型
     * @param urlParams URL参数
     * @param clazz 返回明文Class实例
     * @return 明文响应内容
     * @param <T> 业务请求负载泛型
     * @param <U> 业务请求URL参数泛型
     * @param <R> 业务返回响应泛型
     * @throws Exception
     */protected<TextendsWxApiGatewayBaseDTO,UextendsWxApiGatewayUrlParamBaseDTO,RextendsWxApiGatewayBaseVO>RsendGetToWxApiGateway(WxApiMsgTypeEnum msgType,U urlParams,Class<R> clazz){try{returnthis.sendRequestToWxApiGateway(msgType, urlParams,null, clazz);}catch(Exception e){
            log.error("微信API网关调用异常: {}", e.getMessage(), e);throwthis.processInvocationException(e);}}/**
     *  发送POST请求到微信API网关
     * @param msgType 消息类型
     * @param urlParams URL参数
     * @param payload 请求负载: 只有POST请求才有
     * @param clazz 返回明文Class实例
     * @return 明文响应内容
     * @param <T> 业务请求负载泛型
     * @param <U> 业务请求URL参数泛型
     * @param <R> 业务返回响应泛型
     * @throws Exception
     */protected<TextendsWxApiGatewayBaseDTO,UextendsWxApiGatewayUrlParamBaseDTO,RextendsWxApiGatewayBaseVO>RsendPostToWxApiGateway(WxApiMsgTypeEnum msgType,U urlParams,T payload,Class<R> clazz){try{returnthis.sendRequestToWxApiGateway(msgType, urlParams, payload, clazz);}catch(Exception e){
            log.error("微信API网关调用异常: {}", e.getMessage(), e);throwthis.processInvocationException(e);}}/**
     *  发送请求到微信API网关
     * @param msgType 消息类型
     * @param urlParams URL参数
     * @param payload 请求负载: 只有POST请求才有
     * @param clazz 返回明文Class实例
     * @return 明文响应内容
     * @param <T> 业务请求负载泛型
     * @param <U> 业务请求URL参数泛型
     * @param <R> 业务返回响应泛型
     * @throws Exception
     */private<TextendsWxApiGatewayBaseDTO,UextendsWxApiGatewayUrlParamBaseDTO,RextendsWxApiGatewayBaseVO>RsendRequestToWxApiGateway(WxApiMsgTypeEnum msgType,U urlParams,@NullableT payload,Class<R> clazz)throwsException{// 1、拼接完整的URLString host = wxApiGatewayProperties.getHost();String urlParamsStr =this.generateUrlParams(urlParams);String requestUrl = host + msgType.getUrlPath();String fullRequestUrl = requestUrl + urlParamsStr;// 2、GET请求不支持安全授权,直接发起网关调用if(HttpMethod.GET == msgType.getHttpMethod()){
            log.info("微信API网关[GET]请求, url: [{}]", requestUrl);HttpClientResp httpClientResp = httpClientManager.get(fullRequestUrl);String respStr = httpClientResp.getRespContent();
            log.info("微信API网关[GET]响应, url: [{}], 响应内容:{}", requestUrl, respStr);R response =FastJsonUtils.toJavaBean(respStr, clazz);this.processRespCode(response);return response;}// 3、只有post请求且需要安全验证才验签if(HttpMethod.POST == msgType.getHttpMethod()&& msgType.isSupportSecurityAuth()){// 参数预处理,填充安全字段this.preProcess(payload);// 3.2 请求加密WxApiGatewayRequest wxApiGatewayRequest =this.encryptRequest(requestUrl, payload);String plainReqStr =FastJsonUtils.toJsonString(payload);String cipherReqStr =FastJsonUtils.toKeyLinkedJsonString(wxApiGatewayRequest);// 3.1 签名String signature =this.sign(requestUrl, payload, cipherReqStr);Map<String,Object> headers =newHashMap<>();
            headers.put(WxApiHeaderEnum.APP_ID.getValue(), payload.getAppid());
            headers.put(WxApiHeaderEnum.TIMESTAMP.getValue(), payload.getTimestamp());
            headers.put(WxApiHeaderEnum.SIGNATURE.getValue(), signature);String headersStr =FastJsonUtils.toJsonString(headers);// 3.3 发起网关调用
            log.info("微信API网关[POST]请求, url: [{}], 请求明文:{}", requestUrl, plainReqStr);
            log.info("微信API网关[POST]请求, url: [{}], 请求头:{}, 请求密文:{}", requestUrl, headersStr, cipherReqStr);HttpClientResp httpClientResp = httpClientManager.postInJson(fullRequestUrl, headers, cipherReqStr);String cipherRespStr = httpClientResp.getRespContent();// String respHeaderStr = FastJsonUtils.toJsonString(httpClientResp.getHeaders());
            log.info("微信API网关[POST]响应, url: [{}], 响应密文:{}", requestUrl, cipherRespStr);// 响应可能会失败,解密前处理特殊情况R response =FastJsonUtils.toJavaBean(cipherRespStr, clazz);this.processRespCode(response);// 3.4 解密响应报文WxApiGatewayResponse cipherResp =FastJsonUtils.toJavaBean(cipherRespStr,WxApiGatewayResponse.class);String plainRespStr =this.decryptResp(requestUrl, httpClientResp.getHeaders(), cipherResp);
            log.info("微信API网关[POST]响应, url: [{}], 响应明文:{}", requestUrl, plainRespStr);returnFastJsonUtils.toJavaBean(plainRespStr, clazz);}// 4、只需POST请求无需验签if(HttpMethod.POST == msgType.getHttpMethod()){String plainRequestStr =FastJsonUtils.toJsonString(payload);
            log.info("微信API网关[POST]请求, url: [{}], 请求明文:{}", requestUrl, plainRequestStr);HttpClientResp httpClientResp = httpClientManager.postInJson(fullRequestUrl, plainRequestStr);String plainRespStr = httpClientResp.getRespContent();
            log.info("微信API网关[POST]响应, url: [{}], 响应明文:{}", requestUrl, plainRespStr);R response =FastJsonUtils.toJavaBean(plainRespStr, clazz);this.processRespCode(response);return response;}thrownewUnsupportedOperationException("只支持GET或者POST请求");}private<RextendsWxApiGatewayBaseVO>voidprocessRespCode(R response){if(!Objects.isNull(response.getErrorCode())&&WxApiGatewayErrorCode.SUCCESS != response.getErrorCode()){thrownewBizException(BizExceptionEnum.WX_GATEWAY_BIZ_ERROR, response.getErrorMsg());}}/**
     * 生成URL参数
     * @param urlParam URL参数实例
     * @return 带?的参数字符串
     * @param <U> URL参数泛型
     * @throws Exception
     */private<UextendsWxApiGatewayUrlParamBaseDTO>StringgenerateUrlParams(U urlParam)throwsException{if(Objects.isNull(urlParam)){returnStringUtils.EMPTY;}Field[] fields = urlParam.getClass().getDeclaredFields();if(ArrayUtils.isEmpty(fields)){returnStringUtils.EMPTY;}StringBuilder urlPramsBuilder =newStringBuilder("?");for(Field field : fields){
            field.setAccessible(true);JSONField jsonField = field.getAnnotation(JSONField.class);String fieldName =Objects.isNull(jsonField)? field.getName(): jsonField.name();Object fieldValue = field.get(urlParam);if(!Objects.isNull(fieldValue)){
                urlPramsBuilder.append(fieldName).append("=").append(fieldValue).append("&");}}

        urlPramsBuilder.deleteCharAt(urlPramsBuilder.length()-1);return urlPramsBuilder.toString();}}

5、网关核心调用业务类

/**
 * 微信API网关调用器,指定消息类型,业务请求参数和响应内容类型即可
 * @author Nick Liu
 * @date 2024/7/27
 */@Slf4j@ServicepublicclassWxApiGatewayInvokerextendsAbstractWxApiGatewayInvocationService{@OverrideprotectedBizExceptionprocessInvocationException(Exception e){if(e instanceofBizException){throw(BizException) e;}returnnewBizException(BizExceptionEnum.WX_GATEWAY_SYSTEM_ERROR);}/**
     * 获取稳定版本接口调用凭证
     * @param stableAccessTokenDTO 获取稳定版本Token业务参数
     * @return
     */publicStableAccessTokenVOgetStableAccessToken(StableAccessTokenDTO stableAccessTokenDTO){returnsuper.sendPostToWxApiGateway(WxApiMsgTypeEnum.GET_ACCESS_TOKEN,null, stableAccessTokenDTO,StableAccessTokenVO.class);}/**
     * 查询API调用额度
     * @param genericUrlParamsDTO
     * @param apiQuotaDTO
     * @return
     */publicApiQuotaVOgetApiQuota(GenericUrlParamsDTO genericUrlParamsDTO,ApiQuotaDTO apiQuotaDTO){returnsuper.sendPostToWxApiGateway(WxApiMsgTypeEnum.GET_API_QUOTA, genericUrlParamsDTO, apiQuotaDTO,ApiQuotaVO.class);}/**
     * 查询域名配置
     * @param genericUrlParamsDTO
     * @param domainInfoDTO
     * @return
     */publicDomainInfoVOgetDomainInfo(GenericUrlParamsDTO genericUrlParamsDTO,DomainInfoDTO domainInfoDTO){returnsuper.sendPostToWxApiGateway(WxApiMsgTypeEnum.GET_DOMAIN_INFO, genericUrlParamsDTO, domainInfoDTO,DomainInfoVO.class);}/**
     * 小程序登录接口
     * @param miniProgramLoginDTO 小程序登录接口业务参数
     * @return
     */publicMiniProgramLoginVOlogin(MiniProgramLoginDTO miniProgramLoginDTO){returnsuper.sendGetToWxApiGateway(WxApiMsgTypeEnum.GET_ACCESS_TOKEN, miniProgramLoginDTO,MiniProgramLoginVO.class);}}

六、测试用例

1、application.yml

# http client configurationhttp:client:max-total:500# 连接池最大连接数default-max-per-route:100# 每个路由最大连接数max-idle-time: 5s # 连接最大空闲时间connection-request-timeout: 3s # 从连接池获取连接超时时间connection-timeout: 5s # 与服务端建立连接超时时间socket-timeout: 10s # 客户端从服务器读取数据超时时间# 微信API网关配置wx:gateway:host: https://api.weixin.qq.com
    app-id: appId
    app-secret: appSecret
    # 对称密钥证书编号symmetric-sn: xxx
    # 非对称密钥证书编号asymmetric-sn: xxx
    # AES秘钥aes-key: xxxxxxxxxxxxxxxxxxxxxx
    # 加签私钥private-key: xxxxxxxxxxxxxxxxxxxxxx
    # 验签证书certificate: xxxxxxxxxxxxxxxxxxxxxx

2、相关业务类

1) 获取稳定版接口调用凭据

(1) StableAccessTokenDTO

@Data@Builder@AllArgsConstructor@NoArgsConstructorpublicclassStableAccessTokenDTOextendsWxApiGatewayBaseDTO{/**
     * 填写固定值 client_credential
     */@JSONField(name ="grant_type")privateString grantType ="client_credential";/**
     * 账号唯一凭证,即 AppID
     */@JSONField(name ="appid")privateString appId;/**
     * 账号唯一凭证密钥,即 AppSecret
     */privateString secret;/**
     * 默认使用 false。
     * 1. force_refresh = false 时为普通调用模式,access_token 有效期内重复调用该接口不会更新 access_token;
     * 2. 当force_refresh = true 时为强制刷新模式,会导致上次获取的 access_token 失效,并返回新的 access_token
     */@JSONField(name ="force_refresh")privateboolean forceRefresh;}

(2) StableAccessTokenVO

@Data@Builder@AllArgsConstructor@NoArgsConstructorpublicclassStableAccessTokenVOextendsWxApiGatewayBaseVO{/**
     * 获取到的凭证
     */@JSONField(name ="access_token")privateString accessToken;/**
     * 凭证有效时间,单位:秒。目前是7200秒之内的值。
     */@JSONField(name ="expires_in")privateInteger expiresIn;}

2) 查询小程序域名配置信息

(1) DomainInfoDTO

@Data@Builder@AllArgsConstructor@NoArgsConstructorpublicclassDomainInfoDTOextendsWxApiGatewayBaseDTO{/**
     * 查询配置域名的类型, 可选值如下:
     * 1. getbizdomain 返回业务域名
     * 2. getserverdomain 返回服务器域名
     * 3. 不指明返回全部
     */privateString action;}

(2) DomainInfoVO

@Data@Builder@AllArgsConstructor@NoArgsConstructorpublicclassDomainInfoVOextendsWxApiGatewayBaseVO{@JSONField(name ="requestdomain")privateList<String> requestDomain;}

3、WxApiGatewayController

@RestController@RequestMapping("/wx/api")@RequiredArgsConstructorpublicclassWxApiGatewayController{privatefinalWxApiGatewayInvoker wxApiGatewayInvoker;@PostMapping("/access-token/stable")publicApiResponse<StableAccessTokenVO>getStableAccessToken(@RequestBodyStableAccessTokenDTO stableAccessTokenDTO){returnApiResponse.success(wxApiGatewayInvoker.getStableAccessToken(stableAccessTokenDTO));}@PostMapping("/domain/info")publicApiResponse<DomainInfoVO>getApiQuota(@RequestParamString accessToken,@RequestBodyDomainInfoDTO domainInfoDTO){GenericUrlParamsDTO genericUrlParamsDTO =GenericUrlParamsDTO.builder().accessToken(accessToken).build();returnApiResponse.success(wxApiGatewayInvoker.getDomainInfo(genericUrlParamsDTO, domainInfoDTO));}}

4、测试结果

(1) 获取稳定版接口调用凭据测试

这个接口不支持安全鉴权,测试结果如下:

获取稳定版接口调用凭据测试结果

(2) 查询小程序域名配置信息测试

这个接口支持安全鉴权,测试结果如下:

查询小程序域名配置信息测试结果

在这里插入图片描述


本文转载自: https://blog.csdn.net/lingbomanbu_lyl/article/details/142686691
版权归原作者 Nick说说前后端 所有, 如有侵权,请联系我们删除。

“微信小程序服务端API安全鉴权&统一调用封装”的评论:

还没有评论