☰
Spring Cloud Gateway登录校验实战:自定义GlobalFilter+JWT+Redis统一微服务认证
2026/10/3 2:53:01 网站建设 项目流程

SpringCloud微服务项目做到网关这层,几乎所有人都会撞上同一个需求:登录校验不能在每个服务里重复堆。我目前在项目里就是用网关登录校验配合自定义GlobalFilter把认证逻辑沉淀到统一入口,改造后效果立竿见影。这篇文章围绕SpringCloud Gateway里怎么做登录校验、怎么写自定义GlobalFilter、以及GateWayFilter和GlobalFilter到底什么区别来展开,把我自己的落地方案和踩过的坑完整整理出来。

这个内容适合正在做微服务拆分的团队参考,也适合那些被各种概念绕晕、想直接看可运行代码的读者。我会从架构设计的为什么开始,一路讲到过滤器源码级区别、JWT+Redis的完整校验实现,以及一套可以直接抄的排查清单。看完你不需要再去翻那些只讲概念的博客了。

1. 网关登录校验:为什么微服务一定要有这道统一防线

1.1 微服务拆分的代价:认证逻辑怎么从一份变成八份

微服务架构图看起来很美:拆订单、拆用户、拆商品,各搞各的库,各发各的接口。但很多人把服务拆完之后才发现一个尴尬问题——原来单体应用里写一次的用户登录校验,现在每个服务都得写一遍。

我之前带过的一个项目就是典型:用户服务写了一份JWT解析,订单服务自己又写了一份,后来加了个支付服务,又复制一份。等到token的加密密钥要更换时,噩梦来了。你根本不知道哪些服务还在用旧密钥,得全局搜索代码,挨个服务发版。更离谱的是,有个服务还把JWT解析代码改出了自己的风格,别人一眼看不出是同一个东西。

登录校验重复写的核心问题不只是代码冗余,而是安全策略不一致。有的服务校验了用户状态,有的只校验了签名,有的干脆因为“内部服务”就不校验。网关登录校验解决的就是这个问题:把认证收敛到一个点,所有外部请求进系统只有一条路,必须过这一道检查。

1.2 网关校验的价值与边界:统一入口带来的连锁收益

网关做登录校验,最直观的收益是入口统一。外部客户端根本不直接接触微服务,它只知道一个网关地址。请求先到网关,网关把token验完之后,再转发给后面的订单服务、用户服务。这就是典型的“统一认证、向下透传”。

第二个收益是防止绕过认证。只要下游服务不暴露公网端口,所有流量都经过网关,认证逻辑想跳过都难。生产环境中我建议把服务端口全部绑定内网,管理面用独立的安全组控制,网关是唯一对外的口子。

第三个收益是省掉了每个服务的重复代码,这个价值在团队协作时尤其明显。新来的同事接一个新服务,不再需要搞明白token怎么验、Redis里用户信息怎么取,他只需要读网关传过来的请求头就行。

但也要说清楚边界。网关登录校验是入口防线,不是全部安全体系。涉及支付下单、修改密码这类高危操作,下游服务仍然需要做二次状态校验,比如判断用户是否被禁用、权限是否足够。网关拦截的是“非法访问”,下游校验的是“越权操作”。两者配合,而不是互相替代。

2. 动手前的准备:依赖引入与路由配置

2.1 项目依赖和版本选择的那些细节

我用的是Spring Cloud Gateway,而不是Zuul。原因不复杂:Zuul 1.x基于Servlet,同步阻塞模型;Gateway基于WebFlux,响应式非阻塞,性能和生态都更好,而且Spring Cloud官方后面的版本已经逐步放弃Zuul维护。如果你还在新项目里选Zuul,我建议趁早换。

先看pom.xml里的核心依赖。这里有一个特别容易踩的坑:Gateway基于WebFlux,一定不要引入spring-boot-starter-web,否则启动直接报错,或者出现奇怪的循环依赖。正确的依赖写法是这样:

<dependencies> <!-- Spring Cloud Gateway 核心 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> <!-- 注册中心:我用的是 Nacos --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> <!-- 负载均衡,网关转发到服务必须要有 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-loadbalancer</artifactId> </dependency> <!-- JWT 工具库 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <!-- Redis 客户端:用于读取登录用户信息 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis-reactive</artifactId> </dependency> </dependencies>

