
前两周帮朋友调一个网关项目他上来第一句话就是“明明每个服务里都写了一遍登录校验为什么线上还是各种Token过期、越权问题”看了代码我反倒不意外了——三个服务各写一套校验逻辑一个用拦截器一个用AOP还有一个干脆在Controller里硬解析JWT。改一个密钥要发三个版本新增一个白名单路径要动三个仓库。问题不出在校验逻辑本身出在校验的位置。所以这次教程我不打算一上来就贴代码先把“网关层做登录校验”这件事想透再带你手写一个可用的GlobalFilter和一个自定义GatewayFilter。这两个过滤器是Spring Cloud Gateway里最核心的扩展点也是微服务面试里绕不开的问题。1. 登录校验为什么必须前置到网关从“到处都校验”到“只校验一次”1.1 每个服务自己校验Token到底有多痛我见过太多从单体拆微服务的团队第一反应是保留原来的登录校验方式在用户服务里写校验在订单服务里写校验在支付服务里再写一遍。表面上看每个服务都“安全”了实际上维护成本是成倍涨的。单一职责被破坏了。用户服务校验Token还能理解订单服务校验Token本来跟业务无关却被迫依赖JWT工具类、密钥配置、白名单配置。这些本应该是网关或认证中心的事。权限逻辑散落在各个服务里review代码时根本分不清某处校验是新加的规则还是历史遗留。上线发布也很痛苦。假设你用的对称密钥签发JWT一旦想换成非对称加密或者想引入刷新令牌机制受影响的不只是用户服务而是所有承接请求的下游服务。改完一个服务没改另一个就会出现“登录成功但访问订单提示未认证”这种让人挠头的现象。这也引出一个更根本的问题下游服务真的需要自己校验登录状态吗大多数情况下不需要。下游服务只需要拿到已经通过认证的用户身份比如用户ID、用户名、角色去做业务判断。认证这个动作本身交给处在流量入口的网关最合适。1.2 网关做校验的职责边界该做的和不该做的把登录校验放到网关层不是说下游服务就完全不需要关心用户了。网关解决的是“这个请求是谁发出的、是否有效”下游服务解决的是“这个用户有没有权限操作这笔订单”。举几个具体的例子网关负责解析JWT校验签名和有效期。网关负责把解析出的userId、username写入请求头转发给下游。下游服务从请求头中读取用户信息做数据权限过滤比如只能查询自己的订单。对于需要管理员权限的接口网关只校验“登录有效”但不会替下游判断“是否有admin角色”——这一步放到下游或独立的权限服务里更合理。这种划分能把交叉职责拆干净。网关不深度介入业务权限下游不重复做认证。实际落地时很多人容易走另一个极端网关把所有接口都拦下来连登录接口都不放行导致用户永远无法登录。正确的做法是维护一个白名单登录、注册、验证码、健康检查这些接口直接放行其余接口统一走过滤器校验。1.3 技术选型层面为什么是Spring Cloud Gateway早几年网关领域还是Zuul的天下现在新项目基本默认Spring Cloud Gateway。原因有几点它基于Spring WebFlux和Netty响应式非阻塞性能比Zuul 1.x这种Servlet阻塞模型好很多它天然融入Spring Cloud生态和Nacos、Eureka、Sentinel这些组件配合非常顺滑它的过滤器模型设计得很干净内置了几十种过滤器不够用还能自己写。如果你用的还是Zuul也能做类似的事但过滤器模型和API完全不同网上很多GlobalFilter的代码你是没法直接复制的。这套教程以Spring Cloud Gateway为基础因为它是目前微服务网关的主流选择。面试时谈“Spring Cloud五大组件”网关这一层十有八九问的就是Gateway。2. GlobalFilter与GatewayFilter过滤器家族的“全局”和“局部”两条路线2.1 从接口签名看两者的本质差异初学者最容易把这两个概念搞混。名字确实像一个是GlobalFilter一个是GatewayFilter语法上还都是过滤器。但它们解决的是不同粒度的问题。先看接口定义。GatewayFilter是Spring Cloud Gateway里最基本的过滤器接口定义了一个filter方法public interface GatewayFilter extends ShortcutConfigurable { MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain); }它只针对某一条路由生效。你可以在配置里给“/api/order/**”这个路由挂一个GatewayFilter也可以挂三个别的路由完全不受影响。GlobalFilter则是一个标记接口真正发挥作用的是它继承的接口public interface GlobalFilter extends GatewayFilter { }你没看错GlobalFilter继承自GatewayFilter。但它通过Spring的Bean机制被自动发现并应用到所有路由上。所以GlobalFilter的功能是GatewayFilter的子集但作用范围是全局限定的。从使用场景来区分登录校验这种所有请求都要执行的逻辑适合写成GlobalFilter针对某条路由做特殊处理比如给订单接口加签名校验、给支付接口加额外的参数校验适合写成GatewayFilter。2.2 执行顺序与生命周期数值小先执行过滤器的执行顺序由Ordered接口控制。Spring Cloud Gateway里几乎所有过滤器都实现了Ordered通过getOrder方法返回一个整数值。值越小越先执行。这个顺序机制极其重要。登录校验过滤器必须最先执行吗未必。如果你在GlobalFilter里解析Token然后通过Header把用户信息传给下游那你必须确保这个过滤器在转发请求之前执行完。如果你把登录校验的order设成10000很多内置过滤器已经执行完了你的Header还没来得及加进去请求就发出去了。内置过滤器的order大概分布是这样过滤器Order值说明NettyRoutingFilterInteger.MAX_VALUE最后执行负责发起真实调用ForwardPathFilter0转发路径处理RouteToRequestUrlFilter10000路由到真实URLLoadBalancerClientFilter10100负载均衡选实例WebSocketRoutingFilter2147483646WebSocket转发所以自定义的全局过滤器习惯上把order设为负数比如-100这样能在绝大多数内置过滤器之前执行。如果希望它在负载均衡之后执行就得设成正数且超过10100但这种情况很少见一般没必要跟内置过滤器抢位置。2.3 内置GatewayFilter速览有些功能不用自己写Spring Cloud Gateway内置了几十种GatewayFilter配置就能用别重复造轮子。常用的有几个StripPrefix剥离请求路径前缀。网关对外暴露/api/order转发给order-service的时候要去掉/api用StripPrefix1。AddRequestHeader增加请求头比如在网关统一加上X-From-Gateway。Retry对下游请求做重试。RequestRateLimiter限流过滤器配合Redis实现令牌桶这是“网关如何限流”面试题的标准答案。RequestSize限制请求体大小。这些内置过滤器本质都是GatewayFilter的官方实现。你完全可以模仿它们的方式写自己的过滤器然后在路由配置里挂上去。3. 手写一个登录校验GlobalFilter从拿到Token到放行的完整链路3.1 依赖准备与项目骨架先确认网关项目的依赖。基于Spring Boot 2.7 Spring Cloud 2021.0.x为例pom里除了gateway依赖还需要JWT的工具库dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/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 scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency如果你走的是Spring Cloud Alibaba体系Nacos作为注册中心和配置中心网关项目本身也是注册到Nacos的。这里不展开讲注册中心怎么搭重点放在过滤器本身。3.2 核心代码白名单、Token解析、错误响应下面是一个可以直接使用的AuthGlobalFilter。逻辑分四步判断是否在白名单里从请求头取出Token解析Token把用户信息放到请求头里继续往下传。Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private static final String[] WHITE_LIST { /api/user/login, /api/user/register, /api/captcha, /actuator/health }; Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); String path request.getURI().getPath(); // 白名单放行 for (String whitePath : WHITE_LIST) { if (path.startsWith(whitePath)) { return chain.filter(exchange); } } // 从请求头获取Token兼容Bearer前缀 String token request.getHeaders().getFirst(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } if (token null || token.trim().isEmpty()) { return unauthorizedResponse(exchange, 未登录或Token为空); } // 解析Token这里用JwtUtil封装的工具类 try { Claims claims JwtUtil.parseToken(token); String userId claims.get(userId, String.class); String username claims.get(username, String.class); // 把用户信息写入请求头转发给下游 ServerHttpRequest mutatedRequest request.mutate() .header(X-User-Id, userId) .header(X-Username, username) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (Exception e) { return unauthorizedResponse(exchange, Token校验失败: e.getMessage()); } } private MonoVoid unauthorizedResponse(ServerWebExchange exchange, String message) { ServerHttpResponse response exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().setContentType(MediaType.APPLICATION_JSON); response.getHeaders().setCharacterEncoding(UTF-8); String body {\code\:401,\message\:\ message \}; DataBuffer buffer response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8)); return response.writeWith(Mono.just(buffer)); } Override public int getOrder() { return -100; } }JwtUtil的解析方法大致是这样public static Claims parseToken(String token) { SecretKey key Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)); return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); }关于密钥生产环境一定要放到配置中心用环境变量或Nacos配置注入不能硬编码在代码里。密钥长度也有要求HS256要求至少32字节太短会启动报错。3.3 校验失败时如何返回统一JSON而不是登录页注意上面的错误返回方式我直接设置了response的statusCode为401ContentType为application/json然后写了一段JSON字符串。这是网关校验失败的标准姿势。为什么不直接抛异常或者重定向网关的下游客户端不一定是浏览器很可能是小程序、App、前端Node服务它们需要的是结构化的JSON错误码而不是一个302跳转登录页。移动端收到302反而会懵。既然网关层做了校验就用JSON把失败原因讲清楚客户端拿到401就知道该清理本地Token、跳转登录页了。实际项目里建议把错误返回再抽一个公共方法统一code和message字段。网关返回结构最好和业务服务保持一致这样前端拦截器处理起来只需要写一套。3.4 用户信息如何传递Header注入与防污染过滤器解析出userId之后是通过mutate().header()方式放进下游请求的。下游服务在Controller里直接读请求头即可GetMapping(/orders) public Result listOrders(RequestHeader(X-User-Id) String userId) { // 只查当前用户的订单 }这里有个防坑细节如果客户端伪造X-User-Id请求头怎么办网关在向下游转发之前应该先把客户端可能传入的X-User-Id删掉再写入解析后的值否则可能出现“客户端自己指定userId越权”的安全漏洞。ServerHttpRequest mutatedRequest request.mutate() .headers(headers - headers.remove(X-User-Id)) .header(X-User-Id, userId) .build();这个细节非常容易忽视但线上越权漏洞往往就是这么来的。4. 自定义GatewayFilter针对特定路由的精准确权4.1 什么时候需要GatewayFilter而不是GlobalFilterGlobalFilter管所有请求适合登录校验这种通用逻辑。但有些场景比如第三方开放接口的签名校验、内部接口的AppKey校验只对部分路由生效就够了。如果用GlobalFilter你不得不在过滤器里写一堆if判断路径代码很快就脏了。GatewayFilter的正确用法是绑定到路由上。Spring Cloud Gateway支持两种方式使用Component让Spring注入然后在路由配置里引用或者直接在Java配置里new出来。推荐后者因为你可以在实例化时传入参数灵活性更高。4.2 手写一个签名校验GatewayFilter下面以第三方开放接口的签名校验为例写一个SignatureGatewayFilter。逻辑是对特定路由要求请求带X-Timestamp和X-Sign网关用密钥计算签名并比对。public class SignatureGatewayFilter implements GatewayFilter, Ordered { private final String secret; public SignatureGatewayFilter(String secret) { this.secret secret; } Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); String timestamp request.getHeaders().getFirst(X-Timestamp); String sign request.getHeaders().getFirst(X-Sign); if (timestamp null || sign null) { return forbiddenResponse(exchange, 缺少签名参数); } // 模拟签名规则MD5(timestamp secret) String expected DigestUtils.md5DigestAsHex((timestamp secret).getBytes(StandardCharsets.UTF_8)); if (!expected.equals(sign)) { return forbiddenResponse(exchange, 签名校验失败); } return chain.filter(exchange); } private MonoVoid forbiddenResponse(ServerWebExchange exchange, String message) { ServerHttpResponse response exchange.getResponse(); response.setStatusCode(HttpStatus.FORBIDDEN); response.getHeaders().setContentType(MediaType.APPLICATION_JSON); String body {\code\:403,\message\:\ message \}; DataBuffer buffer response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8)); return response.writeWith(Mono.just(buffer)); } Override public int getOrder() { return -50; } }然后在路由配置里挂到指定路由上。使用Java配置Configuration public class GatewayRoutesConfig { Bean public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { return builder.routes() .route(open-api-route, r - r.path(/open/api/**) .filters(f - f.filter(new SignatureGatewayFilter(my-secret))) .uri(lb://open-service)) .route(user-route, r - r.path(/api/user/**) .filters(f - f.stripPrefix(1)) .uri(lb://user-service)) .build(); } }也可以继续沿用配置文件的写法把filters项加进去。通用内置过滤器直接配名称自定义过滤器如果有Component注解可以使用路由过滤器工厂的方式暴露但这套机制需要额外写一个GatewayFilterFactory代码量更大。日常项目我建议直接用Java DSL简单直接。4.3 GlobalFilter和GatewayFilter的叠加执行顺序当全局过滤器和局部过滤器同时命中时它们会合成一个过滤器链。执行顺序由order决定但有一个细节GlobalFilter可以看成在最外层包了一圈GatewayFilter在路由内部执行。实际的排序逻辑是Spring Cloud Gateway在构建过滤器链时会把所有GlobalFilter和当前路由匹配到的GatewayFilter合并成一个List然后统一按getOrder排序。也就是说不区分“全局”和“局部”只看order值。这就带来一个常见场景登录校验GlobalFilter的order-100签名校验GatewayFilter的order-50那签名校验会先执行还是后执行答案是后执行因为-50大于-100。如果你想先签名校验再登录校验就把两个order对调一下。这个细节比很多人以为的“全局先执行、局部后执行”要精确得多。5. 过滤器执行顺序的三个经典坑权限绕过、404、并发问题5.1 坑一白名单过滤条件写错导致权限绕过有人会在白名单判断时用equals而不是startsWith比如配置了“/api/user/login”结果实际请求路径是“/api/user/login/”equals匹配失败被拦下来。看似是小问题但在多人协作时很容易埋雷。更危险的是正则路径。有些同学喜欢用Ant风格匹配比如“/api/user/**”但Gateway的Path谓词是支持/**的白名单判断直接拿字符串startsWith处理很容易漏掉一些边缘路径。建议统一封装一个方法优先使用PathPatternParser来匹配路径不要纯字符串判断。5.2 坑二GlobalFilter写错导致所有路由404过滤器在转发前抛异常但是你没有做异常捕获有可能表现成404。之前有个同学遇到网关全是404排查半天发现是AuthGlobalFilter里在Token解析失败时返回了401但是因为代码里把response写入和chain.filter的返回搞混了导致底层响应被提前关闭路由转发执行异常Spring Cloud Gateway把异常转成了404。怎么排查先把过滤器的try-catch加到最小范围不要在filter方法最外层catch所有异常后返回一个空Mono否则你会失去Spring的错误处理机制。我看到过有人为了图省事在catch里直接返回Mono.empty()结果网关静默吞掉异常下游收到空响应定位问题浪费半天。5.3 坑三并发场景下的线程安全问题过滤器是单例的多个请求并发共享同一个过滤器实例。如果你在过滤器里声明了一个成员变量用来暂存用户信息比如private String currentUserId那并发时必然串数据。A请求写入userId1还没转发出去B请求写入userId2A请求下游拿到的可能就是2。正确的做法是所有请求级数据全部放在ServerWebExchange的attribute里或者直接放到mutate出来的请求头中。不要用类成员变量保存任何与一次请求相关的状态。这一点和写Servlet Filter时是一样的。5.4 排查过滤器问题的两个调试技巧第一个是添加Spring Cloud Gateway的调试日志。在application.yml里logging: level: org.springframework.cloud.gateway: debug打开后能看到每个请求命中了哪些Route、哪些Filter以及过滤器的执行顺序。线上排查过滤器不生效这一招最直接。第二个技巧是临时写一个Order最小的GlobalFilter在过滤链最前面打印当前exchange的请求路径和所有Headers。如果请求根本没进入你写的过滤器说明问题出在路由匹配或注册中心而不是过滤器本身。如果进入了但没走到chain.filter说明是被前面更早的过滤器拦住了。6. 网关校验的进阶话题动态白名单、缓存与限流6.1 动态白名单的一个可行方案把白名单写死在代码里的做法改一次要发一次版。项目里接口越来越多经常有运营后台说“这个接口临时开放一下免登录”每次都得改代码重启网关用户体验很差。可以做一个白名单配置表存到Nacos配置中心或数据库里网关启动时加载同时通过定时任务或配置监听器刷新。最轻量的做法是利用Nacos的配置监听白名单以JSON数组的格式放在配置里变更后推送到内存Map。多实例部署时每个实例都会收到配置变更通知做到动态生效不用重启。6.2 令牌校验的性能优化Redis缓存解析结果JWT本身是无状态的解析依赖签名计算对网关的CPU有一定消耗。如果接口QPS很高每次都做HMAC签名验证网关的压力会变大。可以引入一层Redis缓存以Token的MD5值为key解析出的用户信息为value设置过期时间略短于Token本身的失效时间。请求进来先查Redis命中就直接用缓存中的用户信息不命中再做JWT解析。这里要注意缓存穿透问题——如果客户端传一个恶意伪造的TokenRedis里必然没有每次都要走一遍完整解析攻击者可以靠大量无效Token打满网关CPU。解决办法是对解析失败且签名非法的Token也做短期缓存比如缓存一个“invalid”标记过期时间设30秒到1分钟。6.3 网关限流与登录校验的配合很多面试题会问“Eureka Gateway怎么做限流”标准答法是用RequestRateLimiter过滤器配合Redis和KeyResolver。在网关做了登录校验的基础上限流的Key不建议再用IP因为同一个IP后面可能有多个用户。既然登录过滤器已经把userId放到了请求头里KeyResolver可以直接读取X-User-Id实现基于用户的限流比IP限流更公平也更容易避免误伤。Bean public KeyResolver userKeyResolver() { return exchange - { String userId exchange.getRequest().getHeaders().getFirst(X-User-Id); if (userId null || userId.isEmpty()) { return Mono.just(anonymous); } return Mono.just(userId); }; }这个例子刚好说明一个架构上的好处过滤器之间不是孤立的前面过滤器处理后的结果能被后面的限流过滤器复用。网关层把认证、鉴权、限流串联起来统一治理流量入口这才是微服务网关存在的真正意义。我在实际项目里踩过不少次坑之后养成了一个习惯每个网关过滤器必须三件事做齐全——order明确、异常路径单独捕获、请求级数据只放在exchange里流转。只要这三个点守住大部分过滤器问题基本都能提前规避。你可以把文中的AuthGlobalFilter和SignatureGatewayFilter当作基础模板结合自己的密钥管理、白名单来源和用户体系改造一下应该能少走不少弯路。