Spring Cloud Gateway登录校验实战:GlobalFilter与GatewayFilter解析

发布时间:2026/9/8 5:43:08
Spring Cloud Gateway登录校验实战:GlobalFilter与GatewayFilter解析 做SpringCloud网关开发的时候很多朋友一开始都会卡在同一个问题上路由配好了服务也能转了但登录校验到底应该写在哪里写在每个微服务里代码重复得让人崩溃写在网关里又不太清楚GlobalFilter和GatewayFilter到底怎么用、有什么区别、执行顺序怎么控制。这篇就是专门来解决这个事的。我会从网关层登录校验的整体设计思路讲起把GlobalFilter自定义全局过滤器、GatewayFilter局部过滤器的写法、适用场景、源码层面的执行顺序全部拆开讲清楚最后还会带上服务间用户身份传递的方案和一份真实踩坑排查清单。内容偏实战适合已经能用SpringCloud搭起基础微服务、但还没搞清楚网关层权限校验怎么落地的同学。1. 前置认知先把Gateway的过滤器体系弄清楚1.1 为什么登录校验必须放在网关层单体应用时代登录校验一般写在拦截器或者AOP里集中在同一个进程里问题不大。但是拆成微服务之后如果每个服务都自己维护一套登录校验逻辑就会出现三个非常现实的问题。第一个是代码重复。假设你有订单服务、用户服务、库存服务三个服务每个服务都写一遍token解析、用户信息获取、权限判断看起来工作量不大但一旦校验逻辑要调整比如要加一个黑名单判断你就得改三个地方漏改一个就是线上事故。第二个是校验标准不统一。不同服务由不同团队维护A服务用JWTB服务用sessionC服务干脆不校验用户一次请求经过多个服务时体验和安全性完全是混乱的。第三个也是最容易被忽视的就是绕过风险。如果把校验逻辑放在业务服务内部网关只做路由转发那么只要有人绕开网关直接访问业务服务的地址整个校验体系就被架空了。所以网关层做登录校验本质上就是把“是否放行”这个决策点收敛到一个统一入口上。所有请求先经过网关网关验明身份后再转发给下游服务下游服务可以信任网关传来的用户身份信息。这样既避免了重复建设也保证了校验策略的统一可控。1.2 GlobalFilter 和 GatewayFilter 到底差在哪Spring Cloud Gateway里有两类过滤器光看名字非常容易搞混GlobalFilter是全局过滤器GatewayFilter是局部过滤器。很多刚接触的朋友会以为GlobalFilter就是全局生效、GatewayFilter就是需要手动配置的这个理解方向是对的但执行细节上有不少坑。先看一张最核心的对比维度GlobalFilterGatewayFilter作用范围所有路由生效只对配置了该过滤器的路由生效定义方式实现GlobalFilter接口实现GatewayFilter接口或使用内置工厂注册方式标注Component即可在yml中对指定route配置或注册为Bean执行顺序通过Ordered接口/Order注解控制配置的顺序决定执行顺序典型场景全局登录校验、日志、限流特定接口的签名校验、特定路由的请求改写这里有一个特别容易踩的坑如果你把自定义的GatewayFilter实现类直接标注了Component那么它实际上会被Spring容器加载作用范围会变成全局而不是你心里想的那种“只对某个路由生效”。我之前在项目里就见过同事这么干结果过滤器在不需要它的路由上也执行了排查了半天才发现是组件扫描的问题。另一个关键点是执行顺序。Gateway的过滤器链顺序是GlobalFilter按Order从小到大执行和GatewayFilter按配置顺序执行会按一定规则合并排序。你在多个GlobalFilter里如果Order值设得一样最终的先后顺序就不确定这种问题通常只有在压测或者并发高的时候才会随机暴露非常隐蔽。所以写GlobalFilter的时候Order值一定要有规划建议从0开始每10个一档留出扩展空间。2. 工程落地第一步搭建一个可用的网关服务2.1 依赖引入和版本选择网关服务本身也是一个SpringBoot应用但它跟普通的Web服务有一个非常重要的区别网关基于Spring WebFlux是响应式编程模型不能使用spring-boot-starter-web否则启动会直接冲突报错。我目前比较推荐的一套稳定组合是Spring Boot 2.7.xSpring Cloud 2021.0.x也就是JubileeSpring Cloud Alibaba 2021.0.5.0Nacos作为注册中心和配置中心如果你用的是Spring Cloud Alibaba 2023.x配合Spring Boot 3.x那属于新版本路线的玩法底层是Jakarta EE和Spring Framework 6写法上部分API有变化但过滤器的核心思想是一致的。这里我以较为稳定的Boot 2.7 Cloud 2021.0.x为例这套在绝大多数国内公司项目里都能直接跑起来。maven核心依赖如下dependencies !-- 网关核心 -- dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependency !-- 注册中心 -- dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-nacos-discovery/artifactId /dependency !-- JWT解析工具 -- 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 /dependencies这里我再强调一次spring-cloud-starter-gateway已经内置了WebFlux所以千万不要再加spring-boot-starter-web也不要加spring-boot-starter-webflux否则启动时会报Spring MVC found on classpath, which is incompatible with Spring Cloud Gateway这个错误。2.2 基础路由配置与前置验证依赖引入之后先把最基础的路由配置写好。这里要明确一个认知网关启动后所有请求都先到网关网关根据路由规则把请求转发到nacos上注册的服务。所以核心配置就是routes列表。如下图所示的意思我直接给一份可以直接用的配置server: port: 9000 spring: application: name: gateway-service cloud: nacos: discovery: server-addr: localhost:8848 gateway: routes: - id: user-service uri: lb://user-service predicates: - Path/api/user/** - id: order-service uri: lb://order-service predicates: - Path/api/order/** filters: - StripPrefix1 - id: auth-service uri: lb://auth-service predicates: - Path/api/auth/** filters: - StripPrefix1 discovery: locator: enabled: true lower-case-service-id: true这里有一个细节值得展开说一下。uri用的是lb://user-service这种写法lb代表load balance也就是通过负载均衡从注册中心找到user-service实例后面的user-service必须跟服务在Nacos上注册的名字完全一致。很多同学路由配好之后访问报503十有八九就是服务名对不上或者顺序问题导致Nacos还没拉取到实例列表。StripPrefix这个过滤器的作用是去掉请求路径中的前缀段。比如你配置了Path/api/user/**但user-service内部接口是/user/list如果不加StripPrefix1请求会被转发为/api/user/list下游就404了。加了StripPrefix1之后网关会把/api/user/这一段去掉转发给下游的就是/user/list。具体的数字代表去掉几段。配置完成后启动网关和注册中心先用Postman直接访问一下http://localhost:9000/api/user/list能通说明路由基础没问题然后再进下一步做过滤器开发。3. 核心实战自定义GlobalFilter实现登录校验3.1 整体拦截逻辑设计登录校验过滤器要解决的核心问题有三个哪些接口不需要校验、如何校验token、校验失败时如何返回统一响应。这三个问题在设计阶段想清楚代码写起来就很快了。先说白名单。一个系统里总有不需要登录就能访问的接口比如登录接口本身、注册接口、验证码接口有的系统还有健康检查接口。这些路径如果也走token校验会形成一个逻辑死锁你连登录都要token那token从哪来所以白名单必须放在过滤器最前面判断匹配上就直接放行。再说token来源。客户端一般在请求头里放Authorization: Bearer 网关就从这个Header里取token。也有放到自定义Header的比如X-Token看团队约定我这里以Authorization为例。然后是校验方式。有两种主流思路一种是网关只负责解析JWT拿到载荷中的用户信息就认为是可信的然后透传给下游另一种是网关拿着token去调用认证服务或查Redis确认token还没有被注销、没有过期再放行。前者的缺点是JWT一旦签发在有效期内很难主动失效用户修改密码后旧token依然能用后者的好处是可控性强但每次请求都会多一次Redis或RPC调用。我个人的建议是两者结合JWT负责无状态校验签名和过期时间Redis负责存储token的黑名单或令牌版本号从而实现主动失效。下面我会重点讲这种方案的代码实现。3.2 过滤器完整代码与逐段解析下面直接上完整代码这是一个实现了GlobalFilter和Ordered接口的登录校验过滤器Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private static final ListString WHITE_LIST Arrays.asList( /api/auth/login, /api/auth/register, /api/auth/captcha ); private static final AntPathMatcher PATH_MATCHER new AntPathMatcher(); Autowired private StringRedisTemplate stringRedisTemplate; Autowired private JwtUtil jwtUtil; Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 1. 获取请求路径 String path exchange.getRequest().getURI().getPath(); // 2. 白名单直接放行 if (isWhiteListed(path)) { return chain.filter(exchange); } // 3. 获取token String token getToken(exchange.getRequest()); if (StringUtils.isBlank(token)) { return unauthorizedResponse(exchange, 未登录请先登录); } // 4. 解析并校验token Claims claims; try { claims jwtUtil.parseToken(token); } catch (Exception e) { return unauthorizedResponse(exchange, token无效或已过期); } // 5. 校验Redis中token是否存在实现主动失效 String redisKey login:token: claims.get(userId); String redisToken stringRedisTemplate.opsForValue().get(redisKey); if (StringUtils.isBlank(redisToken) || !redisToken.equals(token)) { return unauthorizedResponse(exchange, 登录状态已失效请重新登录); } // 6. 将用户信息放入请求头透传给下游 ServerHttpRequest mutatedRequest exchange.getRequest().mutate() .header(X-User-Id, String.valueOf(claims.get(userId))) .header(X-Username, claims.get(username) null ? : claims.get(username).toString()) .header(X-Token, token) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } private boolean isWhiteListed(String path) { for (String pattern : WHITE_LIST) { if (PATH_MATCHER.match(pattern, path)) { return true; } } return false; } private String getToken(ServerHttpRequest request) { String bearer request.getHeaders().getFirst(Authorization); if (StringUtils.isNotBlank(bearer) bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } private MonoVoid unauthorizedResponse(ServerWebExchange exchange, String message) { ServerHttpResponse response exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().setContentType(MediaType.APPLICATION_JSON); MapString, Object result new HashMap(); result.put(code, 401); result.put(message, message); result.put(success, false); byte[] bytes JSON.toJSONString(result).getBytes(StandardCharsets.UTF_8); DataBuffer buffer response.bufferFactory().wrap(bytes); return response.writeWith(Mono.just(buffer)); } Override public int getOrder() { return -100; } }代码本身不长但有五个细节需要特别说明。第一个是getOrder的返回值设置为-100。在Spring Cloud Gateway的过滤器链里Order值越小执行越早。登录校验属于前置拦截类逻辑必须放在其他过滤器之前所以我设为负数。如果你后面还要加白名单过滤器、日志过滤器注意把日志过滤器的Order设为-200或者0以上否则执行顺序会乱。第二个是白名单匹配用了AntPathMatcher。不要自己用String.startWith匹配因为一旦路径规则复杂起来这种写法既不可维护也容易出错。AntPathMatcher支持通配符匹配规则清晰。Spring新版里推荐用PathPatternParser但在网关过滤器中AntPathMatcher已经够用而且用法稳定。第三个是响应返回。因为是WebFlux环境不能直接response.getWriter()去写必须用response.bufferFactory().wrap(bytes)创建DataBuffer然后用response.writeWith(Mono.just(buffer))写回。新手最容易在这步卡住报空指针或者响应永远发不出去。第四个是Redis校验。我用login:token:userId作为keyvalue存JWT本身。登录成功时写入登出时删除修改密码时重新生成token并覆盖。这样每次请求都能精确判断当前token是否还是有效的那一个。第五个非常重要就是放行前给下游添加了X-User-Id、X-Username这些Header。这步的意义我在后面专门讲服务间身份传递的时候会重点展开这里先有个印象。3.3 JWT解析工具类的选择与实现JWT解析工具类看起来不起眼但选型不对会引入一堆依赖冲突。Java生态里主流的JWT库有jjwt、java-jwtauth0、nimbus-jose-jwt。我实际项目里最常用的是jjwt它API设计比较简洁文档也全。一个可用的JwtUtil实现如下Component public class JwtUtil { Value(${jwt.secret}) private String secret; private SecretKey getSecretKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(Long userId, String username, long expireMillis) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(userId, userId) .claim(username, username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expireMillis)) .signWith(getSecretKey(), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSecretKey()) .build() .parseClaimsJws(token) .getBody(); } }这里有一个重要的细节jwt.secret这个配置值的长度必须至少32字节因为HS256算法要求密钥长度不得小于256位32字节。很多人开发阶段随便写一个abc123运行时报错签名密钥长度不够就是这个原因。建议在application.yml里配置一个至少32位的随机字符串。还有一个容易被忽略的问题JWT里不要放敏感信息。JWT默认只是Base64编码不是加密任何能拿到token的人都可以解码看到payload内容。所以用户手机号、身份证号这类敏感数据不要放进JWT的claim里只放userId、username这类非敏感标识就够了。4. GatewayFilter和GlobalFilter到底应该用哪个4.1 内置GatewayFilter工厂的正确使用姿势Spring Cloud Gateway自带了一大批内置的GatewayFilter工厂比如AddRequestHeader、AddRequestParameter、StripPrefix、RewritePath、RequestRateLimiter、Retry等。这些过滤器通过yml配置即可生效不需要写一行Java代码。举个例子给order-service路由统一加一个请求头spring: cloud: gateway: routes: - id: order-service uri: lb://order-service predicates: - Path/api/order/** filters: - StripPrefix1 - AddRequestHeaderX-Gateway-Source, gateway-service这样所有转发到order-service的请求都会自动带上X-Gateway-Source: gateway-service这个Header。内置过滤器最大的优势是开箱即用完全可以满足大部分简单路由改写需求。4.2 自定义GatewayFilter的两种实现方式当内置过滤器满足不了需求时就需要自己写GatewayFilter。这里有两种实现方式很多人会混用但必须明确区分。第一种是只实现GatewayFilter接口然后通过配置类或RouteLocatorBuilder在配置路由时手动指定。这种方式下过滤器只作用于你绑定的那条路由是真正的局部过滤器。第二种是继承AbstractGatewayFilterFactory。这是更常见的做法因为Spring Cloud Gateway的过滤器工厂机制就是围绕它设计的。你定义一个类继承AbstractGatewayFilterFactory再注册为Bean然后就能在yml里像内置过滤器一样使用。简单来看一段代码Component public class SignatureGatewayFilterFactory extends AbstractGatewayFilterFactorySignatureGatewayFilterFactory.Config { public SignatureGatewayFilterFactory() { super(Config.class); } Override public GatewayFilter apply(Config config) { return (exchange, chain) - { // 在这里写具体的过滤器逻辑 String signature exchange.getRequest().getHeaders().getFirst(X-Signature); if (signature null || !config.allowedSign.equals(signature)) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } return chain.filter(exchange); }; } public static class Config { private String allowedSign; // getter/setter省略 } }然后在yml里这样用filters: - name: Signature args: allowedSign: a1b2c3d4注意第二个坑来了如果你给这个类加了Component但又没有在yml中配置它那就不会生效这一点和GlobalFilter完全不同。如果你没有加Component然后在某个route里配置了它Spring会报找不到名为signature的过滤器工厂。所以自定义GatewayFilterFactory必须注册为Bean而是否作用于某条路由取决于你是否在yml中引用它。4.3 选型建议什么场景用什么过滤器结合我自己的项目经验给出一个比较实用的选型思路全局登录校验、全局日志、全局跨域处理用GlobalFilter。因为这类逻辑对所有路由几乎没有差别把它们做成全局统一更省心。某个路由特有的处理比如订单服务需要额外校验签名支付回调需要做特殊的数据改写用GatewayFilterFactory。这样既能复用Spring的配置机制又不会影响其他路由。两个过滤器之间如果有关联依赖比如先登录校验、取得userId后再做签名校验就要注意Order值的设置。GlobalFilter优先于GatewayFilter执行的情况需要看具体版本和Order值不要让局部过滤器假设全局过滤器一定已经执行了。5. 网关登录校验后的关键一步下游服务怎么识别用户5.1 通过请求头实现身份透传网关完成登录校验之后下游服务怎么知道当前请求是谁答案就是我们在GlobalFilter里做的事情把用户信息塞进请求头随请求一起转发给下游。具体做法是看3.2节代码里的这行ServerHttpRequest mutatedRequest exchange.getRequest().mutate() .header(X-User-Id, String.valueOf(claims.get(userId))) .header(X-Username, claims.get(username) null ? : claims.get(username).toString()) .build();这里用的是exchange.getRequest().mutate()方法它返回一个Builder通过.header()添加新的请求头然后重新构建请求对象。最后通过exchange.mutate().request(mutatedRequest).build()把新请求塞回exchange里继续执行过滤器链。这样下游服务在Controller里就可以直接通过RequestHeader(X-User-Id)拿到当前用户ID而不用自己再去解析token。这会节省大量的重复代码。5.2 下游服务切勿直接信任外部请求头这里有一个很多团队都会踩的安全问题如果下游服务直接信任请求头里的X-User-Id那么任何客户端发请求时手动加上X-User-Id: 1这个Header就能越权访问其他用户的数据。解决办法是在网关过滤器里先删除客户端传的原始用户相关Header再写入经过校验后的用户HeaderServerHttpRequest mutatedRequest exchange.getRequest().mutate() .headers(headers - { headers.remove(X-User-Id); headers.remove(X-Username); }) .header(X-User-Id, String.valueOf(claims.get(userId))) .header(X-Username, username) .build();这一步非常关键我在项目里就遇到过这个问题一开始没有清理原始Header用户把X-User-Id改成别人的ID就直接看到了别人的订单数据。后来加了清除逻辑之后这个问题才彻底解决。5.3 服务间调用如何传递用户身份还有一个经常会被忽略的场景网关把X-User-Id传给了order-serviceorder-service在业务逻辑里通过Feign调用user-serviceuser-service此时并不知道当前用户是谁。这个问题有三种常见解法。第一种是手动传递写一个Feign的RequestInterceptor从当前请求上下文里取出X-User-Id和X-Token再放到Feign请求头里带过去。这是目前最主流也最轻量的方案。Component public class FeignAuthRequestInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes ! null) { HttpServletRequest request attributes.getRequest(); template.header(X-User-Id, request.getHeader(X-User-Id)); template.header(X-Token, request.getHeader(X-Token)); } } }第二种是引入分布式链路追踪和用户上下文传递框架比如Spring Cloud Sleuth结合自定义Filter把用户信息通过ThreadLocal在进程内传递再由Feign拦截器传递到下游。这种方案更完善但复杂度也更高。第三种是改成不依赖用户身份也能完成调用。比如把用户ID作为方法参数显式传递而不是依赖隐式的请求上下文。这在DDD设计里是更推荐的做法但在老项目改造时成本较大。对大多数项目来说第一种Feign拦截器方案是最合适的出发点代码量少、逻辑直观、容易排查问题。6. 常见问题与排查技巧实录6.1 高频问题速查表现象可能原因解决方案启动报Spring MVC incompatible引入了spring-boot-starter-web从网关pom剔除web依赖401响应中文乱码没有显式设置Content-Type或编码设置APPLICATION_JSON_UTF8或使用StandardCharsets.UTF_8过滤器不生效GlobalFilter没加ComponentGatewayFilter没在yml中配置检查组件扫描路径或yml配置请求被转发后报404路由路径Predicate与StripPrefix配置不匹配核对stripPrefix段数和下游真实路径访问多个微服务只有部分通路由配置顺序不一致或服务名错误检查uri的lb服务名是否与注册中心一致token解析时报key长度不够HS256密钥不足32字节配置至少32位随机字符串过滤器执行了两次路由配置和discovery定位器都匹配到了同一服务关闭discovery.locator.enabled或精确配置路由请求头透传失败mutate()之后没有把新exchange传给chain确认使用exchange.mutate().request(...).build()6.2 排查思路与定位手段网关出问题时最忌讳上来就加日志瞎猜。我建议按照固定顺序排查先路由通不通再过滤器执行没执行最后看数据传的对不对。第一步先确认路由是否转发成功。最简单的方式是在网关服务里开启debug日志配置如下logging: level: org.springframework.cloud.gateway: debug开启后网关会在控制台打印每个请求匹配到的Route和执行的过滤器链信息非常直观。我自己排查时基本都会先开这个日志。第二步确认过滤器是否被Spring容器加载。可以直接在项目里打印过滤器的Bean名称或者给过滤器里加一行日志在filter()方法入口输出当前路径和Order值。如果这行日志都没有输出那基本可以断定过滤器没有生效。第三步确认下游收到的Header是否正确。可以通过内网测试工具或者临时在Controller里加一行打印GetMapping(/user/info) public Result userInfo(RequestHeader(X-User-Id) Long userId) { System.out.println(当前用户 userId); }如果下游拿到的userId为空或不对问题大概率出在网关的mutate逻辑上而不是下游。还有一个很实用的小技巧网关自身可以通过Actuator暴露路由信息方便实时刷新和观察。在依赖中加入spring-boot-starter-actuator之后在配置里开放gateway端点management: endpoints: web: exposure: include: gateway然后访问http://localhost:9000/actuator/gateway/routes就能看到当前所有路由规则以及它们绑定的过滤器配置。这个调试信息在排查路由问题时价值非常大。6.3 一个印象深刻的实战排查案例最后分享一个我实际项目里遇到过的典型案例。当时出现的问题是某个接口在部分环境下会偶发401而且没有任何规律。一开始我以为是token过期时间设置的问题后来发现一个规律这个接口在网关中配置了StripPrefix1而它本身路径是POST请求。排查后发现是因为两台网关实例的Order值不一致导致其中一台实例上登录校验过滤器先执行另一台实例上却是另外一个全局日志过滤器先执行。日志过滤器在并发高的时候需要获取请求体内容导致后执行的登录校验过滤器拿不到Header上下文从而误判为未登录。这个例子说明一个很重要的道理即使逻辑本身没有错只要多个过滤器之间的Order控制不一致生产环境就会随机出问题。后来我统一把登录校验过滤器的Order固定为-100并且要求所有全局过滤器必须显式声明Order值不允许依赖默认值这个问题才彻底消失。再补充一句网关过滤器里尽量不要读取和缓存请求体request body因为WebFlux里的body是流式的读了一次之后后续过滤器就拿不到了。如果确实需要读取body做加签或参数合法性校验建议把缓存后的body重新封装到exchange里再放行否则下游服务大概率拿到空body。我见过不少同学在上面栽跟头这里提前给大家排个雷。