上个月前同事找我吐槽:他们微服务拆了十几个,安全这套每个服务各写各的,Spring Security 每个服务都引,改一次权限规则要挨个服务改代码、发版本、重启,改完还得担心哪个服务漏了。我问他,你前面不是架了 Spring Cloud Gateway 吗?他愣了一下,说网关不是只管转发吗?这事其实不是个例。Spring Cloud Gateway 整合 Spring Security,并不是把两个框架在代码层面拼起来就完事,而是要理解它们在响应式链路里各自的职责边界:Gateway 提供路由和网关能力,Spring Security 在过滤链里提供认证与授权,二者一前一后配合,才能把权限这件事从“散落在每个服务”变成“收敛在一个入口”。
这篇总结会把几个真正关键的点拆开讲:版本兼容关系、SecurityWebFilterChain 如何挂进 Gateway 过滤链、网关层 JWT 认证过滤器怎么写、路径级权限规则怎么设计、以及很多人会问的“Spring Cloud Gateway 能做集群吗”——能,但安全链路的无状态设计是前提。最后是我实际踩过的几个坑,比官方文档有用。
1. 为什么要费劲整合:安全控制应当收口在入口
1.1 网关是唯一公网入口,安全规则在这里天然具有全局性
微服务架构下,用户请求一定是从某个入口进来的。这个入口可以是 Nginx,可以是 SLB,但在应用层它就是 Spring Cloud Gateway。认证和粗粒度授权放在这一层,效果等同于“一处配置,全局生效”。新上线一个服务,只要它不是独立对外开放的,就天然被网关的安全链保护,不需要新服务自己再做一遍认证逻辑。
反过来,每个微服务都引入 Spring Security 自己校验 Token 的方案,表面上看是“更安全”,实际操作起来非常别扭。权限规则散落在十几个服务里,你根本不知道哪条规则是旧的、哪条是漏改的。改一条权限需求,涉及到的服务都要发版本,这个发布成本和排查成本会随着服务数量线性增长,最后变成维护噩梦。
1.2 网关负责粗粒度,业务服务负责细粒度
很多人把“网关做安全”理解成“在网关把权限判断做完,下游不用管”。这个理解也是错的,网关能做的是认证和粗粒度授权。认证解决的是“你是谁”,粗粒度授权解决的是“你能进哪个区域”,比如/api/admin/**需要 ADMIN 角色。但“这个订单是不是当前用户本人的”这种资源归属判断,网关是不知道的,必须由下游业务服务根据业务数据判断。
所以边界是这样切分:
- 认证:网关解析并校验 JWT / 会话,完成身份识别。
- 粗粒度授权:网关按 URL 路径匹配角色,决定请求能否进入某个服务区域。
- 细粒度授权:业务服务内部校验资源归属,比如用
@PreAuthorize或者方法内校验。 - 内部服务间调用:不依赖外部用户 Token,用独立的内部认证机制,比如内部网络策略、内部 Token、服务间白名单。
这个边界想清楚,整合才不是一句空话。
2. 版本选型:Gateway 和 Security 是两套生态,先对齐版本再写代码
2.1 现役版本矩阵
Spring Cloud Gateway 基于 WebFlux,和传统 Spring MVC 是两套运行时体系。所以 Spring Security 整合时用的必须是 WebFlux 安全栈,不是 Servlet 那套。很多人第一步就栽在这里:在 Gateway 项目里启动了一堆 Servlet 相关的安全配置,最后行为完全不对。
先看版本对照关系:
| Spring Boot | Spring Cloud | Spring Security | JDK | 说明 |
|---|---|---|---|---|
| 2.7.x | 2021.0.x | 5.7.x / 5.8.x | 8+ | 老项目主流,WebSecurityConfigurerAdapter已弃用 |
| 3.0.x | 2022.0.x | 6.0.x | 17+ | 需要 JDK17,API 有较大调整 |
| 3.2.x | 2023.0.x | 6.2.x | 17+ | 当前建议新项目使用的组合 |
Spring Security 5.7 开始弃用WebSecurityConfigurerAdapter,6.x 直接移除。所以网上很多老教程里继承 Adapter 的写法在新版本里编译都过不了。如果看到这类代码,直接绕着走。我的建议是:新项目直接上 Spring Boot 3.x + Spring Cloud 2023.0.x + Spring Security 6.x,老项目如果还在 2.7.x 也够用,但别把 Spring Security 升到 6.x,版本跨度太大会连带改很多东西。
2.2 最小 Maven 依赖清单
Gateway 项目里最常犯的错是额外引入spring-boot-starter-web。这个依赖会和 WebFlux 冲突,导致应用启动时出现奇怪的循环依赖或者路由不生效。原因是 Gateway 自带 WebFlux,你再引入 Servlet 栈的 starter,两个容器会打架。这个坑我在第 7 章还会详细说。
下面是一份在 Spring Boot 2.7.x 环境下能直接跑起来的最小组件:
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <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>如果你使用的 JWT 是标准 RS256 算法,并且认证服务由 OAuth2 Authorization Server 统一颁发,那更推荐直接用 Spring Security 自带的 Resource Server 整合,依赖spring-boot-starter-oauth2-resource-server,配置一个.oauth2ResourceServer()就能完成 Token 校验。这里示例用 jjwt 手写解析,是为了让你看到底下究竟发生了什么,生产环境可以再改成标准方案。
3. 把 SecurityWebFilterChain 挂进网关过滤器链
3.1 理解网关过滤链与安全过滤链的执行顺序
Gateway 底层是 Netty + WebFlux,请求进来之后,先经过的是 WebFlux 的过滤器链。Spring Security 在 WebFlux 体系下的核心组件是SecurityWebFilterChain,它也是以WebFilter的形式注册进来,并且执行顺序在所有路由转发之前。
换句话说,Spring Security 的认证判定发生在 Gateway 的RoutePredicateHandlerMapping路由匹配之前,更早于各个路由过滤器。请求未认证就直接被拦截,根本走不到转发下游那一步。白名单放行之后,请求才进入 Gateway 的 GlobalFilter 和路由过滤器。
理解这个顺序很重要。你写一个自定义GlobalFilter想拿到 Security 的认证结果,这个过滤器的执行顺序必须排在 Security 链之后,也就是 Order 值要比 Security 的默认顺序更靠后。我在第 4 章会展开这个透传场景。
3.2 SecurityConfig 核心配置与白名单
先看核心配置类。注意注解必须是@EnableWebFluxSecurity,如果你写成@EnableWebSecurity,在 WebFlux 下安全链不会生效,请求全部裸奔到下游,这是最容易踩的低级错误。
@Configuration @EnableWebFluxSecurity public class SecurityConfig { private final JwtAuthenticationWebFilter jwtAuthenticationWebFilter; public SecurityConfig(JwtAuthenticationWebFilter jwtAuthenticationWebFilter) { this.jwtAuthenticationWebFilter = jwtAuthenticationWebFilter; } @Bean public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { return http .csrf(ServerHttpSecurity.CsrfSpec::disable) .formLogin(ServerHttpSecurity.FormLoginSpec::disable) .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) .authorizeExchange(exchanges -> exchanges .pathMatchers("/api/auth/**", "/actuator/health").permitAll() .pathMatchers("/api/admin/**").hasRole("ADMIN") .anyExchange().authenticated()) .addFilterAt(jwtAuthenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION) .build(); } }几个关键点解释一下:
- 关闭 CSRF:网关层是无状态 Token 体系,不需要 CSRF 防护,开着反而会拦截掉没有 CSRF Token 的请求。
- 关闭表单登录和 HTTP Basic:网关不维护登录页面,也不需要弹浏览器基本认证框。
permitAll()放行白名单:登录接口、健康检查、文档这类不需要认证的资源。hasRole("ADMIN"):Spring Security 会自动加上ROLE_前缀,也就是要求当前用户的权限集合里有ROLE_ADMIN。addFilterAt(jwtAuthenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION):把自己写的 JWT 过滤器挂到 Security 链的认证节点上,这一步很关键,下一章详细解释。
authorizeExchange里的规则是有顺序的,一旦匹配到某条规则就停止继续匹配。所以宽泛规则写得越靠后越好,/api/admin/**这种具体路径应该放在/api/**这种宽泛路径之前,否则会被后面的任意规则吞掉。
4. 网关层 JWT 认证过滤器:从解析 Token 到上下文传递
4.1 认证管理器:解析 JWT、构建 Authentication
Spring Security 在 WebFlux 下用ReactiveAuthenticationManager做认证。这里实现一个简单但完整的 JWT 认证管理器:把 Token 当作凭证传进来,解析出来之后构建带角色的UsernamePasswordAuthenticationToken。
@Component public class JwtReactiveAuthenticationManager implements ReactiveAuthenticationManager { private final SecretKey key; public JwtReactiveAuthenticationManager(@Value("${app.jwt.secret}") String secret) { this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } @Override public Mono<Authentication> authenticate(Authentication authentication) { String token = authentication.getCredentials().toString(); try { Jws<Claims> jws = Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token); Claims claims = jws.getBody(); String username = claims.getSubject(); List<String> roles = claims.get("roles", List.class); List<GrantedAuthority> authorities = roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .collect(Collectors.toList()); return Mono.just(new UsernamePasswordAuthenticationToken(username, token, authorities)); } catch (JwtException | IllegalArgumentException e) { return Mono.error(new BadCredentialsException("Invalid or expired token", e)); } } }这段代码的核心逻辑是:从Authentication里取出 Token,用 HMAC 密钥验签,解析roles字段,拼上ROLE_前缀生成权限集合。签发 Token 时就把角色塞进 claims,网关验签之后直接从 claims 里拿权限,不需要再查一次用户服务,这也是网关层能做到高效认证的关键。
生产环境使用时要特别注意:密钥不要写死在代码里,通过环境变量或者配置中心注入。如果服务规模更大,建议换成 RSA 非对称签名,认证服务持有私钥签发 Token,网关只配置公钥验签,这样即使某个网关被攻破也不会泄露签发能力。
4.2 认证过滤器:如何接入 Security 链
有了认证管理器,还要把它接进 Security 链。继承AbstractAuthenticationWebFilter是最干净的 WebFlux 做法:
@Component public class JwtAuthenticationWebFilter extends AbstractAuthenticationWebFilter { public JwtAuthenticationWebFilter(JwtReactiveAuthenticationManager authenticationManager) { super(authenticationManager); setServerAuthenticationConverter(exchange -> { String header = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); return Mono.just(new UsernamePasswordAuthenticationToken(token, token)); } return Mono.empty(); }); setAuthenticationSuccessHandler((webFilterExchange, authentication) -> webFilterExchange.getChain().filter(webFilterExchange.getExchange())); setAuthenticationFailureHandler((webFilterExchange, exception) -> { ServerHttpResponse response = webFilterExchange.getExchange().getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); return response.writeWith(Mono.just(response.bufferFactory() .wrap("{\"code\":401,\"message\":\"unauthorized\"}" .getBytes(StandardCharsets.UTF_8)))); }); } }这个过滤器做的事情只有三件:
- 从 Authorization 请求头提取 Bearer Token,没有 Token 就返回空,让匿名请求继续走到
authorizeExchange那里被拦截或者放行白名单。 - 把 Token 包装成
UsernamePasswordAuthenticationToken交给认证管理器。 - 认证成功直接继续过滤器链,认证失败立刻返回 401 JSON。
之所以用SecurityWebFiltersOrder.AUTHENTICATION位置挂载,而不是自己写一个普通WebFilter,是因为只有挂在 Security 链内部,认证成功后的Authentication才会被ReactiveSecurityContextHolder持有,authorizeExchange的授权判定才能识别到当前用户和角色。如果自己写单独的过滤器在 Security 链外层,authorizeExchange看到的永远是匿名用户。
4.3 把认证信息透传给下游微服务
很多时候下游服务也需要知道当前用户是谁。我的做法是在 Security 认证之后追加一个 GlobalFilter,从响应式安全上下文里取出认证信息,拼成内部请求头再转发:
@Component public class UserInfoPropagateGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { return ReactiveSecurityContextHolder.getContext() .defaultIfEmpty(SecurityContextHolder.createEmptyContext()) .flatMap(context -> { Authentication auth = context.getAuthentication(); if (auth != null) { String roles = auth.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(",")); ServerHttpRequest request = exchange.getRequest().mutate() .header("X-User-Id", auth.getName()) .header("X-User-Roles", roles) .build(); return chain.filter(exchange.mutate().request(request).build()); } return chain.filter(exchange); }); } @Override public int getOrder() { return -100; } }这个 Filter 的核心价值在于:下游服务不用再解析 JWT,直接从约定的请求头里拿用户身份即可。但我必须提醒一句:如果你的下游服务暴露在外部网络,这套头传递方案必须配合网络隔离或者内部认证机制,否则请求头是伪造非常容易。正常情况下,网关和下游服务应该在同一内网,并且下游服务只信任来自网关的流量。不要把原始 Authorization 头原样透传给所有服务,Token 泄露面会变大。
5. 路径级权限规则:网关收粗粒度,业务下沉细粒度
5.1 用 pathMatchers 做角色分流
最简单的权限控制就是按路径匹配角色,前面的 SecurityConfig 里已经展示了:
authorizeExchange(exchanges -> exchanges .pathMatchers("/api/auth/**", "/actuator/health").permitAll() .pathMatchers("/api/admin/**").hasRole("ADMIN") .pathMatchers("/api/user/**").hasRole("USER") .anyExchange().authenticated())这套规则的作用是:登录接口放行,管理端接口要 ADMIN,用户端接口要 USER,其余所有接口登录即可访问。规则按声明顺序从上到下匹配,命中了就停止。所以要把具体路径写在前面,anyExchange()这种兜底规则放最后。
这里有个 Spring Security 的小知识点:hasRole("ADMIN")和hasAuthority("ROLE_ADMIN")是等价的,hasRole会自动补ROLE_前缀。如果你的权限字段本身不带前缀,用hasRole更方便;如果权限字段五花八门不是角色体系,用hasAuthority更灵活。
5.2 权限规则外置配置中心,改规则不重新编译
路径和角色的对应关系如果写死在代码里,每次调整权限还要发版。更好的方式是把规则映射放到配置中心,在 SecurityConfig 里动态加载:
@Component @ConfigurationProperties(prefix = "app.auth") public class AuthRouteProperties { private Map<String, String> rules = new LinkedHashMap<>(); // getter / setter 省略 }然后 SecurityConfig 里面通过循环构建规则:
.authorizeExchange(exchanges -> { for (Map.Entry<String, String> entry : authRouteProperties.getRules().entrySet()) { String path = entry.getKey(); String role = entry.getValue(); if ("permitAll".equals(role)) { exchanges.pathMatchers(path).permitAll(); } else if ("authenticated".equals(role)) { exchanges.pathMatchers(path).authenticated(); } else { exchanges.pathMatchers(path).hasRole(role); } } exchanges.anyExchange().authenticated(); })配置则长这样:
app: auth: rules: /api/auth/**: permitAll /api/admin/**: ADMIN /api/user/**: USER这个方案让我在多个项目里省去了大量发布工作。唯一要注意的是,SecurityWebFilterChain是在应用启动时构建的,配置中心刷新后不会自动重建。解决思路有两个:要么把这张规则表丢给一个自定义ReactiveAuthorizationManager,每次请求实时去配置中心查;要么在配置刷新时手动触发SecurityWebFilterChainBean 的重建。我的经验是,绝大多数场景用第一种就够了,启动时加载规则已经能解决 90% 的权限配置维护需求。
细粒度授权切记要下沉。网关的角色判断只解决“能不能进这个区域”,不解决“这个资源是不是你的”。订单详情接口必须由订单服务自己校验订单归属,网关不参与这类业务级判断。
6. 集群部署:无状态网关的传统难题与 JWT 方案的解药
6.1 网关能不能集群?先回答状态问题
直接回答热搜问题:Spring Cloud Gateway 完全可以集群,而且我参与过的生产项目基本都是多实例部署。网关本身不持有业务数据,路由信息从注册中心拉,实例之间不需要互相通信。集群部署的本质就一句话:动态加实例,前面挂负载均衡。
但“能集群”有一个前提条件——安全状态必须无状态化。这里的“状态”指的是用户登录状态。如果网关层维护了 Session,多实例下用户第一次请求到实例 A 登录了,第二次请求被负载均衡到实例 B,B 上查不到 Session,用户就被判定为未登录,表现为“一会登录一会退出”。所以集群之前必须先把安全这块设计好。
6.2 会话模式与 JWT 模式的集群差异
| 方案 | 集群友好度 | 需要共享的资源 | 典型场景 |
|---|---|---|---|
| Session + 网关本地内存 | 差 | 无共享方案,集群不可用 | 仅单实例 |
| Session + Spring Session Redis | 中等 | Session 数据写入 Redis,所有实例共享 | 老系统改造 |
| JWT + 网关本地验签 | 好 | 业务上无共享需求,只需要共用公钥/密钥 | 无状态接口,推荐 |
| JWT + Redis 黑名单 | 好 | JWT 本身无状态,但登出/封禁名单存 Redis | 需要强制下线能力 |
JWT 方案之所以适合集群,是因为每次请求都是自包含验签,不依赖任何共享存储。网关实例 A 验签成功,实例 B 也能用同一个公钥验签成功,因为算法和密钥是一致的。这天然就解决了 Session 方案里“状态漂移”的问题。
如果你额外需要“让某个 Token 立即失效”的能力,那就在网关后面加一个 Redis 黑名单。每次请求验签通过后再查一眼黑名单,Token 被加入黑名单就直接拒绝。Redis 本身是共享存储,所有网关实例连同一个 Redis,这个问题就解决了。
6.3 集群部署需要统一的三件事
网关可以多实例,但有三类配置必须所有实例保持一致,否则就会出现随机性的奇怪问题。
第一,路由配置要统一。网关的路由规则一般放在配置中心,所有实例从同一个配置中心拉取,新增路由不需要逐台机器改。如果直接写死在本地 yml,集群加机器时容易漏。
第二,安全相关配置要统一。包括白名单路径、JWT 公钥或密钥、CORS 规则,这些配置不一致会导致相同请求在不同实例上得到不同响应。我有一个习惯:安全相关的敏感性配置放在配置中心加密存储,实例启动时拉取,全集群天然一致。
第三,长连接和 WebSocket 场景要处理会话保持。网关集群面对 WebSocket 时比较特殊,普通 HTTP 请求因为 JWT 无状态,任意实例都能处理;但 WebSocket 一旦建立连接,连接是绑定在某个实例上的。此时负载均衡器需要开启 IP Hash 或 Sticky Session,让同一个客户端的连接始终打到同一台网关实例,否则连接会断。这是我在做网关集群时被坑过最多的地方。
7. 实战踩坑:响应式安全链路里的五个致命细节
7.1 用了 @EnableWebSecurity 导致安全链不生效
这个错我见过不止一次。Spring Cloud Gateway 是 WebFlux 应用,安全配置注解必须用@EnableWebFluxSecurity。如果引入的是spring-boot-starter-security,并且项目里同时存在spring-boot-starter-web和 WebFlux,Spring Boot 默认会按 Servlet 应用启动,@EnableWebSecurity也能生效,但 Gateway 的路由映射是在 WebFlux 环境里工作的,Servlet 安全链根本拦不住 Netty 进来的请求,结果就变成“安全配置看着没问题,实际请求全部裸奔”。
排错的方法很简单:看项目启动日志里用的是 Netty 还是 Tomcat。如果出现 Tomcat,说明你误引了spring-boot-starter-web,果断排除。Gateway 项目里不需要这个依赖。
7.2 WebFlux 里没有 ThreadLocal,SecurityContextHolder 会返回空
传统 Servlet 应用里,SecurityContextHolder.getContext()随处可拿用户信息,因为请求处理器线程是固定的。WebFlux 是响应式模型,一个请求可能在不同线程之间切换,ThreadLocal 里的数据会丢。所以你在自定义 Filter 里直接调用SecurityContextHolder.getContext().getAuthentication(),拿到的很可能是个空上下文。
正确姿势是用ReactiveSecurityContextHolder.getContext(),它会从 Reactor Context 里取认证信息,这也是我之前透传过滤器里用它的原因。在 Controller 方法里注入@AuthenticationPrincipal是更省事的做法,Spring Security 会自动从响应式上下文里解析。
7.3 登录接口的 RequestBody 只能读一次
网关层如果加了一个“读取请求体做风控或日志”的过滤器,你会发现上游业务服务收到的 body 是空的。原因是 WebFlux 的请求体是流,读取一次就消费掉了。Spring Cloud Gateway 提供了缓存机制解决这个问题:
ServerWebExchangeUtils.cacheRequestBody(exchange, cachedRequest -> { // 在这里读取 cachedRequest 的 body,然后继续转发 });缓存之后,下游拿到的是缓存副本而不是原始流,这个问题才算真正解决。我个人建议:在非必要情况下,网关不要读请求体。每次读 body 都要考虑缓存问题,性能也会额外损失。
7.4 白名单路径 /api/auth/** 并不等于 /api/auth
这是一个非常容易忽略的路径匹配细节。pathMatchers("/api/auth/**")匹配的是/api/auth/xxx这个层级,并不匹配/api/auth本身。如果你希望两个都能匿名访问,需要写成这样:
.pathMatchers("/api/auth", "/api/auth/**").permitAll()另外路径匹配默认是区分大小写的,/API/AUTH/LOGIN和/api/auth/login会被当成两个不同路径。如果外部客户端不统一,这条规则也会造成诡异的 401。
7.5 跨域预检与 Security 的兼容
浏览器跨域时先发 OPTIONS 预检请求,如果 Spring Security 没有放行 OPTIONS,前端看到的不是跨域报错,而是莫名其妙的多重重定向或者 401/403。解决方法是双管齐下:
在 Spring Security 里放行 OPTIONS:
.authorizeExchange(exchanges -> exchanges .pathMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyExchange().authenticated())同时 Gateway 配置全局 CORS:
spring: cloud: gateway: globalcors: cors-configurations: '[/**]': allowed-origin-patterns: "*" allowed-methods: "*" allowed-headers: "*" allow-credentials: true备注一下,allowed-origin-patterns用*适合开发环境,生产环境一定收敛到具体域名。如果你在 Gateway 的 CORS 配置里已经处理了跨域,Security 里的cors()也会读取CorsConfigurationSource,两者不要重复配置,否则规则叠加会出现难以排查的覆盖问题。
最后补一条实际运维经验:网关层的安全规则越简单越好。我见过有人把几十条权限规则全部堆在网关里,最后权限变更比改业务还频繁,网关反而成了发布瓶颈。认证在网关、粗粒度权限在网关、细粒度权限在业务,这条边界想清楚,Gateway 和 Security 的整合就成功了一大半。踩过的坑越多,越觉得这两个框架配合时,真正的难点不是 API 怎么调,而是知道哪一层该干什么。