注意Redis我引入的是reactive版本,因为Gateway整个链路是响应式的,如果你引入普通spring-boot-starter-data-redis,想在过滤器里同步操作Redis会阻塞事件循环线程,高并发下性能会很难看。用ReactiveRedisTemplate配合flatMap处理异步结果,是网关场景下的正确姿势。

2.2 路由配置里的两个关键开关

路由配置相对简单,但有两个配置细节直接影响后续的过滤器开发。先看一份可用的application.yml:

server: port: 8080 spring: application: name: gateway-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 gateway: routes: - id: user-service uri: lb://user-service predicates: - Path=/user/** filters: - StripPrefix=1 - id: order-service uri: lb://order-service predicates: - Path=/order/** filters: - StripPrefix=1 globalcors: add-to-simple-url-handler-mapping: true cors-configurations: '[/**]': allowedOriginPatterns: "*" allowedMethods: - GET - POST - PUT - DELETE - OPTIONS allowedHeaders: "*" allowCredentials: true

第一个关键点是lb://user-service,它表示通过Nacos注册中心发现服务,并走客户端负载均衡。直接写http://localhost:8081也行,但那就丢失了注册中心动态感知的能力,服务实例扩容缩容时网关感知不到,生产环境不建议这么干。

第二个关键点是StripPrefix=1。客户端请求/user/login,经过网关后,Gateway会默认把完整路径转发给下游。如果下游服务的接口本身是/login,多了个/user前缀就会404。StripPrefix=1表示去掉路径中的第一段,把/user/login变成/login再转发。这个配置很多人第一次接触会忽略,结果路由通了但下游报404,排查半天。

2.3 网关的架构位置:一个简单的链路图

先在心里放一张微服务架构图,不用画得很精细,理解链路就够了:

客户端浏览器/App ↓ HTTP Nginx / SLB(可选) ↓ Spring Cloud Gateway(网关:登录校验+路由转发) ↓ AuthFilter(自定义GlobalFilter) ↓ 校验通过,透传用户信息 用户服务 / 订单服务 / 商品服务

网关在整个微服务体系里就像大楼的前台。所有人都要从前门进,前台确认你身份没问题再放行到对应楼层。没有这个前台,每个办公室都得自己装一套门禁,管理起来成本翻倍,安全策略还难以统一。

3. GlobalFilter和GateWayFilter:别再混淆这两个过滤器

3.1 GlobalFilter的底层逻辑是“全局生效”

先强烈建议你在看任何过滤器教程之前,先去翻一下Gateway的源码结构。Spring Cloud Gateway里的过滤器分为两类:GlobalFilter和GatewayFilter。从命名直译,一个是全局过滤器,一个是局部过滤器,但真正理解它们的关系要深入一层。

GlobalFilter是全局过滤器,它会自动作用于所有路由,不需要在路由配置里手动指定。它实现的接口长这样:

public interface GlobalFilter { Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain); }

所有实现了GlobalFilter接口的Bean,都会被Gateway自动装配到过滤链上。也就是说你只要写一个@Component注解的过滤器类,它就生效了,不需要在每个路由下面写filters。全局登录校验、全局日志、全局限流这类横切逻辑,用GlobalFilter最合适。

3.2 GateWayFilter与GlobalFilter的协作方式

GatewayFilter是局部过滤器,它必须配置在某个路由的filters列表里才会生效,比如StripPrefix、AddRequestHeader、Retry这些都是GatewayFilter的实现。如果你想给某个服务单独加一个响应头,不用影响别的服务,用GatewayFilter最合理。

表格对比几个核心维度:

对比项GlobalFilterGatewayFilter
生效范围所有路由仅配置了该filter的路由
定义方式实现GlobalFilter接口实现GatewayFilter接口或使用内置工厂
注册方式交给Spring容器即可配置在yml的routes.filters里
典型使用登录校验、全局限流、日志单路由的StripPrefix、限流
Order优先级必须实现Ordered接口或加@Order注解按配置顺序生效

这里要特别注意:GlobalFilter和GatewayFilter最终会在同一个责任链里执行,不是两套平行的链路。Gateway会把GlobalFilter包装成GatewayFilter,再按Order排序统一执行。所以Order设计得不好,局部过滤器可能跑在全局过滤器前面,导致某些场景逻辑错乱。

