验证票据
验证票据(component_verify_ticket),在第三方平台创建审核通过后,微信服务器会向其 ”授权事件接收URL” 每隔 10 分钟以 POST
的方式推送 component_verify_ticket
接收 POST
请求后,只需直接返回字符串 success
。为了加强安全性,postdata 中的 xml 将使用服务申请时的加解密 key 来进行加密,具体请见《加密解密技术方案》, 在收到推送后需进行解密(详细请见《消息加解密接入指引》)。
注意:
- component_verify_ticket 的有效时间为12小时,比 component_access_token 更长,建议保存最近可用的component_verify_ticket,在 component_access_token 过期之前都可以直接使用该 component_verify_ticket 进行更新,避免出现因为 component_verify_ticket 接收失败而无法更新 component_access_token 的情况
官方文档链接:验证票据 | 微信开放文档
Controller
/**
*
* @param timestamp
* @param nonce
* @param signature
* @param msgSignature
* @param postData
* @return
* Created by xlxu at 2023/4/28 10:40
* email:18366405239@163.com
*/
@ApiOperation(value = "授权事件接收URL,验证票据")
@RequestMapping(value = "/platform/event",
method = { RequestMethod.GET, RequestMethod.POST })
public String wechatPlatformEvent(@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam("signature") String signature,
@RequestParam("msg_signature") String msgSignature,
@RequestBody String postData) {
AjaxResult result = new AjaxResult();
logger.debug("授权事件接收URL,验证票据");
try {
//logger.debug("第三方平台授权事件接收URL,验证票据成功");
return weChatService.parsePequest(timestamp,nonce,msgSignature,postData);
} catch (Exception e) {
logger.error("第三方平台授权事件接收URL,验证票据异常", e.getMessage(), e);
return "第三方平台授权事件接收URL,验证票据异常"+e.getMessage();
}
}
Service处理
/**
* 第三方平台appId
*/
private static final String PLATFORM_APP_ID = "xxxxxxxxxx";
/**
* 第三方平台 secret
*/
private static final String PLATFORM_APP_SECRET = "xxxxxxxxxx";
/**
* 第三方平台 消息加解密Key
*/
private static final String PLATFORM_AES_KEY = "xxxxxxxxx";
/**
* 第三方平台 消息校验Token
*/
private static final String PLATFORM_COMPONENT_TOKEN = "xxxxxxx";
public String parsePequest(String timeStamp, String nonce, String msgSignature, String postData) {
logger.debug("==============================开始授权事件接收URL=================================");
try {
//这个类是微信官网提供的解密类,需要用到消息校验Token 消息加密Key和服务平台appid
WXBizMsgCrypt pc = new WXBizMsgCrypt(PLATFORM_COMPONENT_TOKEN, PLATFORM_AES_KEY, PLATFORM_APP_ID);
Security.setProperty("crypto.policy", "unlimited");// java.security.InvalidKeyException: Illegal key size解密报了这个错,的要设置安全策略,这个坑填了好久,不需要下载网上说的那两个jar包
String xml = pc.decryptMsg(msgSignature, timeStamp, nonce, postData);
Map<String, String> result = WXXmlToMapUtil.xmlToMap(xml);// 将xml转为map
String componentVerifyTicket = MapUtils.getString(result, "ComponentVerifyTicket");
if (StringUtils.isNotEmpty(componentVerifyTicket)) {
// 存储平台授权票据,保存ticket
redisService.setCacheObject("component_verify_ticket", componentVerifyTicket, (long)60 * 10, TimeUnit.SECONDS);
String verifyTicket = redisTemplate.opsForValue().get("component_verify_ticket").toString();
logger.debug("第三方平台授权事件接收URL,验证票据成功!授权票据【ComponentVerifyTicket】:【" + verifyTicket + "】====================");
} else {
throw new RuntimeException("微信开放平台,第三方平台获取【验证票据】失败");
}
} catch (AesException e) {
e.printStackTrace();
}
logger.debug("==============================结束授权事件接收URL=================================");
return "success";
}
WXBizMsgCrypt.java
import org.apache.commons.codec.binary.Base64;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import java.io.StringReader;
import java.nio.charset.Charset;
import java.security.MessageDigest;
import java.util.Arrays;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
/**
* 提供接收和推送给公众平台消息的加解密接口(UTF8编码的字符串).
* <ol> * <li>第三方回复加密消息给公众平台</li> * <li>第三方收到公众平台发送的消息,验证消息的安全性,并对消息进行解密。</li>
* </ol>
* 说明:异常java.security.InvalidKeyException:illegal Key Size的解决方案
* <ol>
* <li>在官方网站下载JCE无限制权限策略文件(JDK7的下载地址: *
* http://www.oracle.com/technetwork/java/javase/downloads/jce-7-download-432124.html</li>
* <li>下载后解压,可以看到local_policy.jar和US_export_policy.jar以及readme.txt</li>
* <li>如果安装了JRE,将两个jar文件放到%JRE_HOME%\lib\security目录下覆盖原来的文件</li>
* <li>如果安装了JDK,将两个jar文件放到%JDK_HOME%\jre\lib\security目录下覆盖原来文件</li>
*
* </ol>
*/
public class WXBizMsgCrypt {
static Charset CHARSET = Charset.forName("utf-8");
Base64 base64 = new Base64();
byte[] aesKey;
String token;
String appId;
/**
* 构造函数
* @param token 公众平台上,开发者设置的token
* @param encodingAesKey 公众平台上,开发者设置的EncodingAESKey
* @param appId 公众平台appid
*
* @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息
*/
public WXBizMsgCrypt(String token, String encodingAesKey, String appId) throws AesException {
if (encodingAesKey.length() != 43) {
throw new AesException(AesException.IllegalAesKey);
}
this.token = token;
this.appId = appId;
aesKey = Base64.decodeBase64(encodingAesKey + "=");
}
// 还原4个字节的网络字节序
int recoverNetworkBytesOrder(byte[] orderBytes) {
int sourceNumber = 0;
for (int i = 0; i < 4; i++) {
sourceNumber <<= 8;
sourceNumber |= orderBytes[i] & 0xff;
}
return sourceNumber;
}
/**
* 对密文进行解密.
* @param text 需要解密的密文
* @return 解密得到的明文
* @throws AesException aes解密失败
*/
String decrypt(String text) throws AesException {
byte[] original;
try {
// 设置解密模式为AES的CBC模式
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
SecretKeySpec key_spec = new SecretKeySpec(aesKey, "AES");
IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));
cipher.init(Cipher.DECRYPT_MODE, key_spec, iv);
// 使用BASE64对密文进行解码
byte[] encrypted = Base64.decodeBase64(text);
// 解密
original = cipher.doFinal(encrypted);
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.DecryptAESError);
}
String xmlContent, from_appid;
try {
// 去除补位字符
byte[] bytes = PKCS7Encoder.decode(original);
// 分离16位随机字符串,网络字节序和AppId
byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20);
int xmlLength = recoverNetworkBytesOrder(networkOrder);
xmlContent = new String(Arrays.copyOfRange(bytes, 20, 20 + xmlLength), CHARSET);
from_appid =
new String(Arrays.copyOfRange(bytes, 20 + xmlLength, bytes.length), CHARSET);
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.IllegalBuffer);
}
// appid不相同的情况
if (!from_appid.equals(appId)) {
throw new AesException(AesException.ValidateSignatureError);
}
return xmlContent;
}
/**
* * 检验消息的真实性,并且获取解密后的明文.
* <ol>
* <li>利用收到的密文生成安全签名,进行签名验证</li>
* <li>若验证通过,则提取xml中的加密消息</li>
* <li>对消息进行解密</li>
* </ol>
*
* @param msgSignature 签名串,对应URL参数的msg_signature
* @param timeStamp 时间戳,对应URL参数的timestamp
* @param nonce 随机串,对应URL参数的nonce
* @param postData 密文,对应POST请求的数据
* @return 解密后的原文
* @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息
*/
public String decryptMsg(String msgSignature, String timeStamp, String nonce, String postData)
throws AesException {
// 密钥,公众账号的app secret
// 提取密文
Object[] encrypt = extract(postData);
// 验证安全签名
String signature = getSHA1(token, timeStamp, nonce, encrypt[1].toString());
// 和URL中的签名比较是否相等
// System.out.println("第三方收到URL中的签名:" + msg_sign);
// System.out.println("第三方校验签名:" + signature);
if (!signature.equals(msgSignature)) {
throw new AesException(AesException.ValidateSignatureError);
}
// 解密
String result = decrypt(encrypt[1].toString());
return result;
}
/**
* 提取出xml数据包中的加密消息
* @param xmltext 待提取的xml字符串
* @return 提取出的加密消息字符串
* @throws AesException
*/
public static Object[] extract(String xmltext) throws AesException {
Object[] result = new Object[3];
try {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
DocumentBuilder db = dbf.newDocumentBuilder();
StringReader sr = new StringReader(xmltext);
InputSource is = new InputSource(sr);
Document document = db.parse(is);
Element root = document.getDocumentElement();
NodeList nodelist1 = root.getElementsByTagName("Encrypt");
NodeList nodelist2 = root.getElementsByTagName("ToUserName");
result[0] = 0;
result[1] = nodelist1.item(0).getTextContent();
//注意这里,获取ticket中的xml里面没有ToUserName这个元素,官网原示例代码在这里会报空
//空指针,所以需要处理一下
if (nodelist2 != null) {
if (nodelist2.item(0) != null) {
result[2] = nodelist2.item(0).getTextContent();
}
}
return result;
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.ParseXmlError);
}
}
/**
* 用SHA1算法生成安全签名
* @param token 票据
* @param timestamp 时间戳
* @param nonce 随机字符串
* @param encrypt 密文
* @return 安全签名
* @throws
* AesException
*/
public static String getSHA1(String token, String timestamp, String nonce, String encrypt)
throws AesException {
try {
String[] array = new String[]{token, timestamp, nonce, encrypt};
StringBuffer sb = new StringBuffer();
// 字符串排序
Arrays.sort(array);
for (int i = 0; i < 4; i++) {
sb.append(array[i]);
}
String str = sb.toString();
// SHA1签名生成
MessageDigest md = MessageDigest.getInstance("SHA-1");
md.update(str.getBytes());
byte[] digest = md.digest();
StringBuffer hexstr = new StringBuffer();
String shaHex = "";
for (int i = 0; i < digest.length; i++) {
shaHex = Integer.toHexString(digest[i] & 0xFF);
if (shaHex.length() < 2) {
hexstr.append(0);
}
hexstr.append(shaHex);
}
return hexstr.toString();
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.ComputeSignatureError);
}
}
}
WXXmlToMapUtil.java
import org.dom4j.Document;
import org.dom4j.DocumentException;
import org.dom4j.DocumentHelper;
import org.dom4j.Element;
import org.dom4j.io.OutputFormat;
import org.dom4j.io.SAXReader;
import org.dom4j.io.XMLWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
public class WXXmlToMapUtil {
private static final Logger logger = LoggerFactory.getLogger(WXXmlToMapUtil.class);
/**
* XML格式字符串转换为Map
*
* @param xml XML字符串
* @return XML数据转换后的Map
*/
public static Map<String, String> xmlToMap(String xml) {
try {
Map<String, String> data = new HashMap<>();
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
InputStream stream = new ByteArrayInputStream(xml.getBytes("UTF-8"));
org.w3c.dom.Document doc = documentBuilder.parse(stream);
doc.getDocumentElement().normalize();
NodeList nodeList = doc.getDocumentElement().getChildNodes();
for (int idx = 0; idx < nodeList.getLength(); ++idx) {
Node node = nodeList.item(idx);
if (node.getNodeType() == Node.ELEMENT_NODE) {
org.w3c.dom.Element element = (org.w3c.dom.Element) node;
data.put(element.getNodeName(), element.getTextContent());
}
}
stream.close();
return data;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* 将Map转换为XML格式的字符串
*
* @param data Map类型数据
* @return XML格式的字符串
*/
public static String mapToXml(Map<String, String> data) throws Exception {
try {
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
org.w3c.dom.Document document = documentBuilder.newDocument();
org.w3c.dom.Element root = document.createElement("xml");
document.appendChild(root);
for (String key : data.keySet()) {
String value = data.get(key);
if (value == null) {
value = "";
}
value = value.trim();
org.w3c.dom.Element filed = document.createElement(key);
filed.appendChild(document.createTextNode(value));
root.appendChild(filed);
}
TransformerFactory tf = TransformerFactory.newInstance();
Transformer transformer = tf.newTransformer();
DOMSource source = new DOMSource(document);
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
StringWriter writer = new StringWriter();
StreamResult result = new StreamResult(writer);
transformer.transform(source, result);
String output = writer.getBuffer().toString(); //.replaceAll("\n|\r", "");
writer.close();
return output;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* (多层)xml格式字符串转换为map
*
* @param xml xml字符串
* @return 第一个为Root节点,Root节点之后为Root的元素,如果为多层,可以通过key获取下一层Map
*/
public static Map<String, Object> multilayerXmlToMap(String xml) {
Document doc = null;
try {
doc = DocumentHelper.parseText(xml);
} catch (DocumentException e) {
logger.error("xml字符串解析,失败 --> {}", e);
}
Map<String, Object> map = new HashMap<>();
if (null == doc) {
return map;
}
// 获取根元素
Element rootElement = doc.getRootElement();
recursionXmlToMap(rootElement, map);
return map;
}
/**
* multilayerXmlToMap核心方法,递归调用
*
* @param element 节点元素
* @param outmap 用于存储xml数据的map
*/
private static void recursionXmlToMap(Element element, Map<String, Object> outmap) {
// 得到根元素下的子元素列表
List<Element> list = element.elements();
int size = list.size();
if (size == 0) {
// 如果没有子元素,则将其存储进map中
outmap.put(element.getName(), element.getTextTrim());
} else {
// innermap用于存储子元素的属性名和属性值
Map<String, Object> innermap = new HashMap<>();
// 遍历子元素
list.forEach(childElement -> recursionXmlToMap(childElement, innermap));
outmap.put(element.getName(), innermap);
}
}
/**
* (多层)map转换为xml格式字符串
*
* @param map 需要转换为xml的map
* @param isCDATA 是否加入CDATA标识符 true:加入 false:不加入
* @return xml字符串
*/
public static String multilayerMapToXml(Map<String, Object> map, boolean isCDATA) {
String parentName = "xml";
Document doc = DocumentHelper.createDocument();
doc.addElement(parentName);
String xml = recursionMapToXml(doc.getRootElement(), parentName, map, isCDATA);
return formatXML(xml);
}
/**
* multilayerMapToXml核心方法,递归调用
*
* @param element 节点元素
* @param parentName 根元素属性名
* @param map 需要转换为xml的map
* @param isCDATA 是否加入CDATA标识符 true:加入 false:不加入
* @return xml字符串
*/
private static String recursionMapToXml(Element element, String parentName, Map<String, Object> map, boolean isCDATA) {
Element xmlElement = element.addElement(parentName);
map.keySet().forEach(key -> {
Object obj = map.get(key);
if (obj instanceof Map) {
recursionMapToXml(xmlElement, key, (Map<String, Object>) obj, isCDATA);
} else {
String value = obj == null ? "" : obj.toString();
if (isCDATA) {
xmlElement.addElement(key).addCDATA(value);
} else {
xmlElement.addElement(key).addText(value);
}
}
});
return xmlElement.asXML();
}
/**
* 格式化xml,显示为容易看的XML格式
*
* @param xml 需要格式化的xml字符串
*/
public static String formatXML(String xml) {
String requestXML = null;
try {
// 拿取解析器
SAXReader reader = new SAXReader();
Document document = reader.read(new StringReader(xml));
if (null != document) {
StringWriter stringWriter = new StringWriter();
// 格式化,每一级前的空格
OutputFormat format = new OutputFormat(" ", true);
// xml声明与内容是否添加空行
format.setNewLineAfterDeclaration(false);
// 是否设置xml声明头部
format.setSuppressDeclaration(false);
// 是否分行
format.setNewlines(true);
XMLWriter writer = new XMLWriter(stringWriter, format);
writer.write(document);
writer.flush();
writer.close();
requestXML = stringWriter.getBuffer().toString();
}
return requestXML;
} catch (Exception e) {
logger.error("格式化xml,失败 --> {}", e);
return null;
}
}
}
HttpUtils.java
package com.ruoyi.common.core.utils;
import com.ruoyi.common.core.exception.ServiceException;
import lombok.extern.slf4j.Slf4j;
import net.sf.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.ConnectException;
import java.net.URL;
/**
* @author :xlxu
* @date :Created in 2023/2/28 15:43
* @description:请求与开链接
*/
public class HttpUtils {
/**
* 发起https请求并获取结果
*
* @param requestUrl 请求地址
* @param requestMethod 请求方式(GET、POST)
* @param outputStr 提交的数据
* @return JSONObject(通过JSONObject.get ( key)的方式获取json对象的属性值)
*/
public static JSONObject httpRequest(String requestUrl, String requestMethod, String outputStr) {
JSONObject jsonObject = null;
StringBuffer buffer = new StringBuffer();
try {
// 创建SSLContext对象,并使用我们指定的信任管理器初始化
TrustManager[] tm = {new MyX509TrustManagerUtil()};
SSLContext sslContext = SSLContext.getInstance("SSL", "SunJSSE");
sslContext.init(null, tm, new java.security.SecureRandom());
// 从上述SSLContext对象中得到SSLSocketFactory对象
SSLSocketFactory ssf = sslContext.getSocketFactory();
URL url = new URL(requestUrl);
HttpsURLConnection httpUrlConn = (HttpsURLConnection) url.openConnection();
httpUrlConn.setSSLSocketFactory(ssf);
httpUrlConn.setDoOutput(true);
httpUrlConn.setDoInput(true);
httpUrlConn.setUseCaches(false);
// 设置请求方式(GET/POST)
httpUrlConn.setRequestMethod(requestMethod);
if ("GET".equalsIgnoreCase(requestMethod))
httpUrlConn.connect();
// 当有数据需要提交时
if (null != outputStr) {
OutputStream outputStream = httpUrlConn.getOutputStream();
// 注意编码格式,防止中文乱码
outputStream.write(outputStr.getBytes("UTF-8"));
outputStream.close();
}
// 将返回的输入流转换成字符串
InputStream inputStream = httpUrlConn.getInputStream();
InputStreamReader inputStreamReader = new InputStreamReader(inputStream, "utf-8");
BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
String str = null;
while ((str = bufferedReader.readLine()) != null) {
buffer.append(str);
}
bufferedReader.close();
inputStreamReader.close();
// 释放资源
inputStream.close();
inputStream = null;
httpUrlConn.disconnect();
jsonObject = JSONObject.fromObject(buffer.toString());
} catch (ConnectException ce) {
throw new ServiceException("http请求异常");
} catch (Exception e) {
throw new ServiceException("http请求异常");
}
return jsonObject;
}
}
微信每10分钟发送一次票据,出现以下日志说明接入成功