上一篇说了双Token认证系统,解决了单服务认证问题:微服务场景怎么办?每个服务都验证一遍JWT?
今天就来解决这个问题。
这篇要做的:把JWT验证提到网关层,下游服务不用再关心Token的事。代码照样开源,保证能跑。
源码地址:gitee.com/sh_wangwanb… 分支:ch3
为什么要做Gateway统一鉴权
先说说不用网关会遇到什么问题。
假设你有5个微服务:用户服务、订单服务、商品服务、支付服务、物流服务。
传统做法是每个服务都集成JWT验证:
flowchart TB
Client[客户端]
Client -->|携带Token| UserService[用户服务<br/>JWT验证]
Client -->|携带Token| OrderService[订单服务<br/>JWT验证]
Client -->|携带Token| ProductService[商品服务<br/>JWT验证]
Client -->|携带Token| PaymentService[支付服务<br/>JWT验证]
Client -->|携带Token| LogisticsService[物流服务<br/>JWT验证]
style UserService fill:#FFB6C1
style OrderService fill:#FFB6C1
style ProductService fill:#FFB6C1
style PaymentService fill:#FFB6C1
style LogisticsService fill:#FFB6C1
问题很明显:
- 每个服务都要引入JWT依赖,写一遍验证逻辑
- JWT密钥要配置5遍,改一次要改5个地方
- 验证逻辑改了,5个服务都要重新部署
- 性能浪费,同一个Token验证5次
用Gateway之后就简单了:
flowchart TB
Client[客户端]
Gateway[API Gateway<br/>统一JWT验证]
Client -->|携带Token| Gateway
Gateway -->|验证通过<br/>传递用户信息| UserService[用户服务<br/>直接使用]
Gateway -->|验证通过<br/>传递用户信息| OrderService[订单服务<br/>直接使用]
Gateway -->|验证通过<br/>传递用户信息| ProductService[商品服务<br/>直接使用]
Gateway -->|验证通过<br/>传递用户信息| PaymentService[支付服务<br/>直接使用]
Gateway -->|验证通过<br/>传递用户信息| LogisticsService[物流服务<br/>直接使用]
style Gateway fill:#87CEEB
style UserService fill:#90EE90
style OrderService fill:#90EE90
style ProductService fill:#90EE90
style PaymentService fill:#90EE90
style LogisticsService fill:#90EE90
优势立刻显现:
- 验证逻辑只写一次,在网关层
- 下游服务不用关心Token,只处理业务
- 改验证逻辑只需重启网关,不影响业务服务
- 性能提升,一个Token只验证一次
整体架构设计
先看完整的架构图:
flowchart LR
Client[客户端<br/>浏览器/APP]
subgraph Gateway["API Gateway (8088)"]
direction TB
Filter1[全局过滤器]
Filter2[JWT验证]
Filter3[路由转发]
Filter1 --> Filter2 --> Filter3
end
subgraph Auth["认证中心 (8080)"]
direction TB
Login[登录接口]
Refresh[Token刷新]
Manage[Token管理]
end
subgraph User["用户服务 (8082)"]
direction TB
Profile[用户信息]
Permission[权限校验]
end
DB[(数据库)]
Client -->|带Token请求| Gateway
Gateway -->|路由| Auth
Gateway -->|路由+用户信息Header| User
Auth --> DB
User --> DB
style Gateway fill:#E3F2FD
style Auth fill:#FCE4EC
style User fill:#E8F5E9
style DB fill:#FFF3E0
核心流程分两步:
第一步:登录获取Token
- 客户端发起登录请求
- 网关路由到认证中心
- 认证中心验证密码,生成双Token
- RefreshToken存数据库
- 返回Token给客户端
第二步:业务请求
- 客户端携带AccessToken访问业务接口
- 网关验证Token有效性
- 验证通过后,提取用户名和角色信息
- 把用户信息放到Header里(X-User-Name、X-User-Roles)
- 转发给下游服务
- 下游服务从Header获取用户信息,处理业务
关键点是:下游服务完全不需要知道JWT的存在,只需要从Header里取用户信息就行。
技术栈选择
- Spring Cloud Gateway:2023.0.3
- Spring Boot:3.3.2
- JWT:0.12.6
- MySQL:8.0
- MyBatis:3.0.3
为什么选Gateway不选Zuul?
Zuul是阻塞式的,基于Servlet。Gateway是响应式的,基于WebFlux,性能更好。而且Zuul 2.x都没正式发布,Spring官方推荐用Gateway。
核心代码实现
1. Gateway的JWT验证过滤器
这是最核心的部分,在网关层验证Token并传递用户信息:
@Component
public class JwtAuthenticationFilter implements GlobalFilter, Ordered {
@Autowired
private JwtService jwtService;
// 不需要验证的路径
private static final List<String> EXCLUDE_PATHS = Arrays.asList(
"/api/auth/login",
"/api/auth/refresh",
"/api/auth/register"
);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// 1. 白名单路径直接放行
if (isExcludePath(path)) {
log.info("放行路径: {}", path);
return chain.filter(exchange);
}
// 2. 提取Token
String token = extractToken(exchange.getRequest());
if (token == null) {
log.warn("未携带Token");
return unauthorizedResponse(exchange, "未携带认证信息");
}
try {
// 3. 验证Token
Claims claims = jwtService.parseClaims(token);
// 4. 验证Token类型
String type = claims.get("type", String.class);
if (!"access".equals(type)) {
log.warn("Token类型错误: {}", type);
return unauthorizedResponse(exchange, "无效的Token类型");
}
// 5. 提取用户信息
String username = claims.getSubject();
List<String> roles = claims.get("roles", List.class);
// 6. 添加到Header传递给下游服务
ServerHttpRequest mutatedRequest = exchange.getRequest()
.mutate()
.header("X-User-Name", username)
.header("X-User-Roles", String.join(",", roles))
.build();
ServerWebExchange mutatedExchange = exchange.mutate()
.request(mutatedRequest)
.build();
log.info("Token验证通过,用户: {}, 角色: {}", username, roles);
// 7. 放行
return chain.filter(mutatedExchange);
} catch (ExpiredJwtException e) {
log.warn("Token已过期");
return unauthorizedResponse(exchange, "Token已过期");
} catch (Exception e) {
log.error("Token验证失败", e);
return unauthorizedResponse(exchange, "Token验证失败");
}
}
private String extractToken(ServerHttpRequest request) {
String bearerToken = request.getHeaders().getFirst("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
private boolean isExcludePath(String path) {
return EXCLUDE_PATHS.stream().anyMatch(path::startsWith);
}
private Mono<Void> unauthorizedResponse(ServerWebExchange exchange, String message) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = String.format("{\"code\":401,\"msg\":\"%s\"}", message);
DataBuffer buffer = response.bufferFactory()
.wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
@Override
public int getOrder() {
return -100; // 优先级要高,在路由之前执行
}
}
这里有几个关键点要说明:
GlobalFilter vs GatewayFilter
Gateway有两种过滤器:
- GlobalFilter:全局过滤器,对所有路由生效
- GatewayFilter:局部过滤器,只对特定路由生效
我们用GlobalFilter,因为JWT验证要对所有接口生效。
Ordered接口
Gateway的过滤器是责任链模式,执行顺序很重要。返回负数表示优先级高,我们设置-100,保证在路由转发之前执行。
ServerWebExchange
这是WebFlux的核心概念,相当于Servlet里的HttpServletRequest + HttpServletResponse。Gateway基于WebFlux,所以API不一样。
mutate模式
WebFlux里的对象都是不可变的,要修改就要用mutate创建新对象。这里我们用mutate添加Header。
Mono返回值
Mono是响应式编程的概念,表示0个或1个元素的异步序列。Gateway是异步非阻塞的,所以返回Mono。
2. Gateway的路由配置
配置很简单,告诉Gateway请求转发到哪里:
spring:
cloud:
gateway:
routes:
# 认证中心路由
- id: auth-center
uri: http://localhost:8080
predicates:
- Path=/api/auth/**
filters:
- StripPrefix=0
# 用户服务路由
- id: user-service
uri: http://localhost:8082
predicates:
- Path=/api/user/**
filters:
- StripPrefix=0
# 全局CORS配置
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "*"
allowedMethods:
- GET
- POST
- PUT
- DELETE
allowedHeaders: "*"
allowCredentials: true
路由规则解释:
- id:路由的唯一标识
- uri:目标服务地址
- predicates:匹配规则,这里按路径匹配
- filters:过滤器,StripPrefix=0表示不去掉路径前缀
举例:请求 http://localhost:8088/api/auth/login
- Gateway收到请求
- 匹配到auth-center路由(Path=/api/auth/**)
- 转发到 http://localhost:8080/api/auth/login
- 认证中心处理请求
3. 下游服务怎么获取用户信息
网关已经把用户信息放到Header里了,下游服务直接取就行:
@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/profile")
public AjaxResult getProfile(
@RequestHeader(value = "X-User-Name", required = false) String username,
@RequestHeader(value = "X-User-Roles", required = false) String rolesStr) {
if (username == null) {
return AjaxResult.error("未认证");
}
List<String> roles = rolesStr != null
? Arrays.asList(rolesStr.split(","))
: Collections.emptyList();
Map<String, Object> profile = new HashMap<>();
profile.put("username", username);
profile.put("roles", roles);
return AjaxResult.success("操作成功", profile);
}
@GetMapping("/admin")
public AjaxResult adminOnly(
@RequestHeader(value = "X-User-Roles", required = false) String rolesStr) {
if (rolesStr == null || !rolesStr.contains("ROLE_ADMIN")) {
return AjaxResult.error(403, "无权限");
}
return AjaxResult.success("管理员专属接口");
}
}
简单吧?下游服务不需要任何JWT依赖,只需要从Header取值就行。
如果觉得每个接口都写@RequestHeader太麻烦,可以用拦截器统一处理:
@Component
public class UserContextInterceptor implements HandlerInterceptor {
private static final ThreadLocal<UserContext> USER_CONTEXT = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String username = request.getHeader("X-User-Name");
String rolesStr = request.getHeader("X-User-Roles");
if (username != null) {
UserContext context = new UserContext();
context.setUsername(username);
if (rolesStr != null) {
context.setRoles(Arrays.asList(rolesStr.split(",")));
}
USER_CONTEXT.set(context);
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
USER_CONTEXT.remove();
}
public static UserContext getCurrentUser() {
return USER_CONTEXT.get();
}
}
然后在业务代码里直接用:
UserContext user = UserContextInterceptor.getCurrentUser();
if (user != null && user.hasRole("ROLE_ADMIN")) {
// 执行管理员逻辑
}
4. 认证中心保持不变
认证中心的代码和上一篇完全一样,不需要改:
- 登录接口:验证密码,生成双Token
- 刷新接口:验证RefreshToken,生成新Token
- Token管理:存储和撤销RefreshToken
唯一的区别是,现在认证中心不直接对外暴露,必须通过网关访问。
请求流程详解
整个流程用时序图表示:
sequenceDiagram
participant C as 客户端
participant G as Gateway
participant A as 认证中心
participant U as 用户服务
participant D as 数据库
Note over C,D: 第一步:登录获取Token
C->>G: POST /api/auth/login<br/>{username, password}
G->>G: 检查路径在白名单
G->>A: 转发登录请求
A->>D: 查询用户信息
D-->>A: 返回用户数据
A->>A: 验证密码
A->>A: 生成双Token
A->>D: 存储RefreshToken
A-->>G: 返回双Token
G-->>C: 返回Token
Note over C,D: 第二步:访问业务接口
C->>G: GET /api/user/profile<br/>Header: Authorization Bearer xxx
G->>G: 提取Token
G->>G: 验证Token签名和有效期
G->>G: 提取username和roles
G->>G: 添加X-User-Name和X-User-Roles
G->>U: 转发请求(带用户信息Header)
U->>U: 从Header获取用户信息
U->>U: 处理业务逻辑
U-->>G: 返回业务数据
G-->>C: 返回响应
Note over C,D: 第三步:Token过期刷新
C->>G: POST /api/auth/refresh<br/>{refreshToken}
G->>G: 检查路径在白名单
G->>A: 转发刷新请求
A->>A: 解析RefreshToken
A->>D: 查询Token状态
D-->>A: 返回Token记录
A->>A: 验证状态为ACTIVE
A->>D: 撤销旧Token
A->>A: 生成新双Token
A->>D: 存储新RefreshToken
A-->>G: 返回新Token
G-->>C: 返回新Token
看这个流程图就清楚了:
- 登录时,网关直接放行,认证中心处理
- 访问业务接口时,网关验证Token,添加用户信息Header
- Token刷新时,网关放行,认证中心处理
和上一篇的区别
对比一下架构变化:
上一篇(单体应用):
flowchart LR
Client[客户端] -->|带Token| Backend[后端应用]
Backend -->|验证Token| Backend
Backend --> Database[(数据库)]
style Backend fill:#FFB6C1
所有逻辑都在一个应用里,包括认证和业务。
这一篇(微服务+网关):
flowchart LR
Client[客户端] -->|带Token| Gateway[网关]
Gateway -->|验证Token| Gateway
Gateway -->|放行+用户信息| Auth[认证中心]
Gateway -->|放行+用户信息| User[用户服务]
Gateway -->|放行+用户信息| Order[订单服务]
Auth --> Database[(数据库)]
User --> Database
Order --> Database
style Gateway fill:#87CEEB
style Auth fill:#90EE90
style User fill:#90EE90
style Order fill:#90EE90
认证和业务分离,网关统一验证。
核心变化:
- 验证逻辑从业务服务移到网关
- 下游服务不需要JWT依赖
- 用户信息通过Header传递
- 认证中心专注Token管理
测试验证
完整的测试脚本在QUICK_TEST_GUIDE.md里,这里说几个关键测试。
测试1:登录获取Token
curl -X POST http://localhost:8088/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"Aa123456"}' \
| python3 -m json.tool
预期结果:
{
"msg": "登录成功",
"code": 200,
"data": {
"accessToken": "eyJhbGci...",
"refreshToken": "eyJhbGci..."
}
}
保存Token到变量:
LOGIN_RESP=$(curl -s -X POST http://localhost:8088/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"Aa123456"}')
ACCESS_TOKEN=$(echo "$LOGIN_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['accessToken'])")
测试2:无Token访问
curl -X GET http://localhost:8088/api/user/profile
预期结果:
HTTP/1.1 401 Unauthorized
{"code":401,"msg":"未携带认证信息"}
测试3:携带Token访问
curl -X GET http://localhost:8088/api/user/profile \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| python3 -m json.tool
预期结果:
{
"msg": "操作成功",
"code": 200,
"data": {
"username": "admin",
"roles": ["ROLE_ADMIN"]
}
}
测试4:Token刷新
curl -X POST http://localhost:8088/api/auth/refresh \
-H 'Content-Type: application/json' \
-d "{\"refreshToken\":\"$REFRESH_TOKEN\"}" \
| python3 -m json.tool
验证Token旋转(令牌轮换):
# 再次使用同一个旧的RefreshToken
curl -X POST http://localhost:8088/api/auth/refresh \
-H 'Content-Type: application/json' \
-d "{\"refreshToken\":\"$REFRESH_TOKEN\"}"
# 预期:{"code": 401, "msg": "刷新令牌已失效"}
查看数据库验证
SELECT
token_id,
user_id,
jti,
CASE
WHEN status = 0 THEN '有效'
WHEN status = 1 THEN '已撤销'
END as status_text,
issued_at,
expires_at
FROM sys_refresh_token
WHERE user_id = 1
ORDER BY issued_at DESC
LIMIT 5;
应该看到最新的Token状态为"有效",之前刷新过的为"已撤销"。
踩过的坑
1. Gateway的依赖冲突
Gateway基于WebFlux,不能和Spring MVC一起用。如果你的pom.xml里同时有这两个:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
启动就报错:
Spring MVC found on classpath, which is incompatible with Spring Cloud Gateway
解决:Gateway项目只用WebFlux,不要引入spring-boot-starter-web。
2. 过滤器执行顺序
刚开始我的过滤器Order设置成0,结果JWT验证总是在路由之后执行,导致请求直接转发到下游,下游拿不到用户信息。
后来改成-100,保证在路由之前执行。
Order值越小,优先级越高:
- -100:JWT验证(最先执行)
- -1:内置的路由过滤器
- 0:默认值
3. Header传递问题
最开始我直接修改exchange.getRequest().getHeaders(),发现不生效。
原因是WebFlux里的对象都是不可变的,要用mutate创建新对象:
// 错误做法
exchange.getRequest().getHeaders().add("X-User-Name", username);
// 正确做法
ServerHttpRequest mutatedRequest = exchange.getRequest()
.mutate()
.header("X-User-Name", username)
.build();
4. 白名单路径匹配
刚开始用equals判断路径,发现登录接口还是被拦截。
原因是路径有时候带斜杠,有时候不带。后来改用startsWith:
private boolean isExcludePath(String path) {
return EXCLUDE_PATHS.stream().anyMatch(path::startsWith);
}
5. Mono异步响应
Gateway是异步的,返回Mono。刚开始我写同步代码,总是编译报错。
记住:
- 返回值必须是Mono或Flux
- 用Mono.just()包装数据
- 用flatMap处理异步操作
性能对比
做了个简单的压测,对比单体和网关架构:
测试场景:1000并发,持续60秒
单体架构(上一篇):
- QPS:1200
- 平均响应时间:80ms
- 每个请求都要验证JWT
网关架构(本篇):
- QPS:1500
- 平均响应时间:65ms
- JWT只验证一次,下游服务轻量
提升25%的性能,主要原因:
- 下游服务不需要JWT验证,减少CPU消耗
- 减少重复的密钥加载和签名验证
- Gateway的异步非阻塞模型
生产环境建议
如果要上生产,还要注意这些:
- 服务注册与发现
现在路由写的是固定地址:
uri: http://localhost:8080
生产环境应该用Nacos或Eureka:
uri: lb://auth-center
lb表示负载均衡,会自动从注册中心获取服务实例。
- 限流熔断
加上Sentinel或Resilience4j,防止服务被打垮:
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("auth-center", r -> r
.path("/api/auth/**")
.filters(f -> f
.requestRateLimiter(c -> c
.setRateLimiter(redisRateLimiter())
.setKeyResolver(userKeyResolver())
)
)
.uri("lb://auth-center")
)
.build();
}
- 统一异常处理
Gateway的异常要单独处理,因为它基于WebFlux:
@Component
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
String message = ex.getMessage();
String body = String.format("{\"code\":500,\"msg\":\"%s\"}", message);
DataBuffer buffer = response.bufferFactory()
.wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
}
总结
这篇把JWT认证升级成Gateway统一鉴权,核心思路是:
- 认证逻辑提到网关层,下游服务无感知
- 通过Header传递用户信息,避免重复验证
- 认证中心专注Token管理,业务服务专注业务
代码已经测试通过,可以直接用。
如果你在实现过程中遇到问题,欢迎评论区留言。
下一篇打算写RBAC权限模型,实现资源级别的访问控制。如果对这个系列感兴趣,建议关注我。
评论区见。