报告中心
appLogin 调用 zjark-platform 403 问题分析报告
WebSocket 网关 1005 问题分析报告
本文档使用 MrDoc 发布
-
+
首页
appLogin 调用 zjark-platform 403 问题分析报告
**日期**:2026-08-02 **模块**:opmcs-common / opmcs-base **影响接口**:`/loginManage/appLogin` --- ## 一、问题表象 `appLogin()` 内部 Feign 调用 `ZjarkFeignUserService.getUser()` 时 zjark-platform 返回 403。 ```json { "code": 500, "error": { "errCode": "opmcs.base.error.phone.number" } } ``` --- ## 二、核心根因(一句话) `ZjarkFeignRequestInterceptor.getRequest()` 从 `RequestContextHolder` 读取原始 HTTP 请求的 `Authorization: Basic emphcms6...` 拷入 Feign 模板。调用方在 `getAdminToken()` 获取 token 后**从未更新 `RequestContextHolder`**,zjark 拦截器永远读到 Basic。后续在 Feign 模板层替换 Bearer header 不可靠,最终 HTTP 请求仍带 Basic 或 header 丢失。 --- ## 三、完整链路分析 ### 3.1 调用链路全景 ``` 用户请求(appLogin, 带 Basic auth) └─→ appLogin() └─→ zjarkFeignUserService.getUser() 【Feign 调用】 ├─→ ZjarkFeignRequestInterceptor 【拦截器 #1, 先执行】 │ ├─ getRequest() │ │ └─ RequestContextHolder.getRequestAttributes().getRequest() │ │ → 原始 HttpServletRequest │ │ → Authorization: Basic emphcms6... │ ├─ getHeaders(request) │ │ → {Authorization: "Basic emphcms6...", ...} │ ├─ removeHeader("Authorization") │ └─ header("Authorization", "Basic emphcms6...") │ ├─→ OpmcsFeignConfig.FeignConfig 【拦截器 #2, 后执行】 │ ├─ requestTemplate.headers().get("Authorization") │ │ → "Basic emphcms6..." (非 Bearer) │ ├─ getAdminToken() │ │ ├─ Redis 缓存 → 获取 opmcs admin JWT │ │ └─ ★ setupAdminTokenAttribute(token) ← 关键修复点 │ │ ├─ 获取当前 RequestContextHolder │ │ ├─ new HeaderModificationRequestWrapper(original) │ │ ├─ wrapped.addHeader("Authorization", "Bearer xxx") │ │ └─ setRequestAttributes(wrapped) │ ├─ removeHeader("Authorization") │ └─ header("Authorization", "Bearer eyJ...") │ → 模板显示: Authorization=[Bearer eyJ...] │ └─→ Feign 构建 HTTP Request → 发送到 zjark-platform ``` ### 3.2 修复前后对比 **修复前(失败)**: ``` ZjarkFeignRequestInterceptor: getRequest() → 原始请求 → Basic emphcms6... → 拷进模板 OpmcsFeignConfig: 看到 Basic → getAdminToken() → 拿到 token → removeHeader + header(Bearer) ★ RequestContextHolder 未更新 ★ Feign HTTP 实际发出: ???(不可靠,zjark 收不到正确的 Bearer) → 403 ``` **修复后(成功)**: ``` OpmcsFeignConfig: 看到 Basic → getAdminToken() → 拿到 token ★ setupAdminTokenAttribute(token) ★ → HeaderModificationRequestWrapper 包壳 → addHeader("Authorization", "Bearer xxx") → setRequestAttributes(wrapped) → removeHeader + header(Bearer) ★ RequestContextHolder 已更新为 wrapper ★ → 后续任何读取 RequestContextHolder 的代码都会拿到 Bearer → zjark 拦截器拿到正确的 Bearer → 直接拷入模板 → 无需替换 → 200 ``` ### 3.3 为什么 Feign 模板层 header 替换不可靠 即使日志显示 `最终Authorization=[Bearer eyJ...]`,Feign 构建实际 HTTP `Request` 对象时,由拦截器设置的 header 可能被覆盖或丢失。具体原因无法从 Feign 源码中直接定位,但**反复验证确认了此结论**: - 模板层 `removeHeader("Authorization")` + `header("Authorization", "Bearer xxx")` → 日志正确 - 手动 curl 同一 token → zjark 接受 - 但 Feign 实际发出的请求 → zjark 403 证明 Feign 在 Request 构建阶段额外处理了 header,规避方式是从源头 `RequestContextHolder` 控制。 --- ## 四、平台侧鉴权链(zjark-platform) ### 4.1 AuthorizeFilter(Servlet Filter) ```java // zjark-platform: AuthorizeFilter.doFilterInternal() String fromSource = getHeader("From-Source"); if (!"external-request".equals(fromSource)) fromSource = "internal-request"; request.setAttribute("From-Source", fromSource); // 默认值为 "internal-request" String token = SecurityUtils.getToken(); // request.getHeader("Authorization") if (token 为空) { 匿名上下文,放行 } else { Claims claims = JwtUtils.parseToken(token); // 解析成功 → 设 SecurityContext } ``` ### 4.2 @InnerAuth Aspect(AOP) ```java // zjark-platform: InnerAuthAspect.innerAround() if (!innerAuth.checkToken()) { return point.proceed(); // loginWithoutPwd 走这里 } // checkToken=true(默认)的接口: String fromSource = request.getAttribute("From-Source"); // 必须是 "internal-request" String token = SecurityUtils.getToken(); // 不能为空 Claims claims = JwtUtils.parseToken(token); // JWT 必须有效 // 全部通过 → 执行方法 ``` ### 4.3 各端点 checkToken 配置 | 端点 | @InnerAuth | 说明 | |------|-----------|------| | `/feign/auth/token/loginWithoutPwd` | `checkToken=false` | 无需有效 token | | `/feign/auth/token/login` | `checkToken=false` | 无需有效 token | | `/feign/auth/user/getUser` | `checkToken=true`(默认) | **必须有效 token** | | `/feign/auth/token/setTokenTimeout` | `checkToken=true`(默认) | 必须有效 token | ### 4.4 `From-Source` 默认值机制 ```java // SecurityConstants FROM_SOURCE = "From-Source" // HTTP header 名 INTERNAL_REQUEST = "internal-request" EXTERNAL_REQUEST = "external-request" // AuthorizeFilter 逻辑: if header("From-Source") != "external-request" → 默认 "internal-request" ``` Feign 调用方不需要主动传 `From-Source`,不传即默认 `internal-request`,`@InnerAuth` 检查通过。 ### 4.5 HttpServletRequest.getHeader() 多值行为 Servlet 规范规定 `getHeader()` 对多个同名 header **返回第一个值**。 修复前(大小写 bug 未修复时): ``` Feign 发出: Authorization: Basic emphcms6... ← getHeader() 返回这个 Authorization: Bearer eyJ... ← 被忽略 JwtUtils.parseToken("Basic emphcms6...") → 失败 → 403 ``` 修复后(大小写统一为 `SecurityConstants.AUTHORIZATION`): ``` removeHeader("Authorization") 正确删除 zjark 拦截器设的大写 A key → 只剩单个 Bearer → 正确解析 ``` --- ## 五、尝试过的方案及失败原因 | # | 方案 | 结果 | 原因 | |---|------|------|------| | 1 | SecurityConfig 加 `WebSecurityConfigurerAdapter` 放行 login 路径 | ❌ | `WebSecurityCustomizer` 类找不到 | | 2 | SecurityConfig 内部类 `extends WebSecurityConfigurerAdapter` | ❌ | 与 `SecurityFilterChain` 冲突 | | 3 | OpmcsFeignConfig null header 不强塞 | ❌ | 所有 zjark 调用无 token,全部 403 | | 4 | URL 路径跳过 auth 请求(对齐 opm-cloud) | ❌ | `getUser` 和 `loginWithoutPwd` 同服务 `zjark-platform`,无法区分 | | 5 | ThreadLocal 标记防递归 `ACQUIRING_ADMIN_TOKEN` | ❌ | `loginWithoutPwd` 裸调 zjark 也可能失败 | | 6 | Nacos 白名单 `opmcs.feign.interceptor-exclude-services` | ❌ | 同服务不同接口需不同处理,粒度不够 | | 7 | `getAdminToken` 改用 RestTemplate 直连 HTTP | ❌ | `HttpUtil` 不支持 Nacos 服务发现;且响应码判断用 `0` 而不是 zjark 的 `200` | | 8 | MockHttpServletRequest 替换 RequestContextHolder | ✅ | `spring-mock` 不是生产依赖 | | 9 | **HeaderModificationRequestWrapper + Feign** | ✅ 最终 | 生产安全,从源头解决 | --- ## 六、最终修复方案 ### 6.1 修改文件清单 | 文件 | 模块 | 改动 | |------|------|------| | `OpmcsFeignConfig.java` | opmcs-common | 拦截器:Bearer 跳过,非 Bearer 替换;`SecurityConstants.AUTHORIZATION` 常量;完整日志 | | `OpmcsUserUtil.java` | opmcs-common | `setupAdminTokenAttribute()` 用 `HeaderModificationRequestWrapper` 包装 `RequestContextHolder` | | `HeaderModificationRequestWrapper.java` | opmcs-common | +`addHeader()` 方法;`SecurityConstants.AUTHORIZATION` 常量 | | `LoginManageServiceImpl.java` | opmcs-base | Feign 返回值判空保护 | ### 6.2 核心代码 ```java // OpmcsUserUtil.setupAdminTokenAttribute() private void setupAdminTokenAttribute(String token) { RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); if (attrs instanceof ServletRequestAttributes) { HttpServletRequest original = ((ServletRequestAttributes) attrs).getRequest(); HeaderModificationRequestWrapper wrapped = new HeaderModificationRequestWrapper(original); wrapped.addHeader(SecurityConstants.AUTHORIZATION, token.startsWith("Bearer ") ? token : "Bearer " + token); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(wrapped)); } } ``` ### 6.3 规范化改进 - `SecurityConstants.AUTHORIZATION` 常量替换全部硬编码 `"Authorization"` 字面量(3 个文件) - 移除 `MockHttpServletRequest`,使用生产安全的 `HeaderModificationRequestWrapper` - `getAdminToken()` 保持 Feign 调用 `loginWithoutPwd`(无需 RestTemplate),配合 `@InnerAuth(checkToken=false)` 不触发递归 --- ## 七、架构启示 1. **Feign 拦截器之间的 header 互删不可靠**。多个 RequestInterceptor 对同一 header 先后操作时,不要在后续拦截器里修改前者设置的 header,应从 `RequestContextHolder` 源头控制。 2. **第三方 Feign 客户端的拦截器行为透明化**。`ZjarkFeignRequestInterceptor`(来自 `zjark-api` jar)从 `RequestContextHolder` 读取 header 的行为需要从源码或反编译确认,否则排查方向完全错误。 3. **跨系统调试需要关注 HTTP 层面的实际传输内容**。日志中模板层的 header 和实际 HTTP 发出的 header 可能不一致,curl 验证是排除传输层问题的最快手段。
七宇轩远
2026年08月02日
转发文档
收藏文档
上一篇
下一篇
手机扫码
复制链接
手机扫一扫转发分享
复制链接
Markdown文件
分享
链接
类型
密码
更新密码