微信消息加解密解析

JFinal weixin提供了微信消息加解密的实现方式,这里对实现的过程进行简单的解析。

企业号回调模式的消息格式说明

企业号在回调企业URL时,会对消息体本身做AES加密,以XML格式POST到企业应用的URL上;企业在被动响应时,也需要对数据加密,以XML格式返回给微信。
微信服务器在五秒内收不到响应会断掉连接,并且重新发起请求,总共重试三次。如果在调试中,发现成员无法收到响应的消息,可以检查是否消息处理超时。

当接收成功后,http头部返回200表示接收ok,其他错误码一律当做失败并发起重试

关于重试的消息排重,有msgid的消息推荐使用msgid排重。事件类型消息推荐使用FromUserName + CreateTime排重。

假如企业无法保证在五秒内处理并回复,可以直接回复空串,企业号不会对此作任何处理,并且不会发起重试。这种情况下,可以使用发消息接口进行异步回复。

假设企业回调URL为http://api.3dept.com。

  1. msg_encrypt 为经过加密的密文
  2. AgentID 为接收的应用id,可在应用的设置页面获取
  3. ToUserName 为企业号的 CorpID
1
2
3
4
5
<xml> 
<ToUserName><[CDATA[toUser]]</ToUserName>
<AgentID><[CDATA[toAgentID]]</AgentID>
<Encrypt><[CDATA[msg_encrypt]]</Encrypt>
</xml>

企业需要对 msg_signature 进行校验,并解密 msg_encrypt ,得出 msg 的原文。

  • 被动响应给微信的数据格式:
  1. msg_encrypt 为经过加密的密文,算法参见附录
  2. MsgSignature 为签名,算法参见附录
  3. TimeStamp 为时间戳,Nonce为随机数,由企业自行生成
1
2
3
4
5
6
<xml>
<Encrypt><[CDATA[msg_encrypt]]></Encrypt>
<MsgSignature><[CDATA[msg_signature]]></MsgSignature>
<TimeStamp>timestamp</TimeStamp>
<Nonce><[CDATA[nonce]]></Nonce>
</xml>

jfinal-weixin的解密的代码实现(存在代码冗余)及改进方案

1、从request请求中读取消息

1
inMsgXml = HttpKit.readIncommingRequestData(getRequest());

readIncommingRequestData 方法通过 getReader() 读取,进行每行读取后进行append拼接;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static String readIncommingRequestData(HttpServletRequest request) {
BufferedReader br = null;
try {
StringBuilder result = new StringBuilder();
br = request.getReader();
for (String line=null; (line=br.readLine())!=null;) {
result.append(line).append("\n");
}

return result.toString();
} catch (IOException e) {
throw new RuntimeException(e);
}
finally {
if (br != null)
try {br.close();} catch (IOException e) {e.printStackTrace();}
}
}

2、在回调模式中可以配置是消息采用明文还是密文;需要去判断是否需要对进行进行解密;

1
2
3
4
5
6
// 是否需要解密消息
if (ApiConfigKit.getApiConfig().isEncryptMessage())
{
inMsgXml = MsgEncryptKit.decrypt(inMsgXml, getPara("timestamp"),
getPara("nonce"), getPara("msg_signature"));
}

3、消息的解密的类 WXBizMsgCrypt 采用的是微信提供的方法;
decryptMsg 方法提供了对消息进行解密接口,接口参数需要传递 msg_signaturetimestampnoncepostData (密文,实际使用的发送过来的请求数据。);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public String decryptMsg(String msgSignature, String timeStamp, String nonce, String postData) throws AesException 
{

// 密钥,公众账号的app secret
// 提取密文
Object[] encrypt = XMLParse.extract(postData);

// 验证安全签名
String signature = SHA1.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);
}

// 解密 encrypt[1] 参照 extract 方法各个参数的存储顺利;
String result = decrypt(encrypt[1].toString());
return result;
}

XMLParse.extract(postData) 提取消息,并将消息转换document,方便进行元素的提取;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 提取出xml数据包中的加密消息
* @param xmltext 待提取的xml字符串
* @return 提取出的加密消息字符串
* @throws AesException
*/

public static Object[] extract(String xmltext) throws AesException
{
Object[] result = new Object[4];
try {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
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");
NodeList nodelist3 = root.getElementsByTagName("AgentID");
result[0] = 0;
result[1] = nodelist1.item(0).getTextContent();
result[2] = nodelist2.item(0).getTextContent();
result[3] = nodelist3.item(0).getTextContent();
return result;
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.ParseXmlError);
}
}

