From 5631f22566fab6b5e1fdb8d84e2cada61521a607 Mon Sep 17 00:00:00 2001 From: Peanut Date: Wed, 3 Jun 2026 07:28:30 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E4=BF=AE=E5=A4=8D=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E7=99=BB=E5=BD=95=20text/plain=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3=EF=BC=88?= =?UTF-8?q?WAF=20=E6=8B=A6=E6=88=AA=20+=20=E5=87=AD=E6=8D=AE=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-03-wechat-resttemplate-waf-fix-design.md | 403 ++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-03-wechat-resttemplate-waf-fix-design.md diff --git a/docs/superpowers/specs/2026-06-03-wechat-resttemplate-waf-fix-design.md b/docs/superpowers/specs/2026-06-03-wechat-resttemplate-waf-fix-design.md new file mode 100644 index 0000000..fa9f03b --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-wechat-resttemplate-waf-fix-design.md @@ -0,0 +1,403 @@ +--- +author: 微信登录修复会话 +created_at: 2026-06-03 +purpose: 修复微信小程序登录 text/plain 错误(Spring RestTemplate 默认 User-Agent 触发 WAF 拦截 + 凭据硬编码安全问题) +--- + +# 修复微信小程序登录 text/plain 错误 — 设计文档 + +## 问题陈述 + +微信小程序用户在小程序里点击「微信授权登录」时,后端 `/api/auth/wechat/login` 接口返回 500 错误: + +```json +{ + "code": 500, + "message": "系统运行异常: Could not extract response: no suitable HttpMessageConverter found for response type [class com.emotion.dto.wechat.WechatCodeSessionResponse] and content type [text/plain]", + "timestamp": 1780414444577 +} +``` + +**业务影响**: +- 真实用户(IP 106.39.148.160)2026-06-02 23:34:04 尝试登录失败 +- 21:22、22:11、23:06 还出现 3 次 `WARN 业务异常: 微信小程序登录未配置`(早期因 env var 未设被 `validateConfigured()` 拦截) +- 微信登录完全不可用 + +## 根因分析 + +### 证据链 + +| # | 证据 | 结论 | +|---|---|---| +| 1 | 错误堆栈:`WechatMiniProgramServiceImpl.java:33` 调用 `restTemplate.getForObject(url, WechatCodeSessionResponse.class)` | ✓ 错误在微信 code2Session 调用 | +| 2 | `application.yml:78-79` 微信配置**硬编码测试凭据**(`app-secret: 4f087bd363a4147ef543d634f9f6950d`)作为默认值 | ⚠️ **安全风险**(生产未设环境变量时会用测试 secret) | +| 3 | `application-prod.yml` **不覆盖**微信配置 | ⚠️ 生产用 application.yml 默认值(测试 secret) | +| 4 | `RestTemplateConfig.java:10-12` 仅 `new RestTemplate()`,13 行 | ⚠️ **无 User-Agent / Accept 头** | +| 5 | Spring 默认 `RestTemplate` → `SimpleClientHttpRequestFactory` → `HttpURLConnection`,默认 `User-Agent: Java/17.0.x` | ❌ **触发了 WAF / CloudFlare / 边缘代理拦截** | +| 6 | SSH+curl 测试(带 `User-Agent: curl/7.61.1`)→ `api.weixin.qq.com` 返回 `Content-Type: application/json; encoding=utf-8` | ✓ 微信 API 本身正常 | +| 7 | 日志中 23:34:04 之前的多次 `Wechat mini program login is not configured` | 23:34 env var 设上了,进入了真实 API 调用 | +| 8 | 已有 `docs/superpowers/specs/2026-05-31-mini-program-wechat-auth-design.md`(265 行完整设计) | 设计已存在,但未覆盖此 bug | + +### 根因结论 + +**主要根因(用户已确认)**:生产环境的 WAF / CloudFlare / 边缘代理拦截了 Java 默认 `User-Agent: Java/17.0.x`,返回 `text/plain` 错误页面(这是 WAF 常见行为)。SSH+curl 用 `User-Agent: curl/7.61.1` 所以能正常通过。 + +**次要根因(必须一并修复)**: + +1. **app-secret 硬编码在源码**(`4f087bd363a4147ef543d634f9f6950d`)—— 安全问题,必须移除默认值 +2. **错误日志不打印响应体** —— 看不到 text/plain 实际内容,下次出问题还是黑盒 +3. **无超时设置** —— 微信 API hang 住时整个请求线程被锁 +4. **RestTemplate 无日志拦截器** —— 出问题时无任何请求/响应可查 + +### 已排除假设 + +- ❌ 微信 API endpoint URL 配错:SSH+curl 直连 `https://api.weixin.qq.com/sns/jscode2session` 返回正常 JSON +- ❌ 部署代码 ≠ 本地代码:日志中 `WechatMiniProgramServiceImpl.java:33` 与本地代码行号完全一致 +- ❌ 网络层完全不通:SSH+curl TLS 握手成功 + +## 目标 + +- **G1** 修复微信小程序登录 `text/plain` 错误,使用户能成功通过微信授权登录 +- **G2** 在 RestTemplate 中显式设置 `User-Agent`,绕过 WAF 对 `Java/*` 默认 UA 的拦截 +- **G3** 错误日志打印原始响应体(raw body),下次类似问题能直接看到内容 +- **G4** 移除 `application.yml` 中微信 `app-secret` 的硬编码默认值,强制从环境变量读取 +- **G5** RestTemplate 统一配置:超时、User-Agent、Accept 头、StringHttpMessageConverter、LoggingInterceptor + +## 非目标 + +- 不改动现有 8 个 RestTemplate 使用点的业务代码(仅共享统一 bean) +- 不做 RestTemplate 连接池(沿用默认 SimpleClientHttpRequestFactory) +- 不重试 / 不熔断(业务层不需要) +- 不做 phone number binding / merge logic(已有 plan 覆盖) +- 不改 mini-program 前端(纯后端 bug) + +## 方案选择 + +经过 brainstorming 对话(澄清问题 → SSH+curl 探针 → 查 logs\server\emotion-single.log),用户选择 **方案 A:防御性增强 + 错误捕获(整合根因)**。 + +**未选方案**: +- 方案 B(仅加 message converter):不解决根因(WAF 仍会拦截) +- 方案 C(先用探针定位再设计):已通过日志 + SSH 探针定位根因,无需再加探针 + +## 设计 + +### 修改清单 + +| # | 文件 | 操作 | 行数估算 | 目的 | +|---|---|---|---|---| +| 1 | `backend-single/src/main/java/com/emotion/config/RestTemplateConfig.java` | **重写** | 30 行 | 改用 `RestTemplateBuilder`:设置 User-Agent、超时、加 StringHttpMessageConverter、加 LoggingInterceptor、加 ErrorHandler | +| 2 | `backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java` | **重写** | 80 行 | 改用 `ResponseEntity` + Jackson 二次解析;捕获所有失败场景的 raw body | +| 3 | `backend-single/src/main/resources/application.yml` | **修改** | 2 行 | 删除 `app-secret` 硬编码默认值(保留 `${ENV_VAR:}` 空兜底) | +| 4 | `backend-single/src/main/java/com/emotion/exception/WechatApiException.java` | **新增** | 35 行 | 业务异常类(errcode + errmsg + rawBody + httpStatus) | +| 5 | `backend-single/src/main/java/com/emotion/http/LoggingInterceptor.java` | **新增** | 50 行 | ClientHttpRequestInterceptor:记录 method/URL/headers/status/body(前 2KB) | +| 6 | `backend-single/src/main/java/com/emotion/http/WechatResponseErrorHandler.java` | **新增** | 40 行 | 4xx/5xx 不抛 RestClientException,记录 body | +| 7 | `backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java` | **修改** | 3 行 | 加 `rawBody` 字段 + getter/setter | + +**总计**:2 重写 + 3 新增 + 2 修改,约 240 行 Java + 2 行 YAML。 + +### 详细设计 + +#### 1. RestTemplateConfig 重写 + +```java +package com.emotion.config; + +import com.emotion.http.LoggingInterceptor; +import com.emotion.http.WechatResponseErrorHandler; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + // 防御性:绕过 WAF / CloudFlare 对默认 User-Agent "Java/*" 的拦截 + .defaultHeader("User-Agent", "Mozilla/5.0 (compatible; EmotionMuseum/1.0)") + .defaultHeader("Accept", "application/json, text/plain, */*") + // 防御性:连接 / 读取超时,避免线程被 hang 住 + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(10)) + // 防御性:支持 text/plain 响应(即使 WAF 返回 text/plain 错误页也能拿到 body) + .additionalMessageConverters(new StringHttpMessageConverter(StandardCharsets.UTF_8)) + // 防御性:记录所有外发请求 / 响应(含 body 前 2KB),方便排查 + .additionalInterceptors(new LoggingInterceptor()) + // 防御性:4xx / 5xx 不抛 RestClientException,由业务层解析 + .errorHandler(new WechatResponseErrorHandler()) + .build(); + } +} +``` + +#### 2. WechatMiniProgramServiceImpl.code2Session 重写 + +```java +@Override +public WechatCodeSessionResponse code2Session(String code) { + validateConfigured(); + String url = buildCode2SessionUrl(code); + + try { + // 第一步:拿原始 String 响应(不直接反序列化为 DTO,避免 content-type 错误就丢 body) + ResponseEntity response = restTemplate.exchange( + url, HttpMethod.GET, null, String.class); + String rawBody = response.getBody(); + HttpStatusCode status = response.getStatusCode(); + MediaType contentType = response.getHeaders().getContentType(); + + log.debug("WeChat code2Session raw response: status={}, contentType={}, body={}", + status, contentType, rawBody); + + // 第二步:HTTP 状态码检查 + if (status == null || !status.is2xxSuccessful()) { + log.error("WeChat API HTTP error: status={}, contentType={}, body={}", + status, contentType, rawBody); + throw new WechatApiException( + status != null ? status.value() : -1, + "WeChat HTTP " + status, rawBody); + } + + // 第三步:content-type 防御 + if (contentType == null || !contentType.includesCompatibleWith(MediaType.APPLICATION_JSON)) { + log.error("WeChat API returned non-JSON: contentType={}, body={}", contentType, rawBody); + throw new WechatApiException(-1, + "WeChat API returned " + contentType + " (body captured in logs)", rawBody); + } + + // 第四步:JSON 解析 + WechatCodeSessionResponse dto; + try { + dto = objectMapper.readValue(rawBody, WechatCodeSessionResponse.class); + } catch (JsonProcessingException e) { + log.error("WeChat API returned invalid JSON. body={}", rawBody, e); + throw new WechatApiException(-2, "Invalid JSON from WeChat", rawBody); + } + + // 第五步:业务错误码检查 + if (!dto.isSuccess()) { + log.warn("WeChat code2Session business error: errcode={}, errmsg={}, body={}", + dto.getErrcode(), dto.getErrmsg(), rawBody); + throw new WechatApiException( + dto.getErrcode() != null ? dto.getErrcode() : -3, + dto.getErrmsg() != null ? dto.getErrmsg() : "Unknown", + rawBody); + } + + dto.setRawBody(rawBody); // 保存原始响应便于排查 + return dto; + + } catch (WechatApiException e) { + throw e; + } catch (RestClientException e) { + // 网络 / DNS / SSL 错误 + log.error("WeChat API call failed (network/SSL/DNS)", e); + throw new WechatApiException(-4, "Network error: " + e.getMessage(), null); + } +} +``` + +#### 3. WechatApiException 新增 + +```java +package com.emotion.exception; + +import lombok.Getter; + +@Getter +public class WechatApiException extends RuntimeException { + private final Integer errcode; // 微信 errcode 或 HTTP status + private final String rawBody; // 原始响应体 + + public WechatApiException(Integer errcode, String message, String rawBody) { + super(message); + this.errcode = errcode; + this.rawBody = rawBody; + } +} +``` + +#### 4. LoggingInterceptor 新增 + +```java +package com.emotion.http; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Slf4j +public class LoggingInterceptor implements ClientHttpRequestInterceptor { + + private static final int MAX_BODY_LOG = 2048; // 仅记录前 2KB + + @Override + public ClientHttpResponse intercept( + HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + + long start = System.currentTimeMillis(); + log.info("[HTTP] ---> {} {} (UA={})", + request.getMethod(), request.getURI(), + request.getHeaders().getFirst("User-Agent")); + + ClientHttpResponse response = execution.execute(request, body); + + long duration = System.currentTimeMillis() - start; + String responseBody = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8); + if (responseBody.length() > MAX_BODY_LOG) { + responseBody = responseBody.substring(0, MAX_BODY_LOG) + "...(truncated)"; + } + log.info("[HTTP] <--- {} {} ({}ms) content-type={} body={}", + response.getStatusCode(), request.getURI(), duration, + response.getHeaders().getContentType(), responseBody); + + return response; + } +} +``` + +#### 5. WechatResponseErrorHandler 新增 + +```java +package com.emotion.http; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.client.DefaultResponseErrorHandler; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Slf4j +public class WechatResponseErrorHandler extends DefaultResponseErrorHandler { + @Override + public void handleError(ClientHttpResponse response) throws IOException { + String body = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8); + log.error("WeChat API HTTP error: status={}, body={}", response.getStatusCode(), body); + // 不抛异常,由 WechatMiniProgramServiceImpl 业务层根据 status 判断 + } +} +``` + +#### 6. application.yml 修改 + +```yaml +wechat: + mini-program: + # 防御性:删除硬编码默认值,强制从环境变量读取 + # 生产必须设置 WECHAT_MINI_PROGRAM_APP_ID 和 WECHAT_MINI_PROGRAM_APP_SECRET + app-id: ${WECHAT_MINI_PROGRAM_APP_ID:} + app-secret: ${WECHAT_MINI_PROGRAM_APP_SECRET:} + base-url: https://api.weixin.qq.com +``` + +#### 7. WechatCodeSessionResponse 修改 + +```java +@Data +public class WechatCodeSessionResponse { + // ... 原有字段 ... + private String openid; + @JsonProperty("session_key") + private String sessionKey; + private String unionid; + private Integer errcode; + private String errmsg; + + // 新增:原始响应体(仅服务端排查用,绝不返回给前端) + @JsonIgnore + private String rawBody; + + public boolean isSuccess() { + return (errcode == null || errcode == 0) && StringUtils.hasText(openid); + } +} +``` + +### 错误处理层次 + +``` +WechatMiniProgramServiceImpl.code2Session() + ├─ RestClientException (网络/SSL/DNS) + │ └─ 抛 WechatApiException(errcode=-4, rawBody=null) + ├─ HTTP 非 2xx + │ └─ 抛 WechatApiException(errcode=status, rawBody) + ├─ Content-Type 非 application/json + │ └─ 抛 WechatApiException(errcode=-1, rawBody) + ├─ JSON 解析失败 + │ └─ 抛 WechatApiException(errcode=-2, rawBody) + └─ errcode != 0 + └─ 抛 WechatApiException(errcode, rawBody) + +GlobalExceptionHandler (已有) + └─ 捕获 WechatApiException → 500 + 友好提示(不暴露 rawBody) +``` + +## 测试 + +### 单元测试(WechatMiniProgramServiceImplTest) + +| 场景 | 模拟返回 | 期望 | +|---|---|---| +| 1. 成功 | HTTP 200 + `application/json` + `{"openid":"oX","session_key":"sK"}` | 返回 DTO,rawBody 已设 | +| 2. 微信业务错误 | HTTP 200 + JSON `{"errcode":40013,"errmsg":"invalid appid"}` | 抛 WechatApiException(40013, rawBody 含 errmsg) | +| 3. text/plain(WAF) | HTTP 200 + `text/plain` + `"blocked"` | 抛 WechatApiException(-1, rawBody="blocked") | +| 4. 4xx | HTTP 401 + body | 抛 WechatApiException(401, rawBody) | +| 5. 5xx | HTTP 500 + body | 抛 WechatApiException(500, rawBody) | +| 6. 非法 JSON | HTTP 200 + `application/json` + `not json` | 抛 WechatApiException(-2, rawBody) | +| 7. 网络异常 | RestClientException (IOException) | 抛 WechatApiException(-4) | +| 8. 未配置 | appId 为空 | 抛 BusinessException("微信小程序登录未配置") | + +### 集成验证 + +| 步骤 | 命令 / 操作 | 期望 | +|---|---|---| +| 1. mvn 编译 | `mvn clean install -pl :backend-single -am -DskipTests` | 退出码 0 | +| 2. 部署 | `python deploy.py backend` | 部署成功,重启 backend | +| 3. 端到端(H5 模式) | 启动 mini-program H5 (端口 5173) → 微信授权登录 | 登录成功,跳转 onboarding 或主页 | +| 4. 看后端日志 | `tail -f /data/logs/emotion-museum/emotion-single.log` | 看到 `[HTTP] ---> GET https://api.weixin.qq.com/sns/jscode2session ...` 和 `<--- 200 ... body={...}` | +| 5. WAF 验证 | Postman 设 `User-Agent: Java/17.0.x` 测后端 | 期望:现在 WAF 不再拦截(因为后端已设 Mozilla UA) | +| 6. 凭据验证 | 部署后从服务器内部 `curl` 微信 API | 期望:返回正常 JSON errcode 或 openid | + +### 防御性测试 + +- 在测试环境人为让微信 API 返回 `text/plain`,确认不会 500(而是 WechatApiException 被 GlobalExceptionHandler 捕获,返回 500 + 友好提示) +- 单元测试覆盖 8 个场景(见上表) + +## 风险与缓解 + +| 风险 | 影响 | 缓解 | +|---|---|---| +| 改 `RestTemplateConfig` 影响其他 8 处使用点 | 中 | 用 `additionalInterceptors` / `additionalMessageConverters`(追加,不覆盖);3 天观察期 | +| 默认 `app-secret` 删除后本地开发挂 | 低 | local 启动报错时本地 dev 配 env var;CI 配 secret | +| 生产 `WECHAT_MINI_PROGRAM_*` 未设导致启动失败 | 低 | 启动日志 warn 但不阻断(保持现状) | +| LoggingInterceptor 性能开销 | 低 | 仅记录前 2KB body;生产可调低 log level;interceptor 是单例 | +| 错误日志泄露敏感信息(openid / session_key) | 中 | rawBody 在 GlobalExceptionHandler 中**不返回**给前端,仅服务端日志;生产日志脱敏审计 | + +## 范围外(Out of Scope) + +- 不做 RestTemplate 连接池(沿用默认 SimpleClientHttpRequestFactory) +- 不重试 / 不熔断(业务层不需要) +- 不做 phone number binding / merge logic(已有 plan 覆盖) +- 不改 mini-program 前端(纯后端 bug) +- 不改其他 8 个 RestTemplate 使用点的业务代码 + +## 参考资料 + +- 已有设计:`docs/superpowers/specs/2026-05-31-mini-program-wechat-auth-design.md` +- 已有 plan:`docs/superpowers/plans/2026-05-31-mini-program-wechat-auth.md` +- 微信 API:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html +- Spring RestTemplateBuilder:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/boot/web/client/RestTemplateBuilder.html +- WAF / CloudFlare 对 Java UA 的拦截(业界已知问题,常见解决方案:显式设置 User-Agent)