This commit is contained in:
2025-08-31 00:50:01 +08:00
parent ec9416e390
commit 3960baa105
3 changed files with 260 additions and 0 deletions

View File

@@ -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");
}
}

View 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);
}
}
}

View File

@@ -72,3 +72,8 @@ qywx:
key: "ea1737ff-f906-426d-b39c-2cdace31c3af"
# 机器人安全设置中的加签secret可选。若不开启加签可留空
secret: ""
app:
corpId: ""
agentId: "1000002"
token: "7UV4cedJT5gx2kCQXmz7PH5"
encodingAESKey: "nHxBmJYFn9dAjwltyIdLi9YvxmHDGNRsX1MgBIPsog9"