资讯动态

Spring Cloud Gateway整合Spring Security:微服务认证授权统一入口实战

发布时间:2026/9/10 7:13:14 来源:尧图企业网站定制
上个月前同事找我吐槽他们微服务拆了十几个安全这套每个服务各写各的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 BootSpring CloudSpring SecurityJDK说明2.7.x2021.0.x5.7.x / 5.8.x8老项目主流WebSecurityConfigurerAdapter已弃用3.0.x2022.0.x6.0.x17需要 JDK17API 有较大调整3.2.x2023.0.x6.2.x17当前建议新项目使用的组合Spring Security 5.7 开始弃用WebSecurityConfigurerAdapter6.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 groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/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如果你使用的 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、构建 AuthenticationSpring 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 MonoAuthentication authenticate(Authentication authentication) { String token authentication.getCredentials().toString(); try { JwsClaims jws Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token); Claims claims jws.getBody(); String username claims.getSubject(); ListString roles claims.get(roles, List.class); ListGrantedAuthority 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 MonoVoid 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 MapString, String rules new LinkedHashMap(); // getter / setter 省略 }然后 SecurityConfig 里面通过循环构建规则.authorizeExchange(exchanges - { for (Map.EntryString, 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 登录了第二次请求被负载均衡到实例 BB 上查不到 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和 WebFluxSpring Boot 默认会按 Servlet 应用启动EnableWebSecurity也能生效但 Gateway 的路由映射是在 WebFlux 环境里工作的Servlet 安全链根本拦不住 Netty 进来的请求结果就变成“安全配置看着没问题实际请求全部裸奔”。排错的方法很简单看项目启动日志里用的是 Netty 还是 Tomcat。如果出现 Tomcat说明你误引了spring-boot-starter-web果断排除。Gateway 项目里不需要这个依赖。7.2 WebFlux 里没有 ThreadLocalSecurityContextHolder 会返回空传统 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 配置全局 CORSspring: 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 怎么调而是知道哪一层该干什么。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价