ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

网关统一登录校验:GlobalFilter与GatewayFilter实战解析

2026/9/26 20:46:05 拓冰建站 浏览量
网关统一登录校验:GlobalFilter与GatewayFilter实战解析 微服务这套东西谈了这么多年落地已经不是选不选的问题而是怎么拆分、怎么治理的问题。网关作为所有外部流量的统一入口在微服务架构图里一直占据最显眼的位置但真正能把它用好的团队其实不多。这个系列写到这里后台问得最集中的就是“登录校验到底放在哪一层”。早期不少项目会把JWT解析逻辑复制到每一个业务服务里改一次密钥要全量发版遇到新服务忘了加校验就裸奔这种玩法在中后期基本是给自己埋雷。今天这篇围绕Spring Cloud Gateway来聊重点做两件事第一把网关上做登录校验的完整思路和代码拆开讲清楚核心是自定义GlobalFilter加JWT校验第二把GlobalFilter和GatewayFilter这两个看起来差不多的过滤器概念一次性理清。很多人会把后者搜成“GatawayFilter”其实这是GatewayFilter的笔误两个东西在网关里的职责完全不同。我会从工程实践角度说明为什么选择这样的方案也会把我实际踩过的坑一并列出来。1. 为什么登录校验要落在网关这一层1.1 网关在微服务架构里的真实位置先回顾一下基础架构。在微服务模式里客户端不直接访问各个业务服务而是先到网关。Spring Cloud Gateway基于WebFlux底层是Netty响应式模型相比传统的同步Servlet方案它能用更少的线程支撑更大的并发。网关做的事很多路由转发、负载均衡、限流、跨域处理、灰度发布等等但最核心的能力是“审片”。把网关想象成小区的门禁。所有访客进入单元楼之前都要在门口登记登记完成后在手腕上贴一个标签。如果每一层住户自己再设置一道检查也不是不行但你会在每一层看到同一套登记设备重复建设维护困难。登录校验放在网关层本质上就是这个门禁进门时验一次身份验完把用户身份信息通过请求头传给后面的服务后面的服务信这个结果即可。从工程管理角度看统一入口的好处是逻辑收口。如果每个微服务都各自接一套Spring Security、各自解析Token、各自维护密钥配置那么校验逻辑会散落在十几个服务里。一旦算法需要升级、密钥需要轮换要同步修改几十个地方漏一个就是一个隐患。把校验收口到网关就能保证所有外部请求必然经过同一道检查。1.2 网关校验不等于业务鉴权这里要澄清一个容易误会的点网关做登录校验只解决“你是谁”的问题不解决“你能干什么”的问题。前者叫身份认证后者叫权限授权。比如用户登录后拿到一个JWT网关校验Token合法就放行但这不代表他有权限调用某个管理接口。具体到角色、菜单、按钮级别的控制还是应该在业务服务内部或者单独的权限中心处理。有人会问那能不能把权限判断也写进GlobalFilter技术上可以但我不建议。网关层如果放满业务路径和角色映射表路由配置会变成一个没人敢动的巨型垃圾场。你和前端联调时改一个路径都要跑到网关过滤器的白名单里加一行这种耦合极其痛苦。合理分工是网关做身份认证和基础校验比如Token有没有、过期没有、签名对不对业务服务做角色鉴权。1.3 什么时候不能完全依赖网关校验网关校验有一个天然的盲区服务之间的内部调用。如果服务A调用服务B时走的是注册中心直连或者走内网地址流量根本不经过网关那网关上的这套校验就不会执行。很多生产事故就是这么来的某个核心接口被外部服务通过内网直接调用绕过了鉴权。所以我对团队的硬性建议是网关校验解决外部流量入口的安全问题但对内调用场景要在服务端保留轻量级的身份校验机制。比如服务间调用通过Feign拦截器统一携带内部Token或者在业务服务里对敏感接口二次校验。如果你把全部安全希望押在网关一层迟早会翻车。定时任务、MQ消费者这类没有用户上下文的场景也同样不要试图走网关校验。2. 搞懂GlobalFilter与GatewayFilter先分清再选型2.1 请求是怎么穿过过滤器的在Spring Cloud Gateway里一条请求的处理过程可以简化成三步请求到达网关被过滤器链处理一遍然后按路由规则转发到下游服务下游返回后响应还会再经过过滤器链返回客户端。很多初学的人以为“过滤器”是配置在路由里的那一堆Filter其实网关里有两种过滤器在起作用。要理解这一点得先知道Spring Cloud Gateway的底层是HandlerMapping和WebHandler机制。其中FilteringWebHandler负责组装过滤器链它会收集当前路由匹配到的所有GatewayFilter以及全局生效的GlobalFilter把它们合并后按照Order值排序再逐个执行。GlobalFilter不写在具体的路由配置里自动对所有请求生效。GatewayFilter则绑定在指定路由上只有请求匹配到该路由才会执行。调用的顺序也很关键Order值越小执行优先级越高。比如你写了一个Order为-100的过滤器它会在Order为0的过滤器前面执行。这里的优先级影响的是谁先看到请求、谁先能修改请求头所以登录校验这种逻辑一定要尽早执行否则后面的过滤器可能已经拿着未校验的请求干活了。2.2 GlobalFilter是什么GlobalFilter是全局过滤器接口定义在org.springframework.cloud.gateway.filter包下。它只有一个filter方法参数是ServerWebExchange和GatewayFilterChain。你需要实现GlobalFilter接口通常还要实现Ordered接口来指定优先级。全局过滤器最常见的应用场景就是登录校验除此之外还有全局限流、请求日志记录、灰度标记注入、统一响应头处理等。因为这些逻辑与具体的业务路由无关所有请求都必须经过所以做成GlobalFilter最合适。在代码上GlobalFilter的关键就是调不调用chain.filter(exchange)。你可以在调用之前拦截请求直接返回这就实现了“不放行”也可以先修改exchange里的请求头再调用chain.filter这就是“先处理后放行”。逻辑不算复杂难的是对响应式编程的理解。ServerWebExchange是WebFlux里的请求上下文对象修改它之后要重新构建一个exchange再传下去不能直接在原对象上改头。2.3 GatewayFilter是什么以及那个“GatawayFilter”先纠正一个常见拼写你搜“GatawayFilter”其实正确拼写是GatewayFilter。网上不少教程笔误直接这么写导致很多人以为它是GlobalFilter的变体实际上两个类在Spring Cloud Gateway里是并行的概念。GatewayFilter是局部过滤器接口。它绑定到具体的路由上通过路由的filters配置段生效。你可以用系统内置的GatewayFilter也可以自定义。内置的GatewayFilter很丰富比如AddRequestHeader是给请求加请求头StripPrefix是去除路径前缀Retry是做重试RequestRateLimiter是做局部限流。Spring Cloud Gateway会为每个内置GatewayFilter生成对应的FilterFactory配置在yml里使用。我们看一个路由配置示例就清楚了spring: cloud: gateway: routes: - id: order-service uri: lb://order-service predicates: - Path/api/order/** filters: - StripPrefix1 - AddRequestHeaderX-Source, gateway这段配置里的StripPrefix1和AddRequestHeaderxx就是GatewayFilter。它们只对这个order-service路由生效其他路由不受影响。2.4 关键区别与选型建议为了让差异更直观我整理了一个对比表格对比项GlobalFilterGatewayFilter作用范围全局所有请求都会经过仅限绑定它的路由是否需要Ordered通常需要控制全局顺序也可实现Ordered但只在该链里排序比较靠前配置方式代码注册为Spring Bean既可以在工厂里定义也可以写在yml的filters段典型场景登录校验、全局限流、日志、跨域单个路由加签名、加请求头、重试、改写路径与路由的关系不关心路由只关心请求必须依赖路由匹配触发选型建议就一句话凡是“所有请求都必须做”的横切逻辑用GlobalFilter凡是“只有某个路由或某组路由才需要做”的特殊逻辑用GatewayFilter。登录校验属于前者所以你会在下文看到核心实现放在GlobalFilter里。如果你试图把登录校验做成GatewayFilter并配置到每个路由上也不是不行但维护成本会很高新增一个路由时很容易忘记配置。3. 改造前先搭好网关基础环境3.1 网关模块的依赖怎么加我下面的示例以Spring Boot 2.7 Spring Cloud 2021.0.x为例这套组合在现网存量项目里用得比较多。如果你用Spring Boot 3核心API基本一致主要是javax到jakarta命名空间的调整。先看Maven依赖dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependency !-- JWT工具基于jjwt 0.11.5 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency重点说一个坑网关模块绝对不要引入spring-boot-starter-web。Spring Cloud Gateway基于WebFlux底层要求响应式栈如果你同时引入了Spring MVC启动时通常直接报“Spring MVC found on classpath”之类的冲突或者运行时出现路由全部失效的诡异问题。网关工程保持纯粹业务服务才引入web starter。如果你需要服务注册发现还要加上注册中心依赖。我这里用Consul或者Nacos都可以网关只负责拉取服务列表配置如下spring: cloud: consul: host: 127.0.0.1 port: 8500 discovery: register: false3.2 最基础的路由配置先构造一个能跑通的项目再往里加过滤器。参考配置server: port: 8080 spring: application: name: gateway-server cloud: gateway: discovery: locator: enabled: true lower-case-service-id: true routes: - id: auth-service uri: lb://auth-service predicates: - Path/api/auth/** filters: - StripPrefix1 - id: user-service uri: lb://user-service predicates: - Path/api/user/** filters: - StripPrefix1这里解释两个容易被忽略的细节。第一uri写成lb://auth-service意思是通过负载均衡器去注册中心找名为auth-service的实例如果直接写http://localhost:8081就绕过了注册中心服务实例一变地址就得改配置。第二StripPrefix1表示转发时去掉第一段路径。比如客户端请求/api/user/getUserInfo网关会转发到user-service的/user/getUserInfo。StripPrefix的去前缀数值取决于你的服务内部Controller路径前缀怎么定义。3.3 环境验证的快速方法配置完后先用一个最简单的Controller验证路由通不通再开发过滤器。如果网关转发过去返回404优先检查服务名匹配、路径StripPrefix是否正确。打开网关的调试日志很有帮助logging: level: org.springframework.cloud.gateway: TRACE org.springframework.http.server.reactive: DEBUGTRACE级别日志会打印每个请求匹配了哪些路由、命中了哪些过滤器、转发到哪个URI。排查路由问题时这个日志比任何断点都直观。后面我们写GlobalFilter时也会频繁用到这个调试开关。4. 核心代码自定义GlobalFilter做统一登录校验4.1 登录校验整体设计到了本篇最核心的部分。我采用的设计路径是网关层持有一个JWT校验GlobalFilter配置一批不校验的白名单路径其余请求必须先通过Token校验再向后转发。整体流程可以概括为客户端带上Authorization请求头访问网关网关过滤器先判断当前路径是否在白名单是就放行不是就从请求头里取出Bearer Token用密钥解析。解析失败返回401解析成功就把用户ID、用户名等信息放进请求头然后继续执行过滤器链由路由转发到下游业务服务。为什么选JWT因为它无状态服务端不用存Session网关校验时只要密钥能验签就行天然适合分布式场景。但JWT也有一个老问题用户退出后Token在有效期内依然可用。这种场景可以引入Redis黑名单机制用户退出时把Token的jti加入黑名单并设置剩余有效期的TTL网关校验时先去Redis查一下。这个属于进阶优化我放在后面的扩展里讲。4.2 白名单配置与属性类白名单不应该硬编码写在过滤器里建议做成配置文件。定义一个属性类Component ConfigurationProperties(prefix auth) Data public class AuthProperties { private ListString whitelist new ArrayList(); }yml里的配置auth: whitelist: - /api/auth/login - /api/auth/refresh - /actuator/health这里要注意匹配方式。上面的配置是目前最简单的contains或equals精确匹配适合固定路径。如果像/api/auth/**这种多级路径我建议用AntPathMatcher或者PathPatternParser去做规则匹配否则直接contains会误伤。比如你配了/api/auth那么/api/auth/deleteUser也会被放行这显然不是你的意图。4.3 自定义GlobalFilter实现JWT校验接下来是核心代码Component public class AuthGlobalFilter implements GlobalFilter, Ordered { Autowired private JwtUtil jwtUtil; Autowired private AuthProperties authProperties; private final AntPathMatcher pathMatcher new AntPathMatcher(); Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path exchange.getRequest().getURI().getPath(); // 白名单直接放行 if (isWhitelist(path)) { return chain.filter(exchange); } // 1. 获取token String token exchange.getRequest().getHeaders().getFirst(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); } // 2. 解析JWT Claims claims; try { claims jwtUtil.parseToken(token); } catch (ExpiredJwtException e) { return unauthorized(exchange, 登录已过期); } catch (JwtException | IllegalArgumentException e) { return unauthorized(exchange, 无效Token); } if (claims null) { return unauthorized(exchange, 未登录); } // 3. 把用户信息放入请求头继续转发 String userId String.valueOf(claims.get(userId)); String username String.valueOf(claims.get(username)); ServerHttpRequest mutatedRequest exchange.getRequest().mutate() .header(X-User-Id, userId) .header(X-Username, username) .build(); ServerWebExchange mutatedExchange exchange.mutate() .request(mutatedRequest) .build(); return chain.filter(mutatedExchange); } private boolean isWhitelist(String path) { return authProperties.getWhitelist().stream() .anyMatch(pattern - pathMatcher.match(pattern, path)); } private MonoVoid unauthorized(ServerWebExchange exchange, String msg) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); byte[] bytes ({\code\:401,\msg\:\ msg \}).getBytes(StandardCharsets.UTF_8); DataBuffer buffer exchange.getResponse().bufferFactory().wrap(bytes); return exchange.getResponse().writeWith(Mono.just(buffer)); } Override public int getOrder() { return -100; } }这段代码有几个细节要重点解释。第一AntPathMatcher来自spring-core可以直接在网关模块使用比手写字符串startsWith判断靠谱得多。第二把请求头注入到原始request后必须通过mutate()重新构造ServerWebExchange再传给chain.filter直接在原exchange上改header是不会生效的。第三返回401时没有调用chain.filter请求链就断在这里不会继续往下游转发。4.4 JwtUtil的简洁实现JwtUtil网上版本很多我提供一个基于jjwt 0.11.5且能直接跑通的版本Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private Long expire; public String createToken(String userId, String username, ListString roles) { SecretKey key Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); return Jwts.builder() .setSubject(userId) .claim(userId, userId) .claim(username, username) .claim(roles, roles) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expire)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { if (token null || token.isEmpty()) { return null; } SecretKey key Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }对应ymljwt: secret: need-a-secret-key-of-at-least-32-bytes-for-hs256-algorithm expire: 7200000强调一个容易踩的坑HS256算法的密钥长度至少要256位32字节。很多人用一个短字符串比如abc123当密钥运行时会抛异常The specified key byte array is 5 bytes which is not secure enough for any JWT HS256 algorithm。切记配置里的secret要足够长上线后也可以通过环境变量注入而不是写死在仓库里。4.5 过滤器的Order为什么是-100我习惯把登录校验过滤器Order设成-100核心是保证它早于大多数路由级过滤器执行。网关里可能会有日志过滤器、灰度过滤器、跨域过滤器如果你不显式设置Order默认都是0那执行顺序就可能变得不可控。但也不是越小越好。如果硬要设成Integer.MIN_VALUE可能抢在框架自带的一些核心过滤器之前执行比如NettyRoutingFilter转发前需要准备的环境还没做好反而会产生意外问题。我在项目里一般用Ordered.HIGHEST_PRECEDENCE加一个偏移量比如官方推荐的-100这个级别既靠前又给后续过滤器留出余地。5. 局部过滤器GatewayFilter的正确用法5.1 什么时候该用GatewayFilter全局登录校验讲清楚了接下来补全GateayFilter这半边。局部过滤器适合做与特定路由绑定的横切操作。举几个实际例子某个服务需要校验请求签名而不是用户的登录状态某个老服务需要兼容旧版的Header格式某个路由要求统一的响应头。这些逻辑不适合全局执行做成GatewayFilter按需绑定最合理。之前我遇到过一个需求部分老系统对接时需要在请求头里额外传递时间戳后续服务用这个时间戳做幂等判断。这个逻辑只在特定网关路由需要其他路由不该有所以我在网关里写了一个带配置的GatewayFilterFactory。5.2 自定义GatewayFilterFactory示例通过继承AbstractGatewayFilterFactory可以让局部过滤器参数化配置Component public class AddTimestampGatewayFilterFactory extends AbstractGatewayFilterFactoryAddTimestampGatewayFilterFactory.Config { public AddTimestampGatewayFilterFactory() { super(Config.class); } Override public GatewayFilter apply(Config config) { return (exchange, chain) - { String timestamp String.valueOf(System.currentTimeMillis()); ServerHttpRequest request exchange.getRequest().mutate() .header(config.headerName, timestamp) .build(); return chain.filter(exchange.mutate().request(request).build()); }; } Data public static class Config { private String headerName X-Timestamp; } }然后yml里只用一行配置即可激活filters: - AddTimestampheaderNameX-Request-Time这种工厂化配置的好处是过滤器的参数可以灵活调整。如果你只是写一个实现GatewayFilter接口的类不配合工厂那参数就只能写死在代码里换一个路由复用时就只能再复制一个新类。企业项目里我建议走工厂模式只是做demo的话直接实现GatewayFilter也够用看你的使用场景。5.3 与GlobalFilter搭配的组合拳一个常见的架构方案是GlobalFilter做基础身份识别GatewayFilter做局部业务校验。比如某个支付回调接口不仅要求登录用户还要求请求来源IP必须来自支付平台白名单、请求体签名必须正确。这些特殊校验放在支付路由的GatewayFilter里。这样做的好处是代码职责清晰。登录校验是通用的放在全局统一处理支付接口的验签是专属逻辑只在支付路由生效。如果你把所有校验都堆进GlobalFilter里分支判断会越来越多最后像一个打满补丁的大杂烩后续维护的人看到就头疼。所以我的建议是优先用GlobalFilter做通用逻辑遇到个性化的路由器需求再沉淀为GatewayFilter。6. 网关注册登录校验常见问题与排查实录6.1 过滤器返回的JSON为什么不是预期格式很多人在GlobalFilter里直接返回一个ResponseEntity或者在Controller里加ControllerAdvice统一处理结果发现在网关里完全不生效。原因是网关是WebFlux应用Spring MVC那套异常处理器和响应封装在这里不是同一套机制。最简单实用的做法就是像我上面那样在过滤器中直接操作ServerWebExchange的Response对象设置状态码、ContentType用DataBuffer写出JSON。如果你要在全网关统一错误响应格式还可以实现ErrorWebExceptionHandler替换默认错误处理但那个属于骨架级改造不建议一上来就做。先把过滤器里的直接返回写对已经能解决80%的问题。6.2 登录过滤器把OPTIONS预检请求拦了前端跨域时浏览器会先发一个OPTIONS预检请求。这个预检请求不带Authorization头如果你在GlobalFilter里无差别校验Token预检请求就会被拦截返回401导致前端真正业务请求根本发不出去。我在本地联调时被这个问题卡了整整一下午排查到最后才发现是过滤器的问题。解决办法很简单在过滤器里判断一下请求方法OPTIONS直接放行或者让它走白名单逻辑。顺便提醒一句网关的跨域配置应该使用CorsWebFilter或者spring.cloud.gateway.globalcors的配置不要只在业务Controller上加CrossOrigin因为经过网关代理后不少场景下跨域配置不会生效。6.3 网关模块千万别乱引Spring Security有一种很常见的启动灾难为了让网关做安全校验引入了spring-boot-starter-security结果网关一启动所有请求都被拦截跳转登录页或者匿名请求直接401。这是因为Spring Security的默认过滤器链接管了所有请求和你的GlobalFilter一起叠加执行相当于一个小区门口有两套门禁流程完全失控。如果你只是想在网关做JWT校验不要引入Spring Security相关依赖。用GlobalFilter就能实现登录校验。如果你确实需要OAuth2、SSO那套能力建议单独研究TokenRelay和Spring Security的整合方案而不是简单加一个starter就想当然能用。我这里不展开避免把基础登录校验玩复杂了。6.4 怎么确认过滤器的执行顺序排查过滤器顺序问题时除了开TRACE日志我一般会在过滤器链的关键节点加临时日志。比如在GlobalFilter过滤方法入口打印请求路径和当前时间在出口打印“pass auth”。这样你能直观看到哪些过滤器先跑了哪些后跑了。下一步再核对每个过滤器的Order值基本就能定位问题。另一个经验同一个请求如果被多个过滤器修改了请求头后执行的过滤器会覆盖前面过滤器的结果。假设日志过滤器在GlobalFilter之后执行它把X-User-Id覆盖成空字符串下游服务拿到的用户信息就是空的。这种问题日志里不一定有报错但数据会出问题所以要养成习惯涉及修改请求头的过滤器Order值一定要谨慎设置。6.5 扩展思路Token黑名单与全局限流JWT登录校验上线后用户反馈最多的一个问题是“我退出登录了但Token还能用一段时间”。因为JWT本身无状态服务端不保存签发记录网关只看签名和过期时间自然无法感知用户已主动退出。解决办法是把Token的jti唯一标识在退出时写入Redis黑名单TTL设置为Token剩余有效期。GlobalFilter在校验JWT通过后再去查一下这个Token是否在黑名单里如果命中就返回401。此外GlobalFilter也能挂全局限流逻辑。我见过一个轻量方案从请求里拿客户端IP配合Redis INCR统计固定时间窗口的请求次数超过阈值直接拦截。全局限流适合放在全局过滤器因为它的触发范围就是所有接口不需要针对单路由配置。这类逻辑不要和登录校验揉在一起最好按过滤器职责拆分成多个GlobalFilter用Order控制先后关系。最后几句话如果你从头看到这里应该能感受到网关登录校验的核心不是代码多复杂而是职责边界要清晰。GlobalFilter承担统一的身份校验GatewayFilter承担特定路由的局部处理两者用Order排序在链路中各司其职。我自己在项目里踩过最大的坑就是把权限判断、限流、日志、校验全部写成一个大过滤器结果每次改动都提心吊胆。后来把所有横切能力拆成独立的GlobalFilter每个过滤器只干一件事出问题时看Order和日志就能快速定位。实操层面建议你在本机先用一个简单的user-service模拟下游Postman里配好Authorization头打开网关的TRACE日志一步步观察请求从白名单判断到Token解析再到请求头注入的全过程。这套链路跑通后再把Redis黑名单、局部GatewayFilter这些扩展逐步加进去比一口气写完整个生产版本要稳得多。