3.3 过滤器链的执行顺序与Order设计

过滤器链执行顺序由Order决定,数字越小越先执行。这里有一个反直觉的细节:请求的“前置”阶段是Order小的先执行,但“后置”阶段却反过来,是Order大的后执行。因为在响应式编程里,pre逻辑写在chain.filter(exchange)之前,post逻辑写在之后,整体执行像洋葱圈一样层层嵌套。

我自己在网关里至少会定义三个Order级别的过滤器:

过滤器Order作用
跨域处理过滤器-1000处理CORS,最先执行
登录校验过滤器-100解析并校验token
日志过滤器-50记录访问日志

跨域必须放在最前面,否则浏览器发OPTIONS预检请求时会被登录校验直接拦截,前端就会莫名其妙看到跨域报错。这个坑我踩过不止一次,后面在常见问题里还会详细说。

4. 登录校验核心实现:自定义GlobalFilter+JWT+Redis

4.1 校验链路设计

网关登录校验不是单纯解析一个JWT就完事,生产环境的校验链路通常要包含五步。我把这套流程用在两个项目里,整体运行比较稳:

第一步白名单放行。像登录接口、注册接口、验证码接口这些本来就不需要token,必须提前放行。白名单维护在Nacos配置中心里,改配置不用重启网关,这是生产环境的基础要求。

第二步提取请求头。从Authorization中取出token,如果为空直接返回401。

第三步解析JWT。用密钥验签,取出userId和username。这里要小心JWT过期和签名算法不一致两个问题,后面我会单独展开。

第四步查询Redis校验用户状态。JWT本身只能证明token没被篡改,但证明不了用户当前是否有效。如果用户被管理员封禁,或者密码被修改导致token必须失效,光验JWT是拦不住的。所以网关需要拿着userId去Redis里查一下当前登录态,看看用户状态是否正常。

第五步透传用户信息。校验通过后,把userId、username放进请求头,转发给下游服务。下游服务不需要再解析JWT,直接读请求头就可以识别当前用户。

4.2 自定义GlobalFilter代码解析

直接上一份我实际用过的代码骨架,每个关键点都有注释说明:

