1. 微服务登录鉴权为什么总在网关这层翻车
统一网关登录流程这件事,说穿了就是一句话:让所有请求在进入内网之前,先在一个固定入口完成身份确认,再把「你是谁」这件事安全地传给后面的业务服务。API Gateway 在这里扮演的是门卫加传令兵的角色,JWT 则是那张盖了章、还能自证真伪的通行证。适合谁看?正在搭微服务、被「每个服务都写一遍登录校验」折磨过的后端同学,以及想把鉴权链路收敛到一处的团队。
我见过太多项目,登录接口散落在用户服务里,订单服务自己又验一遍 token,支付服务再抄一份 JWT 解析代码。结果是密钥轮换时漏改一个服务,或者某个内部接口忘了加拦截器,直接裸奔。更麻烦的是排查问题:用户说登录失败,你得挨个服务翻日志,根本不知道是签发环节错了,还是校验环节把合法 token 拒了。
把这条链路收到 API Gateway 之后,结构会清爽很多。客户端只跟网关打交道,登录请求由网关转发给认证中心,认证中心签发 JWT 返回;后续业务请求带着Authorization: Bearer <token>到网关,网关统一校验签名和过期时间,再把用户身份写进请求头透传给下游。下游服务不再关心 token 怎么来的,只读X-User-Id这类头做权限判断。
这篇要交付的是一套可复制的网关鉴权配置骨架:路由白名单怎么划、JWT 校验中间件怎么写、Header 透传规则怎么定,以及本地怎么发请求验证整条链路通不通。接入层我用 TaoToken 的统一 Key/API 通道来承接模型侧调用,这样网关既要管业务鉴权,也能顺带把 AI 能力的入口统一收口,不用再为每个模型单独配一套密钥。
2. TaoToken 统一 Key/API 通道在鉴权链路里的位置
先把 TaoToken 是什么讲清楚:它是一个统一网关式的 API 接入层,把多家模型的调用收敛到一个 Key、一条 API 通道上。对微服务架构来说,这意味着你的网关后面挂的不只是业务服务,还有一类「模型调用服务」,而这类服务的凭证管理可以交给 TaoToken 统一处理,不必在每个业务服务里散落一堆模型 Key。
官网入口在这里:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api ,注意 API 地址不带 UTM 参数,配置时别把跟踪参数拼进去。
它和本篇 JWT 鉴权的关系是这样的:业务用户的身份用 JWT 在网关层校验,而网关或下游的模型调用服务访问 TaoToken 时,用的是统一 Key。两套凭证各管各的——JWT 管「这个请求代表哪个用户」,TaoToken Key 管「这个服务有没有权限调模型」。把这两件事分开,安全边界才清晰,不会出现把模型 Key 塞进前端 token 里的低级错误。
实际操作上,你需要先去控制台拿 Key。打开 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,在 API Keys 页面创建一把 Key,页面地址是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。这把 Key 只放在网关或后端服务的环境变量里,绝对不要下发到客户端。
如果你后面要接 Claude Code 这类编码工具做长期开发,可以了解下 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。想先验证模型通不通,用模型对话页最直接:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。接入细节看文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
注意:TaoToken 是合规的 API 接入通道,配置时只填官方给的 API 基址,不要自行拼接来路不明的地址。
3. 可复制的网关鉴权配置骨架
下面这套骨架以 Spring Cloud Gateway 为例,其他网关(Kong、APISIX、Nginx+Lua)思路一致,换的是语法不是逻辑。整体分三块:路由与白名单、JWT 校验全局过滤器、Header 透传规则。
3.1 路由白名单与基础配置
先在application.yml里定义路由和放行路径。白名单的原则是「只放必须匿名访问的」,登录、注册、健康检查、公开文档这几类,其余一律走校验。
spring: cloud: gateway: routes: - id: auth-service uri: http://auth-center:8081 predicates: - Path=/auth/** - id: business-service uri: http://business:8082 predicates: - Path=/api/** default-filters: - AddResponseHeader=X-Gateway, tao-gateway gateway: auth: # 白名单:命中即跳过 JWT 校验 public-paths: - /auth/login - /auth/register - /auth/refresh - /actuator/health - /doc/** # JWT 校验相关 jwt: issuer: tao-gateway # 生产环境从环境变量注入,禁止硬编码 secret: ${JWT_SECRET} clock-skew-seconds: 30这里有个容易踩的点:/auth/**整段路由到认证中心,但白名单只放/auth/login、/auth/register、/auth/refresh。也就是说/auth/logout这类接口仍然要带合法 token 才能访问,别图省事把整个/auth/**都放行。
3.2 JWT 校验全局过滤器
过滤器要做四件事:判断是否白名单、提取 token、校验签名与过期、把用户信息写进请求头。顺序上给最高优先级,保证在业务逻辑之前执行。
@Component @Order(-100) public class JwtAuthFilter implements GlobalFilter { @Value("${gateway.auth.jwt.secret}") private String secret; private final List<String> publicPaths; private final JWTVerifier verifier; public JwtAuthFilter(GatewayAuthProperties props) { this.publicPaths = props.getPublicPaths(); this.verifier = JWT.require(Algorithm.HMAC256(props.getJwt().getSecret())) .withIssuer(props.getJwt().getIssuer()) .acceptLeeway(props.getJwt().getClockSkewSeconds()) .build(); } @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getPath().value(); // 1. 白名单直接放行 if (isPublic(path)) { return chain.filter(exchange); } // 2. 提取 Bearer Token String token = extractToken(exchange.getRequest()); if (token == null) { return reject(exchange, 401, "missing_token"); } // 3. 校验签名、过期、签发者 DecodedJWT jwt; try { jwt = verifier.verify(token); } catch (TokenExpiredException e) { return reject(exchange, 401, "token_expired"); } catch (JWTVerificationException e) { return reject(exchange, 401, "token_invalid"); } // 4. 透传用户身份,覆盖客户端可能伪造的同名头 ServerHttpRequest mutated = exchange.getRequest().mutate() .header("X-User-Id", jwt.getSubject()) .header("X-User-Name", safeClaim(jwt, "username")) .header("X-User-Roles", safeClaim(jwt, "roles")) .header("X-Token-Id", jwt.getId()) .build(); return chain.filter(exchange.mutate().request(mutated).build()); } private boolean isPublic(String path) { return publicPaths.stream().anyMatch(p -> path.startsWith(p.replace("/**", ""))); } private String extractToken(ServerHttpRequest request) { String header = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); if (header != null && header.startsWith("Bearer ")) { return header.substring(7); } return null; } private String safeClaim(DecodedJWT jwt, String name) { Claim claim = jwt.getClaim(name); return claim.isNull() ? "" : claim.asString(); } private Mono<Void> reject(ServerWebExchange exchange, int status, String code) { exchange.getResponse().setStatusCode(HttpStatus.valueOf(status)); exchange.getResponse().getHeaders().add("Content-Type", "application/json"); byte[] body = ("{\"error\":\"" + code + "\"}").getBytes(StandardCharsets.UTF_8); return exchange.getResponse().writeWith(Mono.just( exchange.getResponse().bufferFactory().wrap(body))); } }关键点在于第 4 步:mutate().header(...)是覆盖而不是追加。如果客户端自己塞了一个X-User-Id: 999,网关会用 JWT 里的真实 subject 把它盖掉,下游拿到的永远是可信值。这一步不做,等于把权限判断的钥匙交给了调用方。
3.3 Header 透传规则与下游约定
透传规则要写成文档,让所有下游服务遵守同一套约定,否则又会出现「这个服务读 X-Uid,那个服务读 X-User-Id」的混乱。
| Header 名 | 来源 | 含义 | 下游是否可信任 |
|---|---|---|---|
| X-User-Id | JWT sub | 用户唯一标识 | 是,网关已覆盖 |
| X-User-Name | JWT username | 用户名 | 是 |
| X-User-Roles | JWT roles | 角色列表,逗号分隔 | 是 |
| X-Token-Id | JWT jti | Token 唯一 ID,用于黑名单 | 是 |
| Authorization | 客户端原样 | 原始 token | 否,下游不应再解析 |
注意:下游服务必须只信任网关写入的
X-User-*头,并且要在部署层面禁止外部流量绕过网关直连服务端口。否则攻击者直接打服务端口,自己伪造X-User-Id就绕过了整条鉴权链。
3.4 登录签发与刷新接口骨架
认证中心负责签发,这里给一个最小可用的签发逻辑,重点是把jti、sub、roles、exp都带上。
public String issueAccessToken(User user) { Instant now = Instant.now(); return JWT.create() .withIssuer("tao-gateway") .withSubject(String.valueOf(user.getId())) .withJWTId(UUID.randomUUID().toString()) .withClaim("username", user.getName()) .withClaim("roles", String.join(",", user.getRoles())) .withIssuedAt(Date.from(now)) .withExpiresAt(Date.from(now.plus(30, ChronoUnit.MINUTES))) .sign(Algorithm.HMAC256(secret)); }Access Token 给 30 分钟,Refresh Token 单独用不透明随机串存 Redis,有效期 7 天,只用于换新 token,不参与业务请求。这样即使 Access Token 泄露,窗口期也有限。
4. 本地验证请求与成功结果
配置写完,别急着上环境,先在本地把链路跑通。分三步:起服务、拿 token、带 token 访问受保护接口。
第一步,用环境变量注入密钥再启动网关,避免密钥进代码库。
export JWT_SECRET="local-dev-secret-change-me" mvn spring-boot:run -pl gateway第二步,调登录接口拿 token。假设认证中心在 8081,网关在 8080。
curl -s -X POST http://localhost:8080/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"alice","password":"correct-password"}'成功时返回类似:
{ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "rft_9f2c...", "expiresIn": 1800 }第三步,带 token 访问受保护接口,同时故意伪造一个X-User-Id,验证网关是否覆盖。
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." curl -s http://localhost:8080/api/profile \ -H "Authorization: Bearer $TOKEN" \ -H "X-User-Id: 999"在下游服务里打印收到的头,你应该看到X-User-Id是 alice 的真实 ID,而不是 999。这一步验证通过,说明透传规则生效了。
再验证拒绝路径:不带 token 访问/api/profile,应返回 401 和missing_token;带一个过期 token,应返回token_expired。
curl -i http://localhost:8080/api/profile # HTTP/1.1 401 Unauthorized # {"error":"missing_token"}如果网关后面挂了模型调用服务,顺手验证一下 TaoToken 通道。用统一 Key 发一次请求,确认 API 基址和鉴权头都对:
curl -s https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"ping"}]}'返回正常内容就说明模型侧通道通了。想更直观地看模型响应,可以直接在模型对话页试:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。
5. 本篇常见错误排查
5.1 401 一直返回 token_invalid,但 token 明明没过期
九成是密钥不一致。签发用的 secret 和网关校验用的 secret 不是同一个,或者签发时用了withIssuer("auth-center"),校验时却写withIssuer("tao-gateway")。issuer 不匹配会直接抛JWTVerificationException。排查方法:把 token 贴到 jwt.io 看 payload 里的iss,再对照网关配置。
5.2 白名单不生效,登录接口也被拦
看isPublic的匹配逻辑。上面示例里用path.startsWith(p.replace("/**", "")),如果白名单写的是/auth/login,而实际请求路径带了 context-path 变成/gateway/auth/login,就匹配不上。要么统一去掉 context-path,要么白名单里写全路径。另外注意startsWith的边界问题:/auth/login会匹配到/auth/login-extra,严格场景应该用精确匹配或正则。
5.3 下游拿到的 X-User-Id 是客户端伪造的值
检查过滤器里是不是用了header(name, value)的追加语义。Spring 的mutate().header()是覆盖,但如果你用的是headers(h -> h.add(...)),那就是追加,客户端伪造的头会排在前面,下游getFirst拿到的就是假值。统一用覆盖写法,并在下游约定只读第一个值。
5.4 时钟偏移导致 token 刚签发就过期
容器时间不同步时,签发方和校验方差几十秒,短有效期 token 会频繁报过期。配置里加acceptLeeway(30)容忍 30 秒偏移,同时确保所有节点接 NTP。别把 leeway 调太大,那等于变相延长了 token 有效期。
5.5 刷新接口被自己的鉴权拦死
/auth/refresh在白名单里,但它需要读 Refresh Token。如果 Refresh Token 放在请求头X-Refresh-Token里,白名单放行后认证中心自己校验即可,不要让它走 JWT 过滤器。如果误把 refresh 接口放进受保护路径,用户 token 一过期就永远刷不了,只能重新登录。
5.6 TaoToken 调用返回 401
先确认 Key 是从控制台复制的完整串,没有多余空格;再确认请求头是Authorization: Bearer <key>,不是自定义头名。API 基址用 https://taotoken.net/api ,不要带 UTM 参数。如果还是不通,去 API Keys 页面重新生成一把再试:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。
6. 把鉴权链路收口到网关之后
整套骨架跑通后,你会发现新增一个业务服务变得很轻:不用再写登录校验,只要约定好读X-User-*头,注册到网关路由即可。密钥轮换、黑名单、限流这些横切关注点,全都在网关一处维护。
如果你还在用散落各处的模型 Key,建议顺手把模型调用也收到统一通道上。TaoToken 的接入文档里有各语言的调用示例,照着改环境变量就行:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。长期做编码和 Agent 开发的,Coding Plan 那条线也值得看一眼:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。
最后留一个我踩过的坑:网关的 JWT 过滤器一定要在压测环境验证一遍并发表现,JWT 解析是 CPU 操作,QPS 高的时候会成为瓶颈。加一层 Caffeine 缓存解析结果(key 用 token 的 jti,TTL 设短于 token 有效期),能明显降 CPU。但缓存别存太久,否则黑名单生效会有延迟。