文章目录
- [Spring Cloud 学习与实践(9):Gateway + JWT 统一鉴权](#Spring Cloud 学习与实践(9):Gateway + JWT 统一鉴权)
-
- 1、本章目标
- 2、为什么需要统一鉴权
-
- [2.1 当前项目缺少什么能力](#2.1 当前项目缺少什么能力)
- [2.2 认证与鉴权有什么区别](#2.2 认证与鉴权有什么区别)
- [2.3 为什么放在 Gateway 统一处理](#2.3 为什么放在 Gateway 统一处理)
- 3、JWT
-
- [3.1 JWT 是什么](#3.1 JWT 是什么)
- [3.2 JWT 和 Session 的区别](#3.2 JWT 和 Session 的区别)
- 4、本章整体项目结构
- [5、第一阶段:cloud-auth 登录认证与 JWT 签发](#5、第一阶段:cloud-auth 登录认证与 JWT 签发)
-
- [5.1 为什么单独创建 t_auth_account](#5.1 为什么单独创建 t_auth_account)
- [5.2 创建认证账号表](#5.2 创建认证账号表)
- [5.3 BCrypt 是什么](#5.3 BCrypt 是什么)
- [5.4 生成 BCrypt 哈希](#5.4 生成 BCrypt 哈希)
- [5.5 父工程管理 JJWT 版本](#5.5 父工程管理 JJWT 版本)
- [5.6 cloud-auth/pom.xml](#5.6 cloud-auth/pom.xml)
- [5.7 cloud-auth/bootstrap.yml](#5.7 cloud-auth/bootstrap.yml)
- [5.8 cloud-auth-dev.yaml](#5.8 cloud-auth-dev.yaml)
- [5.9 cloud-auth 关键代码](#5.9 cloud-auth 关键代码)
-
- [5.9.1 启动类: CloudAuthApplication](#5.9.1 启动类: CloudAuthApplication)
- [5.9.2 配置密码哈希器:PasswordConfig](#5.9.2 配置密码哈希器:PasswordConfig)
- [5.9.3 AuthAccount.java](#5.9.3 AuthAccount.java)
- [5.9.4 AuthAccountMapper.java](#5.9.4 AuthAccountMapper.java)
- [5.9.5 LoginRequest.java](#5.9.5 LoginRequest.java)
- [5.9.6 LoginResponse.java](#5.9.6 LoginResponse.java)
- [5.9.7 Auth JwtProperties.java](#5.9.7 Auth JwtProperties.java)
- [5.9.8 Auth JwtUtil.java](#5.9.8 Auth JwtUtil.java)
- [5.9.9 AuthService.java](#5.9.9 AuthService.java)
- [5.9.10 AuthServiceImpl.java](#5.9.10 AuthServiceImpl.java)
- [5.9.11 AuthController.java](#5.9.11 AuthController.java)
- [5.10 Gateway 增加 auth 路由](#5.10 Gateway 增加 auth 路由)
- [5.11 创建 auth.http](#5.11 创建 auth.http)
- [5.12 启动顺序](#5.12 启动顺序)
- [5.13 本轮预期结果](#5.13 本轮预期结果)
-
- [5.13.1 正确登录](#5.13.1 正确登录)
- [5.13.2 密码错误](#5.13.2 密码错误)
- [5.13.3 用户名不存在](#5.13.3 用户名不存在)
- [5.14 本轮需要理解的链路](#5.14 本轮需要理解的链路)
- [6、第二阶段:Gateway GlobalFilter 统一校验 JWT](#6、第二阶段:Gateway GlobalFilter 统一校验 JWT)
-
- [6.1 GlobalFilter 是什么](#6.1 GlobalFilter 是什么)
- [6.2 Gateway 为什么不直接依赖 cloud-auth](#6.2 Gateway 为什么不直接依赖 cloud-auth)
- [6.3 cloud-gateway 增加依赖](#6.3 cloud-gateway 增加依赖)
- [6.4 修改 cloud-gateway-dev.yaml](#6.4 修改 cloud-gateway-dev.yaml)
- [6.5 登录接口为什么必须在白名单中](#6.5 登录接口为什么必须在白名单中)
- [6.6 OPTIONS 为什么不能校验 Token](#6.6 OPTIONS 为什么不能校验 Token)
- [6.7 Gateway JwtProperties.java](#6.7 Gateway JwtProperties.java)
- [6.8 GatewayAuthProperties.java](#6.8 GatewayAuthProperties.java)
- [6.9 Gateway JwtUtil.java](#6.9 Gateway JwtUtil.java)
- [6.10 JwtAuthGlobalFilter.java](#6.10 JwtAuthGlobalFilter.java)
- [6.11 为什么必须覆盖 X-User-Id](#6.11 为什么必须覆盖 X-User-Id)
- [6.12 为什么 Gateway 中不直接返回 Result<T>](#6.12 为什么 Gateway 中不直接返回 Result
) - [6.13 `getOrder() = -100` 有什么作用?](#6.13
getOrder() = -100有什么作用?) - [6.14 重启 Gateway](#6.14 重启 Gateway)
- [6.15 更新 gateway.http](#6.15 更新 gateway.http)
- [6.16 本轮预期结果](#6.16 本轮预期结果)
-
- [6.16.1 登录接口无需 Token](#6.16.1 登录接口无需 Token)
- [6.16.2 业务接口缺少 Token](#6.16.2 业务接口缺少 Token)
- [6.16.3 携带合法 Token](#6.16.3 携带合法 Token)
- [6.16.4 篡改 Token](#6.16.4 篡改 Token)
- [6.16.5 Token 格式错误](#6.16.5 Token 格式错误)
- [6.16.6 OPTIONS 预检无需 Token](#6.16.6 OPTIONS 预检无需 Token)
- [6.17 本轮边界:Gateway 校验不等于彻底安全](#6.17 本轮边界:Gateway 校验不等于彻底安全)
- [7、第三阶段:下游用户上下文与 Feign 身份透传](#7、第三阶段:下游用户上下文与 Feign 身份透传)
-
- [7.1 为什么不能继续从请求体读取 userId](#7.1 为什么不能继续从请求体读取 userId)
- [7.2 ThreadLocal 是什么](#7.2 ThreadLocal 是什么)
- [7.3 为什么必须 clear](#7.3 为什么必须 clear)
- [7.4 保存当前请求用户身份:UserContext.java](#7.4 保存当前请求用户身份:UserContext.java)
- [7.5 读取 Gateway 透传的用户身份:UserContextInterceptor.java](#7.5 读取 Gateway 透传的用户身份:UserContextInterceptor.java)
- [7.6 注册用户上下文拦截器: UserContextWebMvcConfig.java](#7.6 注册用户上下文拦截器: UserContextWebMvcConfig.java)
- [7.7 ContextController.java](#7.7 ContextController.java)
- [7.8 修改 CreateOrderRequest.java](#7.8 修改 CreateOrderRequest.java)
- [7.9 修改 OrderServiceImpl.java](#7.9 修改 OrderServiceImpl.java)
- [7.10 先验证 Gateway 到订单服务的身份链路](#7.10 先验证 Gateway 到订单服务的身份链路)
- [7.11 修改商品服务日志](#7.11 修改商品服务日志)
- [7.12 第一次创建订单:观察身份丢失](#7.12 第一次创建订单:观察身份丢失)
- [7.13 Feign RequestInterceptor 是什么](#7.13 Feign RequestInterceptor 是什么)
- [7.14 让 Feign 继续透传用户身份:UserContextFeignInterceptor.java](#7.14 让 Feign 继续透传用户身份:UserContextFeignInterceptor.java)
- [7.12 第二次创建订单:验证 Feign 身份透传](#7.12 第二次创建订单:验证 Feign 身份透传)
- [8、第四阶段:CircuitBreaker 线程切换导致身份丢失](#8、第四阶段:CircuitBreaker 线程切换导致身份丢失)
-
- [8.1 为什么 RequestInterceptor 读取到 null](#8.1 为什么 RequestInterceptor 读取到 null)
- [8.2 对照实验:关闭 CircuitBreaker](#8.2 对照实验:关闭 CircuitBreaker)
- [8.3 在 CircuitBreaker 线程池中传播用户上下文:UserContextAwareExecutorService.java](#8.3 在 CircuitBreaker 线程池中传播用户上下文:UserContextAwareExecutorService.java)
- [8.4 将上下文感知线程池交给 CircuitBreaker:Resilience4jExecutorConfig.java](#8.4 将上下文感知线程池交给 CircuitBreaker:Resilience4jExecutorConfig.java)
- [8.5 恢复 CircuitBreaker 后验证](#8.5 恢复 CircuitBreaker 后验证)
- [8.6 直接访问订单服务会发生什么](#8.6 直接访问订单服务会发生什么)
- [8.7 为什么不用 InheritableThreadLocal](#8.7 为什么不用 InheritableThreadLocal)
- [9、第五阶段:@Async 异步线程身份丢失与 TaskDecorator 修复](#9、第五阶段:@Async 异步线程身份丢失与 TaskDecorator 修复)
-
- [9.1 @Async 是什么](#9.1 @Async 是什么)
- [9.2 OrderAsyncConfig.java 初始版本](#9.2 OrderAsyncConfig.java 初始版本)
- [9.3 AsyncUserContextService.java](#9.3 AsyncUserContextService.java)
- [9.4 为什么 @Async 方法要放在单独 Bean 中](#9.4 为什么 @Async 方法要放在单独 Bean 中)
- [9.5 第一次测试:异步线程身份丢失](#9.5 第一次测试:异步线程身份丢失)
- [9.6 TaskDecorator 是什么](#9.6 TaskDecorator 是什么)
- [9.7 UserContextTaskDecorator.java](#9.7 UserContextTaskDecorator.java)
- [9.8 OrderAsyncConfig.java 修复版本](#9.8 OrderAsyncConfig.java 修复版本)
- [9.9 第二次测试:异步线程身份恢复](#9.9 第二次测试:异步线程身份恢复)
- 10、本章关键概念对比
-
- [10.1 JWT 与 Session](#10.1 JWT 与 Session)
- [10.2 Gateway 鉴权与下游拦截器](#10.2 Gateway 鉴权与下游拦截器)
- [10.3 Feign RequestInterceptor 与 TaskDecorator](#10.3 Feign RequestInterceptor 与 TaskDecorator)
- 11、本章结论
- [12、下一章预告:Sentinel 限流、熔断与降级](#12、下一章预告:Sentinel 限流、熔断与降级)
Spring Cloud 学习与实践(9):Gateway + JWT 统一鉴权
本章目标:实现登录认证、JWT 签发、Gateway 统一鉴权、用户身份透传、下游用户上下文、Feign 身份继续透传,以及 ThreadLocal 在线程切换时丢失的两类真实故障修复。
本章不是直接上完整 Spring Security OAuth2 体系,而是先通过手写 JWT 鉴权链路理解底层原理。掌握这条主线后,再学习 Spring Security、OAuth2 Resource Server 或 Spring Authorization Server 会更容易。
1、本章目标
第 8 章已经完成了 Gateway 统一入口:
text
客户端
↓
cloud-gateway:9000
↓
cloud-user / cloud-product / cloud-order
但是现在所有业务接口只要知道地址就能访问,例如:
http
GET http://localhost:9000/api/product/products/1
POST http://localhost:9000/api/order/orders
这显然不符合真实项目要求。
本章要完成的目标是:
text
1. cloud-auth 实现登录接口
2. 使用 BCrypt 校验密码哈希
3. 登录成功后签发 JWT
4. Gateway 通过 GlobalFilter 统一校验 JWT
5. 白名单放行登录接口和 OPTIONS 预检请求
6. Gateway 将 JWT 中的 userId 写入 X-User-Id 请求头
7. 下游服务通过 MVC Interceptor 读取 X-User-Id
8. 使用 ThreadLocal 保存当前请求用户身份
9. 订单服务不再信任客户端传入的 userId
10. Feign 调用继续透传 X-User-Id
11. 复现 CircuitBreaker 线程切换导致身份丢失
12. 使用上下文感知 ExecutorService 修复
13. 复现 @Async 异步线程身份丢失
14. 使用 TaskDecorator 修复
本章最终请求链路如下:
text
用户登录
↓
cloud-auth 校验账号密码
↓
签发 JWT
↓
客户端携带 Authorization: Bearer <token>
↓
Gateway 统一校验 Token
↓
Gateway 写入 X-User-Id
↓
cloud-order 读取当前用户身份
↓
cloud-order 创建订单
↓
Feign 调用 cloud-product
↓
cloud-product 继续拿到当前用户身份
2、为什么需要统一鉴权
2.1 当前项目缺少什么能力
在没有统一鉴权之前,业务接口存在几个问题。
第一,任何人都能访问业务接口。
text
不登录也能查询商品
不登录也能创建订单
不登录也能访问订单接口
第二,订单服务仍然信任客户端传入的 userId。
json
{
"userId": 999999,
"productId": 1,
"quantity": 1
}
这非常危险。客户端传什么用户 ID,服务端就用什么用户 ID,相当于把身份决定权交给了客户端。
第三,每个服务如果都自己写鉴权逻辑,会导致重复代码。
text
cloud-user 校验一次 Token
cloud-product 校验一次 Token
cloud-order 校验一次 Token
这样会带来:
text
重复解析 Token
重复处理异常
规则不一致
遗漏接口风险
维护成本高
因此,更合理的做法是:
text
外部请求统一经过 Gateway
↓
Gateway 统一鉴权
↓
下游服务只读取可信用户身份
2.2 认证与鉴权有什么区别
这两个概念经常被混用,但含义不同。
text
认证 Authentication:
你是谁?
鉴权 Authorization:
你能做什么?
举例:
text
输入用户名和密码登录:
认证
普通用户不能删除商品,管理员可以删除商品:
鉴权
本章主要完成:
text
登录认证
Token 校验
用户身份透传
还没有深入做:
text
角色权限
菜单权限
按钮权限
接口级权限
方法级权限
这些可以后续在 Spring Security 或权限模型扩展章节中继续学习。
2.3 为什么放在 Gateway 统一处理
Gateway 是外部请求入口。
text
客户端
↓
Gateway
↓
业务服务
因此它天然适合做:
text
统一鉴权
统一跨域
统一日志
统一限流
统一请求头处理
在本项目中,职责划分如下:
| 模块 | 职责 |
|---|---|
cloud-auth |
负责登录、校验账号密码、签发 JWT |
cloud-gateway |
负责校验 JWT、白名单、身份透传 |
cloud-user |
只处理用户业务 |
cloud-product |
只处理商品业务 |
cloud-order |
只处理订单业务 |
一句话总结:
text
登录认证由 cloud-auth 做;
统一 Token 校验由 Gateway 做;
业务服务只接收 Gateway 透传后的可信身份。
3、JWT
3.1 JWT 是什么
JWT 全称:
text
JSON Web Token
它是一种紧凑的 Token 格式,常用于在客户端和服务端之间传递声明信息。
一个 JWT 通常长这样:
text
xxxxx.yyyyy.zzzzz
由三部分组成:
text
Header.Payload.Signature
| 部分 | 作用 |
|---|---|
| Header | 描述 Token 类型和签名算法 |
| Payload | 保存声明信息,例如 userId、username、过期时间 |
| Signature | 防止 Token 被篡改 |
本章使用的是签名 JWT,不是加密 JWT。
需要特别注意:
text
JWT 的 Payload 通常只是 Base64URL 编码,不是加密。
不要把密码、身份证号、银行卡号等敏感信息放进去。
本项目中,JWT 中只保存:
text
sub:用户 ID
username:用户名
iat:签发时间
exp:过期时间
3.2 JWT 和 Session 的区别
传统 Session 登录大致是:
text
用户登录
↓
服务端创建 Session
↓
浏览器保存 Cookie:JSESSIONID
↓
后续请求携带 Cookie
↓
服务端根据 SessionId 查询登录状态
JWT 登录大致是:
text
用户登录
↓
服务端生成 JWT
↓
客户端保存 Token
↓
后续请求携带 Authorization
↓
服务端校验 Token 签名和过期时间
简单对比:
| 对比项 | Session | JWT |
|---|---|---|
| 登录状态保存位置 | 服务端 | 客户端保存 Token |
| 服务端是否需要存储登录态 | 通常需要 | 通常不需要 |
| 微服务横向扩展 | 需要共享 Session 或粘性会话 | 更容易无状态扩展 |
| 主动失效 | 相对容易 | 需要黑名单、版本号或短有效期 |
| Token 泄露风险 | Cookie 泄露有风险 | JWT 泄露同样有风险 |
JWT 的优势是适合无状态认证,但它不是万能方案。
生产项目仍然需要考虑:
text
Token 有效期
Refresh Token
Token 黑名单
密钥轮换
HTTPS
客户端安全存储
4、本章整体项目结构
本章涉及的模块:
text
cloud-demo
├── cloud-common
│ └── src/main/java/com/example/cloud/common
│ ├── context
│ │ └── UserContext.java
│ └── web
│ ├── UserContextInterceptor.java
│ └── UserContextWebMvcConfig.java
├── cloud-auth
│ └── src/main/java/com/example/cloud/auth
│ ├── CloudAuthApplication.java
│ ├── config/PasswordConfig.java
│ ├── controller/AuthController.java
│ ├── dto/LoginRequest.java
│ ├── dto/LoginResponse.java
│ ├── entity/AuthAccount.java
│ ├── mapper/AuthAccountMapper.java
│ ├── properties/JwtProperties.java
│ ├── service/AuthService.java
│ ├── service/impl/AuthServiceImpl.java
│ └── util/JwtUtil.java
├── cloud-gateway
│ └── src/main/java/com/example/cloud/gateway/auth
│ ├── filter/JwtAuthGlobalFilter.java
│ ├── properties/GatewayAuthProperties.java
│ ├── properties/JwtProperties.java
│ └── util/JwtUtil.java
├── cloud-order
│ └── src/main/java/com/example/cloud/order
│ ├── client/interceptor/UserContextFeignInterceptor.java
│ ├── config/OrderAsyncConfig.java
│ ├── config/Resilience4jExecutorConfig.java
│ ├── config/UserContextAwareExecutorService.java
│ ├── config/UserContextTaskDecorator.java
│ ├── controller/ContextController.java
│ ├── dto/CreateOrderRequest.java
│ ├── service/AsyncUserContextService.java
│ └── service/impl/OrderServiceImpl.java
└── cloud-product
└── ProductServiceImpl 中增加用户上下文日志
5、第一阶段:cloud-auth 登录认证与 JWT 签发
5.1 为什么单独创建 t_auth_account
项目中已经有:
text
t_user
它表示用户业务信息,例如昵称、手机号、状态等。
本章新增:
text
t_auth_account
它专门保存认证相关信息:
text
登录用户名
密码哈希
关联 userId
账号状态
这样职责更清晰:
text
t_user:
用户业务资料
t_auth_account:
认证凭据
不要把明文密码写入数据库。
5.2 创建认证账号表
在 cloud_demo 数据库中执行:
sql
DROP TABLE IF EXISTS t_auth_account;
CREATE TABLE t_auth_account (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '认证账号ID',
user_id BIGINT NOT NULL COMMENT '关联用户ID',
username VARCHAR(64) NOT NULL COMMENT '登录用户名',
password_hash VARCHAR(100) NOT NULL COMMENT 'BCrypt 密码哈希',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用,0禁用',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_user_id (user_id),
UNIQUE KEY uk_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='认证账号表';

5.3 BCrypt 是什么
BCrypt 是一种常用的密码哈希算法。
它的特点是:
text
不是简单加密
不能反向解密出原始密码
每次生成结果不同
内部包含随机 Salt
可以通过 matches() 验证原始密码是否匹配哈希
本项目使用:
java
BCryptPasswordEncoder
用于:
text
注册或初始化账号:
明文密码 -> BCrypt 哈希 -> 写入数据库
登录时:
用户输入密码 + 数据库哈希 -> matches() 校验
5.4 生成 BCrypt 哈希
在 cloud-auth 中临时创建:
text
src/test/java/com/example/cloud/auth/PasswordHashGenerator.java
代码:
java
package com.example.cloud.auth;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class PasswordHashGenerator {
public static void main(String[] args) {
BCryptPasswordEncoder encoder =
new BCryptPasswordEncoder();
System.out.println(
encoder.encode("123456")
);
}
}
运行后得到类似:
text
$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
然后插入数据库:
sql
INSERT INTO t_auth_account (
user_id,
username,
password_hash,
status
) VALUES (
1,
'zhangsan',
'粘贴刚才生成的 BCrypt 哈希',
1
);

5.5 父工程管理 JJWT 版本
打开:
text
cloud-demo/pom.xml
在 <properties> 中确认存在:
xml
<jjwt.version>0.11.5</jjwt.version>
本章 cloud-auth 和 cloud-gateway 都会使用同一个版本。
5.6 cloud-auth/pom.xml
完整配置:
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example.cloud</groupId>
<artifactId>cloud-demo</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>cloud-auth</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>com.example.cloud</groupId>
<artifactId>cloud-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
5.7 cloud-auth/bootstrap.yml
位置:
text
cloud-auth/src/main/resources/bootstrap.yml
内容:
yaml
spring:
application:
name: cloud-auth
profiles:
active: dev
cloud:
nacos:
config:
server-addr: 127.0.0.1:8848
file-extension: yaml
group: DEFAULT_GROUP
namespace: 你的-dev-Namespace-ID
5.8 cloud-auth-dev.yaml
Nacos 中创建:
text
Data ID:cloud-auth-dev.yaml
Group:DEFAULT_GROUP
Namespace:dev
内容:
yaml
server:
port: 9100
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/cloud_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: 你的数据库密码
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto
jwt:
secret: VhxU/uOVwrgavYRbnWfzAYTUDjY8jC/rYOyDkoAFyig=
expire-minutes: 120
5.9 cloud-auth 关键代码
5.9.1 启动类: CloudAuthApplication
java
package com.example.cloud.auth;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@EnableDiscoveryClient
@SpringBootApplication(scanBasePackages = "com.example.cloud")
public class CloudAuthApplication {
public static void main(String[] args) {
SpringApplication.run(
CloudAuthApplication.class,
args
);
}
}
5.9.2 配置密码哈希器:PasswordConfig
java
package com.example.cloud.auth.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration
public class PasswordConfig {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
5.9.3 AuthAccount.java
java
package com.example.cloud.auth.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("t_auth_account")
public class AuthAccount {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String username;
private String passwordHash;
private Integer status;
private LocalDateTime createTime;
}
5.9.4 AuthAccountMapper.java
java
package com.example.cloud.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.cloud.auth.entity.AuthAccount;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AuthAccountMapper
extends BaseMapper<AuthAccount> {
}
5.9.5 LoginRequest.java
java
package com.example.cloud.auth.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class LoginRequest {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}
5.9.6 LoginResponse.java
java
package com.example.cloud.auth.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class LoginResponse {
private String token;
private String tokenType;
private Long expiresIn;
private Long userId;
private String username;
}
5.9.7 Auth JwtProperties.java
java
package com.example.cloud.auth.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private String secret;
private Long expireMinutes = 120L;
}
5.9.8 Auth JwtUtil.java
java
package com.example.cloud.auth.util;
import com.example.cloud.auth.properties.JwtProperties;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
@Component
@RequiredArgsConstructor
public class JwtUtil {
private final JwtProperties jwtProperties;
public String generateToken(
Long userId,
String username
) {
Date issuedAt = new Date();
Date expiration = new Date(
issuedAt.getTime()
+ jwtProperties.getExpireMinutes()
* 60
* 1000
);
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("username", username)
.setIssuedAt(issuedAt)
.setExpiration(expiration)
.signWith(
getSigningKey(),
SignatureAlgorithm.HS256
)
.compact();
}
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(
jwtProperties.getSecret()
);
return Keys.hmacShaKeyFor(keyBytes);
}
}
5.9.9 AuthService.java
java
package com.example.cloud.auth.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.example.cloud.auth.dto.LoginRequest;
import com.example.cloud.auth.dto.LoginResponse;
import com.example.cloud.auth.entity.AuthAccount;
public interface AuthService
extends IService<AuthAccount> {
LoginResponse login(LoginRequest request);
}
5.9.10 AuthServiceImpl.java
java
package com.example.cloud.auth.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.auth.dto.LoginRequest;
import com.example.cloud.auth.dto.LoginResponse;
import com.example.cloud.auth.entity.AuthAccount;
import com.example.cloud.auth.mapper.AuthAccountMapper;
import com.example.cloud.auth.properties.JwtProperties;
import com.example.cloud.auth.service.AuthService;
import com.example.cloud.auth.util.JwtUtil;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AuthServiceImpl
extends ServiceImpl<AuthAccountMapper, AuthAccount>
implements AuthService {
private final BCryptPasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final JwtProperties jwtProperties;
@Override
public LoginResponse login(LoginRequest request) {
AuthAccount account = lambdaQuery()
.eq(
AuthAccount::getUsername,
request.getUsername()
)
.one();
if (account == null
|| !passwordEncoder.matches(
request.getPassword(),
account.getPasswordHash()
)) {
throw new BizException(
ErrorCode.UNAUTHORIZED,
"用户名或密码错误"
);
}
if (account.getStatus() == null
|| account.getStatus() != 1) {
throw new BizException(
ErrorCode.UNAUTHORIZED,
"账号已禁用"
);
}
String token = jwtUtil.generateToken(
account.getUserId(),
account.getUsername()
);
return LoginResponse.builder()
.token(token)
.tokenType("Bearer")
.expiresIn(
jwtProperties.getExpireMinutes()
* 60
)
.userId(account.getUserId())
.username(account.getUsername())
.build();
}
}
5.9.11 AuthController.java
java
package com.example.cloud.auth.controller;
import com.example.cloud.auth.dto.LoginRequest;
import com.example.cloud.auth.dto.LoginResponse;
import com.example.cloud.auth.service.AuthService;
import com.example.cloud.common.result.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@Validated
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
public Result<LoginResponse> login(
@Valid @RequestBody LoginRequest request
) {
return Result.success(
authService.login(request)
);
}
}

5.10 Gateway 增加 auth 路由
修改 Nacos 中的:
text
cloud-gateway-dev.yaml
在 routes 中加入:
yaml
- id: auth-route
uri: lb://cloud-auth
predicates:
- Path=/api/auth/**
filters:
- StripPrefix=1
外部请求:
text
/api/auth/login
经过 StripPrefix=1 后,下游收到:
text
/auth/login
正好匹配:
java
@RequestMapping("/auth")
@PostMapping("/login")

5.11 创建 auth.http
为了验证 cloud-auth 登录接口和 Gateway 转发是否正常,创建接口测试文件:
text
cloud-auth
└── src/test/http
└── auth.http
内容如下:
http
### 直接访问认证服务:登录成功
POST http://localhost:9100/auth/login
Content-Type: application/json
{
"username": "zhangsan",
"password": "123456"
}
### 通过 Gateway 登录成功
POST http://localhost:9000/api/auth/login
Content-Type: application/json
{
"username": "zhangsan",
"password": "123456"
}
### 密码错误
POST http://localhost:9000/api/auth/login
Content-Type: application/json
{
"username": "zhangsan",
"password": "wrong-password"
}
### 用户名不存在
POST http://localhost:9000/api/auth/login
Content-Type: application/json
{
"username": "not-exists",
"password": "123456"
}
5.12 启动顺序
启动顺序如下:
text
1. Nacos Server
2. CloudAuthApplication
3. CloudUserApplication
4. CloudProductApplication
5. CloudOrderApplication
6. CloudGatewayApplication
启动完成后,进入 Nacos 服务列表,应能看到新增服务:
text
cloud-auth
端口为:
text
9100
这说明认证服务已经成功注册到 Nacos,Gateway 后续才能通过:
yaml
uri: lb://cloud-auth
按服务名找到认证服务实例。
5.13 本轮预期结果
5.13.1 正确登录
请求:
http
POST http://localhost:9000/api/auth/login
Content-Type: application/json
{
"username": "zhangsan",
"password": "123456"
}
预期返回类似:
json
{
"code": 0,
"message": "success",
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9...",
"tokenType": "Bearer",
"expiresIn": 7200,
"userId": 1,
"username": "zhangsan"
}
}


其中:
text
token:
登录成功后签发的 JWT
tokenType:
后续请求头使用 Bearer 类型
expiresIn:
Token 有效秒数
userId:
当前登录用户 ID
username:
当前登录用户名
后续访问业务接口时,客户端需要携带:
http
Authorization: Bearer <token>
5.13.2 密码错误
请求:
http
POST http://localhost:9000/api/auth/login
Content-Type: application/json
{
"username": "zhangsan",
"password": "wrong-password"
}
预期返回:
json
{
"code": 40100,
"message": "用户名或密码错误",
"data": null
}
具体 code 以当前项目中 ErrorCode.UNAUTHORIZED 的实际数值为准。
5.13.3 用户名不存在
请求:
http
POST http://localhost:9000/api/auth/login
Content-Type: application/json
{
"username": "not-exists",
"password": "123456"
}
预期同样返回:
text
用户名或密码错误
不要返回:
text
用户不存在
原因是:
text
如果明确提示"用户不存在",
攻击者就可以通过登录接口枚举哪些账号真实存在。
因此本章统一返回:
text
用户名或密码错误
这样不会向外部暴露账号是否存在。

5.14 本轮需要理解的链路
当前阶段完整链路如下:
text
客户端提交用户名和密码
↓
Gateway 根据 auth-route 转发
↓
cloud-auth 查询 t_auth_account
↓
BCrypt 校验密码哈希
↓
JwtUtil 使用密钥签名
↓
生成 JWT
↓
返回给客户端
此时需要注意:
text
当前 Token 只是签发成功。
下一阶段才会完成:
text
客户端携带 Token
↓
Gateway GlobalFilter 校验 Token
↓
提取 userId
↓
写入 X-User-Id
↓
转发到业务服务
也就是说,第一阶段只解决:
text
登录后如何拿到 Token
还没有解决:
text
业务接口如何校验 Token
Gateway 支持全局 Filter。后续 JwtAuthGlobalFilter 会在请求进入下游服务之前执行,用于统一处理 Token 校验、白名单放行和用户身份透传。
6、第二阶段:Gateway GlobalFilter 统一校验 JWT
6.1 GlobalFilter 是什么
第 8 章使用过配置式 Filter:
yaml
filters:
- StripPrefix=2
这种 Filter 属于某条路由。
本章使用:
java
GlobalFilter
它是全局过滤器,适合处理:
text
统一鉴权
统一日志
链路追踪
请求头加工
全局审计
本章的 JwtAuthGlobalFilter 负责:
text
放行 OPTIONS
放行登录白名单
检查 Authorization
校验 JWT
提取 userId
写入 X-User-Id
| 过滤器类型 | 作用范围 | 核心底层机制 | 经典配置/代码示例 | 黄金应用场景 | 默认执行顺序权重 |
|---|---|---|---|---|---|
| Route Filter (路由过滤器) | 某条特定路由 只对绑定的单个服务生效 | 继承 GatewayFilterFactory 在特定路由的 filters 属性下配置 |
StripPrefix=2 AddRequestHeader=X-User, Admin |
针对单个微服务的路径重写、特定接口的请求头增强。 | 居中 (同等 Order 下先于 GlobalFilter 执行) |
| Default Filter (默认过滤器) | 所有路由 无差别应用到配置文件里的全部服务 | 同样继承 GatewayFilterFactory 但在网关的 default-filters 节点下全局配置 |
DedupeResponseHeader AddResponseHeader=X-Platform, Web |
全局跨域响应头去重、全局添加统一响应标识、全局基础限流。 | 最高 (同等 Order 下最先被加载执行) |
| GlobalFilter (全局过滤器) | 所有路由 通过 Java 代码硬编码,全量拦截 | 实现 GlobalFilter 和 Ordered 接口 作为 Bean 注入到 Spring 容器 |
JWT 权限校验 全局接口耗时统计 黑名单统一拦截 | 统一身份安全认证(Token 校验)、全局日志审计、系统级核心防刷。 | 最末 (同等 Order 下最后执行,但通常手动指定高优先级) |
6.2 Gateway 为什么不直接依赖 cloud-auth
cloud-auth 里已经有 JwtUtil。
但是不推荐让:
text
cloud-gateway
直接依赖:
text
cloud-auth
因为:
text
cloud-auth 是认证服务
cloud-gateway 是网关服务
如果 Gateway 依赖认证服务模块,会导致:
text
模块边界模糊
依赖传递过重
Gateway 被动引入 MVC、MyBatis-Plus、MySQL 等依赖
当前阶段在 Gateway 中创建轻量级 JWT 解析工具。
后续如果复用需求变多,可以抽取:
text
cloud-security-core
6.3 cloud-gateway 增加依赖
打开:
text
cloud-gateway/pom.xml
增加:
xml
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok:JwtProperties、GatewayAuthProperties 使用 @Data -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
注意:
text
只加 JJWT 不够。
只要新增代码使用 @Data、@Slf4j、@RequiredArgsConstructor,
就要检查当前模块是否已经引入 Lombok。
6.4 修改 cloud-gateway-dev.yaml
在 Nacos 中的:
text
cloud-gateway-dev.yaml
增加:
yaml
jwt:
secret: VhxU/uOVwrgavYRbnWfzAYTUDjY8jC/rYOyDkoAFyig=
gateway:
auth:
white-paths:
- /api/auth/login
6.5 登录接口为什么必须在白名单中
如果登录接口也要求 Token:
text
登录需要 Token
↓
Token 又必须登录后才有
这会造成死循环。
因此:
text
/api/auth/login
必须匿名放行。
6.6 OPTIONS 为什么不能校验 Token
浏览器跨域复杂请求会先发送:
http
OPTIONS
预检请求通常不会携带业务 Token。
如果 OPTIONS 被 Gateway 返回 401:
text
浏览器认为跨域失败
↓
正式请求根本不会发送
因此 JwtAuthGlobalFilter 必须放行:
text
OPTIONS
6.7 Gateway JwtProperties.java
java
package com.example.cloud.gateway.auth.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private String secret;
}
6.8 GatewayAuthProperties.java
java
package com.example.cloud.gateway.auth.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Data
@Component
@ConfigurationProperties(prefix = "gateway.auth")
public class GatewayAuthProperties {
private List<String> whitePaths = new ArrayList<>();
}
6.9 Gateway JwtUtil.java
java
package com.example.cloud.gateway.auth.util;
import com.example.cloud.gateway.auth.properties.JwtProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
@Component
@RequiredArgsConstructor
public class JwtUtil {
private final JwtProperties jwtProperties;
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(
jwtProperties.getSecret()
);
return Keys.hmacShaKeyFor(keyBytes);
}
}
6.10 JwtAuthGlobalFilter.java
java
package com.example.cloud.gateway.auth.filter;
import com.example.cloud.gateway.auth.properties.GatewayAuthProperties;
import com.example.cloud.gateway.auth.util.JwtUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Gateway JWT 全局鉴权 Filter。
*
* 职责:
* 1. 放行 OPTIONS 预检请求
* 2. 放行白名单接口
* 3. 读取 Authorization: Bearer <token>
* 4. 校验 JWT
* 5. 从 JWT 中提取 userId
* 6. 覆盖写入可信请求头 X-User-Id
* 7. 将请求继续转发给下游服务
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthGlobalFilter
implements GlobalFilter, Ordered {
private static final String BEARER_PREFIX = "Bearer ";
/**
* Gateway 写给下游服务的可信用户身份请求头。
*/
public static final String USER_ID_HEADER = "X-User-Id";
/**
* 与 cloud-common 中 Result 的返回结构保持一致。
*
* Gateway 基于 WebFlux,
* 当前不直接依赖 cloud-common 的 MVC 相关实现。
*/
private static final int UNAUTHORIZED_CODE = 40100;
private final JwtUtil jwtUtil;
private final GatewayAuthProperties authProperties;
private final ObjectMapper objectMapper;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
@Override
public Mono<Void> filter(
ServerWebExchange exchange,
GatewayFilterChain chain
) {
ServerHttpRequest request = exchange.getRequest();
/*
* 1. 放行 OPTIONS 预检请求。
*
* 浏览器跨域请求可能先发送 OPTIONS。
* 如果 OPTIONS 也要求 JWT,
* 浏览器将无法发出正式请求。
*/
if (HttpMethod.OPTIONS.equals(request.getMethod())) {
return chain.filter(exchange);
}
String path = request.getURI().getPath();
/*
* 2. 放行白名单。
*
* 登录接口必须匿名访问,
* 否则客户端无法获得 Token。
*/
if (isWhitePath(path)) {
return chain.filter(exchange);
}
/*
* 3. 读取 Authorization。
*/
String authorization = request.getHeaders()
.getFirst(HttpHeaders.AUTHORIZATION);
if (!StringUtils.hasText(authorization)
|| !authorization.startsWith(BEARER_PREFIX)) {
return unauthorized(
exchange,
"未登录或 Token 缺失"
);
}
String token = authorization
.substring(BEARER_PREFIX.length())
.trim();
if (!StringUtils.hasText(token)) {
return unauthorized(
exchange,
"未登录或 Token 缺失"
);
}
try {
/*
* 4. 校验 Token。
*/
Claims claims = jwtUtil.parseToken(token);
/*
* 5. sub 中保存 userId。
*/
String userId = claims.getSubject();
if (!StringUtils.hasText(userId)) {
return unauthorized(
exchange,
"Token 中缺少用户身份"
);
}
/*
* 6. 覆盖写入可信 X-User-Id。
*
* 必须覆盖,而不是直接信任客户端传入值。
*
* 否则攻击者可以自己构造:
* X-User-Id: 999999
*/
ServerHttpRequest trustedRequest = request
.mutate()
.headers(headers -> {
headers.remove(USER_ID_HEADER);
headers.set(USER_ID_HEADER, userId);
})
.build();
/*
* 7. 使用加工后的请求继续执行 Filter Chain。
*/
return chain.filter(
exchange.mutate()
.request(trustedRequest)
.build()
);
} catch (ExpiredJwtException e) {
log.warn("JWT 已过期:{}", e.getMessage());
return unauthorized(
exchange,
"Token 已过期,请重新登录"
);
} catch (JwtException | IllegalArgumentException e) {
log.warn("JWT 校验失败:{}", e.getMessage());
return unauthorized(
exchange,
"Token 无效,请重新登录"
);
}
}
/**
* 判断路径是否属于匿名白名单。
*/
private boolean isWhitePath(String path) {
return authProperties.getWhitePaths()
.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
}
/**
* 直接在 Gateway 返回 401 JSON。
*
* Gateway 基于 WebFlux,
* 返回类型是 Mono<Void>。
*/
private Mono<Void> unauthorized(
ServerWebExchange exchange,
String message
) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders()
.setContentType(MediaType.APPLICATION_JSON);
byte[] bytes = serializeError(message);
return response.writeWith(
Mono.just(
response.bufferFactory().wrap(bytes)
)
);
}
/**
* 统一错误响应:
*
* {
* "code": 40100,
* "message": "...",
* "data": null
* }
*/
private byte[] serializeError(String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", UNAUTHORIZED_CODE);
body.put("message", message);
body.put("data", null);
try {
return objectMapper.writeValueAsBytes(body);
} catch (JsonProcessingException e) {
log.error("序列化 Gateway 鉴权失败响应异常", e);
return (
"{\"code\":40100,"
+ "\"message\":\"Unauthorized\","
+ "\"data\":null}"
).getBytes(StandardCharsets.UTF_8);
}
}
/**
* 数值越小,前置逻辑越早执行。
*/
@Override
public int getOrder() {
return -100;
}
}

6.11 为什么必须覆盖 X-User-Id
客户端可能主动伪造:
http
X-User-Id: 999999
如果下游服务直接信任这个请求头,就会产生身份伪造风险。
正确做法是:
text
Gateway 从 JWT 提取真实 userId
↓
删除客户端传来的 X-User-Id
↓
重新写入可信 X-User-Id
所以:
text
X-User-Id 只能由 Gateway 写入。
6.12 为什么 Gateway 中不直接返回 Result
在普通业务服务中,接口通常是 Spring MVC Controller,例如:
java
@GetMapping("/users/{id}")
public Result<UserDTO> getById(@PathVariable Long id) {
return Result.success(userService.getById(id));
}
这种场景中,Controller 可以直接返回:
java
Result<T>
但 Gateway 不一样。
Gateway 使用的是:
text
Spring WebFlux
Mono<Void>
ServerWebExchange
它不是传统 Spring MVC Controller。
当前鉴权失败时,JwtAuthGlobalFilter 是直接向响应对象写入 JSON 字节流:
text
ServerHttpResponse
↓
DataBuffer
↓
Mono<Void>
也就是说,Gateway 中的返回方式不是:
text
return Result.fail(...)
而是:
text
response.writeWith(...)
不过响应结构仍然和业务服务保持一致:
json
{
"code": 40100,
"message": "...",
"data": null
}
这样做的好处是:
text
实现方式符合 Gateway 的 WebFlux 模型;
响应格式又能和业务服务保持统一。
一句话总结:
text
Gateway 不直接返回 Result<T>,
但可以手动写出同样结构的 JSON。
6.13 getOrder() = -100 有什么作用?
JwtAuthGlobalFilter 实现了:
java
Ordered
并重写:
java
@Override
public int getOrder() {
return -100;
}
这个值决定当前 Filter 在 Gateway Filter Chain 中的执行顺序。
Gateway 会将:
text
GlobalFilter
Route Filter
合并到同一条 Filter Chain 中。
然后根据:
java
Ordered#getOrder()
排序。
数值越小:
text
前置逻辑越早执行
鉴权属于非常靠前的逻辑。
因为如果请求没有 Token,或者 Token 无效,就没有必要继续执行后续路由、负载均衡、转发等逻辑。
所以这里设置为:
text
-100
表示让 JWT 鉴权尽量提前执行。
Gateway 官方文档说明,GlobalFilter 和 GatewayFilter 会合并为一条过滤器链,并通过 Ordered 接口排序;优先级最高的 Filter 会最先执行 pre 阶段,并在 post 阶段最后执行。
可以这样理解:
text
请求进入 Gateway
↓
优先执行 JWT 鉴权
↓
鉴权失败:直接返回 401
↓
鉴权成功:继续路由匹配和转发
一句话总结:
text
鉴权 Filter 越早执行,越能避免无效请求继续进入后续链路。
6.14 重启 Gateway
发布 Nacos 配置后,需要刷新 Maven,并重新启动:
text
CloudGatewayApplication
观察控制台是否正常启动。
如果启动失败,优先检查:
text
1. cloud-gateway 是否已经添加 JJWT 依赖
2. cloud-gateway 是否已经添加 Lombok 依赖
3. jwt.secret 是否存在
4. gateway.auth.white-paths 是否配置正确
5. JwtAuthGlobalFilter 是否有导包错误
6. Gateway 中是否误引入 spring-boot-starter-web
特别注意:
text
cloud-gateway 使用 Spring WebFlux;
不要引入 spring-boot-starter-web。
6.15 更新 gateway.http
打开:
text
cloud-gateway/src/test/http/gateway.http
追加:
http
### --------------------------------------------------
### JWT 鉴权测试
### --------------------------------------------------
### 登录:无需 Token
POST http://localhost:9000/api/auth/login
Content-Type: application/json
{
"username": "zhangsan",
"password": "123456"
}
### 将上方登录返回的 token 粘贴到这里
@token = 粘贴你的JWT
### 业务接口:缺少 Token,预期返回 401
GET http://localhost:9000/api/product/products/1
### 业务接口:携带合法 Token,预期正常返回
GET http://localhost:9000/api/product/products/1
Authorization: Bearer {{token}}
### 创建订单:携带合法 Token,预期成功
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"userId": 1,
"productId": 1,
"quantity": 1
}
### Token 被篡改,预期返回 401
GET http://localhost:9000/api/product/products/1
Authorization: Bearer {{token}}abc
### Token 格式错误,预期返回 401
GET http://localhost:9000/api/product/products/1
Authorization: invalid-token
### OPTIONS 预检:无需 Token,预期仍然成功
OPTIONS http://localhost:9000/api/order/orders
Origin: http://localhost:5500
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
这里仍然保留:
json
{
"userId": 1,
"productId": 1,
"quantity": 1
}
是因为当前还处于第二阶段。
此时只验证:
text
Gateway 能不能校验 Token
下一阶段才会改造订单接口,让订单服务不再信任请求体中的 userId。
6.16 本轮预期结果
6.16.1 登录接口无需 Token
请求:
http
POST /api/auth/login
预期:
text
正常返回 JWT。
原因:
text
/api/auth/login 在白名单中,
不需要携带 Authorization。
6.16.2 业务接口缺少 Token
请求:
http
GET /api/product/products/1
不携带:
http
Authorization
预期 HTTP 状态码:
text
401 Unauthorized
响应:
json
{
"code": 40100,
"message": "未登录或 Token 缺失",
"data": null
}

说明 Gateway 已经开始保护业务接口。
6.16.3 携带合法 Token
请求头:
http
Authorization: Bearer <token>
预期:
text
商品接口正常返回。
说明:
text
Token 签名正确;
Token 没有过期;
Gateway 可以从 JWT 中提取 userId;
请求可以继续转发到下游服务。


6.16.4 篡改 Token
在合法 Token 末尾增加:
text
abc
请求:
http
GET /api/product/products/1
Authorization: Bearer {{token}}abc
预期返回:
json
{
"code": 40100,
"message": "Token 无效,请重新登录",
"data": null
}
原因是:
text
JWT 内容被篡改后,
签名校验无法通过。
签名校验失败的 Token 不应该被信任。

6.16.5 Token 格式错误
请求:
http
GET /api/product/products/1
Authorization: invalid-token
预期返回:
json
{
"code": 40100,
"message": "未登录或 Token 缺失",
"data": null
}
原因是当前 Filter 要求请求头必须符合:
text
Authorization: Bearer <token>
如果没有 Bearer 前缀,就认为格式不合法。
6.16.6 OPTIONS 预检无需 Token
请求:
http
OPTIONS http://localhost:9000/api/order/orders
Origin: http://localhost:5500
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
预期仍然返回允许跨域的响应头,例如:
http
Access-Control-Allow-Origin: http://localhost:5500
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
原因是:
text
浏览器跨域预检请求通常不会携带业务 Token。
如果 OPTIONS 被 JWT Filter 拦截,浏览器会认为跨域失败,正式请求不会继续发送。
6.17 本轮边界:Gateway 校验不等于彻底安全
当前外部入口:
text
http://localhost:9000
已经受 Gateway JWT Filter 保护。
也就是说,通过 Gateway 访问业务接口时:
text
无 Token:
返回 401
Token 无效:
返回 401
Token 合法:
允许转发
但本地学习环境中,仍然可以直接访问业务服务端口:
text
http://localhost:9200
http://localhost:9300
http://localhost:9400
这会绕过 Gateway。
这是本地学习环境的正常现象。
因为我们在本地直接启动了多个 Spring Boot 服务,每个服务端口都暴露在本机。
真实部署中,还需要通过:
text
安全组
防火墙
Kubernetes Service
内网隔离
反向代理
限制业务服务端口不对公网开放。
一句话总结:
text
代码层由 Gateway 统一鉴权;
网络层还要限制下游服务绕过 Gateway 被直接访问。
否则攻击者如果能直接访问:
text
cloud-order:9400
就可能绕开:
text
cloud-gateway:9000
所以真实项目中,Gateway 鉴权和网络隔离必须配合使用。
7、第三阶段:下游用户上下文与 Feign 身份透传
7.1 为什么不能继续从请求体读取 userId
前面虽然 Gateway 已经验证了 Token,但订单接口仍然接收:
json
{
"userId": 1,
"productId": 1,
"quantity": 1
}
这不合理。
因为客户端可以伪造:
json
{
"userId": 999999,
"productId": 1,
"quantity": 1
}
既然 Gateway 已经从 JWT 中提取出可信 userId,订单服务就不应该再信任请求体里的 userId。
因此改成:
json
{
"productId": 1,
"quantity": 1
}
用户身份只来自:
text
JWT
↓
Gateway
↓
X-User-Id
↓
UserContext
7.2 ThreadLocal 是什么
ThreadLocal 可以理解为:
text
线程自己的变量盒子
同一个变量名,不同线程看到的是自己的值。
text
线程 A:userId = 1
线程 B:userId = 2
线程 C:userId = null
在一个普通 HTTP 请求中,Controller、Service、Mapper 通常运行在同一个 Tomcat 工作线程里。
因此可以在请求进入时:
text
读取 X-User-Id
↓
写入 ThreadLocal
业务代码中再通过:
java
UserContext.getUserId()
获取当前用户身份。
7.3 为什么必须 clear
Tomcat 使用线程池。
线程处理完一个请求后,不会销毁,而是继续处理下一个请求。
如果不清理 ThreadLocal,可能出现:
text
请求 A:userId = 1
↓
线程放回线程池
↓
请求 B:没有身份头
↓
复用同一个线程
↓
错误读到 userId = 1
所以必须形成完整生命周期:
text
请求进入:set
请求处理:get
请求结束:remove
7.4 保存当前请求用户身份:UserContext.java
前面 Gateway 已经完成了两件事:
- 校验 JWT 是否合法
- 从 JWT 中提取 userId,并写入 X-User-Id 请求头
但请求进入 cloud-order、cloud-product 等业务服务后,业务代码不能每一层都手动传递:
userId
否则 Controller、Service、Mapper、Feign 调用中都会出现大量重复参数。
因此需要一个统一的当前用户上下文:
UserContext
它的作用是:
在同一个请求线程中保存当前用户 ID,
让业务代码可以随时获取当前登录用户。
java
package com.example.cloud.common.context;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
/**
* 当前请求用户上下文。
*
* 作用:
* 在同一个请求线程内保存当前用户 ID。
*
* 生命周期:
* 请求进入时 set
* 业务处理时 get
* 请求结束后 clear
*/
public final class UserContext {
/**
* Gateway 写给下游服务的可信请求头。
*/
public static final String USER_ID_HEADER = "X-User-Id";
/**
* 每个线程拥有独立的用户 ID 副本。
*/
private static final ThreadLocal<Long> USER_ID_HOLDER =
new ThreadLocal<>();
private UserContext() {
}
/**
* 保存当前线程用户 ID。
*/
public static void setUserId(Long userId) {
USER_ID_HOLDER.set(userId);
}
/**
* 获取当前线程用户 ID。
*
* 允许返回 null:
* 某些公开接口或内部任务可能没有用户身份。
*/
public static Long getUserId() {
return USER_ID_HOLDER.get();
}
/**
* 获取必须存在的用户 ID。
*
* 适用于创建订单等必须登录的业务。
*/
public static Long requireUserId() {
Long userId = getUserId();
if (userId == null) {
throw new BizException(
ErrorCode.UNAUTHORIZED,
"用户身份缺失"
);
}
return userId;
}
/**
* 清理当前线程上下文。
*
* Tomcat 使用线程池。
* 请求结束后必须 remove,
* 防止线程复用导致用户身份串线。
*/
public static void clear() {
USER_ID_HOLDER.remove();
}
}
7.5 读取 Gateway 透传的用户身份:UserContextInterceptor.java
UserContext 只是一个保存用户 ID 的工具类。
但它本身不会自动知道:
X-User-Id 是多少
因此还需要一个 MVC 拦截器,在请求进入业务服务时读取请求头。
这个拦截器的职责是:
- 请求进入 Controller 之前,读取 X-User-Id
- 如果请求头中存在用户 ID,就写入 UserContext
- 如果请求头不存在,就允许继续放行
- 请求结束后,清理 UserContext
为什么请求头不存在也放行?
因为不是所有接口都必须有用户身份。
例如后续可能会有:
公开接口
健康检查接口
内部任务接口
测试接口
所以拦截器只负责:
有身份就读取并写入上下文。
至于某个业务接口是否必须登录,由业务代码自己决定。
例如创建订单时调用:
UserContext.requireUserId()
如果没有用户身份,就抛出:
用户身份缺失
这里还要注意一个安全边界:
UserContextInterceptor 不是完整的安全认证逻辑。
真正的 JWT 校验已经在 Gateway 中完成。
当前拦截器只负责读取 Gateway 透传后的可信请求头,并写入当前线程上下文。
也就是说:
Gateway:
负责校验 Token
UserContextInterceptor:
负责读取 X-User-Id,并保存到 ThreadLocal
下面创建 UserContextInterceptor.java。
java
package com.example.cloud.common.web;
import com.example.cloud.common.context.UserContext;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* MVC 请求用户上下文拦截器。
*
* 注意:
* 它不是完整安全边界。
*
* 真正的 JWT 校验在 Gateway 中完成。
* 当前拦截器只负责:
* 1. 读取 Gateway 写入的 X-User-Id
* 2. 保存到 ThreadLocal
* 3. 请求结束后清理 ThreadLocal
*/
@Component
@Slf4j
public class UserContextInterceptor
implements HandlerInterceptor {
/**
* Controller 执行前调用。
*/
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler
) {
/*
* 防御性清理:
* 避免当前线程残留上一次请求的数据。
*/
UserContext.clear();
String userId = request.getHeader(
UserContext.USER_ID_HEADER
);
log.info(
"接收请求,uri={},线程={},X-User-Id={}",
request.getRequestURI(),
Thread.currentThread().getName(),
userId
);
/*
* 公开接口或内部接口可能没有用户身份。
* 当前只在有值时写入。
*
* 具体业务是否必须登录,
* 由业务方法调用 requireUserId() 决定。
*/
if (!StringUtils.hasText(userId)) {
return true;
}
try {
UserContext.setUserId(
Long.valueOf(userId)
);
return true;
} catch (NumberFormatException e) {
throw new BizException(
ErrorCode.UNAUTHORIZED,
"用户身份格式错误"
);
}
}
/**
* 请求完成后调用。
*
* 无论请求成功还是异常,
* 都必须清理 ThreadLocal。
*/
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex
) {
UserContext.clear();
}
}
7.6 注册用户上下文拦截器: UserContextWebMvcConfig.java
创建了 UserContextInterceptor 之后,还需要把它注册到 Spring MVC 的拦截器链中。
否则这个类虽然被 Spring 扫描到了,但不会自动拦截请求。
因此需要创建:
java
package com.example.cloud.common.web;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class UserContextWebMvcConfig
implements WebMvcConfigurer {
private final UserContextInterceptor
userContextInterceptor;
@Override
public void addInterceptors(
InterceptorRegistry registry
) {
registry.addInterceptor(userContextInterceptor)
.addPathPatterns("/**");
}
}

7.7 ContextController.java
java
package com.example.cloud.order.controller;
import com.example.cloud.common.context.UserContext;
import com.example.cloud.common.result.Result;
import com.example.cloud.order.service.AsyncUserContextService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/context")
@RequiredArgsConstructor
public class ContextController {
private final AsyncUserContextService
asyncUserContextService;
@GetMapping("/user-id")
public Result<Long> currentUserId() {
return Result.success(
UserContext.requireUserId()
);
}
@GetMapping("/async-user-id")
public Result<String> asyncUserId() {
return Result.success(
asyncUserContextService
.currentUserId()
.join()
);
}
}
通过 Gateway 访问:
http
GET http://localhost:9000/api/order/context/user-id
Authorization: Bearer <token>
执行过程:
bash
Gateway:
JWT sub = 1
↓
写入 X-User-Id: 1
↓
cloud-order:
UserContextInterceptor 读取请求头
↓
UserContext.setUserId(1)
↓
ContextController 读取 ThreadLocal
↓
返回 1
7.8 修改 CreateOrderRequest.java
java
package com.example.cloud.order.dto;
import lombok.Data;
@Data
public class CreateOrderRequest {
private Long productId;
private Integer quantity;
}
7.9 修改 OrderServiceImpl.java
核心变化:
text
不再使用 request.getUserId()
改为 UserContext.requireUserId()
关键代码:
java
Long userId = UserContext.requireUserId();
完整代码:
java
package com.example.cloud.order.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.api.product.dto.ProductDTO;
import com.example.cloud.api.user.dto.UserDTO;
import com.example.cloud.common.context.UserContext;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import com.example.cloud.common.result.Result;
import com.example.cloud.order.client.ProductClient;
import com.example.cloud.order.client.UserClient;
import com.example.cloud.order.dto.CreateOrderRequest;
import com.example.cloud.order.entity.Order;
import com.example.cloud.order.mapper.OrderMapper;
import com.example.cloud.order.service.OrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
/**
* 订单业务实现类。
*/
@Service
@RequiredArgsConstructor
public class OrderServiceImpl
extends ServiceImpl<OrderMapper, Order>
implements OrderService {
private final UserClient userClient;
private final ProductClient productClient;
/**
* 创建订单。
*
* 当前用户 ID 不再由客户端请求体传入。
* 只从 UserContext 获取。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Order createOrder(CreateOrderRequest request) {
if (request == null) {
throw new BizException(
ErrorCode.PARAM_ERROR,
"订单参数不能为空"
);
}
if (request.getProductId() == null) {
throw new BizException(
ErrorCode.PARAM_ERROR,
"商品 ID 不能为空"
);
}
if (request.getQuantity() == null
|| request.getQuantity() <= 0) {
throw new BizException(
ErrorCode.PARAM_ERROR,
"购买数量必须大于 0"
);
}
/*
* 1. 从当前请求上下文获取可信用户 ID。
*
* 不再信任客户端请求体中的 userId。
*/
Long userId = UserContext.requireUserId();
/*
* 2. 远程校验用户。
*/
UserDTO user = requireData(
userClient.getById(userId),
"用户不存在或用户服务调用失败"
);
if (user.getStatus() == null
|| user.getStatus() != 1) {
throw new BizException(
ErrorCode.BIZ_ERROR,
"用户已禁用"
);
}
/*
* 3. 远程查询商品。
*/
ProductDTO product = requireData(
productClient.getById(
request.getProductId()
),
"商品不存在或商品服务调用失败"
);
if (product.getStatus() == null
|| product.getStatus() != 1) {
throw new BizException(
ErrorCode.BIZ_ERROR,
"商品未上架"
);
}
/*
* 4. 远程扣减库存。
*/
ensureSuccess(
productClient.deductStock(
request.getProductId(),
request.getQuantity()
),
"库存扣减失败"
);
/*
* 5. 使用可信用户身份和真实商品信息落库。
*/
Order order = new Order();
order.setUserId(userId);
order.setProductId(request.getProductId());
order.setProductName(product.getName());
order.setQuantity(request.getQuantity());
BigDecimal amount = product.getPrice()
.multiply(
BigDecimal.valueOf(
request.getQuantity()
)
);
order.setAmount(amount);
order.setStatus(0);
boolean success = save(order);
if (!success) {
throw new BizException(
ErrorCode.BIZ_ERROR,
"创建订单失败"
);
}
return order;
}
private <T> T requireData(
Result<T> result,
String defaultMessage
) {
if (result == null
|| result.getCode() != 0
|| result.getData() == null) {
String message = result == null
? defaultMessage
: result.getMessage();
throw new BizException(
ErrorCode.BIZ_ERROR,
message
);
}
return result.getData();
}
private void ensureSuccess(
Result<?> result,
String defaultMessage
) {
if (result == null
|| result.getCode() != 0) {
String message = result == null
? defaultMessage
: result.getMessage();
throw new BizException(
ErrorCode.BIZ_ERROR,
message
);
}
}
}
完整创建订单流程变为:
text
校验请求参数
↓
从 UserContext 获取可信 userId
↓
远程查询用户
↓
远程查询商品
↓
远程扣减库存
↓
使用可信 userId 落库
7.10 先验证 Gateway 到订单服务的身份链路
完成 UserContext、UserContextInterceptor、UserContextWebMvcConfig、ContextController 和 OrderServiceImpl 改造后,开始验证。
这一轮只验证:
text
Gateway
↓
cloud-order
↓
MVC Interceptor
↓
ThreadLocal
也就是先确认订单服务本身能不能读取到 Gateway 透传的用户身份。
重新启动:
text
CloudOrderApplication
CloudProductApplication
CloudUserApplication
登录获取 Token:
http
POST http://localhost:9000/api/auth/login
Content-Type: application/json
{
"username": "zhangsan",
"password": "123456"
}
将登录返回的 Token 写入 HTTP Client 变量:
http
@token = 粘贴你的JWT
执行:
http
### Gateway → cloud-order → ThreadLocal
GET http://localhost:9000/api/order/context/user-id
Authorization: Bearer {{token}}
预期返回:
json
{
"code": 0,
"message": "success",
"data": 1
}

这证明下面这条链路已经跑通:
text
JWT
↓
Gateway
↓
X-User-Id
↓
MVC Interceptor
↓
ThreadLocal
也就是说:
text
Gateway 已经能从 JWT 中提取 userId;
Gateway 已经能把 userId 写入 X-User-Id;
cloud-order 已经能读取 X-User-Id;
UserContext 已经能在当前请求线程中保存 userId。
但这还不能证明服务间调用时身份也能继续传递。
因为:
text
cloud-order 调用 cloud-product
会发起一次新的 HTTP 请求。
这个问题需要下一步继续验证。
7.11 修改商品服务日志
为了观察用户身份是否能继续跨服务传播,需要在商品服务扣库存逻辑中打印当前用户 ID。
打开:
text
cloud-product
└── src/main/java
└── com.example.cloud.product.service.impl
└── ProductServiceImpl.java
如果类上还没有:
java
@Slf4j
则加入导入:
java
import lombok.extern.slf4j.Slf4j;
并在类上增加:
java
@Slf4j
@Service
public class ProductServiceImpl {
// 原有代码
}
然后在扣库存方法开头加入日志:
java
log.info(
"扣减库存,当前用户 ID:{}",
UserContext.getUserId()
);
并加入导入:
java
import com.example.cloud.common.context.UserContext;
注意:
text
这里只增加日志,
不要修改原有库存扣减逻辑。
本轮的目的不是改变商品业务,而是观察:
text
cloud-product 能不能读取到当前用户身份。
7.12 第一次创建订单:观察身份丢失
通过 Gateway 创建订单:
http
### 创建订单:请求体不再传 userId
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 1,
"quantity": 1
}
预期:
text
订单应当仍然可以创建成功。
原因是:
text
cloud-order 已经可以从 UserContext 中获取 userId;
创建订单不再依赖请求体中的 userId。

但是观察 cloud-product 日志,会看到类似:
text
扣减库存,当前用户 ID:null

这就是一个非常重要的现象。
它说明:
text
cloud-order 中有 UserContext
↓
但 ProductClient 发起的是新的 HTTP 请求
↓
ThreadLocal 只属于当前 JVM 当前线程
↓
不会自动跨网络传播
↓
cloud-product 中 userId = null
也就是说,当前只解决了:
text
Gateway → cloud-order
还没有解决:
text
cloud-order → cloud-product
的身份继续传递问题。
因此下一步需要引入:
text
Feign RequestInterceptor
让 cloud-order 发起 Feign 请求时,自动把当前用户身份继续写入请求头:
http
X-User-Id: 1
这样 cloud-product 才能继续通过 UserContextInterceptor 读取到当前用户身份。
7.13 Feign RequestInterceptor 是什么
现在 cloud-order 已经能拿到用户身份。
但 cloud-order 还会通过 Feign 调用:
text
cloud-user
cloud-product
此时会发起新的 HTTP 请求。
ThreadLocal 不会自动跨网络传递。
所以需要在 Feign 发请求前,把当前用户 ID 放到请求头中。
这就是:
java
RequestInterceptor
它适合统一添加:
text
X-User-Id
X-Trace-Id
租户 ID
语言
内部调用标识
7.14 让 Feign 继续透传用户身份:UserContextFeignInterceptor.java
java
package com.example.cloud.order.client.interceptor;
import com.example.cloud.common.context.UserContext;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class UserContextFeignInterceptor
implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
Long userId = UserContext.getUserId();
log.info(
"Feign 准备发送请求,线程={},url={},当前用户 ID={}",
Thread.currentThread().getName(),
template.url(),
userId
);
if (userId == null) {
return;
}
template.header(
UserContext.USER_ID_HEADER,
String.valueOf(userId)
);
}
}

7.12 第二次创建订单:验证 Feign 身份透传
再来尝试创建订单。

日志显示:
text
cloud-order:
Feign 准备发送请求,线程=pool-3-thread-3,
url=/products/1/deduct-stock?quantity=1,
当前用户 ID=null
cloud-product:
接收请求,uri=/products/1/deduct-stock,
线程=http-nio-9300-exec-4,
X-User-Id=null
ProductServiceImpl:
扣减库存,当前用户 ID:null
这说明:
text
UserContextFeignInterceptor 生效了
但它执行时 UserContext 已经是 null
8、第四阶段:CircuitBreaker 线程切换导致身份丢失
8.1 为什么 RequestInterceptor 读取到 null
第 7 章中为了 Feign 降级,开启了:
yaml
feign:
circuitbreaker:
enabled: true
开启后,Feign 调用会被 CircuitBreaker 包装。
实际日志显示:
text
线程=pool-3-thread-3
而不是:
text
线程=http-nio-9400-exec-1
这说明 Feign 调用已经切换到了 CircuitBreaker 使用的线程池中。
普通 ThreadLocal 不能跨线程传递。
所以:
text
Tomcat 请求线程:
UserContext = 1
CircuitBreaker 工作线程:
UserContext = null
8.2 对照实验:关闭 CircuitBreaker
临时修改 Nacos:
yaml
feign:
circuitbreaker:
enabled: false
重启 cloud-order 后再次创建订单。


日志显示:
text
cloud-order:
Feign 准备发送请求,线程=http-nio-9400-exec-1,
url=/products/1/deduct-stock?quantity=1,
当前用户 ID=1
cloud-product:
接收请求,uri=/products/1/deduct-stock,
线程=http-nio-9300-exec-1,
X-User-Id=1
ProductServiceImpl:
扣减库存,当前用户 ID:1
这证明:
text
template.header(...) 没问题
UserContextFeignInterceptor 没问题
问题来自 CircuitBreaker 线程切换
但不能为了身份透传成功,就放弃 CircuitBreaker。
8.3 在 CircuitBreaker 线程池中传播用户上下文:UserContextAwareExecutorService.java
前面已经做过对照实验。
当关闭 CircuitBreaker 时:
yaml
feign:
circuitbreaker:
enabled: false
Feign 调用日志显示:
bash
线程=http-nio-9400-exec-1
当前用户 ID=1
说明 Feign 调用仍然在原来的 Tomcat 请求线程中执行,所以 UserContext 没有丢失。
但当开启 CircuitBreaker 时:
yaml
feign:
circuitbreaker:
enabled: true
Feign 调用日志显示:
bash
线程=pool-3-thread-3
当前用户 ID=null
这说明 Feign 调用被 CircuitBreaker 包装后,实际执行线程已经发生了变化。
原来的请求线程中:
bash
UserContext = 1
但 CircuitBreaker 的工作线程中:
bash
UserContext = null
根本原因是:
bash
ThreadLocal 只属于当前线程,
不会自动复制到线程池中的另一个线程。
所以,仅仅有 UserContextFeignInterceptor 还不够。
因为它执行时已经处于 CircuitBreaker 的工作线程中,此时 UserContext.getUserId() 已经变成了 null。
修复思路是:
bash
任务提交时:
捕获父线程中的 UserContext
任务执行前:
把捕获到的 userId 恢复到工作线程
任务执行后:
清理或恢复工作线程原有上下文
这正是 UserContextAwareExecutorService 要做的事情。
它不是一个新的业务线程池,而是对已有 ExecutorService 的包装:
原始 ExecutorService
↓
UserContextAwareExecutorService 包装
↓
提交任务时捕获 userId
↓
执行任务前恢复 userId
↓
任务结束后清理 userId
为什么任务结束后必须清理?
因为线程池会复用线程。
如果不清理,可能出现:
用户 A 的任务:
userId = 1
↓
线程回到线程池
↓
用户 B 的任务复用同一线程
↓
错误读到 userId = 1
这就是用户身份串线,属于非常严重的问题。
所以本类的核心原则是:
捕获
恢复
执行
清理
下面创建 UserContextAwareExecutorService.java
java
package com.example.cloud.order.config;
import com.example.cloud.common.context.UserContext;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
/**
* 用户上下文感知线程池包装器。
*
* 作用:
* 将提交任务线程中的 UserContext,
* 安全传递到真正执行任务的线程。
*
* 当前主要用于:
* Resilience4j CircuitBreaker / TimeLimiter。
*/
public class UserContextAwareExecutorService
extends AbstractExecutorService {
private final ExecutorService delegate;
public UserContextAwareExecutorService(
ExecutorService delegate
) {
this.delegate = delegate;
}
@Override
public void execute(Runnable command) {
Objects.requireNonNull(command, "command");
/*
* 提交任务时仍然位于父线程。
*
* 此时捕获父线程中的用户身份。
*/
Long capturedUserId = UserContext.getUserId();
delegate.execute(() -> {
/*
* 保存工作线程原有值。
*
* 线程池会复用线程,
* 因此任务结束后必须恢复或清理。
*/
Long previousUserId = UserContext.getUserId();
try {
if (capturedUserId == null) {
UserContext.clear();
} else {
UserContext.setUserId(capturedUserId);
}
command.run();
} finally {
if (previousUserId == null) {
UserContext.clear();
} else {
UserContext.setUserId(previousUserId);
}
}
});
}
@Override
public void shutdown() {
delegate.shutdown();
}
@Override
public List<Runnable> shutdownNow() {
return delegate.shutdownNow();
}
@Override
public boolean isShutdown() {
return delegate.isShutdown();
}
@Override
public boolean isTerminated() {
return delegate.isTerminated();
}
@Override
public boolean awaitTermination(
long timeout,
TimeUnit unit
) throws InterruptedException {
return delegate.awaitTermination(timeout, unit);
}
}
8.4 将上下文感知线程池交给 CircuitBreaker:Resilience4jExecutorConfig.java
UserContextAwareExecutorService 只是定义了一个"能传播 UserContext 的线程池包装器"。
但如果不把它交给 Resilience4j CircuitBreaker 使用,Feign 调用仍然会继续使用默认线程池。
因此还需要创建配置类:
bash
Resilience4jExecutorConfig
java
package com.example.cloud.order.config;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cloud.client.circuitbreaker.Customizer;
import org.springframework.cloud.circuitbreaker.resilience4j
.Resilience4JCircuitBreakerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Resilience4j CircuitBreaker 线程池配置。
*
* 为什么需要自定义?
*
* Feign 开启 CircuitBreaker 后,
* 远程调用可能切换到线程池中执行。
*
* 普通 ThreadLocal 不会自动跨线程传递,
* 因此需要包装 ExecutorService。
*/
@Configuration
public class Resilience4jExecutorConfig {
/**
* 创建用户上下文感知线程池。
*
* destroyMethod = "shutdown":
* Spring 容器关闭时,自动关闭线程池。
*/
@Bean(
name = "userContextAwareCircuitBreakerExecutor",
destroyMethod = "shutdown"
)
public ExecutorService userContextAwareCircuitBreakerExecutor() {
AtomicInteger index = new AtomicInteger();
ThreadFactory threadFactory = task -> {
Thread thread = new Thread(task);
thread.setName(
"resilience4j-context-"
+ index.incrementAndGet()
);
/*
* 学习项目中设置为守护线程。
*
* 正式项目还需要结合:
* 核心线程数
* 最大线程数
* 队列容量
* 拒绝策略
* 监控指标
*/
thread.setDaemon(true);
return thread;
};
ExecutorService delegate =
Executors.newFixedThreadPool(
10,
threadFactory
);
return new UserContextAwareExecutorService(
delegate
);
}
/**
* 将自定义线程池交给 Resilience4j CircuitBreaker。
*/
@Bean
public Customizer<Resilience4JCircuitBreakerFactory>
resilience4jExecutorCustomizer(
@Qualifier(
"userContextAwareCircuitBreakerExecutor"
)
ExecutorService executorService
) {
return factory ->
factory.configureExecutorService(
executorService
);
}
}
8.5 恢复 CircuitBreaker 后验证
恢复 Nacos:
yaml
feign:
circuitbreaker:
enabled: true
重启 cloud-order 后再次创建订单。


实际验证结果:
text
cloud-order:
Feign 拦截器日志中 userId=1
cloud-product:
X-User-Id=1
ProductServiceImpl:
扣减库存,当前用户 ID:1
说明:
text
CircuitBreaker 开启
↓
线程发生切换
↓
上下文感知 ExecutorService 捕获并恢复 UserContext
↓
Feign RequestInterceptor 成功写入 X-User-Id
↓
cloud-product 成功读取用户身份
8.6 直接访问订单服务会发生什么
在cloud-gateway/src/test/http/gateway.http追加:
http
### 绕过 Gateway 直接访问 cloud-order
### 预期:用户身份缺失
POST http://localhost:9400/orders
Content-Type: application/json
{
"productId": 1,
"quantity": 1
}
预期:
json
{
"code": 40100,
"message": "用户身份缺失",
"data": null
}
执行直接访问↓

原因:
bash
请求绕过 Gateway
↓
没有 X-User-Id
↓
UserContext.requireUserId() 失败
这说明订单服务已经不再信任请求体中的 userId。
但需要注意,攻击者如果能够直接访问内部端口,仍然可能手工伪造:
bash
X-User-Id: 999999
因此真实部署还必须限制:
bash
业务服务端口不对公网开放
只允许 Gateway 或可信内网访问
8.7 为什么不用 InheritableThreadLocal
InheritableThreadLocal 只适合:
text
父线程临时创建新子线程
但线程池中的线程通常早已存在,并且会反复复用。
在这种场景中,它容易失效或造成身份串线。
因此更推荐:
text
任务提交时捕获上下文
任务执行前恢复上下文
任务结束后清理上下文
9、第五阶段:@Async 异步线程身份丢失与 TaskDecorator 修复
9.1 @Async 是什么
@Async 表示异步执行。
普通方法调用:
text
调用方线程
↓
执行方法
↓
等待方法执行完成
@Async 方法调用:
text
调用方线程
↓
提交任务到线程池
↓
异步线程执行方法
常见用途:
text
发送通知
记录审计日志
导出文件
异步计算
无需阻塞主流程的附加任务
但它会带来一个问题:
text
线程切换后 ThreadLocal 丢失
9.2 OrderAsyncConfig.java 初始版本
先创建不带 TaskDecorator 的版本,用于复现故障:
java
package com.example.cloud.order.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class OrderAsyncConfig {
@Bean("orderAsyncExecutor")
public Executor orderAsyncExecutor() {
ThreadPoolTaskExecutor executor =
new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("order-async-");
executor.initialize();
return executor;
}
}
9.3 AsyncUserContextService.java
java
package com.example.cloud.order.service;
import com.example.cloud.common.context.UserContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
public class AsyncUserContextService {
@Async("orderAsyncExecutor")
public CompletableFuture<String> currentUserId() {
Long userId = UserContext.getUserId();
log.info(
"异步任务执行,线程={},当前用户 ID={}",
Thread.currentThread().getName(),
userId
);
String result = userId == null
? "null"
: String.valueOf(userId);
return CompletableFuture.completedFuture(result);
}
}

9.4 为什么 @Async 方法要放在单独 Bean 中
不要在同一个类里自己调用自己的 @Async 方法。
错误示例:
java
public Result<String> asyncUserId() {
return Result.success(
currentUserId().join()
);
}
@Async
public CompletableFuture<String> currentUserId() {
...
}
这是同一个对象内部调用,不会经过 Spring 代理。
结果是:
text
@Async 不生效
方法仍然在原线程执行
所以这里单独创建:
text
AsyncUserContextService
由 Controller 注入后调用。
9.5 第一次测试:异步线程身份丢失
请求同步接口:
http
GET http://localhost:9000/api/order/context/user-id
Authorization: Bearer {{token}}
返回:
json
{
"code": 0,
"message": "success",
"data": 1
}

请求异步接口:
http
GET http://localhost:9000/api/order/context/async-user-id
Authorization: Bearer {{token}}
返回:
json
{
"code": 0,
"message": "success",
"data": "null"
}

日志:
text
异步任务执行,
线程=order-async-1,
当前用户 ID=null

说明:
text
请求线程 UserContext=1
↓
@Async 切换到 order-async-1
↓
普通 ThreadLocal 无法跨线程
↓
异步线程 UserContext=null
9.6 TaskDecorator 是什么
TaskDecorator 可以理解为:
text
异步任务包装器
任务提交到线程池之前,它可以把原始 Runnable 包装一层。
text
原始 Runnable
↓
TaskDecorator 包装
↓
包装后的 Runnable
↓
线程池执行
适合处理:
text
用户身份
TraceId
MDC 日志上下文
租户 ID
语言环境
监控埋点
9.7 UserContextTaskDecorator.java
java
package com.example.cloud.order.config;
import com.example.cloud.common.context.UserContext;
import org.springframework.core.task.TaskDecorator;
public class UserContextTaskDecorator
implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Long capturedUserId = UserContext.getUserId();
return () -> {
Long previousUserId = UserContext.getUserId();
try {
if (capturedUserId == null) {
UserContext.clear();
} else {
UserContext.setUserId(
capturedUserId
);
}
runnable.run();
} finally {
if (previousUserId == null) {
UserContext.clear();
} else {
UserContext.setUserId(
previousUserId
);
}
}
};
}
}
9.8 OrderAsyncConfig.java 修复版本
java
package com.example.cloud.order.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class OrderAsyncConfig {
@Bean("orderAsyncExecutor")
public Executor orderAsyncExecutor() {
ThreadPoolTaskExecutor executor =
new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("order-async-");
executor.setTaskDecorator(
new UserContextTaskDecorator()
);
executor.initialize();
return executor;
}
}

9.9 第二次测试:异步线程身份恢复
再次请求:
http
GET http://localhost:9000/api/order/context/async-user-id
Authorization: Bearer {{token}}

返回:
json
{
"code": 0,
"message": "success",
"data": "1"
}

日志:
text
异步任务执行,
线程=order-async-1,
当前用户 ID=1
说明:
text
Gateway 写入 X-User-Id
↓
cloud-order 请求线程 UserContext = 1
↓
调用 @Async 方法
↓
TaskDecorator 捕获 userId = 1
↓
任务进入 order-async-1
↓
TaskDecorator 恢复 userId = 1
↓
异步任务读取 UserContext = 1
↓
finally 清理或恢复线程上下文
10、本章关键概念对比
10.1 JWT 与 Session
| 对比项 | JWT | Session |
|---|---|---|
| 状态保存 | 客户端保存 Token | 服务端保存 Session |
| 服务端扩展 | 更适合无状态扩展 | 需要共享 Session |
| 主动失效 | 相对麻烦 | 相对容易 |
| 微服务网关场景 | 常用 | 也能用,但要额外设计 |
10.2 Gateway 鉴权与下游拦截器
| 位置 | 职责 |
|---|---|
| Gateway GlobalFilter | 校验 JWT,决定请求能否进入系统 |
| MVC Interceptor | 读取可信请求头,写入 UserContext |
| UserContext | 保存当前线程用户身份 |
| Feign RequestInterceptor | 发起远程调用时继续透传身份 |
10.3 Feign RequestInterceptor 与 TaskDecorator
| 技术 | 解决问题 |
|---|---|
| Feign RequestInterceptor | HTTP 远程调用时添加请求头 |
| UserContextAwareExecutorService | CircuitBreaker 线程池中传播上下文 |
| TaskDecorator | @Async 线程池中传播上下文 |
11、本章结论
本章完成了完整的统一鉴权主线:
text
cloud-auth 登录
BCrypt 密码校验
JWT 签发
Gateway GlobalFilter
白名单
Authorization: Bearer
JWT 校验
X-User-Id 透传
UserContext
ThreadLocal
MVC Interceptor
Feign RequestInterceptor
CircuitBreaker 线程切换故障
UserContextAwareExecutorService
@Async 身份丢失故障
TaskDecorator
到目前为止架构如图↓

12、下一章预告:Sentinel 限流、熔断与降级
到目前为止,项目的主链路已经打通:
text
客户端
↓
Gateway 统一入口
↓
JWT 统一鉴权
↓
cloud-order 创建订单
↓
OpenFeign 调用 cloud-user 校验用户
↓
OpenFeign 调用 cloud-product 查询商品、扣减库存
↓
订单创建完成
但是主链路打通之后,还会面临新的问题。
例如:
text
如果短时间内大量请求同时访问商品接口,会不会把商品服务打垮?
如果订单服务频繁调用商品服务,而商品服务响应很慢,订单服务会不会被拖垮?
如果下游服务异常,Feign fallback 应该怎么处理?
如果库存不足这种业务异常也进入 fallback,会不会掩盖真实业务错误?
这些问题已经不属于"服务能不能调用成功",而属于:
text
服务稳定性
流量治理
异常隔离
熔断降级
所以第 10 章将进入:
text
Sentinel 限流、熔断与降级
Sentinel 是 Alibaba 开源的流量治理组件,它以"流量"为切入点,提供流量控制、并发限制、熔断降级、系统自适应保护等能力,用于提升微服务系统可靠性。
下一章主要学习:
text
1. Sentinel 是什么
2. Sentinel 和 Hystrix 有什么区别
3. 如何接入 Sentinel Dashboard
4. 如何给商品接口配置 QPS 限流
5. 如何观察接口被限流后的返回结果
6. 如何让 Feign 调用配合 Sentinel fallback
7. 如何复现"降级掩盖真实业务异常"的问题
8. 如何区分业务异常和系统异常