@Component @Slf4j public class AuthGlobalFilter implements GlobalFilter, Ordered { @Autowired private JwtProperties jwtProperties; @Autowired private ReactiveRedisTemplate<String, String> redisTemplate; private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String BEARER_PREFIX = "Bearer "; // 白名单:不需要登录即可访问的路径 private static final List<String> WHITE_LIST = Arrays.asList( "/user/login", "/user/register", "/user/captcha" ); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getPath().value(); // 1. 白名单直接放行 if (WHITE_LIST.contains(path)) { return chain.filter(exchange); } // 2. 跨域预检请求直接放行 if ("OPTIONS".equals(request.getMethod().name())) { return chain.filter(exchange); } // 3. 提取token String token = resolveToken(request); if (StringUtils.isBlank(token)) { return unauthorizedResponse(exchange, "未携带Token,请先登录"); } // 4. 解析JWT Claims claims; try { claims = Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8))) .build() .parseClaimsJws(token) .getBody(); } catch (ExpiredJwtException e) { return unauthorizedResponse(exchange, "登录已过期,请重新登录"); } catch (JwtException e) { return unauthorizedResponse(exchange, "Token无效,请重新登录"); } String userId = claims.get("userId", String.class); String username = claims.get("username", String.class); // 5. 查Redis校验登录态 String redisKey = "login:token:" + userId; return redisTemplate.opsForValue().get(redisKey) .flatMap(tokenInRedis -> { if (!token.equals(tokenInRedis)) { return unauthorizedResponse(exchange, "登录状态已失效"); } // 6. 透传用户信息到下游服务 ServerHttpRequest mutatedRequest = request.mutate() .header("X-User-Id", userId) .header("X-Username", username) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); }) .switchIfEmpty(unauthorizedResponse(exchange, "登录状态已失效")); } private String resolveToken(ServerHttpRequest request) { String header = request.getHeaders().getFirst(AUTHORIZATION_HEADER); if (header != null && header.startsWith(BEARER_PREFIX)) { return header.substring(BEARER_PREFIX.length()); } return null; } private Mono<Void> unauthorizedResponse(ServerWebExchange exchange, String message) { ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().add("Content-Type", "application/json;charset=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; } }

这份代码有几个细节值得展开讲。

首先是token解析用的Keys.hmacShaKeyFor,这个API要求密钥长度至少32字节,你用“123456”这种短密钥会直接抛异常。生产环境中密钥要通过配置中心下发,不要写在代码里。

其次是Redis的比对方式。这里是简单比较Redis里存的token和当前token是否一致。如果用户改密码、被踢下线、或者在别处重新登录,Redis里的token应该被更新或被删除,旧token就会失效。这样就实现了“用户主动退出后token立刻失效”的效果。

还有switchIfEmpty这个响应式操作符。从Redis取不到值时,会执行备用逻辑,返回登录失效的响应,。这里需要注意方法的返回类型统一用Mono<Void>,避免出现“看起来能编译但请求悬挂不返回”的问题。我之前就是因为漏掉了switchIfEmpty,导致Redis里没有键时网关既不响应也不放行,请求直接卡死,超时后前端才报错。

4.3 统一返回格式和跨域预检的细节

网关层返回401时,很多团队的内部协议返回体结构是统一的,比如code、message、data三段式。我这里简化成了最普通的JSON结构,实际项目中你可以抽一个公共的Result对象,但要注意网关层尽量不要依赖下游服务里的统一响应类,不然网关和微服务之间的jar包耦合会越来越重。我见过团队把用户服务的Result类直接引入网关,后来重构时两边互相拖累,很不舒服。

另一个值得注意的点是跨域预检。前端项目在调用接口时,浏览器会先发送一个OPTIONS请求探路。如果网关的全局过滤器没有放行OPTIONS,这个预检请求就会被登录校验拦截,返回401甚至403,浏览器就会报“CORS policy”错误,而且这个报错往往不会提示你具体原因。上面代码里我也专门加了对OPTIONS的放行判断,配合application.yml里的globalcors配置才能正常工作。

这里有个容易翻车的地方:如果你们的前端网关前面还有一层Nginx做域名转发,那么跨域配置的allowedOriginPatterns不能写死前端域名,最好用*配合allowCredentials: true的模式,同时确认Nginx也把Origin和Authorization这两个头正确传下来了。我在排障时多次发现,网关配置没问题,问题出在Nginx把headers过滤掉了。

4.4 把用户信息透传给下游服务

网关校验通过后,下游服务如何拿到当前用户信息?答案就是请求头。我习惯统一约定X-User-Id、X-Username这两个内部请求头。下游服务用一个拦截器从请求头里读取,放到ThreadLocal里,业务代码直接用。

下游服务的用户上下文读取代码可以这样写:

@Component public class UserContextInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String userId = request.getHeader("X-User-Id"); String username = request.getHeader("X-Username"); if (StringUtils.hasText(userId)) { UserContext.set(userId, username); } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }

这里的ThreadLocal存储一定要注意清理。app服务都是线程池复用线程的,如果不在afterCompletion里调用clear(),高并发下会出现用户A请求里带着用户B的信息,这个bug非常隐蔽,而且线上偶发,排查起来极其痛苦。

还有一个透传的坑,存在于微服务间的Feign调用。服务A接收网关的请求头后,如果服务A再通过Feign调用服务B,Feign默认不会把网关传过来的X-User-Id头继续传递给服务B。这时候需要在Feign的RequestInterceptor里手动透传:

@Configuration public class FeignHeaderConfig { @Bean public RequestInterceptor requestInterceptor() { return template -> { ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs != null) { HttpServletRequest request = attrs.getRequest(); String userId = request.getHeader("X-User-Id"); if (StringUtils.hasText(userId)) { template.header("X-User-Id", userId); } } }; } }

有些团队会选择用Seata或者Sleuth这类组件来传递链路上下文,但对于简单的登录用户信息,手动透传请求头是成本最低、最可控的方案。安全方面,我额外建议在网关层对这三个内部请求头做一步操作:清掉客户端传进来的同名头。因为外部请求完全可以伪造X-User-Id,如果不先移除再自己添加,下游服务可能被恶意伪造用户身份。上面代码里用request.mutate()重新添加header之前,最好先调用headers.remove("X-User-Id")。

