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

时间:2024-10-03 07:07:39

目录

  • 一、序言
  • 二、前置准备
    • 1、获取小程序AppID和AppSecret
    • 2、下载对称加密密钥
    • 3、下载加签私钥
    • 4、下载验签证书
  • 三、加解密封装
    • 1、相关基础类
    • 2、加解密工具类
  • 四、HTTP调用封装
  • 五、微信服务端API网关调用封装
    • 1、基础类
    • 2、属性类和工具类
    • 3、枚举类
    • 4、网关核心调用抽象类
    • 5、网关核心调用业务类
  • 六、测试用例
    • 1、application.yml
    • 2、相关业务类
      • 1) 获取稳定版接口调用凭据
      • 2) 查询小程序域名配置信息
    • 3、WxApiGatewayController
    • 4、测试结果
      • (1) 获取稳定版接口调用凭据测试
      • (2) 查询小程序域名配置信息测试

一、序言

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

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

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

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


二、前置准备

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

1、获取小程序AppID和AppSecret

2、下载对称加密密钥

同时我们需要获取对称加密秘钥,这里对称加密密钥类型,我们选择AES256用于数据加解密。
在这里插入图片描述

3、下载加签私钥

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

4、下载验签证书

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


三、加解密封装

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

1、相关基础类

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

@Data
public class WxApiGatewayRequest {

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

}

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

@Data
public class WxApiGatewayResponse {

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

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

2、加解密工具类

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

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

import com.xlyj.common.dto.WxApiGatewayRequest;
import com.xlyj.common.vo.WxApiGatewayResponse;
import org.apache.commons.lang3.StringUtils;

import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.SecureRandom;
import java.security.Signature;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.PSSParameterSpec;
import java.util.Arrays;
import java.util.Base64;

/**
 * 微信API请求和响应加解密、加验签工具类
 * @author Nick Liu
 * @date 2024/7/3
 */
public abstract class WxApiCryptoUtils {

	private static final String AES_ALGORITHM = "AES";
	private static final String AES_TRANSFORMATION = "AES/GCM/NoPadding";
	private static final int GCM_TAG_LENGTH = 128;

	private static final String RSA_ALGORITHM = "RSA";
	private static final String SIGNATURE_ALGORITHM = "RSASSA-PSS";
	private static final String HASH_ALGORITHM = "SHA-256";
	private static final String MFG_ALGORITHM = "MGF1";

	private static final String CERTIFICATE_TYPE = "X.509";

	private static final Base64.Decoder DECODER = Base64.getDecoder();
	private static final Base64.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 加密后的请求数据
	 */
	public static WxApiGatewayRequest encryptByAES(String base64AesKey, String iv, String aad, String plainText) throws Exception {
		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 = new SecretKeySpec(keyAsBytes, AES_ALGORITHM);
		GCMParameterSpec parameterSpec = new GCMParameterSpec(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 = new WxApiGatewayRequest();
		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
	 */
	public static String decryptByAES(String base64AesKey, String aad, WxApiGatewayResponse response) throws Exception {
		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 = new byte[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 = new SecretKeySpec(keyAsBytes, AES_ALGORITHM);
		GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, ivAsBytes);
		cipher.init(Cipher.DECRYPT_MODE, keySpec, parameterSpec);
		cipher.updateAAD(aadAsBytes);
		byte[] plainTextAsBytes = cipher.doFinal(cipherTextAsBytes);

		return new String(plainTextAsBytes, StandardCharsets.UTF_8);
	}

	/**
	 * RSA with SHA256请求参数加签
	 * @param base64PrivateKey Base64编码RSA加签私钥
	 * @param payload          请求负载(url_path + app_id + req_timestamp + req_data), 中间换行符分隔
	 * @return 签名后的字符串
	 */
	public static String signByRSAWithSHA256(String base64PrivateKey, String payload) throws Exception {
		byte[] privateKeyAsBytes = DECODER.decode(base64PrivateKey);
		PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyAsBytes);
		RSAPrivateKey privateKey = (RSAPrivateKey) KeyFactory.getInstance(RSA_ALGORITHM).generatePrivate(keySpec);

		Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
		PSSParameterSpec parameterSpec = new PSSParameterSpec(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
	 */
	public static boolean verifySignature(String payload, String base64Certificate, String signature) throws Exception {
		CertificateFactory certificateFactory = CertificateFactory.getInstance(CERTIFICATE_TYPE);
		ByteArrayInputStream inputStream = new ByteArrayInputStream(DECODER.decode(base64Certificate));
		X509Certificate x509Certificate = (X509Certificate) certificateFactory.generateCertificate(inputStream);

		Signature verifier = Signature.getInstance(SIGNATURE_ALGORITHM);
		PSSParameterSpec parameterSpec = new PSSParameterSpec(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
	 */
	public static String generateRandomIV() {
		byte[] bytes = new byte[12];
		new SecureRandom().nextBytes(bytes);
		return ENCODER.encodeToString(bytes);
	}

	public static String generateNonce(){
		byte[] bytes = new byte[16];
		new SecureRandom().nextBytes(bytes);
		return ENCODER.encodeToString(bytes).replace("=", StringUtils.EMPTY);
	}

}

四、HTTP调用封装

(1) HttpClientProperties

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

import java.time.Duration;

/**
 * @author 刘亚楼
 * @date 2022/5/10
 */
@Data
@ConfigurationProperties(prefix = "http.client")
public class HttpClientProperties {

	/**
	 * 连接最大空闲时间
	 */
	private Duration maxIdleTime = Duration.ofSeconds(5);

	/**
	 * 与服务端建立连接超时时间
	 */
	private Duration connectionTimeout = Duration.ofSeconds(5);

	/**
	 * 客户端从服务器读取数据超时时间
	 */
	private Duration socketTimeout = Duration.ofSeconds(10);

	/**
	 * 从连接池获取连接超时时间
	 */
	private Duration connectionRequestTimeout = Duration.ofSeconds(3);

	/**
	 * 连接池最大连接数
	 */
	private int maxTotal = 500;

	/**
	 * 每个路由(即ip+端口)最大连接数
	 */
	private int defaultMaxPerRoute = 50;

}

(2) HttpClientManager

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

import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import org.apache.http.Consts;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.message.AbstractHttpMessage;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import org.springframework.util.CollectionUtils;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Convenient class for http invocation.
 * @author 刘亚楼
 * @date 2022/5/10
 */
public class HttpClientManager {

	private final HttpClient httpClient;

	public HttpClientManager(HttpClient httpClient) {
		this.httpClient = httpClient;
	}

	public HttpClientResp get(String url) throws Exception {
		return this.get(url, Collections.emptyMap(), Collections.emptyMap());
	}

	/**
	 * 发送get请求
	 * @param url 资源地址
	 * @param headers
	 * @param params 请求参数
	 * @return
	 * @throws Exception
	 */
	public HttpClientResp get(String url, Map<String, Object> headers, Map<String, Object> params) throws Exception {
		URIBuilder uriBuilder = new URIBuilder(url);
		if (!CollectionUtils.isEmpty(params)) {
			for (Map.Entry<String, Object> param : params.entrySet()) {
				uriBuilder.setParameter(param.getKey(), String.valueOf(param.getValue()));
			}
		}

		HttpGet httpGet = new HttpGet(uriBuilder.build())