☰
Spring Cloud Gateway 局部过滤器实现权限校验完全指南
2026/10/23 19:31:22 网站建设 项目流程

网关系列写到第四篇,终于可以聊聊权限校验了。前面几篇我们把路由和谓词都过了一遍,但说实话,光有路由和谓词,网关只算是个“聪明的转发器”,真正让它在微服务架构里不可替代的,是过滤器。今天就集中梳理 GatewayFilterFactory 这个局部过滤器工厂:从内置的常用工厂怎么选,到自定义一个权限校验工厂,再到和限流、熔断这些过滤器如何配合,一次讲透。

看完这篇,你至少能回答这几个问题:局部过滤器和全局过滤器到底怎么选;自定义 GatewayFilterFactory 的标准写法是什么;权限校验在网关层到底校验到哪一层才算够。适合已经搭好 Gateway 基础、正准备做统一鉴权的同学。

1. 网关权限校验的两条路线:为什么我优先用局部过滤器

1.1 全局过滤器与局部过滤器的实质区别

Gateway 里其实有两套过滤器体系,很多人一开始会混淆。

GlobalFilter 是全局过滤器,实现了这个接口的 Bean 会作用于所有路由。它不需要挂到某个路由上,启动时就全量生效,适合做全链路日志、请求 ID 埋点、跨域处理这类通用横切逻辑。

GatewayFilterFactory 则是“过滤器工厂”,它的产物是一个 GatewayFilter。这种过滤器通过路由配置显式挂载,Spring Cloud Gateway 根据 Bean 名约定把工厂类名变成配置里的过滤器名。它只对绑定的路由生效,所以叫局部过滤器。

一句话区分:全局过滤器是“无差别覆盖”,局部过滤器是“按路由精准投放”。权限校验这种逻辑,往往不同服务不同路径规则千差万别,全局一刀切反而难维护。

还有一个很多人忽略的点——执行顺序。全局过滤器实现 Ordered 接口可以控制彼此先后,局部过滤器则跟随路由配置中的装配顺序,它们最终会混在一起按 order 排序执行。这个细节我放到第四章详细讲,这里先记住一个结论:权限校验放在局部过滤器里,更可控、更好调整。

1.2 内置 GatewayFilterFactory 实用清单

虽然主题是自定义过滤器,但动手之前一定要先看看官方的内置工厂。很多时候我们根本不用写代码,几个内置过滤器一拼就够用了。

过滤器工厂作用典型场景
AddRequestHeader为请求增加请求头写入内部标记、透传链路 ID
RemoveRequestHeader移除指定请求头抹掉客户端传入的危险头
AddResponseHeader为响应增加头统一 CORS 头、安全头
StripPrefix去除路径前 N 段去掉 /api 前缀
PrefixPath为路径加前缀老服务路径迁移
SetPath重写请求路径网关路由收敛
RedirectTo重定向短链、强制跳转
Retry请求失败重试瞬时网络抖动
RequestSize限制请求体大小上传接口防护
RequestRateLimiter按维度限流防刷、配额控制
CircuitBreaker熔断降级下游故障兜底
SaveSession保存 WebSession有状态会话

这些工厂在官方文档里都有,但实际项目里我发现很多同学只用到 StripPrefix 和 RequestRateLimiter。建议对每个都建个 Demo 路由试一遍,理解参数含义,后面自定义时才明白框架的设计思路。

1.3 为什么权限校验优先考虑局部过滤器

先说不建议用全局过滤器的场景。假设你有三条路由:管理端接口、用户端接口、开放 API。管理端要校验管理员 Token,用户端只要校验普通登录态,开放 API 要走签名验签。如果用一个 GlobalFilter 写,代码里全是 if/else 判断请求路径,越写越长,加一条路由就要动一次全局过滤器。

局部过滤器天然适合这种差异化的场景,因为过滤器工厂可以参数化。同一个 Auth 过滤器,在管理端路由上配置严格校验模式,在用户端路由上配置宽松模式,在开放 API 路由上配置验签模式。代码只写一份,参数通过路由配置注入,行为却完全不同。