5. 常见问题排查与踩坑实录

5.1 高频报错速查表

把网关相关的问题整理成一张速查表,方便你后续排查时直接对照:

现象可能原因解决方案
启动报“Spring MVC found on classpath, which is incompatible with Spring Cloud Gateway”网关引入了spring-boot-starter-web排除web依赖,保留WebFlux
路由转发404没配StripPrefix,路径前缀没去掉在路由filters里加StripPrefix=1
前端报CORS跨域,但网关配置看着没问题OPTIONS预检被全局过滤器拦截了在全局过滤器最前面放行OPTIONS
请求卡死不返回Redis取不到值时没走switchIfEmpty给Mono加switchIfEmpty兜底
解析JWT报SecretKeyTooLong密钥长度不足32字节换成长密钥,用Base64或128位随机数
下游服务获取不到用户信息请求头被网关丢弃,或下游没加拦截器网关mutate后记得重新build exchange,下游加解析逻辑
服务间Feign调用丢失登录态Feign默认不透传请求头配置RequestInterceptor透传
Filter代码改了不生效没加@Component注解交给Spring容器管理,Gateway才能扫描到

5.2 踩坑实录:过滤器里操作了阻塞API导致性能骤降

有一次压测网关登录校验场景,发现TPS始终上不去,排查后发现我在GlobalFilter里用了RestTemplate调了一个用户服务接口。在WebFlux的Netty线程模型里,RestTemplate是阻塞式的,任何一个阻塞操作都会把事件循环线程卡住。并发一高,请求挤压,整个网关就像堵死了一样。

解决办法是把阻塞调用改成响应式调用,或者把这类校验信息提前同步到Redis里,用ReactiveRedisTemplate去读。这也是我前面一直强调要用reactive版Redis客户端的原因。如果你在网关过滤器里看到try/catch包着一个同步RPC调用,几乎可以断定这地方迟早出性能事故。

5.3 网关层过滤器设计的一些实用心得

过滤器职责划分上,我强烈建议保持单一。不要写一个超级过滤器又做登录校验又做参数校验又做加解密,最后几百行代码堆在一个类里。每加一个独立逻辑,就新建一个GlobalFilter,用Order控制先后顺序。这样排错时只要看Order就知道哪个先执行,日志也好定位。

白名单维护要放在配置中心,不要把路径写死在代码里。我一开始把白名单定义成代码常量,结果每次新增放行接口都要发版一次网关。后来移到Nacos配置里,再配合@RefreshScope动态刷新,效率高了一截。注意发布变更后要确认配置中心真的刷新成功,不要盲目信赖自动刷新机制。

服务间传递用户信息的请求头,强烈建议用常量类统一管理,然后Gateway服务和各微服务引入同一个“内部API常量模块”,免得每个服务里各写一份字符串,一个字母拼错就查半天。如果我每次都要凭记忆手写X-User-Id,迟早有一天会写错。

生产环境一定要给网关加访问日志,记录请求路径、响应状态码、耗时、调用方IP。出问题的时候这些数据能帮你快速定位是网关拦截了还是路由转发失败。我用的是在过滤器里记录开始时间,在chain.filter(exchange).then()的回调里计算耗时的写法,对性能影响极小。

6. 最后的最后:网关登录校验的延伸思路

网关登录校验这套方案做顺手之后,你会发现很多横切逻辑都可以往网关挪。比如接口级权限校验、限流熔断、黑白名单IP、操作审计日志、接口幂等控制,这些都是我后续在网关上一层层加出来的能力。每次加新能力前我都会先问自己:这个逻辑是所有服务都需要的吗?如果是,放到网关才是正确位置;如果只有某个服务需要,那就用局部过滤器,不要污染全局链路。

按我个人经验,微服务拆分初期最值得做的三件事就是:统一日志规范、统一网关认证、统一配置中心。其中网关登录校验收益最快,做成之后整个团队的接口安全基线立刻上了一个台阶,后面新增十个服务都不用再担心认证问题。你在自己项目里动手时,先从最简单的GlobalFilter+JWT开始,把链路跑通后再慢慢叠加Redis校验、白名单刷新、请求头透传这些增强功能,千万不要一上来就追求大而全,否则会把自己绕晕。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询