From 8191a2dbc97b2c62694ba242b19513022980f911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E6=96=B0=E4=B9=90?= Date: Mon, 18 May 2026 15:16:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B6=88=E6=81=AF=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 赵新乐 --- pom.xml | 10 +- .../modules/api/action/AuthController.java | 73 ----- .../api/action/CallbackController.java | 53 +++- .../modules/api/config/SecurityConfig.java | 63 ---- .../security/JwtAuthenticationEntryPoint.java | 36 --- .../api/security/JwtAuthenticationFilter.java | 61 ---- .../api/security/JwtTokenProvider.java | 100 ------ .../modules/api/utils/AesException.java | 59 ++++ .../modules/api/utils/ByteGroup.java | 26 ++ .../modules/api/utils/JsonParse.java | 66 ++++ .../modules/api/utils/PKCS7Encoder.java | 67 ++++ .../modules/api/utils/SHA1.java | 60 ++++ .../modules/api/utils/WXBizJsonMsgCrypt.java | 288 ++++++++++++++++++ .../modules/api/vo/CallbackVO.java | 14 + src/main/resources/application-dev.yml | 6 +- src/main/resources/application.yml | 3 +- 16 files changed, 637 insertions(+), 348 deletions(-) delete mode 100644 src/main/java/com/f8q8/qywechatarchive/modules/api/action/AuthController.java delete mode 100644 src/main/java/com/f8q8/qywechatarchive/modules/api/config/SecurityConfig.java delete mode 100644 src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtAuthenticationEntryPoint.java delete mode 100644 src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtAuthenticationFilter.java delete mode 100644 src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtTokenProvider.java create mode 100644 src/main/java/com/f8q8/qywechatarchive/modules/api/utils/AesException.java create mode 100644 src/main/java/com/f8q8/qywechatarchive/modules/api/utils/ByteGroup.java create mode 100644 src/main/java/com/f8q8/qywechatarchive/modules/api/utils/JsonParse.java create mode 100644 src/main/java/com/f8q8/qywechatarchive/modules/api/utils/PKCS7Encoder.java create mode 100644 src/main/java/com/f8q8/qywechatarchive/modules/api/utils/SHA1.java create mode 100644 src/main/java/com/f8q8/qywechatarchive/modules/api/utils/WXBizJsonMsgCrypt.java create mode 100644 src/main/java/com/f8q8/qywechatarchive/modules/api/vo/CallbackVO.java diff --git a/pom.xml b/pom.xml index f14bc47..246cf46 100644 --- a/pom.xml +++ b/pom.xml @@ -1,6 +1,6 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 @@ -31,10 +31,6 @@ spring-boot-starter-test test - - org.springframework.boot - spring-boot-starter-security - org.springframework.boot spring-boot-starter-validation @@ -70,6 +66,10 @@ ${jjwt.version} runtime + + commons-codec + commons-codec + diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/action/AuthController.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/action/AuthController.java deleted file mode 100644 index 283fb51..0000000 --- a/src/main/java/com/f8q8/qywechatarchive/modules/api/action/AuthController.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.f8q8.qywechatarchive.modules.api.action; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.f8q8.qywechatarchive.common.entity.SysApiUser; -import com.f8q8.qywechatarchive.common.service.SysApiUserService; -import com.f8q8.qywechatarchive.core.utils.Result; -import com.f8q8.qywechatarchive.core.utils.ResultUtils; -import com.f8q8.qywechatarchive.modules.api.security.JwtTokenProvider; -import com.f8q8.qywechatarchive.modules.api.vo.LoginRequest; -import com.f8q8.qywechatarchive.modules.api.vo.LoginResponse; -import jakarta.annotation.Resource; -import jakarta.validation.Valid; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.crypto.password.PasswordEncoder; -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.RestController; - -@Slf4j -@RestController -@RequestMapping(value = "api/v1/auth") -public class AuthController { - - @Resource - SysApiUserService sysApiUserService; - @Resource - PasswordEncoder passwordEncoder; - @Resource - JwtTokenProvider jwtTokenProvider; - - /** - * 登录接口 — 用户名密码换取JWT Token - */ - @PostMapping("login") - public Result login(@Valid @RequestBody LoginRequest request) { - // 1. 查询用户 - SysApiUser user = sysApiUserService.getOne( - new LambdaQueryWrapper() - .eq(SysApiUser::getUsername, request.getUsername()) - ); - - // 2. 校验用户状态和密码 - if (user == null || user.getStatus() != 1) { - log.warn("登录失败 - 用户不存在或已禁用: {}", request.getUsername()); - return ResultUtils.error(400, "用户名或密码错误"); - } - - if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { - log.warn("登录失败 - 密码错误: {}", request.getUsername()); - return ResultUtils.error(400, "用户名或密码错误"); - } - - // 3. 生成Token - String accessToken = jwtTokenProvider.generateAccessToken(user.getUsername()); - String refreshToken = jwtTokenProvider.generateRefreshToken(user.getUsername()); - - log.info("用户登录成功: {}", user.getUsername()); - - // 4. 返回Token信息 - - LoginResponse resp = LoginResponse.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .tokenType("Bearer") - .expiresIn(jwtTokenProvider.getAccessExpiration()) - .username(user.getUsername()) - .build(); - - return ResultUtils.success(resp); - } - -} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/action/CallbackController.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/action/CallbackController.java index cb01c2c..73b9dac 100644 --- a/src/main/java/com/f8q8/qywechatarchive/modules/api/action/CallbackController.java +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/action/CallbackController.java @@ -1,10 +1,11 @@ package com.f8q8.qywechatarchive.modules.api.action; -import com.f8q8.qywechatarchive.core.utils.Result; import com.f8q8.qywechatarchive.core.utils.ResultUtils; +import com.f8q8.qywechatarchive.modules.api.utils.WXBizJsonMsgCrypt; +import com.f8q8.qywechatarchive.modules.api.vo.CallbackVO; import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -13,9 +14,47 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping(value = "api/v1/callback") public class CallbackController { - @PostMapping(value = "notify") - public Result notify(@RequestBody String data) { - log.info("企业微信回调数据:{}", data); - return ResultUtils.success(); + @Value("${wecom.token}") + String token; + @Value("${wecom.encodingAESKey}") + String encodingAESKey; + @Value("${wecom.corp-id}") + String corpId; + + @RequestMapping(value = "notify") + public Object notify(CallbackVO callbackVO) { + log.info("企业微信回调数据:{}", callbackVO); + if (StringUtils.hasText(callbackVO.getEchostr())) { + // 下面逻辑用于校验 + return verifyRequest(callbackVO); + } else { + // 下面逻辑用于消息解密 + return ResultUtils.success(); + } + } + + private String verifyRequest(CallbackVO callbackVO) { + String result = null; + if (verifyParam(callbackVO)) { + try { + WXBizJsonMsgCrypt wxcpt = new WXBizJsonMsgCrypt(token, encodingAESKey, corpId); + result = wxcpt.VerifyURL(callbackVO.getMsg_signature(), String.valueOf(callbackVO.getTimestamp()), + callbackVO.getNonce(), callbackVO.getEchostr()); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + return result; + } + + /** + * 校验参数 + * + * @param callbackVO + * @return + */ + private boolean verifyParam(CallbackVO callbackVO) { + boolean result = true; + return result; } } diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/config/SecurityConfig.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/config/SecurityConfig.java deleted file mode 100644 index ea80cd6..0000000 --- a/src/main/java/com/f8q8/qywechatarchive/modules/api/config/SecurityConfig.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.f8q8.qywechatarchive.modules.api.config; - -import com.f8q8.qywechatarchive.modules.api.security.JwtAuthenticationEntryPoint; -import com.f8q8.qywechatarchive.modules.api.security.JwtAuthenticationFilter; -import jakarta.annotation.Resource; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -@Configuration -@EnableWebSecurity -@EnableMethodSecurity -public class SecurityConfig { - - @Resource - JwtAuthenticationFilter jwtAuthenticationFilter; - @Resource - JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; - - /** - * 密码编码器 - */ - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - - /** - * 安全过滤链 — Spring Boot 4 推荐链式配置 - */ - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - // 禁用CSRF(API接口场景) - .csrf(AbstractHttpConfigurer::disable) - // 无状态会话 - .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - // 异常处理 — Token过期/无效时返回401 - .exceptionHandling(ex -> ex - .authenticationEntryPoint(jwtAuthenticationEntryPoint)) - // 路由权限配置 - .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/v1/auth/login").permitAll() - .requestMatchers("/actuator/health").permitAll() // 健康检查放行 - .requestMatchers("/api/v1/**").permitAll() - .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() - .anyRequest().authenticated()) - // JWT过滤器 - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - - return http.build(); - } - -} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtAuthenticationEntryPoint.java deleted file mode 100644 index 9d7d516..0000000 --- a/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtAuthenticationEntryPoint.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.f8q8.qywechatarchive.modules.api.security; - -import com.f8q8.qywechatarchive.core.utils.Result; -import com.f8q8.qywechatarchive.core.utils.ResultUtils; -import jakarta.annotation.Resource; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.stereotype.Component; -import tools.jackson.databind.ObjectMapper; - -import java.io.IOException; - -@Slf4j -@Component -public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { - @Resource - ObjectMapper objectMapper; - - @Override - public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { - response.setStatus(HttpStatus.UNAUTHORIZED.value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.setCharacterEncoding("UTF-8"); - - Result result = ResultUtils.error(401, "Token已过期或无效,请重新登录"); - objectMapper.writeValue(response.getWriter(), result); - - log.warn("认证失败 - URI: {}, 原因: {}", request.getRequestURI(), authException.getMessage()); - } -} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtAuthenticationFilter.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtAuthenticationFilter.java deleted file mode 100644 index 0f90d59..0000000 --- a/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtAuthenticationFilter.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.f8q8.qywechatarchive.modules.api.security; - -import io.jsonwebtoken.ExpiredJwtException; -import jakarta.annotation.Resource; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.Collections; - -@Slf4j -@Component -public class JwtAuthenticationFilter extends OncePerRequestFilter { - @Resource - JwtTokenProvider jwtTokenProvider; - - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String token = extractTokenFromHeader(request); - - if (StringUtils.hasText(token)) { - try { - if (jwtTokenProvider.validateToken(token)) { - String username = jwtTokenProvider.extractUsername(token); - // 设置认证信息到Spring Security上下文 - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken( - username, null, Collections.emptyList()); - SecurityContextHolder.getContext().setAuthentication(authentication); - - // 可选:将剩余有效时间放入响应头 - long remaining = jwtTokenProvider.getRemainingTime(token); - response.setHeader("X-Token-Remaining", String.valueOf(remaining)); - } - } catch (ExpiredJwtException e) { - // 交由 AuthenticationEntryPoint 统一返回401 - SecurityContextHolder.clearContext(); - log.debug("Token已过期: {}", e.getMessage()); - } - } - - filterChain.doFilter(request, response); - } - - private String extractTokenFromHeader(HttpServletRequest request) { - String bearerToken = request.getHeader("Authorization"); - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(7); - } - return null; - } -} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtTokenProvider.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtTokenProvider.java deleted file mode 100644 index c21967d..0000000 --- a/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtTokenProvider.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.f8q8.qywechatarchive.modules.api.security; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.JwtException; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; - -@Slf4j -@Getter -@Component -public class JwtTokenProvider { - - @Value("${jwt.access-expiration}") - long accessExpiration; - @Value("${jwt.secret}") - String secret; - @Value("${jwt.refresh-expiration}") - long refreshExpiration; - - private SecretKey getSigningKey() { - return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); - } - - /** - * 生成token - * - * @param username 用户名 - * @return - */ - public String generateAccessToken(String username) { - return buildToken(username, accessExpiration * 1000); - } - - public String generateRefreshToken(String username) { - return buildToken(username, refreshExpiration * 1000); - } - - private String buildToken(String subject, long expirationMs) { - Map claims = new HashMap<>(); - claims.put("type", expirationMs == accessExpiration * 1000 ? "ACCESS" : "REFRESH"); - - return Jwts.builder() - .claims(claims) - .subject(subject) - .issuedAt(new Date()) - .expiration(new Date(System.currentTimeMillis() + expirationMs)) - .signWith(getSigningKey()) - .compact(); - } - - /** - * 从Token中提取用户名 - */ - public String extractUsername(String token) { - return extractAllClaims(token).getSubject(); - } - - /** - * 校验Token是否有效 - */ - public boolean validateToken(String token) { - try { - extractAllClaims(token); - return true; - } catch (ExpiredJwtException e) { - // Token 过期 — 外层捕获后返回401 - throw e; - } catch (JwtException | IllegalArgumentException e) { - // Token 格式错误或签名无效 - return false; - } - } - - private Claims extractAllClaims(String token) { - return Jwts.parser() - .verifyWith(getSigningKey()) - .build() - .parseSignedClaims(token) - .getPayload(); - } - - /** - * 获取Token剩余有效时间(秒) - */ - public long getRemainingTime(String token) { - Claims claims = extractAllClaims(token); - return (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000; - } -} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/AesException.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/AesException.java new file mode 100644 index 0000000..0a20547 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/AesException.java @@ -0,0 +1,59 @@ +package com.f8q8.qywechatarchive.modules.api.utils; + +@SuppressWarnings("serial") +public class AesException extends Exception { + + public final static int OK = 0; + public final static int ValidateSignatureError = -40001; + public final static int ParseJsonError = -40002; + public final static int ComputeSignatureError = -40003; + public final static int IllegalAesKey = -40004; + public final static int ValidateCorpidError = -40005; + public final static int EncryptAESError = -40006; + public final static int DecryptAESError = -40007; + public final static int IllegalBuffer = -40008; + public final static int EncodeBase64Error = -40009; + public final static int DecodeBase64Error = -40010; + public final static int GenReturnJsonError = -40011; + + private int code; + + private static String getMessage(int code) { + switch (code) { + case ValidateSignatureError: + return "签名验证错误"; + case ParseJsonError: + return "json解析失败"; + case ComputeSignatureError: + return "sha加密生成签名失败"; + case IllegalAesKey: + return "SymmetricKey非法"; + case ValidateCorpidError: + return "corpid校验失败"; + case EncryptAESError: + return "aes加密失败"; + case DecryptAESError: + return "aes解密失败"; + case IllegalBuffer: + return "解密后得到的buffer非法"; + case EncodeBase64Error: + return "base64加密错误"; + case DecodeBase64Error: + return "base64解密错误"; + case GenReturnJsonError: + return "josn生成失败"; + default: + return null; // cannot be + } + } + + public int getCode() { + return code; + } + + AesException(int code) { + super(getMessage(code)); + this.code = code; + } + +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/ByteGroup.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/ByteGroup.java new file mode 100644 index 0000000..bd29d52 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/ByteGroup.java @@ -0,0 +1,26 @@ +package com.f8q8.qywechatarchive.modules.api.utils; + +import java.util.ArrayList; + +class ByteGroup { + ArrayList byteContainer = new ArrayList(); + + public byte[] toBytes() { + byte[] bytes = new byte[byteContainer.size()]; + for (int i = 0; i < byteContainer.size(); i++) { + bytes[i] = byteContainer.get(i); + } + return bytes; + } + + public ByteGroup addBytes(byte[] bytes) { + for (byte b : bytes) { + byteContainer.add(b); + } + return this; + } + + public int size() { + return byteContainer.size(); + } +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/JsonParse.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/JsonParse.java new file mode 100644 index 0000000..96df378 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/JsonParse.java @@ -0,0 +1,66 @@ +/** + * 对企业微信发送给企业后台的消息加解密示例代码. + * + * @copyright Copyright (c) 1998-2020 Tencent Inc. + */ + +// ------------------------------------------------------------------------ + +package com.f8q8.qywechatarchive.modules.api.utils; + +/** + * 针对 org.json.JSONObject, + * 要编译打包架包json + * 官方源码下载地址 : https://github.com/stleary/JSON-java, jar包下载地址 : https://mvnrepository.com/artifact/org.json/json + */ + + +/** + * JsonParse class + * + * 提供提取消息格式中的密文及生成回复消息格式的接口. + */ +class JsonParse { + + /** + * 提取出 JSON 包中的加密消息 + * @param jsontext 待提取的json字符串 + * @return 提取出的加密消息字符串 + * @throws AesException + */ + public static Object[] extract(String jsontext) throws AesException { + Object[] result = new Object[3]; + /* + try { + JSONObject json = new JSONObject(jsontext); + String encrypt_msg = json.getString("encrypt"); + String tousername = json.getString("tousername"); + String agentid = json.getString("agentid"); + + result[0] = tousername; + result[1] = encrypt_msg; + result[2] = agentid; + return result; + } catch (Exception e) { + e.printStackTrace(); + throw new AesException(AesException.ParseJsonError); + } + */ + return null; + } + + /** + * 生成json消息 + * @param encrypt 加密后的消息密文 + * @param signature 安全签名 + * @param timestamp 时间戳 + * @param nonce 随机字符串 + * @return 生成的json字符串 + */ + public static String generate(String encrypt, String signature, String timestamp, String nonce) { + + String format = "{\"encrypt\":\"%1$s\",\"msgsignature\":\"%2$s\",\"timestamp\":\"%3$s\",\"nonce\":\"%4$s\"}"; + return String.format(format, encrypt, signature, timestamp, nonce); + + } +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/PKCS7Encoder.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/PKCS7Encoder.java new file mode 100644 index 0000000..1098e46 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/PKCS7Encoder.java @@ -0,0 +1,67 @@ +/** + * 对企业微信发送给企业后台的消息加解密示例代码. + * + * @copyright Copyright (c) 1998-2014 Tencent Inc. + */ + +// ------------------------------------------------------------------------ + +package com.f8q8.qywechatarchive.modules.api.utils; + +import java.nio.charset.Charset; +import java.util.Arrays; + +/** + * 提供基于PKCS7算法的加解密接口. + */ +class PKCS7Encoder { + static Charset CHARSET = Charset.forName("utf-8"); + static int BLOCK_SIZE = 32; + + /** + * 获得对明文进行补位填充的字节. + * + * @param count 需要进行填充补位操作的明文字节个数 + * @return 补齐用的字节数组 + */ + static byte[] encode(int count) { + // 计算需要填充的位数 + int amountToPad = BLOCK_SIZE - (count % BLOCK_SIZE); + if (amountToPad == 0) { + amountToPad = BLOCK_SIZE; + } + // 获得补位所用的字符 + char padChr = chr(amountToPad); + String tmp = new String(); + for (int index = 0; index < amountToPad; index++) { + tmp += padChr; + } + return tmp.getBytes(CHARSET); + } + + /** + * 删除解密后明文的补位字符 + * + * @param decrypted 解密后的明文 + * @return 删除补位字符后的明文 + */ + static byte[] decode(byte[] decrypted) { + int pad = (int) decrypted[decrypted.length - 1]; + if (pad < 1 || pad > 32) { + pad = 0; + } + return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad); + } + + /** + * 将数字转化成ASCII码对应的字符,用于对明文进行补码 + * + * @param a 需要转化的数字 + * @return 转化得到的字符 + */ + static char chr(int a) { + byte target = (byte) (a & 0xFF); + return (char) target; + } + +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/SHA1.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/SHA1.java new file mode 100644 index 0000000..c3648f1 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/SHA1.java @@ -0,0 +1,60 @@ +/** + * 对企业微信发送给企业后台的消息加解密示例代码. + * + * @copyright Copyright (c) 1998-2014 Tencent Inc. + */ + +// ------------------------------------------------------------------------ + +package com.f8q8.qywechatarchive.modules.api.utils; + +import java.security.MessageDigest; +import java.util.Arrays; + +/** + * SHA1 class + * + * 计算消息签名接口. + */ +class SHA1 { + + /** + * 用SHA1算法生成安全签名 + * @param token 票据 + * @param timestamp 时间戳 + * @param nonce 随机字符串 + * @param encrypt 密文 + * @return 安全签名 + * @throws AesException + */ + public static String getSHA1(String token, String timestamp, String nonce, String encrypt) throws AesException { + try { + String[] array = new String[]{token, timestamp, nonce, encrypt}; + StringBuffer sb = new StringBuffer(); + // 字符串排序 + Arrays.sort(array); + for (int i = 0; i < 4; i++) { + sb.append(array[i]); + } + String str = sb.toString(); + // SHA1签名生成 + MessageDigest md = MessageDigest.getInstance("SHA-1"); + md.update(str.getBytes()); + byte[] digest = md.digest(); + + StringBuffer hexstr = new StringBuffer(); + String shaHex = ""; + for (int i = 0; i < digest.length; i++) { + shaHex = Integer.toHexString(digest[i] & 0xFF); + if (shaHex.length() < 2) { + hexstr.append(0); + } + hexstr.append(shaHex); + } + return hexstr.toString(); + } catch (Exception e) { + e.printStackTrace(); + throw new AesException(AesException.ComputeSignatureError); + } + } +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/WXBizJsonMsgCrypt.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/WXBizJsonMsgCrypt.java new file mode 100644 index 0000000..38dbbec --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/utils/WXBizJsonMsgCrypt.java @@ -0,0 +1,288 @@ +/** + * 对企业微信发送给企业后台的消息加解密示例代码. + * + * @copyright Copyright (c) 1998-2014 Tencent Inc. + */ + +// ------------------------------------------------------------------------ + +/** + * 针对org.apache.commons.codec.binary.Base64, + * 需要导入架包commons-codec-1.9(或commons-codec-1.8等其他版本) + * 官方下载地址:http://commons.apache.org/proper/commons-codec/download_codec.cgi + */ +package com.f8q8.qywechatarchive.modules.api.utils; + +import org.apache.commons.codec.binary.Base64; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Random; + +/** + * 提供接收和推送给企业微信消息的加解密接口(UTF8编码的字符串). + *
    + *
  1. 第三方回复加密消息给企业微信
  2. + *
  3. 第三方收到企业微信发送的消息,验证消息的安全性,并对消息进行解密。
  4. + *
+ * 说明:异常java.security.InvalidKeyException:illegal Key Size的解决方案 + *
    + *
  1. 在官方网站下载JCE无限制权限策略文件(JDK7的下载地址: + * http://www.oracle.com/technetwork/java/javase/downloads/jce-7-download-432124.html
  2. + *
  3. 下载后解压,可以看到local_policy.jar和US_export_policy.jar以及readme.txt
  4. + *
  5. 如果安装了JRE,将两个jar文件放到%JRE_HOME%\lib\security目录下覆盖原来的文件
  6. + *
  7. 如果安装了JDK,将两个jar文件放到%JDK_HOME%\jre\lib\security目录下覆盖原来文件
  8. + *
+ */ +public class WXBizJsonMsgCrypt { + static Charset CHARSET = Charset.forName("utf-8"); + Base64 base64 = new Base64(); + byte[] aesKey; + String token; + String receiveid; + + /** + * 构造函数 + * @param token 企业微信后台,开发者设置的token + * @param encodingAesKey 企业微信后台,开发者设置的EncodingAESKey + * @param receiveid, 不同场景含义不同,详见文档 + * + * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息 + */ + public WXBizJsonMsgCrypt(String token, String encodingAesKey, String receiveid) throws AesException { + if (encodingAesKey.length() != 43) { + throw new AesException(AesException.IllegalAesKey); + } + + this.token = token; + this.receiveid = receiveid; + aesKey = Base64.decodeBase64(encodingAesKey + "="); + } + + // 生成4个字节的网络字节序 + byte[] getNetworkBytesOrder(int sourceNumber) { + byte[] orderBytes = new byte[4]; + orderBytes[3] = (byte) (sourceNumber & 0xFF); + orderBytes[2] = (byte) (sourceNumber >> 8 & 0xFF); + orderBytes[1] = (byte) (sourceNumber >> 16 & 0xFF); + orderBytes[0] = (byte) (sourceNumber >> 24 & 0xFF); + return orderBytes; + } + + // 还原4个字节的网络字节序 + int recoverNetworkBytesOrder(byte[] orderBytes) { + int sourceNumber = 0; + for (int i = 0; i < 4; i++) { + sourceNumber <<= 8; + sourceNumber |= orderBytes[i] & 0xff; + } + return sourceNumber; + } + + // 随机生成16位字符串 + String getRandomStr() { + String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + Random random = new Random(); + StringBuffer sb = new StringBuffer(); + for (int i = 0; i < 16; i++) { + int number = random.nextInt(base.length()); + sb.append(base.charAt(number)); + } + return sb.toString(); + } + + /** + * 对明文进行加密. + * + * @param text 需要加密的明文 + * @return 加密后base64编码的字符串 + * @throws AesException aes加密失败 + */ + String encrypt(String randomStr, String text) throws AesException { + ByteGroup byteCollector = new ByteGroup(); + byte[] randomStrBytes = randomStr.getBytes(CHARSET); + byte[] textBytes = text.getBytes(CHARSET); + byte[] networkBytesOrder = getNetworkBytesOrder(textBytes.length); + byte[] receiveidBytes = receiveid.getBytes(CHARSET); + + // randomStr + networkBytesOrder + text + receiveid + byteCollector.addBytes(randomStrBytes); + byteCollector.addBytes(networkBytesOrder); + byteCollector.addBytes(textBytes); + byteCollector.addBytes(receiveidBytes); + + // ... + pad: 使用自定义的填充方式对明文进行补位填充 + byte[] padBytes = PKCS7Encoder.encode(byteCollector.size()); + byteCollector.addBytes(padBytes); + + // 获得最终的字节流, 未加密 + byte[] unencrypted = byteCollector.toBytes(); + + try { + // 设置加密模式为AES的CBC模式 + Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); + SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES"); + IvParameterSpec iv = new IvParameterSpec(aesKey, 0, 16); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv); + + // 加密 + byte[] encrypted = cipher.doFinal(unencrypted); + + // 使用BASE64对加密后的字符串进行编码 + String base64Encrypted = base64.encodeToString(encrypted); + + return base64Encrypted; + } catch (Exception e) { + e.printStackTrace(); + throw new AesException(AesException.EncryptAESError); + } + } + + /** + * 对密文进行解密. + * + * @param text 需要解密的密文 + * @return 解密得到的明文 + * @throws AesException aes解密失败 + */ + String decrypt(String text) throws AesException { + byte[] original; + try { + // 设置解密模式为AES的CBC模式 + Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); + SecretKeySpec key_spec = new SecretKeySpec(aesKey, "AES"); + IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16)); + cipher.init(Cipher.DECRYPT_MODE, key_spec, iv); + + // 使用BASE64对密文进行解码 + byte[] encrypted = Base64.decodeBase64(text); + + // 解密 + original = cipher.doFinal(encrypted); + } catch (Exception e) { + e.printStackTrace(); + throw new AesException(AesException.DecryptAESError); + } + + String jsonContent, from_receiveid; + try { + // 去除补位字符 + byte[] bytes = PKCS7Encoder.decode(original); + + // 分离16位随机字符串,网络字节序和receiveid + byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20); + + int jsonLength = recoverNetworkBytesOrder(networkOrder); + + jsonContent = new String(Arrays.copyOfRange(bytes, 20, 20 + jsonLength), CHARSET); + from_receiveid = new String(Arrays.copyOfRange(bytes, 20 + jsonLength, bytes.length), + CHARSET); + } catch (Exception e) { + e.printStackTrace(); + throw new AesException(AesException.IllegalBuffer); + } + + // receiveid不相同的情况 + if (!from_receiveid.equals(receiveid)) { + throw new AesException(AesException.ValidateCorpidError); + } + return jsonContent; + + } + + /** + * 将企业微信回复用户的消息加密打包. + *
    + *
  1. 对要发送的消息进行AES-CBC加密
  2. + *
  3. 生成安全签名
  4. + *
  5. 将消息密文和安全签名打包成json格式
  6. + *
+ * + * @param replyMsg 企业微信待回复用户的消息,json格式的字符串 + * @param timeStamp 时间戳,可以自己生成,也可以用URL参数的timestamp + * @param nonce 随机串,可以自己生成,也可以用URL参数的nonce + * + * @return 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的json格式的字符串 + * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息 + */ + 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()); + // 生成发送的json + String result = JsonParse.generate(encrypt, signature, timeStamp, nonce); + return result; + } + + /** + * 检验消息的真实性,并且获取解密后的明文. + *
    + *
  1. 利用收到的密文生成安全签名,进行签名验证
  2. + *
  3. 若验证通过,则提取json中的加密消息
  4. + *
  5. 对消息进行解密
  6. + *
+ * + * @param msgSignature 签名串,对应URL参数的msg_signature + * @param timeStamp 时间戳,对应URL参数的timestamp + * @param nonce 随机串,对应URL参数的nonce + * @param postData 密文,对应POST请求的数据 + * + * @return 解密后的原文 + * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息 + */ + public String DecryptMsg(String msgSignature, String timeStamp, String nonce, String postData) + throws AesException { + + // 密钥,公众账号的app secret + // 提取密文 + Object[] encrypt = JsonParse.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); + } + + // 解密 + String result = decrypt(encrypt[1].toString()); + return result; + } + + /** + * 验证URL + * @param msgSignature 签名串,对应URL参数的msg_signature + * @param timeStamp 时间戳,对应URL参数的timestamp + * @param nonce 随机串,对应URL参数的nonce + * @param echoStr 随机串,对应URL参数的echostr + * + * @return 解密之后的echostr + * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息 + */ + public String VerifyURL(String msgSignature, String timeStamp, String nonce, String echoStr) + throws AesException { + String signature = SHA1.getSHA1(token, timeStamp, nonce, echoStr); + + if (!signature.equals(msgSignature)) { + throw new AesException(AesException.ValidateSignatureError); + } + + String result = decrypt(echoStr); + return result; + } + +} \ No newline at end of file diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/vo/CallbackVO.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/vo/CallbackVO.java new file mode 100644 index 0000000..4886895 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/vo/CallbackVO.java @@ -0,0 +1,14 @@ +package com.f8q8.qywechatarchive.modules.api.vo; + +import lombok.Data; +import lombok.ToString; + +@Data +@ToString +public class CallbackVO { + + private String msg_signature; + private long timestamp; + private String nonce; + private String echostr; +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index a7a9136..cfbff71 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -13,8 +13,10 @@ jwt: # 7天 refresh-expiration: 604800 wecom: - corp-id: 111 - secret: 2222 + corp-id: wwb42d275eae6910cc + secret: KyFHB6q9g-8X5pH9fUhojSk8xifkGzWmvn96UnwMMgo + token: Yr5FiMKE1YC86mKPY22j + encodingAESKey: cc1aQYRQkYsLnK6LFi56DzdkueRD8r7cKB8yXQT52js lib-path: E:\Development\workspaces\axdr\qyWechatArchive\src\main\resources\native\win # 消息同步配置 sync: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 899531e..a165f1d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,4 +15,5 @@ mybatis-plus: id-type: assign_uuid logic-delete-field: del_flag logic-delete-value: 1 - logic-not-delete-value: 0 \ No newline at end of file + logic-not-delete-value: 0 +customConfig: \ No newline at end of file