全局过滤器更适合做“无差别的事”:打印请求日志、注入 traceId、统计流量。把鉴权逻辑塞进全局过滤器,短期看省事,后期每个路由做差异化时就要开始痛苦的拆代码了。除非你的网关真的只有一条路由,否则我都建议权限校验走局部过滤器。

2. 自定义 GatewayFilterFactory:从零手写一个权限校验过滤器

2.1 先设计清楚,这个过滤器到底要做什么

动手写代码之前,先把职责列清楚。我这个权限过滤器要干五件事:

  1. 维护白名单,白名单内的路径直接放行;
  2. 从 Authorization 头里提取 Token,并校验格式;
  3. 当配置开启签名校验时,校验请求签名;
  4. 校验 Token 是否有效、是否过期;
  5. 校验通过后,把用户 ID 写入请求头,转发给下游服务。

为什么需要白名单?因为登录接口、注册接口、健康检查这些路径根本没有 Token 可用,如果过滤器一视同仁全部拦截,登录入口就死循环了。这个设计是所有网关鉴权的基础,先想清楚再写代码会顺手很多。

2.2 配置类与参数绑定

GatewayFilterFactory 的精髓在于参数绑定。yml 里配置过滤器时写的那些参数,最终会映射到工厂内部的一个 Config 类上。所以第一步先定义 Config 类。

public static class Config { private List<String> whiteList = new ArrayList<>(); private boolean verifySignature = false; private String userIdHeader = "X-User-Id"; public List<String> getWhiteList() { return whiteList; } public void setWhiteList(List<String> whiteList) { this.whiteList = whiteList; } public boolean isVerifySignature() { return verifySignature; } public void setVerifySignature(boolean verifySignature) { this.verifySignature = verifySignature; } public String getUserIdHeader() { return userIdHeader; } public void setUserIdHeader(String userIdHeader) { this.userIdHeader = userIdHeader; } }

两点注意事项。第一,Config 类必须是 public static 内部类,并且要有无参构造函数,否则 Spring 无法完成自动绑定。第二,如果你的过滤器希望支持 yml 简写形式,还需要重写 shortcutFieldOrder 方法:

@Override public List<String> shortcutFieldOrder() { return Arrays.asList("whiteList", "verifySignature", "userIdHeader"); }

这个方法定义了简写参数的顺序。比如- Auth=/api/user/login,true,X-User-Id会被按照这个顺序解析到 Config 里。不过我的建议是白名单这种 List 类型参数不要用简写,多个路径用逗号分隔时很容易踩解析的坑,后面第五章会详细说。

2.3 核心 apply 方法与完整过滤器代码

接下来是重头戏,apply 方法。这个方法接收一个 Config,返回一个 GatewayFilter。GatewayFilter 的核心逻辑写在 filter 方法里,通过 exchange 可以拿到请求和响应对象。

下面是一份完整的代码示例,为了方便阅读我使用了同步的 StringRedisTemplate,生产环境建议换成响应式 Redis,这点后面会展开。

