三周手撸企业级认证系统(三) 从JWT认证升级到Gateway统一鉴权

77 阅读10分钟

上一篇说了双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

问题很明显:

  1. 每个服务都要引入JWT依赖,写一遍验证逻辑
  2. JWT密钥要配置5遍,改一次要改5个地方
  3. 验证逻辑改了,5个服务都要重新部署
  4. 性能浪费,同一个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

  1. 客户端发起登录请求
  2. 网关路由到认证中心
  3. 认证中心验证密码,生成双Token
  4. RefreshToken存数据库
  5. 返回Token给客户端

第二步:业务请求

  1. 客户端携带AccessToken访问业务接口
  2. 网关验证Token有效性
  3. 验证通过后,提取用户名和角色信息
  4. 把用户信息放到Header里(X-User-Name、X-User-Roles)
  5. 转发给下游服务
  6. 下游服务从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

  1. Gateway收到请求
  2. 匹配到auth-center路由(Path=/api/auth/**)
  3. 转发到 http://localhost:8080/api/auth/login
  4. 认证中心处理请求

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

看这个流程图就清楚了:

  1. 登录时,网关直接放行,认证中心处理
  2. 访问业务接口时,网关验证Token,添加用户信息Header
  3. 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

认证和业务分离,网关统一验证。

核心变化:

  1. 验证逻辑从业务服务移到网关
  2. 下游服务不需要JWT依赖
  3. 用户信息通过Header传递
  4. 认证中心专注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%的性能,主要原因:

  1. 下游服务不需要JWT验证,减少CPU消耗
  2. 减少重复的密钥加载和签名验证
  3. Gateway的异步非阻塞模型

生产环境建议

如果要上生产,还要注意这些:

  1. 服务注册与发现

现在路由写的是固定地址:

uri: http://localhost:8080

生产环境应该用Nacos或Eureka:

uri: lb://auth-center

lb表示负载均衡,会自动从注册中心获取服务实例。

  1. 限流熔断

加上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();
}
  1. 统一异常处理

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权限模型,实现资源级别的访问控制。如果对这个系列感兴趣,建议关注我。

评论区见。