4、MsgEncryptKit 调用 WXBizMsgCrypt.decryptMsg 方法进行消息的解密;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private static final String format = "<xml><ToUserName><[CDATA[toUser]]></ToUserName><Encrypt><[CDATA[%1$s]]></Encrypt><AgentID><[CDATA[agentId]]></AgentID></xml>";
public static String decrypt(String encryptedMsg, String timestamp, String nonce, String msgSignature)
{

try {
ApiConfig ac = ApiConfigKit.getApiConfig();

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder db = dbf.newDocumentBuilder();
StringReader sr = new StringReader(encryptedMsg);
InputSource is = new InputSource(sr);
Document document = db.parse(is);

Element root = document.getDocumentElement();
NodeList nodelist1 = root.getElementsByTagName("Encrypt");
// NodeList nodelist2 = root.getElementsByTagName("MsgSignature");

String encrypt = nodelist1.item(0).getTextContent();
// String msgSignature = nodelist2.item(0).getTextContent();

String fromXML = String.format(format, encrypt);

String encodingAesKey = ac.getEncodingAesKey();
if (encodingAesKey == null)
throw new IllegalStateException("encodingAesKey can not be null, config encodingAesKey first.");

WXBizMsgCrypt pc = new WXBizMsgCrypt(ac.getToken(), encodingAesKey, ac.getCorpId());
return pc.decryptMsg(msgSignature, timestamp, nonce, fromXML);
// 此处
// timestamp
// 如果与加密前的不同则报签名不正确的异常
} catch (Exception e) {
throw new RuntimeException(e);
}
}

这里的 encryptedMsg 为解密的密文,使用 String fromXML = String.format(format, encrypt) ;将密文重新封装到XML格式 format 的字符串中 fromXML
然后在调用 WXBizMsgCrypt.decryptMsg() 方法中,XMLPraser.extract() 方法去解析xml文本;这里进行了重复性的工作。

改进方法是去除重复的密文的封装和拆解;在decrypt方法中不需要再进行读取密文,root.getElementsByTagName(“Encrypt”);然后进行封装,String fromXML = String.format(format, encrypt)
可以修改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static String decrypt(String encryptedMsg, String timestamp, String nonce, String msgSignature) 
{

try {
ApiConfig ac = ApiConfigKit.getApiConfig();

String encodingAesKey = ac.getEncodingAesKey();
if (encodingAesKey == null)
throw new IllegalStateException("encodingAesKey can not be null, config encodingAesKey first.");

WXBizMsgCrypt pc = new WXBizMsgCrypt(ac.getToken(), encodingAesKey, ac.getCorpId());
return pc.decryptMsg(msgSignature, timestamp, nonce, encryptedMsg);
// 此处
// timestamp
// 如果与加密前的不同则报签名不正确的异常
} catch (Exception e) {
throw new RuntimeException(e);
}
}

或者 WXBizMsgCrypt.decryptMsgpostData 为密文,去除 XMLParse.extract(postData) 方法;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public String decryptMsg(String msgSignature, String timeStamp, String nonce, String postData) throws AesException 
{

// 验证安全签名
String signature = SHA1.getSHA1(token, timeStamp, nonce, postData.toString());

// 和URL中的签名比较是否相等
// System.out.println("第三方收到URL中的签名:" + msg_sign);
// System.out.println("第三方校验签名:" + signature);
if (!signature.equals(msgSignature)) {
throw new AesException(AesException.ValidateSignatureError);
}

// 解密 encrypt[1] 参照 extract 方法各个参数的存储顺利;
String result = decrypt(postData.toString());
return result;
}

jfinal-weixin的加密的代码实现

1、先将发送的消息按照消息的格式进行封装

1
String outMsgXml = OutMsgXmlBuilder.build(outMsg);

2、判断消息是否需要加密

1
if (ApiConfigKit.getApiConfig().isEncryptMessage())

3、使用 MsgEncryptKit.encrypt 方法进行加密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
outMsgXml = MsgEncryptKit.encrypt(outMsgXml,getPara("timestamp"), getPara("nonce"));

public static String encrypt(String msg, String timestamp, String nonce)
{

try {
ApiConfig ac = ApiConfigKit.getApiConfig();
WXBizMsgCrypt pc = new WXBizMsgCrypt(ac.getToken(), ac.getEncodingAesKey(), ac.getCorpId());
log.info("WXBizMsgCrypt pc" + pc.toString());
log.info(pc.encryptMsg(msg, timestamp, nonce));
return pc.encryptMsg(msg, timestamp, nonce);
} catch (Exception e) {
throw new RuntimeException(e);
}
}

具体的加密工作是由 WXBizMsgCrypt.encryptMsg 方法来进行操作;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public String encryptMsg(String replyMsg, String timeStamp, String nonce) throws AesException 
{

// 加密
String encrypt = encrypt(getRandomStr(), replyMsg);

// 生成安全签名
if (timeStamp == "") {
timeStamp = Long.toString(System.currentTimeMillis());
}

String signature = SHA1.getSHA1(token, timeStamp, nonce, encrypt);

// System.out.println("发送给平台的签名是: " + signature[1].toString());
// 生成发送的xml
String result = XMLParse.generate(encrypt, signature, timeStamp, nonce);
return result;
}

这里 XMLParse.generate() 方法将密文封装为要求的xml格式的字符串。

1
2
3
4
5
6
7
public static String generate(String encrypt, String signature, String timestamp, String nonce) 
{

String format = "<xml>\n" + "<Encrypt><![CDATA[%1$s]]></Encrypt>\n"
+ "<MsgSignature><![CDATA[%2$s]]></MsgSignature>\n"
+ "<TimeStamp>%3$s</TimeStamp>\n" + "<Nonce><![CDATA[%4$s]]></Nonce>\n" + "</xml>";
return String.format(format, encrypt, signature, timestamp, nonce);
}

这里得到一个教训,在学习别人的代码的时候,一定要认真去理解,不要认为别人写的代码就是完美的。要勇于去提出问题和进行代码的优化和修改。