Sa-Token 认证过了,Gateway 还拦请求?用 TaoToken 接 Codex 对着白名单查
登录接口明明已经返回 token,Gateway 却继续对/order/**、/user/**返回 401;或者网关看起来放行了,订单服务里的UserContextFilter还是读不到X-User-Id、X-Tenant-Id,多租户查询直接查不到数据。这是企业 Java 网关排障里非常典型的一类问题。本文用 TaoToken 接 Codex,对着config.toml、application.yml、AuthGlobalFilter.java、UserContextFilter.java逐行查断点。TaoToken 只给 Codex 提供 Key 和 Base URL,不参与鉴权,也不替代 Gateway。动手前先打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 注册并创建 Key,再把 Key 和https://taotoken.net/api填进 Codex,让它按白名单、token 空分支、mutate 透传、下游上下文这几条线排查。
这里的核心目标不是重新设计认证体系,而是把“登录成功但请求仍被网关拦下”或“放行后下游拿不到身份”拆成可验证的断点。Sa-Token 负责登录态和权限,OAuth2 负责授权协议,SSO 负责多系统共享登录,多租户负责数据隔离,Gateway 负责统一入口和前置校验,自定义 Framework 负责把上下文统一封装。排障时要先盯住 Gateway 这一段,不要一上来就怀疑 Sa-Token 登录失败。
一、原问题与场景:登录成功,Gateway 仍拦或下游拿不到身份
典型链路是这样的:
浏览器/App -> Gateway -> IAM 认证中心 -> Sa-Token/OAuth2/SSO -> 业务服务 -> UserContextFilter/TenantContext
用户先请求/iam/auth/login,IAM 校验用户名密码、客户端信息,调用 Sa-Token 完成登录,返回 token。前端拿到 token 后,后续请求带:
Authorization: Bearer xxxxxx X-Tenant-Id: 1001按预期,Gateway 应该放行该请求,并把用户身份和租户身份继续往下游透传。但线上常见现象是:
- 登录接口 200,业务接口 401。
- 白名单接口匿名能过,带 token 的业务接口反而被拦。
- 业务接口 200,但下游
AuthContext.getUserId()为空。 TenantContext.getTenantId()为空,数据权限和租户隔离失效。- 网关日志显示放行,业务服务日志却显示没有收到
X-User-Id、X-Username、X-Tenant-Id。 - 本地 curl 正常,前端联调异常,最后发现是 Header 名称或大小写不一致。
这类问题通常不是单点故障,而是 Gateway 认证链路上的多个小配置对不上:
auth.white-list只写了/iam/auth/login,漏掉了/iam/auth/logout、/iam/captcha、/doc.html、/v3/api-docs/**。AuthGlobalFilter.filter()里先判断Authorization是否为空,再判断白名单,导致白名单请求也被 401。- 白名单采用
startsWith前缀匹配,路径边界没考虑清楚,误放行或漏放行。 mutate()之后只加了X-Request-From,没有把解析出来的X-User-Id、X-Username、X-Tenant-Id往下游传。StripPrefix=1改变了转发路径,但 IAM 白名单、业务权限注解仍按带前缀路径配置。- 网关、IAM、下游服务各自解析 token,Header 规范不统一,排查时不知道信哪个。
Gateway 是门卫,IAM 才更像办证大厅。Gateway 可以做 token 前置校验、白名单放行、Header 透传,但真正的登录态和用户身份通常仍来自 IAM 或 Sa-Token 体系。排障时先把“谁拦的、在哪拦的、拦之前 path 是什么、Header 有没有变化”查清楚。
二、TaoToken 前置:Codex 的 config.toml 只配 Key 和 Base URL
这一篇里,TaoToken 的职责很窄:给 Codex 提供调用模型所需的 Key 和 Base URL,让 Codex 能读取项目文件并按断点清单做静态排查。它不参与 Sa-Token 登录,不参与 OAuth2 授权,不参与 Gateway 白名单判断,也不是 Gateway 的替代品。
先打开官网注册账号:
https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
创建 Key 后,把 Key 写入环境变量。Base URL 使用:
https://taotoken.net/apiCodex 的配置文件一般在~/.codex/config.toml。可以按下面这样配置:
# ~/.codex/config.toml model = "gpt-5-codex" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "chat"如果你本机 Codex 版本要求使用 Responses API,可以把wire_api改为"responses",但base_url和 Key 不变。然后设置环境变量:
export TAOTOKEN_API_KEY=YOUR_API_KEY codexWindows PowerShell 下可以这样:
$env:TAOTOKEN_API_KEY="YOUR_API_KEY" codex配置完成后,可以让 Codex 按下面这个提示词检查项目:
请按以下顺序检查本项目 Gateway 认证链路: 1. 打开 application.yml,确认 routes 中 /iam/**、/order/** 的 StripPrefix=1 是否正确; 2. 打开 auth.white-list,确认 /iam/auth/login、/iam/auth/logout、/iam/captcha、/doc.html、/v3/api-docs/** 是否都放行; 3. 打开 AuthGlobalFilter.java,检查白名单判断是否在 Authorization 为空判断之前; 4. 检查 mutate 后是否写入 X-User-Id、X-Username、X-Tenant-Id,而不只是 X-Request-From; 5. 打开 UserContextFilter.java,确认能读取并写入 AuthContext 和 TenantContext; 6. 分别给出白名单请求和带 token 请求的 curl 验证命令。这样做的意义是:把原本靠肉眼看配置的排障过程,变成按文件、按分支、按 Header 逐项核对的检查清单。Codex 只负责辅助查断点,最终放行逻辑仍然在 Gateway 和 IAM 里。
三、可复制配置:application.yml、AuthGlobalFilter.java、UserContextFilter.java
先看 Gateway 路由。原步骤保持不变:/iam/**转发到iam-service,/order/**转发到order-service,并用StripPrefix=1去掉第一级路径。
spring: cloud: gateway: routes: - id: iam-route uri: lb://iam-service predicates: - Path=/iam/** filters: - StripPrefix=1 - id: order-route uri: lb://order-service predicates: - Path=/order/** filters: - StripPrefix=1 auth: white-list: - /iam/auth/login - /iam/auth/logout - /iam/captcha - /doc.html - /v3/api-docs/**注意StripPrefix=1后,进入下游 IAM 的路径可能从/iam/auth/login变成/auth/login。所以 Gateway 白名单匹配的是进入网关时的原始 path,而 IAM 自己的白名单、权限注解、Controller 映射要按下游收到的 path 配置。这里一旦混淆,就会出现“网关放行但 IAM 继续 401”或“网关误拦登录接口”的情况。
再看AuthGlobalFilter.java。核心顺序是:先判断白名单,白名单命中直接放行;再判断Authorization是否为空;非空时构造新的请求,把X-Request-From、用户身份、租户身份往下游透传。
@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private static final List<String> WHITE_LIST = List.of( "/iam/auth/login", "/iam/auth/logout", "/iam/captcha", "/doc.html", "/v3/api-docs" ); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getURI().getPath(); // 先白名单放行,再判断 token 是否为空 if (WHITE_LIST.stream().anyMatch(path::startsWith)) { return chain.filter(exchange); } String token = exchange.getRequest().getHeaders().getFirst("Authorization"); if (token == null || token.isBlank()) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } // 真实项目里应从 token 解析或从 IAM 缓存查询 LoginUser String userId = "20001"; String username = "demo"; String tenantId = "1001"; ServerHttpRequest request = exchange.getRequest().mutate() .header("X-Request-From", "gateway") .header("X-User-Id", userId) .header("X-Username", username) .header("X-Tenant-Id", tenantId) .build(); return chain.filter(exchange.mutate().request(request).build()); } @Override public int getOrder() { return -100; } }这里的userId、username、tenantId只是示例占位,真实项目里必须从 token 对应的登录缓存、IAM 或 Sa-Token 会话中获取。重点是:如果你想解决“下游拿不到身份”,mutate()里就不能只加X-Request-From,而要把X-User-Id、X-Username、X-Tenant-Id一起透传。
下游业务服务的UserContextFilter.java负责把这些 Header 写入线程上下文:
@Component public class UserContextFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String userId = request.getHeader("X-User-Id"); String username = request.getHeader("X-Username"); String tenantId = request.getHeader("X-Tenant-Id"); if (userId != null && !userId.isBlank()) { LoginUser user = new LoginUser(); user.setUserId(Long.parseLong(userId)); user.setUsername(username); AuthContext.set(user); } if (tenantId != null && !tenantId.isBlank()) { TenantContext.setTenantId(Long.parseLong(tenantId)); } filterChain.doFilter(request, response); } finally { AuthContext.clear(); TenantContext.clear(); } } }业务代码最终只读上下文:
Long userId = AuthContext.getUserId(); Long tenantId = TenantContext.getTenantId();如果这里读不到,就不要再往业务逻辑里查,应该立刻回到 Gateway,看 Header 有没有在 mutate 阶段被丢掉。
四、验证请求与成功结果:白名单匿名通过,带 token 请求透传身份
配置改完后,让 Codex 各跑一次白名单请求和一次带 token 的业务请求。白名单请求用登录接口验证:
curl -i -X POST "http://localhost:8080/iam/auth/login" \ -H "Content-Type: application/json" \ -d '{"username":"demo","password":"123456","clientId":"web-client","clientSecret":"web-secret"}'预期结果:
- HTTP 状态为 200。
- 返回体里有 token。
- Gateway 日志显示命中
auth.white-list。 - 不会出现 401。
- 即使没有
Authorization,该请求也应该匿名通过并到达 IAM。
带 token 的业务请求:
TOKEN="把上一步返回的 token 填这里" curl -i "http://localhost:8080/order/orders?page=1" \ -H "Authorization: Bearer ${TOKEN}" \ -H "X-Tenant-Id: 1001"预期结果:
- Gateway 不再返回 401。
- 请求被转发到
order-service。 - 下游
UserContextFilter能读到X-User-Id、X-Username、X-Tenant-Id。 AuthContext.getUserId()非空。TenantContext.getTenantId()等于 1001。- 业务查询能按当前用户和当前租户返回数据。
可以在下游服务日志里加一行临时日志:
[UserContextFilter] userId=20001 username=demo tenantId=1001如果这里打印为空,但 Gateway 日志显示已经放行,就重点查 mutate 后的 Header 是否真的写入,以及下游过滤器是否被注册到 Servlet 容器中。如果这里正常,但 Sa-Token 注解仍然报无权限,再查 IAM 或业务服务自己的权限配置。
最后回到 TaoToken 官网查看本次 Codex 调用记录是否成功:
https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
这里确认的是 Key 和 Base URL 配置是否可用,不是 Gateway 的鉴权结果。Gateway 的结果仍以网关日志、下游日志和 curl 返回为准。
五、本篇常见错排查:白名单前缀、token 空分支、mutate 透传、StripPrefix
第一类错:白名单漏项或过期。
只写/iam/auth/login往往不够。登录之外,验证码、退出、文档、OpenAPI 路径也可能需要匿名访问。如果这些接口也被AuthGlobalFilter拦下,前端会表现为“登录页都打不开”或“登录后立刻退出”。
第二类错:白名单前缀匹配过宽。path.startsWith("/iam/auth/login")会放行/iam/auth/login-admin、/iam/auth/login-test这类路径。如果希望精确匹配,应该用equals或AntPathMatcher。如果业务确实需要前缀放行,就要明确路径边界,避免把受保护接口放进白名单。
第三类错:Authorization空分支提前命中。AuthGlobalFilter.filter()中如果先取 token,再判断白名单,那么白名单请求也会因为没有Authorization被返回 401。正确顺序是先白名单放行,再判断 token。这个分支顺序一错,表现就是“登录接口也被拦”,非常容易误判成 IAM 故障。
第四类错:token 名称不一致。
Sa-Token 的token-name可能配置为Authorization,也可能配置为satoken、token。前端传的是Authorization: Bearer xxx,网关读的却是token,两边对不上就会一直 401。Bearer 前缀、大小写、CORS 预检OPTIONS请求也要一起检查。
第五类错:mutate 只加了X-Request-From。
网关放行不代表下游能拿到身份。如果AuthGlobalFilter只做了:
.header("X-Request-From", "gateway")却没有加X-User-Id、X-Username、X-Tenant-Id,那么UserContextFilter自然读不到。要解决“下游拿不到身份”,必须在网关阶段完成身份解析或从缓存查询,再写入这些 Header。
第六类错:StripPrefix=1与下游路径不一致。/iam/auth/login经过StripPrefix=1后可能变成/auth/login。IAM 自己的白名单如果还写/iam/auth/login,就会漏掉。业务接口同理,/order/orders变成/orders后,Controller 映射、权限注解、网关日志里的 path 都要按实际转发路径核对。
第七类错:Header 规范不统一。Authorization、X-User-Id、X-Username、X-Tenant-Id、X-Trace-Id如果没有统一约定,网关写一个名,下游读另一个名,排查会非常痛苦。建议在自定义 Framework 里统一常量,避免各服务手写字符串。
第八类错:ThreadLocal 没清理。AuthContext.clear()、TenantContext.clear()必须放在finally中。否则线程复用时会串用户、串租户,这类问题比单纯 401 更危险。
第九类错:网关和 IAM 重复认证。
Gateway 做前置校验,IAM 也做登录态校验,两边对 token 有效期、缓存、续期策略理解不一致时,会出现“网关说有效、IAM 说无效”或反过来。排障时要明确每一层分别负责什么。
六、语义一致 CTA:排障后把 API Key 与接入文档固定下来
这条故障线的排查顺序可以固定为:
- 看进入 Gateway 的原始 path。
- 看
auth.white-list是否覆盖登录、验证码、退出、文档路径。 - 看
AuthGlobalFilter.filter()是否先白名单、后 token 空判断。 - 看
mutate()后是否写入X-User-Id、X-Username、X-Tenant-Id。 - 看
StripPrefix=1后下游真实 path。 - 看
UserContextFilter是否写入AuthContext和TenantContext。 - 用白名单请求和带 token 请求各验证一次。
TaoToken 在这条链路里只负责给 Codex 提供 Key 和 Base URL,不参与 Sa-Token 鉴权,不参与 OAuth2 授权,也不替代 Gateway。把 Key 管理好之后,后续每次遇到网关白名单、Header 透传、多租户上下文问题,都可以让 Codex 按同一份清单查。
创建和管理 Key 可以从这里进入:
https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite
Codex 接入、Base URL、环境变量和常见配置看接入文档:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
如果团队会把 Codex 长期用于网关排障、微服务接入和日常编码,也可以再看 Coding Plan:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite
但本篇的核心仍然是:Sa-Token 认证成功只是第一步,Gateway 的白名单、token 前置校验、AuthGlobalFilter、StripPrefix、下游UserContextFilter和租户上下文必须逐段对齐,才算真正把请求放行并让身份传到业务服务。