尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Cloud Gateway全局过滤器实战:基于GlobalFilter的JWT登录校验与白名单设计

Spring Cloud Gateway全局过滤器实战:基于GlobalFilter的JWT登录校验与白名单设计 微服务拆到第14篇终于要聊网关里的最后一个核心环节了。前面我们把服务注册、配置中心、熔断限流这些基石都过了一遍不少朋友在评论区留言网关的登录校验到底怎么做是不是每个服务都要写一套JWT拦截器说实话如果做了三五个服务就复制三份鉴权代码改签名要一个服务一个服务去动那这套微服务架构离“能落地”还差得远。这篇我用一个可以直接跑通的实战案例把 GlobalFilter 和 GatewayFilter 这两个接口彻底讲清楚同时落地一套可复用的网关登录校验覆盖白名单、Token 解析、用户信息透传、异常返回这些关键环节。本文适合已经能把路由转发跑通的同学如果你刚接触 Spring Cloud Gateway建议先把路由配置和断言 Predicate 玩熟再回来。这篇内容基本覆盖 2026 年 SpringCloud 面试里“网关鉴权”这类高频问题也能直接拿来改造你正在写的业务项目。1. 登录校验放到网关是微服务架构的必答题1.1 从“每个服务写一遍”到“网关统一拦截”没有网关时用户的请求直接打到各个服务每个服务都要自己校验一遍 Token。听起来没什么但算一笔账就清楚了。假设一个商城项目拆成用户、订单、商品、库存、支付五个服务你需要写五份几乎一模一样的 JWT 解析逻辑每一份都要处理 Token 过期、非法签名、白名单放行、返回 401 这些分支。这还只是一个集群如果团队把服务拆得更细比如十几个、二十几个任何一个服务漏掉过滤器就是一个安全隐患。把登录校验放到网关后情况就完全不一样了。客户端只认识网关这个入口请求进来先过全局过滤器Token 合法才路由到下游服务非法就在网关直接挡回去。业务服务可以完全不感知用户登录状态只专注自己的业务逻辑。这份“登录是登录、业务是业务”的边界感才是微服务架构拆分出来的真正价值。你可以把它类比成小区的门禁。没有门禁系统每一栋楼都要自己安排保安验证住户身份有了一楼的总门禁之后楼内各层只需要关注来访者要去哪一层不用再重复验证身份。网关就是这扇总门GlobalFilter 就是门禁的执行人员。1.2 网关校验的完整数据链路登录校验在网关上的完整链路是这个样子的浏览器或 App 请求携带 Token通常放在 Authorization 头值类似 Bearer xxxxx→ 请求落到网关的过滤器链 → GlobalFilter 拦截并解析 Token → 校验签名、过期时间 → 从 Token 中取出用户 ID、用户名等关键信息 → 把用户信息写入请求头透传给下游 → 网关按路由规则转发到具体服务 → 业务服务直接通过请求头获取当前用户。这个过程里有两个容易被新手忽略的点。第一Token 解析和校验要在网关这一层完成业务服务不应该再去解析 JWT否则就回到了“每个服务写一遍”的老路。第二下游服务拿到的用户信息是网关透传的请求头从网关到服务内网这一段是可信的所以业务侧不需要再验证这个头是不是用户伪造的——前提是你不能把这个头直接暴露给公网客户端。1.3 为什么是 JWT 白名单这套组合登录校验的方案有很多种Token Redis 会话、JWT 无状态 Token、OAuth2/Spring Authorization Server。目前中小企业项目里用得最多的还是 JWT 这一套原因就是无状态、天然适合水平扩容。网关不查数据库、不查 Redis拿到 Token 验个签名就能确认身份性能非常好。JWT 的缺点是服务端不太好主动让它失效所以生产环境一般会配合 Redis 黑名单或者 token 版本号解决登出、踢人这样的场景。我在本文里先演示纯 JWT 校验因为它是理解网关鉴权的最小骨架后面第 4.4 节再展开讲怎么加 Redis 和刷新 Token。还有一个核心问题登录接口本身是不能被拦截的。用户还没登录你让他拿 Token 来他拿不出来。所以要配置一个白名单列表把登录、注册、验证码、健康检查这类路径全部放行。白名单匹配的细节在 3.3 节我会单独说这里先记住一个原则白名单越精确越好宁可多写几个具体路径也别用一个幂等不精准的泛匹配。2. GlobalFilter 和 GatewayFilter别再傻傻分不清2.1 两个接口的本质区别很多同学看到这两个名字就头大面试也爱问。先说结论GlobalFilter 是全局过滤器对所有路由自动生效GatewayFilter 是局部过滤器只对配置了它的路由生效。这是它们最本质的区别。GlobalFilter 的使用方式是写一个类实现org.springframework.cloud.gateway.filter.GlobalFilter接口把它注册成 Spring Bean它就会自动作用到所有经过网关的请求上。你不需要在路由配置里引用它它天然就在过滤链里。再配合实现Ordered接口用getOrder()来控制执行顺序。GatewayFilter 的使用方式则完全不同。Spring Cloud Gateway 内置了一堆现成的 GatewayFilter 工厂比如 AddRequestHeader、StripPrefix、Retry、RequestRateLimiter。它们必须在路由配置的filters里显式声明才会生效。比如你在某个路由上写了StripPrefix1那只有转发到这个路由上的请求才会去掉一级路径。如果你想写一个自定义 GatewayFilter需要实现 GatewayFilter 接口并且往往还要配套实现一个 GatewayFilterFactory代码比 GlobalFilter 复杂但好处是可以带着参数用在某一条路由上。我整理了一张对比表方便你一眼看明白对比项GlobalFilterGatewayFilter作用范围所有路由自动生效仅对声明了该过滤器的路由生效注册方式实现接口注册成 Spring Bean实现接口或使用内置 Filter Factory在路由配置里声明执行顺序通过 getOrder() 控制全局顺序与路由配置里 filters 的书写顺序相关典型场景登录校验、日志、限流、灰度路径改写、加 Header、重试2.2 过滤器链的执行顺序Spring Cloud Gateway 底层是 WebFlux 的 Netty 引擎请求不是像 Servlet 那样一套 Filter 链简单过滤而是通过FilteringWebHandler把所有过滤器和全局过滤器按 Order 值组装成一条责任链。Order 值越小越先执行负数的优先级高于正数。网关内置过滤器里有几个重要的 Order 值如果你自己写过滤器时用错 Order可能出现用户信息还没设置就被转发、或者 Filter 没执行到的问题。比如RouteToRequestUrlFilter的 Order 是 10000NettyWriteResponseFilter的 Order 是 -1。我们自定义的登录校验过滤器Order 一般设置为负数确保它先于路由转发执行。我自己习惯用-100留足缓冲避免以后要在登录校验之前再插一个灰色的日志埋点过滤器Order 还能排得进去。如果你想知道当前情况下过滤器链到底是怎么排列的可以开启网关的调试日志把logging.level.org.springframework.cloud.gatewayDEBUG打开日志里会打出过滤器的装配顺序。出问题时这个调试信息非常救命。2.3 登录校验为什么必须选 GlobalFilter明确了区别之后登录校验选哪一种就很清晰了必须选 GlobalFilter。理由很简单登录校验是一个全局性的横切需求它需要拦截所有路由而不是某一条路由。如果你用 GatewayFilter 去实现就得在每个路由的filters里手动加一遍。新增一个微服务、新增一条路由就要去改路由配置漏改一个服务等于这个服务完全裸奔维护成本高且容易出错。那 GatewayFilter 是不是没有用了当然不是。你想对某个路由做特殊处理比如给某个老服务的接口统一加前缀、或者对某个请求重试三次这时候用 GatewayFilter 更精准。记住一句话全局的能力用 GlobalFilter局部的差异用 GatewayFilter。这个选型逻辑面试官听完会点头的。3. 手写一个 GlobalFilter网关登录校验落地上线3.1 工程准备与配置类搭建先交代一下我演示使用的环境Spring Boot 3.x Spring Cloud 2023 系列 Java 17 Maven。网关项目引入的是spring-cloud-starter-gateway。这里有个大坑必须先说Spring Cloud Gateway 基于 WebFlux项目里千万不能引入spring-boot-starter-web也不能同时引入 WebFlux 和 Web MVC否则启动会直接报 SpringMVC 和 WebFlux 共存的错误。如果你是从传统 Spring Boot 项目改造过来的第一件事就是检查 pom 依赖把 Web Starter 去掉。第一步我建一个配置属性类用来承载白名单路径和 JWT 的配置项。写一个AuthPropertiesComponent ConfigurationProperties(prefix auth) public class AuthProperties { /** * 不需要登录校验的路径使用 Spring PathPattern 语法 */ private ListString skipUrls new ArrayList(); public ListString getSkipUrls() { return skipUrls; } public void setSkipUrls(ListString skipUrls) { this.skipUrls skipUrls; } }使用ConfigurationProperties的好处是配置和代码隔离白名单加路径时不用改 Java 代码、改配置文件即可。缺点是这个类需要被 Spring 扫描到所以保留了Component注解。如果你项目里开了ConfigurationPropertiesScan也可以去掉Component。第二步在application.yml里配置白名单和 JWT 密钥auth: skip-urls: - /api/auth/login - /api/auth/register - /api/captcha/** jwt: secret: your-256-bit-secret-key-please-change-in-production-2026需要提醒的是JWT 的 HMAC 密钥长度至少 32 字节也就是 256 位太短会在启动解析时直接报错。密钥一定不能写死在代码里生产环境把它放到 Nacos、K8s Secret 或者环境变量里。我这边为了演示才写进 yml。3.2 核心过滤器完整代码下面就是核心的AuthGlobalFilter。我用 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过滤器的完整实现如下Slf4j Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private final ListPathPattern skipPatterns; private final SecretKey secretKey; private final ObjectMapper objectMapper new ObjectMapper(); public AuthGlobalFilter(AuthProperties authProperties, Value(${jwt.secret}) String secret) { // 提前把所有白名单编译成 PathPattern避免每次请求都重新解析 this.skipPatterns authProperties.getSkipUrls() .stream() .map(url - new PathPatternParser().parse(url)) .collect(Collectors.toList()); this.secretKey Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path exchange.getRequest().getURI().getPath(); // 1. 白名单路径直接放行 for (PathPattern pattern : skipPatterns) { if (pattern.matches(path)) { return chain.filter(exchange); } } // 2. 从 Authorization 头解析 Token String token resolveToken(exchange.getRequest()); if (StringUtils.hasText(token)) { try { // 3. 解析并校验 JWT Claims claims Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token) .getBody(); String userId claims.get(userId, String.class); String username claims.get(username, String.class); // 4. 把用户信息写入请求头透传给下游服务 ServerHttpRequest newRequest exchange.getRequest().mutate() .header(X-User-Id, userId) .header(X-User-Name, username) .build(); return chain.filter(exchange.mutate().request(newRequest).build()); } catch (Exception e) { log.warn(Token 校验失败, path{}, reason{}, path, e.getMessage()); } } // 5. 校验失败返回 401 return unauthorized(exchange); } private String resolveToken(ServerHttpRequest request) { String header request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); if (StringUtils.hasText(header) header.startsWith(Bearer )) { return header.substring(7); } return null; } private MonoVoid unauthorized(ServerWebExchange exchange) { ServerHttpResponse response exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().setContentType(MediaType.APPLICATION_JSON); MapString, Object body new HashMap(); body.put(code, 401); body.put(message, 未登录或登录已失效); try { byte[] bytes objectMapper.writeValueAsBytes(body); DataBuffer buffer response.bufferFactory().wrap(bytes); return response.writeWith(Mono.just(buffer)); } catch (JsonProcessingException e) { return response.setComplete(); } } Override public int getOrder() { return -100; } }这段代码有四个关键点值得单独拿出来说。第一白名单用PathPattern匹配而不是String.matches。String.matches走的是正则语法你写一个/**它根本匹配不到这是个隐蔽的坑。PathPattern 是 Spring WebFlux 官方的路径匹配方案支持*、**、?这类通配符和路由谓词的语义一致。我在构造函数里就把白名单编译好避免每次请求都重新解析。第二JWT 解析的 API 在 0.11.5 里变得严格了。旧教程里经常出现Jwts.parser().setSigningKey(字符串).parseClaimsJws(token)的写法但在新版本里这类写法会报错。必须用parserBuilder()并且密钥要用Keys.hmacShaKeyFor()转成SecretKey密钥长度必须大于等于 32 字节。第三用户信息透传的请求头名字我建议统一用X-开头。X-User-Id、X-User-Name这种命名在 HTTP 规范里属于自定义头可读性好也能避免和标准头冲突。这里要注意这些头只在网关内网传播客户端不应该能直接设置。为防止客户端伪造生产环境还要做一步处理我放到 4.4 节讲。第四getOrder()返回-100。这个负数保证登录校验在所有内置过滤器之前执行请求还没做路由转发就被拦住了。如果你把 Order 设成 10000 之后那就有趣了请求可能已经转发出去了你的校验过滤器才在响应阶段开始执行完全失去拦截意义。3.3 白名单设计与请求头透传白名单要想清楚哪些接口不需要登录。最简单的分类登录认证类接口比如login、register、captcha系统级接口比如health、actuator/health第三方回调接口比如支付回调、微信回调。配置白名单时有两种写法我建议用精确路径和通配符结合的方式auth: skip-urls: - /api/auth/login - /api/auth/register - /api/captcha/** - /api/pay/notify/**/api/captcha/**表示 captcha 下的所有子路径都放行/api/pay/notify/**表示支付回调的全部路径都放行。不要直接配一个/**把所有接口都放行了那登录校验就形同虚设了。请求头透传这块核心就是exchange.getRequest().mutate()。ServerWebExchange 是不可变的你不能直接在原请求上改 Header必须通过 mutate 生成一个新的 request 对象再生成一个新的 exchange用这个新 exchange 继续走过滤器链。很多新手直接调用exchange.getRequest().getHeaders().add(...)想加请求头结果发现完全没生效就是这个原因。3.4 下游服务如何拿到用户信息网关把用户信息写入请求头之后业务服务需要把它变成可以直接使用的对象。我习惯写一个UserContextHolder 一个拦截器代码侵入性最小。public class UserContextHolder { private static final ThreadLocalUserInfo USER_HOLDER new ThreadLocal(); public static void set(UserInfo userInfo) { USER_HOLDER.set(userInfo); } public static UserInfo get() { return USER_HOLDER.get(); } public static void clear() { USER_HOLDER.remove(); } }然后在 Spring MVC 拦截器里读取请求头存到 ThreadLocal 中Component public class UserInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String userId request.getHeader(X-User-Id); String username request.getHeader(X-User-Name); if (StringUtils.hasText(userId)) { UserContextHolder.set(new UserInfo(Long.valueOf(userId), username)); } return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContextHolder.clear(); } }业务代码里直接UserContextHolder.get().getUserId()就能拿到当前用户了完全不用在每个 Controller 里重复解析 Token。注意afterCompletion里清理 ThreadLocal 是必须的因为业务服务可能是 Tomcat 线程池复用的不清理会造成用户信息串线这是线上非常隐蔽的事故源。如果业务服务之间的内部调用也需要传递用户还有一个做法是给 Feign 写一个 RequestInterceptor把X-User-Id和X-User-Name从当前请求复制到 Feign 发起的微服务调用请求里。我建议把它和 UserInterceptor 放在同一个公共包里做成一个通用组件。4. 网关登录校验的雷区与排查指南4.1 高频踩坑现场我把自己和身边同事实际碰到过的问题整理成一张速查表遇到症状可以直接按表排查。症状可能原因解决办法网关启动报 Reactor 相关冲突项目里同时引入了 spring-boot-starter-web移除 Web 依赖只保留 WebFlux白名单/**不生效用了String.matches正则匹配改用PathPattern匹配JWT 解析抛 WeakKeyException密钥长度小于 32 字节生成至少 32 字节的 HMAC 密钥Token 解析报 Header 缺失jjwt 版本太旧或者 API 用错使用parserBuilder()setSigningKey(SecretKey)下游服务获取不到用户信息直接改原 request 的 Header使用mutate()生成新 request 和 exchange过滤器不执行没有注入 Spring 容器确认类上有Component注解教验逻辑执行了但请求仍放行没有返回 401 响应返回response.writeWith(...)终止链路用户信息串线ThreadLocal 没有清理在 afterCompletion 中调用 remove()4.2 在 WebFlux 里千万别写阻塞代码这是网关开发里最要命的一个问题。Spring Cloud Gateway 是 Netty 驱动的响应式服务事件循环线程数量默认只有 CPU 核数的两倍非常金贵。如果你在过滤器里写了一个阻塞操作比如用 RestTemplate 同步调用户服务查用户信息、用同步 Redis 客户端查黑名单、甚至Thread.sleep(100)这个线程就会被占住并发一上来整个网关的吞吐量会瞬间崩掉。我见过真实的线上事故一个同学在网关过滤器里加了同步调用用户服务获取用户状态的逻辑压测时线程池被打满连健康检查接口都响应不了了。所以记住一个铁律GlobalFilter 里只能写非阻塞逻辑。JWT 的签名校验是纯 CPU 计算没有问题需要查 Redis 或远程服务时必须用响应式客户端比如ReactiveRedisTemplate或者 WebClient。如果 Redis 的查询逻辑比较复杂我建议在网关启动时把黑名单、白名单这类变化不频繁的数据加载到本地内存过滤器里只读本地缓存最大程度减少阻塞。这同时也是面试时很容易被追问的点面试官问的就是“网关鉴权怎么做能保证高并发”如果你能主动说出“网关过滤器里不能有阻塞调用”这个点会显得非常有实战经验。4.3 响应处理与鉴权失败返回鉴权失败时网关要返回一个结构统一的 JSON而不是 Spring Boot 默认的错误页。我上面代码里unauthorized(exchange)方法已经处理了核心逻辑是三步设置状态码 401、设置 Content-Type 为 application/json、把 JSON 写入响应体。这里有几个注意点。第一设置响应头要在writeWith之前完成否则返回头可能已经写入了Content-Type 就改不掉了。第二JSON 序列化要指定 UTF-8 编码防止中文乱码。第三如果业务方要求不同的状态码比如 401 登录过期、403 无权限你可以扩展方法传入 HttpStatus把逻辑沉淀成一个通用的响应工具类。还有一个小细节官方文档其实推荐用response.writeWith(Mono.just(buffer))或者response.setComplete()。如果你在过滤器里已经决定不再放行就不要再去调用chain.filter(exchange)否则可能出现已经写了 401 响应请求却还被继续转发到下游的情况。4.4 扩展思路刷新 Token、二级校验与性能优化上面的代码是一个可用的最小方案但生产环境至少要再考虑三件事。第一件是刷新 Token。纯 JWT 的 access token 有效期不会设太长通常 15 分钟到 2 小时。过期后网关返回 401客户端拿 refresh token 调用刷新接口换新的 access token。这里要注意网关要放行 refresh 接口同时在刷新接口里续签的时候可以把旧 Token 加入黑名单防止被重放。第二件是网关注册的校验和业务服务的权限校验要分层。网关只做“这个用户是谁、登录没登录”的认证到了业务层再通过注解做“这个用户能不能访问这个资源”的授权。不要试图在网关里把权限规则全做掉否则网关会变成一个大杂烩业务扩展一个接口都要去动网关配置。第三件是性能和安全上的几个细节。JWT 密钥在构造函数里转成 SecretKey不要每次 filter 都转一次白名单 PathPattern 也在构造函数里编译好不要每次请求都 parse。安全上用户信息请求头虽然是从网关内网传给下游但如果担心下游服务被暴露在不完全可信的网络里建议在网关内做一层请求头签名或者把用户信息做加密后再传下游拿到密文解析。另一个做法是网关里校验一个可选的X-Real-IP头只处理可信来源的请求防止外部客户端直接绕过网关调用内部服务。最后再分享一个我的习惯我在自己的项目里过滤器链的逻辑一定保持短平快只做校验和透传不做业务加工。之前有段时间我在网关里顺手做了参数加解密结果每次改动都要重新发布网关风险极高后来全拆出去了。如果你现在要在一个老项目里加网关登录校验我建议先用第一版代码跑通登录放行和 Token 过期返回 401 这两条主链路再逐步把刷新 Token、Redis 黑名单、内部调用头透传加进去。网关这东西稳定比功能多更重要。这个小习惯帮我避免过好几次线上事故。
返回列表