From 3960baa105f6dd4bf9dcdaa807c045ec8f92e3fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8D=92?= Date: Sun, 31 Aug 2025 00:50:01 +0800 Subject: [PATCH] 1 --- .../controller/WeComCallbackController.java | 91 ++++++++++ .../wxsend/util/WeComCallbackCrypto.java | 164 ++++++++++++++++++ src/main/resources/application.yml | 5 + 3 files changed, 260 insertions(+) create mode 100644 src/main/java/cn/van333/wxsend/business/controller/WeComCallbackController.java create mode 100644 src/main/java/cn/van333/wxsend/util/WeComCallbackCrypto.java diff --git a/src/main/java/cn/van333/wxsend/business/controller/WeComCallbackController.java b/src/main/java/cn/van333/wxsend/business/controller/WeComCallbackController.java new file mode 100644 index 0000000..e36f167 --- /dev/null +++ b/src/main/java/cn/van333/wxsend/business/controller/WeComCallbackController.java @@ -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) { + // 简单提取 + String start = ""; + int i = xml.indexOf(start); + int j = xml.indexOf(end); + if (i >= 0 && j > i) { + return xml.substring(i + start.length(), j); + } + // 兼容无 CDATA 的情况 + start = ""; + end = ""; + 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"); + } +} + + diff --git a/src/main/java/cn/van333/wxsend/util/WeComCallbackCrypto.java b/src/main/java/cn/van333/wxsend/util/WeComCallbackCrypto.java new file mode 100644 index 0000000..fc7dd90 --- /dev/null +++ b/src/main/java/cn/van333/wxsend/util/WeComCallbackCrypto.java @@ -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 "" + + "" + + "" + + "" + timestamp + "" + + "" + + ""; + } + + 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); + } + } +} + + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ddabfc2..0b17772 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -72,3 +72,8 @@ qywx: key: "ea1737ff-f906-426d-b39c-2cdace31c3af" # 机器人安全设置中的加签secret(可选)。若不开启加签可留空 secret: "" + app: + corpId: "" + agentId: "1000002" + token: "7UV4cedJT5gx2kCQXmz7PH5" + encodingAESKey: "nHxBmJYFn9dAjwltyIdLi9YvxmHDGNRsX1MgBIPsog9"