diff --git a/src/main/java/com/company/agentfoundation/config/FoundationProperties.java b/src/main/java/com/company/agentfoundation/config/FoundationProperties.java index 015a50e..4d0619f 100644 --- a/src/main/java/com/company/agentfoundation/config/FoundationProperties.java +++ b/src/main/java/com/company/agentfoundation/config/FoundationProperties.java @@ -57,6 +57,7 @@ public class FoundationProperties { private Integer agentId; private String secret; private String token; + private String encodingAesKey; private String crmAgentId = "crm-assistant"; private String callbackPath = "/v1/integrations/wecom/callback"; @@ -70,6 +71,8 @@ public class FoundationProperties { public void setSecret(String secret) { this.secret = secret; } public String getToken() { return token; } public void setToken(String token) { this.token = token; } + public String getEncodingAesKey() { return encodingAesKey; } + public void setEncodingAesKey(String encodingAesKey) { this.encodingAesKey = encodingAesKey; } public String getCrmAgentId() { return crmAgentId; } public void setCrmAgentId(String crmAgentId) { this.crmAgentId = crmAgentId; } public String getCallbackPath() { return callbackPath; } diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/WecomCallbackController.java b/src/main/java/com/company/agentfoundation/integration/wecom/WecomCallbackController.java index 3c52d86..5447253 100644 --- a/src/main/java/com/company/agentfoundation/integration/wecom/WecomCallbackController.java +++ b/src/main/java/com/company/agentfoundation/integration/wecom/WecomCallbackController.java @@ -7,12 +7,13 @@ import com.company.agentfoundation.security.AgentContext; import com.company.agentfoundation.service.ChatService; import com.fasterxml.jackson.dataformat.xml.XmlMapper; import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Arrays; -import java.util.HexFormat; import java.util.List; @RestController @@ -21,35 +22,43 @@ public class WecomCallbackController { private final FoundationProperties properties; private final ChatService chatService; private final WecomClient wecomClient; + private final WecomCryptoService wecomCryptoService; private final XmlMapper xmlMapper = new XmlMapper(); - public WecomCallbackController(FoundationProperties properties, ChatService chatService, WecomClient wecomClient) { + public WecomCallbackController( + FoundationProperties properties, + ChatService chatService, + WecomClient wecomClient, + WecomCryptoService wecomCryptoService + ) { this.properties = properties; this.chatService = chatService; this.wecomClient = wecomClient; + this.wecomCryptoService = wecomCryptoService; } @GetMapping("/callback") - public String verify(@RequestParam("msg_signature") String signature, - @RequestParam("timestamp") String timestamp, - @RequestParam("nonce") String nonce, - @RequestParam("echostr") String echostr) { - if (!validSignature(signature, timestamp, nonce)) { + public String verify( + @RequestParam("msg_signature") String signature, + @RequestParam("timestamp") String timestamp, + @RequestParam("nonce") String nonce, + @RequestParam("echostr") String echostr + ) { + String payload = wecomCryptoService.isSafeModeEnabled() ? echostr : null; + if (!wecomCryptoService.validateSignature(signature, timestamp, nonce, payload)) { throw new IllegalArgumentException("Invalid WeCom signature"); } - return echostr; + return wecomCryptoService.isSafeModeEnabled() ? wecomCryptoService.decrypt(echostr) : echostr; } @PostMapping(value = "/callback", consumes = {MediaType.TEXT_XML_VALUE, MediaType.APPLICATION_XML_VALUE}) - public String receive(@RequestParam("msg_signature") String signature, - @RequestParam("timestamp") String timestamp, - @RequestParam("nonce") String nonce, - @RequestBody String body) throws Exception { - if (!validSignature(signature, timestamp, nonce)) { - throw new IllegalArgumentException("Invalid WeCom signature"); - } - - WecomIncomingMessage msg = xmlMapper.readValue(body, WecomIncomingMessage.class); + public String receive( + @RequestParam("msg_signature") String signature, + @RequestParam("timestamp") String timestamp, + @RequestParam("nonce") String nonce, + @RequestBody String body + ) throws Exception { + WecomIncomingMessage msg = parseIncomingMessage(signature, timestamp, nonce, body); if (msg == null || msg.getMsgType() == null || !"text".equalsIgnoreCase(msg.getMsgType())) { return "success"; } @@ -62,36 +71,36 @@ public class WecomCallbackController { ChatRequest req = new ChatRequest( null, List.of( - new ChatRequest.Message("system", "你是企业CRM智能助手。请给出简洁、可执行的客户跟进建议。"), + new ChatRequest.Message("system", "You are an enterprise CRM assistant. Provide concise, actionable follow-up advice."), new ChatRequest.Message("user", userText) ), 0.3 ); ChatResponse chat = chatService.chat(new AgentContext(properties.getWecom().getCrmAgentId()), req); - String reply = (chat.content() == null || chat.content().isBlank()) ? "我已收到你的请求,但暂时无法生成建议。" : chat.content(); + String reply = (chat.content() == null || chat.content().isBlank()) + ? "Request received, but I could not generate advice right now." + : chat.content(); wecomClient.sendText(msg.getFromUserName(), reply); - return "success"; } - private boolean validSignature(String signature, String timestamp, String nonce) { - String token = properties.getWecom().getToken(); - if (token == null || token.isBlank()) { - return false; + private WecomIncomingMessage parseIncomingMessage(String signature, String timestamp, String nonce, String body) throws Exception { + if (wecomCryptoService.isSafeModeEnabled()) { + WecomEncryptedEnvelope envelope = xmlMapper.readValue(body, WecomEncryptedEnvelope.class); + if (envelope == null || envelope.getEncrypt() == null || envelope.getEncrypt().isBlank()) { + throw new IllegalArgumentException("Missing WeCom encrypted payload"); + } + if (!wecomCryptoService.validateSignature(signature, timestamp, nonce, envelope.getEncrypt())) { + throw new IllegalArgumentException("Invalid WeCom signature"); + } + String plainXml = wecomCryptoService.decrypt(envelope.getEncrypt()); + return xmlMapper.readValue(plainXml, WecomIncomingMessage.class); } - String[] arr = new String[]{token, timestamp, nonce}; - Arrays.sort(arr); - String raw = String.join("", arr); - - try { - MessageDigest digest = MessageDigest.getInstance("SHA-1"); - byte[] sha1 = digest.digest(raw.getBytes(StandardCharsets.UTF_8)); - String localSignature = HexFormat.of().formatHex(sha1); - return signature.equalsIgnoreCase(localSignature); - } catch (Exception e) { - return false; + if (!wecomCryptoService.validateSignature(signature, timestamp, nonce, null)) { + throw new IllegalArgumentException("Invalid WeCom signature"); } + return xmlMapper.readValue(body, WecomIncomingMessage.class); } } diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/WecomCryptoService.java b/src/main/java/com/company/agentfoundation/integration/wecom/WecomCryptoService.java new file mode 100644 index 0000000..f1d8e14 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/wecom/WecomCryptoService.java @@ -0,0 +1,93 @@ +package com.company.agentfoundation.integration.wecom; + +import com.company.agentfoundation.config.FoundationProperties; +import org.springframework.stereotype.Service; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.Base64; +import java.util.HexFormat; + +@Service +public class WecomCryptoService { + private final FoundationProperties properties; + + public WecomCryptoService(FoundationProperties properties) { + this.properties = properties; + } + + public boolean isSafeModeEnabled() { + String key = properties.getWecom().getEncodingAesKey(); + return key != null && !key.isBlank(); + } + + public boolean validateSignature(String signature, String timestamp, String nonce, String payload) { + String token = properties.getWecom().getToken(); + if (token == null || token.isBlank()) { + return false; + } + + String[] values = payload == null + ? new String[]{token, timestamp, nonce} + : new String[]{token, timestamp, nonce, payload}; + Arrays.sort(values); + String raw = String.join("", values); + + try { + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + String local = HexFormat.of().formatHex(digest.digest(raw.getBytes(StandardCharsets.UTF_8))); + return local.equalsIgnoreCase(signature); + } catch (Exception e) { + return false; + } + } + + public String decrypt(String cipherText) { + try { + byte[] aesKey = getAesKey(); + Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, "AES"), new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16))); + byte[] plain = cipher.doFinal(Base64.getDecoder().decode(cipherText)); + byte[] unpadded = removePadding(plain); + + ByteBuffer buffer = ByteBuffer.wrap(unpadded, 16, unpadded.length - 16); + int xmlLength = buffer.getInt(); + byte[] xmlBytes = new byte[xmlLength]; + buffer.get(xmlBytes); + byte[] receiveIdBytes = new byte[buffer.remaining()]; + buffer.get(receiveIdBytes); + + String receiveId = new String(receiveIdBytes, StandardCharsets.UTF_8); + String expected = properties.getWecom().getCorpId(); + if (expected != null && !expected.isBlank() && !expected.equals(receiveId)) { + throw new IllegalArgumentException("WeCom receiveId mismatch"); + } + return new String(xmlBytes, StandardCharsets.UTF_8); + } catch (IllegalArgumentException ex) { + throw ex; + } catch (Exception ex) { + throw new IllegalArgumentException("Failed to decrypt WeCom payload", ex); + } + } + + private byte[] getAesKey() { + String key = properties.getWecom().getEncodingAesKey(); + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("WECOM_ENCODING_AES_KEY is missing"); + } + return Base64.getDecoder().decode(key + "="); + } + + private byte[] removePadding(byte[] plainText) { + int pad = plainText[plainText.length - 1] & 0xFF; + if (pad < 1 || pad > 32) { + pad = 0; + } + return Arrays.copyOfRange(plainText, 0, plainText.length - pad); + } +} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/WecomEncryptedEnvelope.java b/src/main/java/com/company/agentfoundation/integration/wecom/WecomEncryptedEnvelope.java new file mode 100644 index 0000000..e65d816 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/wecom/WecomEncryptedEnvelope.java @@ -0,0 +1,23 @@ +package com.company.agentfoundation.integration.wecom; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; + +@JacksonXmlRootElement(localName = "xml") +public class WecomEncryptedEnvelope { + @JacksonXmlProperty(localName = "ToUserName") + private String toUserName; + + @JacksonXmlProperty(localName = "AgentID") + private String agentId; + + @JacksonXmlProperty(localName = "Encrypt") + private String encrypt; + + public String getToUserName() { return toUserName; } + public void setToUserName(String toUserName) { this.toUserName = toUserName; } + public String getAgentId() { return agentId; } + public void setAgentId(String agentId) { this.agentId = agentId; } + public String getEncrypt() { return encrypt; } + public void setEncrypt(String encrypt) { this.encrypt = encrypt; } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5e9f78c..32d0eaf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -32,5 +32,6 @@ foundation: agent-id: ${WECOM_AGENT_ID:1000002} secret: ${WECOM_SECRET:} token: ${WECOM_TOKEN:} + encoding-aes-key: ${WECOM_ENCODING_AES_KEY:} crm-agent-id: crm-assistant callback-path: /v1/integrations/wecom/callback