ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Cloud Gateway整合Spring Security:微服务网关统一鉴权实战

Spring Cloud Gateway整合Spring Security:微服务网关统一鉴权实战 我先说明一下背景实际负责过多个微服务网关层搭建Spring Cloud Gateway 和 Spring Security 这个组合踩坑记录攒了不少。这篇就来完整梳理一遍整合思路和落地细节。1. 项目背景与基础认知1.1 网关为什么需要安全防护微服务架构里网关是所有外部请求流量的统一入口可以把鉴权、限流、日志、灰度、路由转发这一类横切逻辑从业务服务中剥离出来让下游服务只管业务。这是网关备受青睐的核心理由。但网关一旦承担了鉴权职责就变成整个系统的安全边界如果边界失守所有下游服务直接暴露在风险中。很多团队最初的方案是每个服务自己做 token 校验结果各家实现五花八门有的用了拦截器有的写 Filter有的在 AOP 里做。某次重构把其中一个服务的 token 逻辑改漏了线上出现了越权访问才意识到鉴权逻辑必须收敛到一个地方。用 Spring Cloud Gateway 整合 Spring Security 做统一认证和鉴权是目前 Java 技术栈里比较标准、较稳妥的做法。Spring Cloud Gateway 提供路由与过滤能力Spring Security 提供认证与授权能力两者通过 WebFlux 响应式模型天然衔接能在网关层完成 JWT 解析、权限校验、白名单放行、用户信息透传下游服务不再关心这个请求是谁、有没有权限这类横切问题。1.2 技术栈差异从 Servlet 到 WebFlux我刚接触这个组合时第一反应是直接用 Spring Security 传统的配置方式比如继承 WebSecurityConfigurerAdapter 或者写一堆 Filter 注册。实际做下来才发现完全不是这么回事踩了不少坑才知道根源在哪。Spring Cloud Gateway 基于 Spring WebFlux 构建底层是 Netty 而非 Servlet 容器整个请求处理链路都是响应式、非阻塞模型。而经典的 Spring Security 过滤链基于 Servlet 规范依赖 HttpServletRequest、HttpServletResponse 这些同步 API。两者根本不在同一个请求模型上工作硬套 Servlet 方案就会出现各种莫名其妙的问题。因此整合时使用的必须是 Spring Security 的 WebFlux 版本核心链SecurityWebFilterChain、ReactiveAuthenticationManager、ServerHttpSecurity。配置方式从过滤器链里注册 Filter变成声明安全规则 配置认证管理器 挂载 WebFilter。这个认知转换是整套整合方案的第一步也是后续所有设计的基础。1.3 整合方案选型网关层的安全方案业界有几种常见路线方案一基于 Spring Security 原生 oauth2ResourceServer JWT适合统一认证中心签发 token、网关验签放行的场景。方案二完全自研全局过滤器解析 token逻辑自己掌控适合 token 格式特殊或团队不想引入 Security 复杂度的场景。方案三网关对接已有的 SSO/SAML/OIDC 协议适合企业集成场景。综合可维护性和生态成熟度我在实际项目里优先推荐方案一Spring Security 的 SecurityWebFilterChain oauth2ResourceServer JWT。它把 token 解析、签名校验、过期检查这些复杂度封装好同时支持自定义 JwtAuthenticationConverter 做权限映射方便扩展。方案二作为补充适合项目里自定义逻辑特别多、无法用标准 JWT 流程表达的场景。2. 核心设计思路与架构决策2.1 为什么优先选择 SecurityWebFilterChain 而不是全局过滤器很多人会问一个问题Spring Cloud Gateway 自己有 GlobalFilter 机制那直接在全局过滤器里解析一下 token不也能实现鉴权吗为什么还要引入 Spring Security我早期也这么干过。全局过滤器方案看起来简单直接但实际写到后面会发现自己把 Spring Security 做过的所有事情重新实现了一遍。比如安全上下文传递认证信息怎么在过滤器之间共享全局过滤器方案要自己想。注解权限支持下游如果要用 PreAuthorize全局过滤器方案基本支持不了。断言与匹配pathMatchers 的规则匹配、HTTP method 组合匹配自己写容易出漏。社区生态后续想接入 OAuth2 客户端、方法级安全、CSRF、Session 管理等全局过滤器方案得全部重写。SecurityWebFilterChain 则把认证、授权、异常处理、上下文传递整合成一套标准链路。它是以 ServerHttpSecurity 为中心的构建器所有安全规则声明式配置思想上是声明规则不做选择由框架在请求链路上统一执行。项目后续扩展时加一个过滤器、改一条规则都是小改动。从踩坑经验来看能用框架标准能力解决的尽量不要自研免得给团队留技术债。2.2 认证方案设计OAuth2 JWT 闭环网关层认证的核心诉求如何确认请求携带的 token 有效以及如何把 token 里的身份信息转成框架可识别的 Authentication 对象。OAuth2 JWT 是目前匹配度很高的组合。JWT 本身是自包含令牌签名后携带用户 id、角色、过期时间等信息网关不需要每次访问用户服务去校验有效性只要信任签名即可。配合 OAuth2 的资源服务器模式网关只做 JWT 验签和角色提取认证中心负责颁发令牌、管理客户端和令牌撤销。这个分工边界清晰易于扩展。落地时核心配置是 oauth2ResourceServer.oauth2ResourceServer(oauth2 - oauth2 .jwt(jwt - jwt .jwtDecoder(jwtDecoder()) .jwtAuthenticationConverter(jwtAuthenticationConverter()) ) )JwtDecoder 负责验签与解析JwtAuthenticationConverter 负责把 JWT 里的 claims 转换成 Authentication 的权限列表。认证成功后的 Authentication 会写入 Reactor Context后续下游过滤器可以通过 ReactiveSecurityContextHolder 取到。2.3 白名单与动态权限设计安全设计必须留出口子。登录接口、注册接口、验证码、静态资源、健康检查这些路径天然不能要求携带 token否则用户根本无法登录。锚定一刀切全部认证的方案在实际项目里基本没法落地。Spring Security 的 authorizeExchange 规则里permitAll 就是白名单放行的标准手段.authorizeExchange(exchange - exchange .pathMatchers(/api/auth/login, /api/auth/register, /actuator/health).permitAll() .pathMatchers(/admin/**).hasAuthority(ROLE_ADMIN) .anyExchange().authenticated() )这里有一个非常容易翻车的细节规则顺序。authorizeExchange 的匹配是从上往下的第一个匹配到的规则生效。如果把 anyExchange().authenticated() 放在 permitAll 前面白名单就全部失效因为任何路径都会优先命中必须认证规则。这个坑我见过不止一次配置白名单时务必把特例规则放前面兜底规则放最后。对于更灵活的场景可以把白名单路径放到配置中心或数据库里启动时动态构建规则数组。不过要注意SecurityWebFilterChain 构建后规则是相对固定的频繁动态刷新链路这种需求建议评估清楚再决定是否引入。2.4 用户信息在下游服务的安全传递网关认证通过后下游服务仍然需要识别当前用户。最常见的做法网关在转发请求前把认证信息写入请求头下游从请求头里读取。比如写入 X-User-Id、X-User-Name、X-User-Roles。但这里有个安全窟窿如果客户端自己伪造这些请求头网关直接透传下游服务就拿到假身份。解决方案很简单转发前先剥离客户端传入的受信请求头再用可信的认证信息重新填充。我在项目里核心的过滤逻辑是这样的ServerHttpRequest request exchange.getRequest().mutate() .headers(httpHeaders - { httpHeaders.remove(X-User-Id); httpHeaders.remove(X-User-Name); httpHeaders.remove(X-User-Roles); }) .header(X-User-Id, userId) .header(X-User-Name, username) .build();顺序是先移除再写入防止伪造。3. 实操实现Gateway 整合 Spring Security 完整落地3.1 环境准备与依赖引入我用的是 Spring Boot 3.x Spring Cloud 2023.x Spring Security 6.x 的组合这套是目前比较新的稳定版本组合。如果你的项目还在 Spring Boot 2.x配置 API 有差异但整体思路一致。依赖引入需要注意网关项目在 pom 里不建议再引入 spring-boot-starter-web否则 Spring Boot 会同时加载 WebMvc 和 WebFlux导致启动冲突或路由失效。标准依赖组合如下dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-resource-server/artifactId /dependency如果 JWT 解析不用 Nimbus 默认实现而是使用自定义解析器还需要额外引入 jjwt 或 nimbus-jose-jwt 相关依赖。我这边是直接用框架默认的 nimbus 实现稳定性和兼容性都过关。3.2 安全配置类实现核心配置类采用 EnableWebFluxSecurity 注解这标志着启用 Spring Security 的 WebFlux 模式。然后配置一个 SecurityWebFilterChain Bean把认证规则、白名单、异常处理串起来Configuration EnableWebFluxSecurity public class GatewaySecurityConfig { Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http, ReactiveAuthenticationManager authenticationManager, ServerAuthenticationEntryPoint entryPoint, ServerAccessDeniedHandler deniedHandler) { return http .csrf(ServerHttpSecurity.CsrfSpec::disable) .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) .formLogin(ServerHttpSecurity.FormLoginSpec::disable) .authenticationManager(authenticationManager) .authorizeExchange(exchange - exchange .pathMatchers(/api/auth/login, /api/auth/register).permitAll() .pathMatchers(/actuator/health).permitAll() .pathMatchers(/admin/**).hasAuthority(ROLE_ADMIN) .anyExchange().authenticated() ) .exceptionHandling(handling - handling .authenticationEntryPoint(entryPoint) .accessDeniedHandler(deniedHandler) ) .build(); } }CSRF 在网关纯接口场景下建议关闭因为网关的调用方主要是服务端或客户端应用不是浏览器表单提交开启 CSRF 反而会拦截掉一部分合法的接口调用。httpBasic 和 formLogin 也必须关闭网关场景下没有用户名密码表单登录的需求关闭后避免链路里额外触发认证流程。3.3 JWT 解析与认证管理器SecurityWebFilterChain 里的 authenticationManager 是认证核心。我这边使用自定义的 ReactiveAuthenticationManager 实现负责把请求头里取出的 Bearer token 解析、验签、构建 Authentication。Spring Security 也有默认实现但自定义的方式更加透明出错也好排查。核心逻辑如下Component public class JwtReactiveAuthenticationManager implements ReactiveAuthenticationManager { private final JwtTokenUtil jwtTokenUtil; public JwtReactiveAuthenticationManager(JwtTokenUtil jwtTokenUtil) { this.jwtTokenUtil jwtTokenUtil; } Override public MonoAuthentication authenticate(Authentication authentication) { return Mono.just(authentication) .filter(auth - auth.getCredentials() ! null) .cast(BearerTokenAuthenticationToken.class) .flatMap(bearer - { String token bearer.getToken(); try { Claims claims jwtTokenUtil.parseToken(token); String userId claims.get(userId, String.class); String username claims.get(username, String.class); ListString roles claims.get(roles, List.class); ListGrantedAuthority authorities roles.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); JwtUserDetails userDetails new JwtUserDetails(userId, username, roles); return Mono.just(new UsernamePasswordAuthenticationToken(userDetails, null, authorities)); } catch (Exception e) { return Mono.error(new BadCredentialsException(token invalid or expired: e.getMessage())); } }); } }JwtTokenUtil 内部使用 jjwt 做解析核心包括签名校验和过期时间检查。签名密匙建议放到配置中心或环境变量里不要硬编码在代码里。这里比较重要的点是JWT 解析是 CPU 密集的轻量操作不会阻塞 IO 线程可以在响应式链路里直接执行不需要额外包装线程池。如果后续 token 校验逻辑出现阻塞那就要考虑是不是引入了同步调用或数据库访问那才是性能隐患。3.4 自定义 token 校验过滤器与用户信息透传虽然 SecurityWebFilterChain 提供了认证链路但 Gateway 还需要把认证后的用户信息注入到下游请求头。这里需要一个额外的 WebFilter 或 GlobalFilter 完成。我采用的是实现 GlobalFilter并读取 SecurityContext 中的数据Component public class UserInfoHeaderGlobalFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { return ReactiveSecurityContextHolder.getContext() .filter(context - context.getAuthentication() ! null) .flatMap(context - { Authentication authentication context.getAuthentication(); Object principal authentication.getPrincipal(); if (principal instanceof JwtUserDetails userDetails) { ServerHttpRequest mutatedRequest exchange.getRequest().mutate() .headers(headers - { headers.remove(X-User-Id); headers.remove(X-User-Name); headers.remove(X-User-Roles); }) .header(X-User-Id, userDetails.getUserId()) .header(X-User-Name, userDetails.getUsername()) .header(X-User-Roles, String.join(,, userDetails.getRoles())) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } return chain.filter(exchange); }) .switchIfEmpty(chain.filter(exchange)); } Override public int getOrder() { return -100; } }getOrder 返回值决定过滤器在链路中的优先级。负数会让该过滤器更早执行。但要注意它必须在 Security 的认证过滤器之后才能取到 SecurityContext。实际执行顺序由框架保证我这边经验值是 -100 到 -200 之间都可行具体值根据项目情况微调。如果登录接口本身也在网关后面那么登录接口的转发不应该触发用户信息注入逻辑可以通过白名单路径判断跳过来避免多余操作。3.5 异常处理与响应格式统一默认的 Spring Security 异常响应是 401 状态码配一行文本对前端很不友好。网关层需要统一返回 JSON 格式的错误信息这里要配置 ServerAuthenticationEntryPoint 和 ServerAccessDeniedHandler。一个典型的 JSON 响应结构如下Component public class JsonAuthenticationEntryPoint implements ServerAuthenticationEntryPoint { private final ObjectMapper objectMapper; public JsonAuthenticationEntryPoint(ObjectMapper objectMapper) { this.objectMapper objectMapper; } Override public MonoVoid commence(ServerWebExchange exchange, AuthenticationException ex) { return Mono.fromRunnable(() - { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); try { byte[] body objectMapper.writeValueAsBytes(Map.of( code, 401, message, 未认证或token已过期 )); exchange.getResponse().writeWith(Mono.just(exchange.getResponse() .bufferFactory().wrap(body))); } catch (JsonProcessingException e) { throw new RuntimeException(e); } }); } }注意 writeWith 返回的 Mono 要作为 commence 的返回值之一否则响应可能无法正常写出。这里我用了 Mono.fromRunnable 包裹同步写出的逻辑保证非阻塞链路中不会占满事件循环线程。4. 常见问题与排查技巧实录4.1 依赖冲突与启动失败最经典的问题是启动时控制台报 DispatcherServlet 相关错误或路由不生效。十有八九是网关项目里引入了 spring-boot-starter-web把 WebFlux 的自动配置顶掉了。排查方式检查项目依赖树看有没有 spring-boot-starter-web 或 spring-boot-starter-webmvc如果有排除掉。另一个问题是 spring-security-oauth2-resource-server 版本与 Spring Security 版本不一致导致的类找不到。导出依赖树mvn dependency:tree -Dincludesorg.springframework.security确认没有混用多个 Spring Security 版本即可。4.2 跨域配置失效Spring Cloud Gateway 自身可以配置全局跨域spring: cloud: gateway: globalcors: cors-configurations: [/**]: allowedOriginPatterns: * allowedMethods: * allowedHeaders: * allowCredentials: true但配置后如果发现跨域请求仍然被拦截大概率是 Spring Security 的安全链在 Gateway 的 CORS 处理之前就拒绝了 OPTIONS 预检请求。需要在 SecurityWebFilterChain 里显式开启 CORS.httpCors(ServerHttpSecurity.HttpCorsSpec::disable)我是在安全链里通过自定义 CorsWebFilter 配置 关闭默认 httpCors 的处理来保证链路的可控。4.3 白名单不生效与 401白名单路径配置了 permitAll但请求仍然返回 401。最常见原因是路径匹配写错。Spring Security 的 pathMatchers 其实走的是 AntPathMatcher某些网关自身的路由规则是 PathPatternParser两者对结尾斜杠、通配符的支持有细微差别。排查时先确认路径是否完全匹配比如 /api/auth/login 和 /api/auth/login/ 是不一样的。再确认规则顺序是否是特例在前、兜底在后。还有一点容易被忽略如果全局过滤器里主动执行了鉴权逻辑或读取了 SecurityContext即使路径放行也可能触发新的认证需求。放行路径上的过滤器调用要格外小心。4.4 性能优化与 JWT 缓存JWT 验签过程需要解析 JWT、做签名校验在高 QPS 下也是有一定成本的。通过 jmh 压测来看单次验签大概在几十微秒到几百微秒之间业务量不大时不需要过度优化。如果确实追求极致性能有几个方向使用 JwtDecoder 时配置 JwkSet 缓存避免每次请求都从远程拉取公钥。本地内存缓存公钥解析结果Spring Security 的 NimbusJwtDecoder 默认带缓存确认没有关闭。把验签后的用户信息快照缓存到 Caffeine 或 Redis减少重复解析相同 token 的场景但要小心缓存与 token 撤销的时序问题。实际项目里token 过期后要求强制重新登录缓存策略可以做成过期时间内可复用过期后强制重验。4.5 问题排查速查表现象可能原因处理办法启动即报 DispatcherServlet 错误存在 spring-boot-starter-web 依赖排除 WebMvc 依赖保持纯 WebFlux白名单接口返回 401pathMatchers 规则顺序错误或路径不匹配把 permitAll 放前面检查精确路径跨域预检请求失败Security 链拦截了 OPTIONS在 Security 配置中显式放行 OPTIONS 或启用 CORS下游拿到的用户信息为 null全局过滤器取不到 SecurityContext调整过滤器 order确认在认证过滤器之后执行头部信息被客户端伪造未在转发前剥离受信头先 remove 再 set禁止盲目透传401 响应不是 JSON 格式未配置 ServerAuthenticationEntryPoint自定义统一格式的 entryPoint5. 实操经验与扩展建议整个方案跑下来我最有感触的是网关安全绝对不是一个过滤器、一个配置类就能解决的事它是一条完整链路认证、授权、上下文传递、透传规范、异常处理、性能优化缺一不可。如果你是从零搭建建议按这个顺序落地先确认纯 WebFlux 环境再配 SecurityWebFilterChain 和 JWT 认证接着处理用户信息透传最后补异常处理和跨域。每一步完成后都实际验证一遍不要一次堆完再调试否则出了问题非常难定位。网关鉴权稳定后还可以考虑扩展这些方向在网关层加接口级别的权限代码比如按钮级权限就放行的场景可以把权限点写入 JWT 的 claimsSpring Security 的 SpEL 表达式直接判断。接入 token 黑名单机制用户登出时把 token 加入 Redis 黑名单网关在认证管理器里先查黑名单。这套逻辑和 oauth2ResourceServer 是兼容的只需在 ReactiveAuthenticationManager 里增加一次缓存查询。与 Sentinel 或 Resilience4j 结合在鉴权通过后做限流熔断。安全与稳定性属于同一道防线在网关层一起治理更为合适。最后再分享一个小技巧调试 Spring Security 过滤器链时把日志级别调到 DEBUG观察输出中的 AuthorizationFilter、TokenAuthenticationFilter 等关键节点基本可以看安全链路执行到哪一步被拦截。我排查过的大部分鉴权问题靠这个方式都能快速定位。这个整合方案只要前期把链路拆清楚、规则理明白后期维护成本比自研方案低得多。希望这篇梳理能帮你绕开我踩过的那些坑。
返回列表