1
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package cn.van333.wxsend.business.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.van333.wxsend.business.model.R;
|
||||
import cn.van333.wxsend.util.WeComCallbackCrypto;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/wecom/callback")
|
||||
public class WeComCallbackController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WeComCallbackController.class);
|
||||
|
||||
@Value("${qywx.app.corpId:}")
|
||||
private String corpId;
|
||||
|
||||
@Value("${qywx.app.token:}")
|
||||
private String token;
|
||||
|
||||
@Value("${qywx.app.encodingAESKey:}")
|
||||
private String encodingAESKey;
|
||||
|
||||
@GetMapping(produces = MediaType.TEXT_PLAIN_VALUE)
|
||||
public String verify(@RequestParam("msg_signature") String msgSignature,
|
||||
@RequestParam("timestamp") String timestamp,
|
||||
@RequestParam("nonce") String nonce,
|
||||
@RequestParam("echostr") String echostr) {
|
||||
logger.info("WeCom callback verify: ts={}, nonce={}", timestamp, nonce);
|
||||
WeComCallbackCrypto crypto = new WeComCallbackCrypto(token, encodingAESKey, corpId);
|
||||
String plain = crypto.verifyURL(msgSignature, timestamp, nonce, echostr);
|
||||
return plain;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.TEXT_XML_VALUE, produces = MediaType.TEXT_PLAIN_VALUE)
|
||||
public String receiveXml(@RequestParam("msg_signature") String msgSignature,
|
||||
@RequestParam("timestamp") String timestamp,
|
||||
@RequestParam("nonce") String nonce,
|
||||
HttpServletRequest request) throws IOException {
|
||||
String xml = readBody(request);
|
||||
logger.info("WeCom callback received xml: {}", xml);
|
||||
String encrypt = parseEncrypt(xml);
|
||||
WeComCallbackCrypto crypto = new WeComCallbackCrypto(token, encodingAESKey, corpId);
|
||||
String plainXml = crypto.decryptMsg(msgSignature, timestamp, nonce, encrypt);
|
||||
logger.info("WeCom callback plain xml: {}", plainXml);
|
||||
// TODO: 在此解析 plainXml 的 MsgType/Event/Content 等,进行业务处理
|
||||
return "success";
|
||||
}
|
||||
|
||||
private static String readBody(HttpServletRequest request) throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
try (BufferedReader reader = request.getReader()) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String parseEncrypt(String xml) {
|
||||
// 简单提取 <Encrypt><![CDATA[...]]></Encrypt>
|
||||
String start = "<Encrypt><![CDATA[";
|
||||
String end = "]]></Encrypt>";
|
||||
int i = xml.indexOf(start);
|
||||
int j = xml.indexOf(end);
|
||||
if (i >= 0 && j > i) {
|
||||
return xml.substring(i + start.length(), j);
|
||||
}
|
||||
// 兼容无 CDATA 的情况
|
||||
start = "<Encrypt>";
|
||||
end = "</Encrypt>";
|
||||
i = xml.indexOf(start);
|
||||
j = xml.indexOf(end);
|
||||
if (i >= 0 && j > i) {
|
||||
return xml.substring(i + start.length(), j);
|
||||
}
|
||||
throw new RuntimeException("Encrypt not found");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
164
src/main/java/cn/van333/wxsend/util/WeComCallbackCrypto.java
Normal file
164
src/main/java/cn/van333/wxsend/util/WeComCallbackCrypto.java
Normal file
@@ -0,0 +1,164 @@
|
||||
package cn.van333.wxsend.util;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
|
||||
/**
|
||||
* 企业微信回调加解密工具(等价于官方 WXBizMsgCrypt 的精简实现)。
|
||||
* 参考:https://developer.work.weixin.qq.com/document/path/90238
|
||||
*/
|
||||
public class WeComCallbackCrypto {
|
||||
|
||||
private static final String AES_ALGORITHM = "AES/CBC/NoPadding";
|
||||
|
||||
private final String token;
|
||||
private final byte[] aesKey; // 32字节
|
||||
private final String corpId;
|
||||
private final byte[] iv;
|
||||
|
||||
public WeComCallbackCrypto(String token, String encodingAESKey, String corpId) {
|
||||
this.token = token;
|
||||
this.aesKey = Base64.getDecoder().decode(encodingAESKey + "=");
|
||||
this.corpId = corpId;
|
||||
this.iv = Arrays.copyOfRange(aesKey, 0, 16);
|
||||
}
|
||||
|
||||
public String verifyURL(String msgSignature, String timestamp, String nonce, String echoStr) {
|
||||
String sign = sha1(token, timestamp, nonce, echoStr);
|
||||
if (!sign.equals(msgSignature)) {
|
||||
throw new RuntimeException("signature not match");
|
||||
}
|
||||
String plain = decrypt(echoStr);
|
||||
return plain;
|
||||
}
|
||||
|
||||
public String decryptMsg(String msgSignature, String timestamp, String nonce, String encrypt) {
|
||||
String sign = sha1(token, timestamp, nonce, encrypt);
|
||||
if (!sign.equals(msgSignature)) {
|
||||
throw new RuntimeException("signature not match");
|
||||
}
|
||||
return decrypt(encrypt);
|
||||
}
|
||||
|
||||
public String encryptMsg(String reply, String timestamp, String nonce) {
|
||||
String encrypt = encrypt(reply);
|
||||
String sign = sha1(token, timestamp, nonce, encrypt);
|
||||
// 返回 XML,与官方结构一致
|
||||
return "<xml>" +
|
||||
"<Encrypt><![CDATA[" + encrypt + "]]></Encrypt>" +
|
||||
"<MsgSignature><![CDATA[" + sign + "]]></MsgSignature>" +
|
||||
"<TimeStamp>" + timestamp + "</TimeStamp>" +
|
||||
"<Nonce><![CDATA[" + nonce + "]]></Nonce>" +
|
||||
"</xml>";
|
||||
}
|
||||
|
||||
private String decrypt(String base64CipherText) {
|
||||
try {
|
||||
byte[] cipherData = Base64.getDecoder().decode(base64CipherText);
|
||||
Cipher cipher = Cipher.getInstance(AES_ALGORITHM);
|
||||
SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
|
||||
cipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(iv));
|
||||
byte[] plainPadded = cipher.doFinal(cipherData);
|
||||
byte[] plain = PKCS7Encoder.decode(plainPadded);
|
||||
|
||||
// 结构:16字节随机 + 4字节网络序正文长度 + 正文 + corpId
|
||||
byte[] networkOrder = Arrays.copyOfRange(plain, 16, 20);
|
||||
int xmlLength = ByteBuffer.wrap(networkOrder).order(ByteOrder.BIG_ENDIAN).getInt();
|
||||
byte[] xmlBytes = Arrays.copyOfRange(plain, 20, 20 + xmlLength);
|
||||
String fromCorpId = new String(Arrays.copyOfRange(plain, 20 + xmlLength, plain.length), StandardCharsets.UTF_8);
|
||||
if (!fromCorpId.equals(corpId)) {
|
||||
throw new RuntimeException("corpId mismatch");
|
||||
}
|
||||
return new String(xmlBytes, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private String encrypt(String plainText) {
|
||||
try {
|
||||
byte[] random16 = randomBytes(16);
|
||||
byte[] xmlBytes = plainText.getBytes(StandardCharsets.UTF_8);
|
||||
ByteBuffer lenBuf = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(xmlBytes.length);
|
||||
byte[] corpBytes = corpId.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
byte[] raw = concat(random16, lenBuf.array(), xmlBytes, corpBytes);
|
||||
byte[] padded = PKCS7Encoder.encode(raw);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(AES_ALGORITHM);
|
||||
SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, new IvParameterSpec(iv));
|
||||
byte[] cipherData = cipher.doFinal(padded);
|
||||
return Base64.getEncoder().encodeToString(cipherData);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String sha1(String token, String timestamp, String nonce, String encrypt) {
|
||||
try {
|
||||
String[] arr = new String[]{token, timestamp, nonce, encrypt};
|
||||
Arrays.sort(arr, Comparator.naturalOrder());
|
||||
String joined = String.join("", arr);
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||
byte[] digest = md.digest(joined.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : digest) {
|
||||
String hex = Integer.toHexString(b & 0xff);
|
||||
if (hex.length() < 2) sb.append('0');
|
||||
sb.append(hex);
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] randomBytes(int n) {
|
||||
byte[] b = new byte[n];
|
||||
// 简易随机即可满足要求
|
||||
new java.security.SecureRandom().nextBytes(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
private static byte[] concat(byte[]... parts) {
|
||||
int len = 0;
|
||||
for (byte[] p : parts) len += p.length;
|
||||
byte[] all = new byte[len];
|
||||
int pos = 0;
|
||||
for (byte[] p : parts) {
|
||||
System.arraycopy(p, 0, all, pos, p.length);
|
||||
pos += p.length;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
private static class PKCS7Encoder {
|
||||
private static final int BLOCK_SIZE = 32;
|
||||
|
||||
static byte[] encode(byte[] src) {
|
||||
int amountToPad = BLOCK_SIZE - (src.length % BLOCK_SIZE);
|
||||
if (amountToPad == 0) amountToPad = BLOCK_SIZE;
|
||||
byte padChr = (byte) (amountToPad & 0xFF);
|
||||
byte[] padding = new byte[amountToPad];
|
||||
Arrays.fill(padding, padChr);
|
||||
return concat(src, padding);
|
||||
}
|
||||
|
||||
static byte[] decode(byte[] decrypted) {
|
||||
int pad = decrypted[decrypted.length - 1] & 0xFF;
|
||||
if (pad < 1 || pad > BLOCK_SIZE) pad = 0;
|
||||
return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,3 +72,8 @@ qywx:
|
||||
key: "ea1737ff-f906-426d-b39c-2cdace31c3af"
|
||||
# 机器人安全设置中的加签secret(可选)。若不开启加签可留空
|
||||
secret: ""
|
||||
app:
|
||||
corpId: ""
|
||||
agentId: "1000002"
|
||||
token: "7UV4cedJT5gx2kCQXmz7PH5"
|
||||
encodingAESKey: "nHxBmJYFn9dAjwltyIdLi9YvxmHDGNRsX1MgBIPsog9"
|
||||
|
||||
Reference in New Issue
Block a user