@Component public class AuthGatewayFilterFactory extends AbstractGatewayFilterFactory<AuthGatewayFilterFactory.Config> { private final StringRedisTemplate redisTemplate; public AuthGatewayFilterFactory(StringRedisTemplate redisTemplate) { super(Config.class); this.redisTemplate = redisTemplate; } @Override public List<String> shortcutFieldOrder() { return Arrays.asList("whiteList", "verifySignature", "userIdHeader"); } @Override public GatewayFilter apply(Config config) { return (exchange, chain) -> { ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath(); // 第一层:白名单直接放行 boolean inWhiteList = config.getWhiteList().stream() .anyMatch(pattern -> path.startsWith(pattern)); if (inWhiteList) { return chain.filter(exchange); } // 第二层:Token 存在性与格式校验 String token = request.getHeaders().getFirst("Authorization"); if (token == null || !token.startsWith("Bearer ")) { return unauthorized(exchange, "缺少Token或格式错误"); } token = token.substring(7); // 第三层:Token 状态校验,这里用 Redis 判断是否有效 String userId = redisTemplate.opsForValue().get("login:token:" + token); if (userId == null) { return unauthorized(exchange, "Token无效或已过期"); } // 第四层:可选签名校验 if (config.isVerifySignature()) { String signature = request.getHeaders().getFirst("X-Signature"); if (!checkSign(request, signature, userId)) { return unauthorized(exchange, "签名校验失败"); } } // 第五层:透传用户信息,并防伪造 ServerHttpRequest newRequest = request.mutate() .headers(httpHeaders -> { httpHeaders.remove(config.getUserIdHeader()); httpHeaders.set(config.getUserIdHeader(), userId); }) .build(); ServerWebExchange newExchange = exchange.mutate().request(newRequest).build(); return chain.filter(newExchange); }; } private Mono<Void> unauthorized(ServerWebExchange exchange, String message) { ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().setContentType(MediaType.APPLICATION_JSON); byte[] bytes = ("{\"code\":401,\"message\":\"" + message + "\"}") .getBytes(StandardCharsets.UTF_8); return response.writeWith(Mono.just(response.bufferFactory().wrap(bytes))); } private boolean checkSign(ServerHttpRequest request, String signature, String userId) { if (StringUtils.isEmpty(signature)) { return false; } String body = "userId=" + userId + "&path=" + request.getURI().getPath(); String expected = DigestUtils.md5DigestAsHex(body.getBytes(StandardCharsets.UTF_8)); return expected.equals(signature); } }

有几个细节必须强调:第一,chain.filter(exchange)是放行的唯一入口,如果条件不满足却没有调用它,请求会一直挂着不返回。第二,失败响应里必须调用response.writeWith(...)并返回这个 Mono,同时不要再继续调用chain.filter,否则响应会被写两次。第三,透传用户信息时要先 remove 再 set,目的是把外部客户端伪造的同名请求头覆盖掉,避免下游被欺骗。

2.4 注册 Bean 并在路由中生效

类上已经有 @Component 注解,Spring Boot 启动时会自动注册这个过滤器工厂。但这里有一个命名约定,务必记牢:Spring Cloud Gateway 是根据 Bean 的名字去掉 “GatewayFilterFactory” 后缀来匹配 yml 配置的。

也就是说,类名AuthGatewayFilterFactory注册后的 Bean 名是authGatewayFilterFactory,去掉后缀变成auth,yml 里配置过滤器时写Auth就能匹配上。如果你把类名改成AuthFilterFactory,那配置名就要写成AuthFilter。我见过不少同学在类名上随意发挥,结果过滤器怎么都不生效,卡了半天。

如果你不喜欢 @Component 自动注册,也可以在用配置类手动 new,效果一样:

@Configuration public class GatewayConfig { @Bean public AuthGatewayFilterFactory authGatewayFilterFactory(StringRedisTemplate redisTemplate) { return new AuthGatewayFilterFactory(redisTemplate); } }

yml 里的路由配置如下:

spring: cloud: gateway: routes: - id: user-service uri: lb://user-service predicates: - Path=/api/user/** filters: - StripPrefix=1 - name: Auth args: whiteList: /api/user/login,/api/user/register,/actuator/health verifySignature: true userIdHeader: X-User-Id

这里同时挂了两个局部过滤器:StripPrefix 先执行,把 /api/user/** 的 /api 去掉;然后 Auth 执行,按参数校验白名单、Token 和签名。filter 列表的顺序就是过滤器链的加入顺序,这个马上会在第四章展开。

3. 权限校验的细节设计与方案取舍

3.1 校验的四个层次

很多刚接触网关鉴权的同学以为“校验 Token”就是“查一下 Redis 里有没有这个 key”,其实一个成熟的权限校验至少要分四层。

第一层是白名单。不需要登录的路径,包括登录接口、注册接口、验证码、健康检查、静态资源等,必须显式放行。这里要注意匹配方式,不要用path.startsWith("/api/user/login")这种写法,因为/api/user/login_admin也会被误放行。更稳妥的做法是使用 PathPattern 或者 Spring 的 AntPathMatcher,精确匹配路径规则。

第二层是 Token 的存在性和格式。Authorization 头有没有、是不是 Bearer 开头、Token 切出来之后能不能做 Base64 解码。这层校验非常快,不涉及网络请求,主要用来拦截明显不合法的请求。

第三层是签名校验。适合开放 API 场景,防止请求参数被篡改。网关验签要共享密钥,签名规则要前后端对齐,这是所有层次里最灵活也最容易被忽略的。不需要每个路由都开,做成开关按需启用就好。

第四层是 Token 的有效状态。Token 是否过期、用户是否被踢下线、密码修改后旧 Token 是否失效。这层必须依赖存储,用 Redis 是最常见的做法。

3.2 无状态 Token 还是 Redis 状态校验

在实际项目里,Token 方案的选择直接影响权限校验的实现方式。我遇到过不少团队一开始用纯 JWT,后来发现“踢人”和“登出失效”根本做不了,又回来改造。

方案优点缺点适用场景
纯 JWT校验快,不需要存储无法主动失效,泄漏难追责开放 API、第三方合作
Token + Redis登出立即可用,支持踢人每次请求多一次 Redis 交互内部系统、后台管理
混合方案JWT 负责签名,Redis 做黑名单实现复杂中大型平台

如果你做的是公司内部系统,我强烈建议 Token + Redis。因为内部系统的“登出”和“多端登录控制”是刚需,纯 JWT 很难优雅支持。Redis 的交互成本可以接受,只要做好高可用和降级策略。

Redis 挂掉怎么办?这也要提前想好。网关可以做降级:校验失败时记录日志并放行,保证业务可用,同时触发告警;或者直接返回 503,宁可不可用也不放行。这个取舍取决于公司的容错倾向,没有绝对正确的答案,但一定要有预案。

3.3 用户信息透传与防伪造

网关校验通过后,下游服务怎么知道当前用户是谁?常规做法是把 userId 写进请求头,随请求一起转发。

但这里有一个严重的隐患:外部客户端完全可以自己伪造一个X-User-Id头传给网关,如果网关不做处理,下游服务一旦信任了这个头,就会造成越权。所以网关在透传用户信息之前,必须先把外部传入的同名头清理掉,再写入真实的 userId。

ServerHttpRequest newRequest = request.mutate() .headers(httpHeaders -> { httpHeaders.remove(config.getUserIdHeader()); httpHeaders.set(config.getUserIdHeader(), userId); }) .build();

哪怕 Spring 的 mutate 机制本身会覆盖同名头,我也建议显式 remove 一次,代码意图更清晰,也不依赖框架升级后的行为变化。

还有一个点,下游服务要约定“只信任来自网关的请求头”。如果服务被内网其他服务直接调用,绕过网关,同样要能识别出没有 X-User-Id 头,并拒绝或要求显式身份标识。网关只是收敛了流量入口,真正的信任边界还要靠服务端配合。

3.4 失败响应的全局统一处理

权限校验失败时,网关返回什么响应,直接影响前端的对接体验。默认的响应是一段 HTML 报错页,前后端分离项目拿到这种响应非常痛苦。

统一 JSON 结构是基本操作,比如{"code":401,"message":"Token无效或已过期"}。在此基础上还可以加上path和timestamp,方便排查。响应头要显式设置Content-Type: application/json;charset=UTF-8,否则中文很可能乱码。

401 和 403 要区分清楚:未登录返回 401,已登录但权限不足返回 403。我见过一些项目图省事,一律返回 401,导致前端无法区分“需要跳登录页”和“提示无权限”,体验很差。

另外,不要在响应里暴露内部异常信息,比如 Redis 连接失败、空指针堆栈之类的,这些应该记录在网关日志里,而不是展示给客户端。

4. 过滤器组合与执行顺序:和限流、熔断放在一起怎么调

4.1 局部过滤器之间的执行顺序从哪来

如果你在一个路由上挂了多个局部过滤器,它们的执行顺序是怎么定的?答案是:默认按 yml 配置顺序执行。

但这里有个细节容易被忽略。Spring Cloud Gateway 最终会把路由上的局部过滤器和全局过滤器混在一起排序,全局过滤器可以实现getOrder()返回一个 int 来控制顺序。局部过滤器如果没实现 Ordered 接口,它的 order 就是 0,排序时靠稳定排序保持原来的相对顺序。

换句话说,yml 里的顺序在大多数情况下有效,但如果你在某处改了全局过滤器的 order,有可能会插到局部过滤器链中间,打乱你预期的执行顺序。所以我的习惯是:对关键过滤器显式实现 Ordered 接口,给每个过滤器划定 order 区间,而不是完全依赖配置顺序。

比如权限过滤器希望优先执行,可以实现:

@Override public int getOrder() { return -100; }

数值越小越靠前。这样就算混入其他过滤器,Auth 也能排在最前面。

4.2 与 RequestRateLimiter 组合实现限流

限流和鉴权经常一起出现。一个常见的问题是:先限流还是先鉴权?答案是看限流维度。

如果按 IP 限流,那 RequestRateLimiter 可以放在 Auth 前面,因为还没解析用户身份就可以按 IP 计数,这样能拦截大量非法请求,减轻鉴权压力。如果按用户维度限流,那就必须先执行 Auth,拿到 userId 之后,KeyResolver 才能从请求头里取到用户标识。

组合配置长这样:

filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 10 redis-rate-limiter.burstCapacity: 20 key-resolver: "#{@ipKeyResolver}" - name: Auth args: whiteList: /api/user/login,/api/user/register,/actuator/health

这个配置是 IP 限流在前、鉴权在后。对于登录接口来说,限流能防止别人用脚本暴力重试,即使登录接口在白名单里,限流依然有效,这是很实用的小技巧。

4.3 与 CircuitBreaker 组合实现降级返回

下游服务不稳定时,网关需要快速失败,而不是让前端一直等。CircuitBreaker 过滤器就是干这个的。

它的基本用法是配一个 fallbackUri,当调用下游失败时转发到本地的一个降级接口:

filters: - name: CircuitBreaker args: name: userServiceCB fallbackUri: forward:/fallback

这里有一个重要注意点:CircuitBreaker 通常放在 Auth 之后、靠近转发的位置。如果放在 Auth 前面,鉴权失败返回的 401 也会被熔断器统计,导致下游一抖动就把健康的鉴权逻辑也熔断了,误伤面会很大。

Gateway 提供的这个降级能力,配合权限校验过滤器,可以组合出一套比较完整的网关防护链:限流挡恶意流量,鉴权挡非法请求,熔断挡住下游故障,三件事各司其职,互不干扰。

5. 常见问题与排查技巧实录

5.1 过滤器不生效的三大自查点

权限过滤器写完但完全不执行,这是最常见的故障,没有之一。我的排查顺序基本固定,依次看三处。

第一,Bean 有没有注册。用了 @Component 的话,检查类所在的包有没有被主类扫描到;用了 @Bean 的话,检查配置类是否被加载。另外一个低级错误是类名后缀不对,不是 GatewayFilterFactory 结尾,导致 yml 里写的名字匹配不上。

第二,路由上有没有挂过滤器。我见过有人把过滤器写到了别的路由下面,当前请求路径命中的是另一条路由,自然就绕过去了。用- Path=/api/user/**去对照实际请求路径,一步步缩小范围。

第三,yml 配置的过滤器名拼写。Auth 对应的 Bean 是 authGatewayFilterFactory,如果你写成了AuthFilter,框架在上下文里找不到对应类型的 Bean,启动时会直接报错;如果启动没报错但过滤不执行,多半是命中错误。

排查时可以临时在过滤器入口打印一行日志,确认代码真的进来了。这一步能筛掉绝大多数环境问题。

5.2 getConfig() 返回 null 和参数绑定问题

自定义 GatewayFilterFactory 时,最经典的空指针就是 config 为 null。原因基本只有一个:构造方法里没有调用super(Config.class)。

AbstractGatewayFilterFactory需要知道 Config 的类型,才能完成参数自动绑定。不传的话,apply 拿到的 config 就是个 null,访问里面的白名单列表立刻空指针。这个错误很隐蔽,因为代码编译期不会暴露。

还有一类问题是参数绑不上。比如我上面提到的简写顺序,当你用- Auth=/api/user/login,true,X-User-Id这种写法时,shorcutFieldOrder 的顺序必须和参数一一对应,多一个少一个都会解析异常。尤其是 List 参数,简写时很容易因为路径里有逗号造成误解。

我的建议是:自定义过滤器一律用完整写法,name 加 args,不要用简写。可读性好,也不容易踩绑定的坑。除非你的参数全是简单类型,且数量稳定。

5.3 性能隐患:不要在过滤器里做同步阻塞操作

Gateway 底层是 Netty 加 WebFlux,线程模型和传统 Tomcat 完全不同。它适合的是非阻塞 IO,事件循环线程非常宝贵,如果在过滤器里用同步 StringRedisTemplate 查 Redis,一个阻塞就会拖住整个线程,高并发下网关性能直线下降。

我这里给出的完整示例为了让大家看懂,用了同步的 StringRedisTemplate,生产环境如果要查 Redis,请换成 ReactiveStringRedisTemplate,核心写法差异不大:

return reactiveRedisTemplate.opsForValue().get("login:token:" + token) .flatMap(userId -> { ServerHttpRequest newRequest = request.mutate() .header(config.getUserIdHeader(), userId) .build(); ServerWebExchange newExchange = exchange.mutate().request(newRequest).build(); return chain.filter(newExchange); }) .switchIfEmpty(Mono.defer(() -> unauthorized(exchange, "Token无效或已过期")));

如果团队对响应式编程还不熟悉,另一个务实方案是:网关只做轻量校验,比如签名和 Token 格式,登录状态这种需要查存储的校验下沉到业务服务。网关保持轻量化,也是很多团队的最终选择。

5.4 问题速查表

现象可能原因处理方案
过滤器完全不执行未注册 Bean、路由没挂、名字拼写错误按 5.1 三步自查
config 为 null构造方法没调用 super(Config.class)补上父类构造调用
白名单参数解析失败简写参数与 shortcutFieldOrder 不匹配改用 name + args 完整写法
响应中文乱码编码不是 UTF-8写入时显式使用 StandardCharsets.UTF_8
401 响应写了两遍失败分支没有返回,又调用了 chain.filter失败时直接 return writeWith 的结果
网关高并发下 CPU 飙高过滤器里做同步阻塞 IO改响应式 Redis,或把重校验下沉

另外补充一个调试技巧:把logging.level.org.springframework.cloud.gateway=DEBUG打开,可以看到每个路由匹配了哪些过滤器、过滤器的执行顺序,排查配置问题效率能提升很多。我调试过滤器顺序时基本都靠它。

我个人在网关权限这块的经验是,不要一上来就追求“万能过滤器”。先把内置工厂玩熟,再动手写自定义过滤器。自定义时优先做白名单和 Token 校验,验签这种开销大的能力做成可配置开关,按路由按需启用。

网关层做权限校验,核心不是“拦截了多少非法请求”,而是让下游服务默认信任网关,把公共的安全逻辑收敛到一个入口。后面如果想继续扩展,可以考虑把白名单和密钥放到配置中心做动态热更新,在过滤器里每次读取配置再决定是否放行。那就比单纯写过滤器更有意思了,下一篇有机会再细聊。

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

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

立即咨询