微服务拆着拆着大家迟早会遇到同一个尴尬登录校验到底放哪单体的时代一个Session过滤器搞定一切拆成微服务之后用户服务管登录订单服务要验身份商品服务也要知道操作人是谁——总不能每个服务各写一套校验逻辑到时候密钥、规则、异常处理全都对不上维护起来就是一场灾难。Spring Cloud Gateway作为整个微服务体系的统一入口天然最适合干这件事。这篇教程我直接围绕网关登录校验展开重点带你写自定义过滤器——GlobalFilter和GatewayFilter从原理到落地代码一路捋清楚并把我在实际项目里踩过的坑一并交代了。这套内容适合谁看正在搭Spring Cloud微服务、手头有网关场景但还没做统一鉴权的同学或者已经在用Gateway但总觉得过滤器执行顺序、路径匹配这些细节不够通透的人。我会先把整体设计思路讲明白再逐个拆解过滤器的区别和登录校验的实现细节最后给出一套可以直接抄的代码以及几个典型的排查案例。1. 网关登录校验的整体设计思路1.1 为什么登录校验必须下沉到网关在做微服务拆分的时候很多人容易陷入一个误区把业务拆得很细却把通用逻辑留在各个服务里重复实现。登录校验就是典型的通用逻辑。想一想一张订单的创建接口需要判断用户是否登录商品的详情接口可能也需要暴露给登录用户看到不同的价格这些场景在每个服务里都存在。如果把校验逻辑放在每个微服务内部你会遇到三连问题第一代码重复每个服务都要引入JWT解析依赖、写一遍过滤器或拦截器第二标准不统一A服务校验失败返回401B服务返回403前端对接起来很痛苦第三扩展性差如果将来公司要求统一启用某种风控策略或者签名校验你得改多少个服务网关这层就完美规避了上述问题。请求进入系统的大门只有一个就是Gateway网关。在这个节点完成登录校验相当于给整个系统的后端服务装上了一道统一的安检门。用户请求到达网关时先判断这个路径需不需要登录如果需要就验证携带的令牌是否合法合法才放行到下游服务否则直接返回401。下游服务拿到的请求已经是经过了认证的请求各业务模块不必再关心“你是谁”只需要关心“你能干什么”。这里要额外说明一点网关做的是认证Authentication也就是确认身份。而授权Authorization也就是判断有没有权限执行某个操作这个建议放在业务层做。因为权限模型往往和具体业务强相关是用户角色判断还是资源归属判断网关这种通用组件很难写死。简单项目可以在网关做角色级的粗粒度控制但一旦权限逻辑复杂起来硬塞进网关只会把网关代码搞得又臭又长。1.2 过滤器家族GlobalFilter 与 GatewayFilter 的分工Spring Cloud Gateway的过滤机制是它最灵活的地方。在网关里过滤器可以分为两大类GlobalFilter和GatewayFilter字面上都有“Filter”但职责很不一样。GlobalFilter是全局过滤器它对所有转发到网关的请求生效只要网关接收了请求就会进入全局过滤器的处理链。因为这种“无差别覆盖”的特性统一登录校验、全局日志追踪、请求耗时统计这些横切逻辑几乎都会用GlobalFilter来实现。你不需要在路由配置里挂任何额外的东西只要把GlobalFilter实现类注册成Spring Bean它就自动生效了。GatewayFilter则是针对特定路由生效的过滤器。它的粒度更细只对配置了该过滤器的路由起作用。比如你希望某个服务接口在转发前做参数签名校验但其他服务不需要那就很适合用GatewayFilter绑到那条路由上。另一个常见场景是灰度发布只对灰度版本的服务路由附加某些特殊处理逻辑。打个不太严谨但好记的比方GlobalFilter像小区门口的保安每个进小区的人他都要过目GatewayFilter像单元楼的门禁只有该单元的住户才需要交互。一个是全覆盖一个是按需触发。2. 自定义过滤器的核心细节与原理2.1 GlobalFilter 与 GatewayFilter 的区别详解很多初学者会把这两个过滤器的概念搞混这里我用一张对比表把关键差异列出来后续看代码的时候心里也更有谱。对比维度GlobalFilterGatewayFilter作用范围所有经过网关的路由仅绑定到特定路由生效方式实现接口并注册为Bean后自动生效需要在路由配置中显式指定配置位置Java代码中完成注册路由配置的filters段中声明典型场景登录校验、日志链路、限流特定服务的签名校验、灰度逻辑有没有Order有实现Ordered接口指定执行顺序有同一个路由内多个过滤器也有顺序两条腿走路的时候要特别注意执行顺序的问题。Spring Cloud Gateway的过滤器链是有顺序的Order值越小越先执行。内置了很多过滤器NettyRoutingFilter之类的默认Order都不小。自定义GlobalFilter的时候如果想让它尽早执行Order要设成负数比如-100。如果设成正数很可能请求已经经过路由匹配甚至开始转发链路了你拦截的意义就弱了。还有一个特别容易踩的点GlobalFilter和GatewayFilter虽然叫“过滤器”但它们的语法是基于Spring WebFlux的响应式编程模型方法签名返回的是MonoVoid而不是传统Servlet环境下的void。这意味着你不能在过滤器里直接做阻塞式操作比如用Thread.sleep、调用同步的JDBC接口这会把事件循环线程卡住。想查Redis或者访问数据库请务必用响应式客户端。2.2 登录校验的完整链路设计有了过滤器的理解现在来设计登录校验的完整链路。这套链路是目前微服务项目里最常见也最稳妥的做法第一步客户端发起请求带上前端保存的令牌一般放在Authorization请求头里格式是Bearer token。第二步请求到达网关GlobalFilter先取到请求路径拿这个路径和白名单做比对。白名单里的路径比如登录接口、注册接口、验证码接口、静态资源是允许匿名访问的直接放行到下游。白名单之外的路径必须要有合法令牌。第三步从Authorization头中取出令牌做三件事来验证真伪一是验签确认这个令牌确实是服务端签发的二是看有效期确认令牌没有过期三是按业务需求检查Redis中的会话状态比如用户是否被强制下线令牌是否在黑名单里。第四步验证通过后把令牌里携带的用户ID、用户角色等信息解析出来写入转发请求的Header中然后透传给下游微服务。这样下游服务根本不关心令牌是什么直接读X-User-Id请求头就能知道当前操作人。第五步验证失败或者令牌缺失直接返回401响应并附带一条明确的错误信息提示。前端拿到401就知道要跳登录页或者刷新令牌了谁还会去关心后端服务长什么样这套链路看得复杂其实核心就是“白名单放行 令牌校验 身份透传”。在代码里GlobalFilter一个类就能承载全部逻辑。2.3 JWT 令牌网关校验的信任基础登录校验绕不开令牌方案而JWT是目前网关鉴权场景里使用最广泛的选择。JWT全称是JSON Web Token它本质上是一串由三部分组成的字符串Header、Payload、Signature。Header里声明了令牌的算法类型Payload里存放业务声明的字段比如userId、userName、角色等Signature则是用密钥对前两部分进行签名的结果。JWT有个很实用的特性它是自包含的。网关拿到JWT不需要去用户服务里查数据库只要用服务端密钥验一下签名再检查一下Expiration时间就能确认令牌是否合法。这种设计在微服务的高频调用场景下非常友好验签的耗时和本地计算相比几乎可以忽略不像Session方案每一次校验都要求一次Redis或者DB查询。但自包含特性也带来了一个先天弱点JWT一旦签发在有效期内很难主动让它失效。RedisJWT双校验模式可以缓解这个问题。网关在验签通过之后再查一次Redis看看该用户的token是否还在有效会话集合里。如果用户修改密码、被管理员封禁或者主动退出登录服务端可以删除Redis中的会话记录网关就能立刻把这个令牌判死。安全性要求高的系统我强烈建议加上这一层。3. 实操过程与核心代码实现3.1 网关工程的基础环境准备演示前先把工程搭起来。创建一个Spring Boot项目Spring Boot版本用2.7.x或者3.x都可以注意Spring Cloud版本要与之对应。这里我以Spring Cloud 2021.0.x Spring Boot 2.7.x为例这部分组合在实际生产里用得最普遍资料也多。pom.xml里引入网关核心依赖不要引入spring-boot-starter-webSpring Cloud Gateway基于WebFlux如果同时引入Web MVC启动会直接冲突报错这是新手常踩的坑。dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis-reactive/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version /dependencyRedis响应式客户端是为了做令牌黑名单、会话状态校验用的如果你的项目没到那个规模也可以先不加纯JWT验签也能跑通。但既然要写完整的生产级方案我建议把Redis加上反正依赖不大。路由的基础配置写在application.yml里下面这个是示例把用户服务的路由挂上去同时声明两个白名单路径。server: port: 8080 spring: application: name: gateway-service cloud: gateway: routes: - id: user-service uri: lb://user-service predicates: - Path/api/user/** - id: order-service uri: lb://order-service predicates: - Path/api/order/** auth: white-list: - /api/auth/login - /api/auth/register jwt: secret: your-secret-key-please-change-in-production expire: 7200000lb://前缀表示要负载均衡到这个服务名这要求项目里已经引入了服务发现组件比如Nacos或Eureka并且微服务能正常注册进来。没有注册中心的话可以先用uri: http://localhost:8081指向具体地址做本地联调能把过滤器的逻辑跑起来再说。3.2 自定义 GlobalFilter 实现登录校验当前期准备做完之后最核心的工作就是编写GlobalFilter过滤器了。先实现一个JWT工具类负责令牌的生成和验证。实际项目中生成令牌通常在用户服务里网关这边只需要验签的方法但工具类内部把两者都写了也没关系。Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private Long expire; private SecretKey getSecretKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(MapString, Object claims) { Date now new Date(); Date expirationDate new Date(now.getTime() expire); return Jwts.builder() .setClaims(claims) .setIssuedAt(now) .setExpiration(expirationDate) .signWith(getSecretKey(), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSecretKey()) .build() .parseClaimsJws(token) .getBody(); } }注意这里的secret至少需要32位字节长度否则使用HS256算法时会报错因为该算法要求密钥长度不低于256位。很多人在本地测试图省事写个abc当密钥启动一调用就抛WeakKeyException或者类似签名相关的异常排查半天发现是密钥长度不够。接着写登录校验的核心过滤器。这个类实现GlobalFilter和Ordered两个接口核心逻辑按前文设计的链路来。Component public class AuthGlobalFilter implements GlobalFilter, Ordered { Autowired private JwtUtil jwtUtil; Autowired private ReactiveStringRedisTemplate redisTemplate; Value(#{${auth.white-list}.split(,)}) private ListString whiteList; Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path exchange.getRequest().getURI().getPath(); // 白名单路径直接放行 if (whiteList.stream().anyMatch(path::startsWith)) { return chain.filter(exchange); } // 获取请求头中的令牌 String authHeader exchange.getRequest().getHeaders().getFirst(Authorization); if (StringUtils.isBlank(authHeader) || !authHeader.startsWith(Bearer )) { return unauthorized(exchange, missing or invalid token); } String token authHeader.substring(7); Claims claims; try { claims jwtUtil.parseToken(token); } catch (Exception e) { return unauthorized(exchange, token parse failed); } String userId claims.get(userId, String.class); // 可选增强验证Redis中的会话状态 return redisTemplate.hasKey(login:token: userId).flatMap(exists - { if (!Boolean.TRUE.equals(exists)) { return unauthorized(exchange, token expired in redis); } // 把用户信息透传给下游服务 ServerWebExchange newExchange exchange.mutate() .request(builder - builder.header(X-User-Id, userId)) .build(); return chain.filter(newExchange); }); } private MonoVoid unauthorized(ServerWebExchange exchange, String message) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); DataBuffer buffer exchange.getResponse().bufferFactory() .wrap(({\code\:401,\msg\:\ message \}).getBytes(StandardCharsets.UTF_8)); return exchange.getResponse().writeWith(Mono.just(buffer)); } Override public int getOrder() { return -100; } }代码里几个关键点我展开说一下。首先白名单的匹配方式是startsWith不是全路径等值。因为登录接口可能是/api/auth/login而验证码接口可能是/api/auth/captcha都属于/api/auth/这个前缀下用前缀匹配会把它们一并放行配置和维护都比较省事。如果你的某个接口路径存在歧义再精确到全路径即可。其次透传用户信息的写法是exchange.mutate()返回一个新的ServerWebExchange。这里有个细节请求头在mutate时通过request.builder操作可以新增或者覆盖Header。下游服务只要约定好读取X-User-Id这个Header就能拿到当前操作人的ID不需要再解析JWT。服务之间调用链路如果有多层这种透传的设计能省掉很多重复工作。最后也是响应式编程比较特殊的点过滤器不能再通过return chain.filter(exchange)一放到底需要中间查询的时候就通过Reacto的flatMap组合操作。Redis的hasKey返回的是MonoBoolean所以后续的放行或拒绝逻辑都要写进flatMap回调里。如果你一不留神在里面调了exists.block()网关上配置的工作线程池很容易被拖垮高并发场景下Gateway的吞吐会直线下降这是WebFlux和传统Servlet思维的显著差异。3.3 自定义 GatewayFilter 绑定到指定路由上一节演示了GlobalFilter它的覆盖范围是全路由。但有的场景只希望某个路由做额外校验或者做轻量级的定制处理这时候就该上GatewayFilter了。我举一个很典型的例子订单服务的关键接口要求额外的签名头其他服务不要求你就可以写一个专属订单服务的GatewayFilter。GatewayFilter的自定义有两种姿势。第一种是直接在Java代码中组装路由时添加过滤器第二种是通过实现GatewayFilterFactory接口把过滤器做成可配置的Bean在yml里声明。第二种方式更灵活也更符合Gateway框架的风格我用第二种来演示。Component public class SignatureGatewayFilterFactory extends AbstractGatewayFilterFactorySignatureGatewayFilterFactory.Config { public SignatureGatewayFilterFactory() { super(Config.class); } Override public GatewayFilter apply(Config config) { return (exchange, chain) - { String sign exchange.getRequest().getHeaders().getFirst(X-Sign); if (StringUtils.isBlank(sign) || !config.getSecret().equals(sign)) { exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN); return exchange.getResponse().setComplete(); } return chain.filter(exchange); }; } Override public ListString shortcutFieldOrder() { return Collections.singletonList(secret); } public static class Config { private String secret; public String getSecret() { return secret; } public void setSecret(String secret) { this.secret secret; } } }然后在路由配置里这样引用spring: cloud: gateway: routes: - id: order-service uri: lb://order-service predicates: - Path/api/order/** filters: - Signaturemy-sign-secret这里实现的过滤器工厂类名为SignatureGatewayFilterFactoryGateway框架会自动截取类名前缀Signature作为yml中的过滤器配置名。配置中的Signaturemy-sign-secret相当于给Config对象的secret属性赋值。这是GatewayFilter和GlobalFilter在配置方式上的最大不同GatewayFilter是声明式的用起来更像一个可叠加的插件GlobalFilter则是全自动生效的。实际项目中GatewayFilter更常见的用途其实是重写路径、添加响应头、请求限流这一类局部策略和登录校验的主流程配合起来效果更好。比如给某个路由单独加一个“验证验证码”的逻辑或者给某条链路加一个“灰度版本号”响应头都很方便。3.4 前端配合401 处理与令牌传递网关把终极任务完成了一部分但还有一个关键环节是前端的配合。你在网关返回401的时候需要有一个约定好的响应格式前端统一拦截。如果网关返回的401格式和业务返回的格式不一致前端要么做两层解析要么就得吞掉错误信息体验很差。建议网关的401响应统一返回JSON格式包含code、msg两个字段就像前面unauthorized方法里写的那样。前端的axios拦截器或者fetch封装里遇到HTTP 401就触发一次全局的“跳转登录页”动作同时把当前页面的地址记录下来登录成功后可以跳回来。这种做法比每个页面自己判断状态码要省心得多。令牌传递也一样。前端每次请求都要把令牌放到Authorization头里一般来说是登录成功后把JWT存到localStorage或pinia/vuex等前端状态管理器里然后在请求拦截器里统一给请求头附加。不要只在某几个请求里手动加切换页面之后容易漏掉在HTTP客户端层做成全局的才算真正省心。4. 常见问题与排查技巧实录4.1 过滤器静默失效怎么排查我在项目里见过太多次这种情况代码写了类也建了但请求就是不进过滤器。排查看似无从下手其实套路很固定。第一个要查的就是组件是否被Spring容器扫描到。如果你把过滤器类放在了启动类所在包之外又没通过ComponentScan指定扫描路径那这个过滤器就不会被注册所有逻辑自然都不生效。这问题在多人开发、分包不规范的项目里尤其频繁。第二个要查的是Order值。自定义的GlobalFilter往往不只一个团队里每个人可能都写了自己的过滤器。如果别人的过滤器Order比你的小他会先执行并且他可能在你的过滤器之前就返回了响应你的逻辑就被跳过了。排查时可以通过在过滤器的entry和exit处打印日志确认自己的过滤器到底有没有被调用在日志里看到[AuthGlobalFilter] token check start这样的输出问题就清楚了一大半。第三个隐藏坑在路由匹配上。网关的全局过滤器虽然对所有路由生效但如果路径根本没有匹配到任何路由请求会在路由匹配阶段直接返回404根本走不到后续的过滤器逻辑。尤其是自定义GlobalFilter的Order设为了正数会排在路由转发之后这种情况下你甚至连请求什么时候失败的都看不明白。所以全局过滤器要尽早执行Order用负数。4.2 请求放行了但拿不到用户信息有一种常见现象过滤器确实执行了token也校验通过了但是下游服务拿不到X-User-Id这个请求头。排查之后发现原因往往出在代码细节上比如用了exchange.getRequest().getHeaders().set()而不是通过exchange.mutate()去传递新增请求头。ServerWebExchange的Request实例默认是不可变的直接在原对象上改请求头是不行的必须通过mutate方法构建一个新的exchange新的Request才会带上你加的header。另一个原因可能是服务间的路由配置用了StripPrefix之类的过滤器它会把请求路径的第一段前缀去掉。请求头本身不受StripPrefix影响但如果下游服务收到请求后被另一个框架层拦截器清洗了请求头那也可能看不到自定义头。这时候建议下游服务统一约定从X-User-Id读用户信息并且在入口处把自定义请求头打印出来确认网关到下游这一跳是否完整透传。4.3 CORS 跨域与 OPTIONS 预检拦截前端项目在本地开发时地址往往和网关地址不一样跨域请求就来了。网关作为后端入口如果跨域配置没做好浏览器预检请求会被自己的登录校验过滤器拦住前端控制台报出一堆红色CORS错误。这个坑非常经典OPTIONS请求不带Authorization头而你的过滤器看到没有令牌直接就返回401了浏览器自然认为这个跨域请求不可用。解决办法是在全局配置里统一处理跨域让OPTIONS请求直接放行。Spring Cloud Gateway提供了内置的CorsWebFilter也可以通过配置文件设置spring.cloud.gateway.globalcors.cors-configurations。参考配置如下spring: cloud: gateway: globalcors: cors-configurations: [/**]: allowedOriginPatterns: * allowedMethods: * allowedHeaders: * allowCredentials: true但要注意CORS配置是针对路由转发阶段的。如果你自定义的GlobalFilterOrder设得很靠前请求可能在CORS处理之前就被你的过滤器拦截OPTIONS请求依然会被拦死。我见过有人在这上面折腾了一整天。更稳妥的做法是在自定义过滤器里主动判断一下请求方法如果是OPTIONS请求直接放行不参与登录校验逻辑。if (OPTIONS.equalsIgnoreCase(exchange.getRequest().getMethodValue())) { return chain.filter(exchange); }这段代码放在过滤器的最前面CORS问题能规避掉大半。4.4 JWT 校验中的那些坑最后集中说一下JWT校验的过程中我遇到过的实际问题。最经常出问题的是签发和验签使用了两套密钥。用户服务签发token用的是A密钥网关验签时配置的却是B密钥结果就是所有合法token在网关这里全部验签失败返回401。这个问题的排查最简单把两边的secret配置打出来对比就知道了但恰恰因为这个错误太低级很多人反而不容易想到。第二个坑是token过期时间的时区或者时钟偏移问题。服务端签发token的机器时钟比网关验签的机器快了几秒钟导致token刚签发出来就被网关判定为过期。项目里的通用做法是给过期时间预留一个宽容窗口比如校验时不直接判断exp而是判断exp 5秒是否大于当前时间。写工业级代码的时候千万不要低估服务器之间那几秒的时间漂移。第三个坑和token的缓存有关。如果网关集群部署了多个实例你在Redis里存了token状态但有些实例没有接入同一个Redis就会出现部分请求401。解决方案是确保Redis为一个统一共享的实例或者集群不要在每台网关本地存会话状态。只要你用JWT Redis校验的姿势这个点就必须格外注意。5. 一点心得网关这套登录校验方案我自己在几个项目里落地过最大的感受就是代码本身不难难的是把过滤器的执行时机和响应式模型搞明白。GlobalFilter做统一登录校验GatewayFilter做局部定制两者各司其职配合JWT自包含特性和Redis的状态管理整个微服务体系的认证入口就清晰了。如果你是在老项目里改造建议先加白名单把所有现有接口放行然后逐个业务模块切换成必须登录才能访问的状态这样不会出现一次改造导致全站不可用的情况。这一期内容到这里下一篇我想写网关的限流和灰度发布到时候见。