Spring Cloud Gateway 整合 Spring Security 实现网关认证鉴权实战
网关层做认证鉴权这件事很多团队一开始觉得简单无非就是加个过滤器校验一下Token。但真正动手把 Spring Cloud Gateway 和 Spring Security 整合到一起的时候才发现坑比想象中多路由怎么放行、Token去哪里解析、Reactor 上下文怎么传递用户信息、Spring Security 的过滤链在 WebFlux 环境下为什么和 Servlet 环境下完全不一样。这篇文章从我的实战经验出发把 Spring Cloud Gateway 整合 Spring Security 的完整思路、核心配置、踩坑记录一次讲清楚适合正在搭建微服务网关认证体系的后端开发同学参考。1. 整体设计思路与方案选型1.1 为什么要在网关层做认证而不是在各微服务里各做各的微服务架构下认证逻辑如果散落在每个服务里会出现几个很现实的问题。第一个是重复代码爆炸每个服务都要引入 Spring Security、都要配置过滤链、都要写一遍 Token 解析逻辑而且一旦认证规则调整所有服务要同步修改发布窗口被拉得很长。第二个是安全口径不统一有人用了白名单、有人没配有人校验了签名、有人只验了过期时间时间一长整个系统的安全水位参差不齐。第三个是性能浪费每个服务各自调用用户中心校验 Token给下游带来无谓的压力。把 Spring Cloud Gateway 作为统一认证入口之后客户端只跟网关打交道网关负责校验身份、解析用户信息、把合法请求转发到下游服务。下游服务可以假设请求一定经过了认证只需要信任网关传递过来的用户上下文就够了。这种做法在实际项目中几乎是标准形态也就是所谓的“认证前置、业务后置”。当然这不代表下游服务可以完全不设防内网之间依然建议做基本的信任校验但网关层面的统一认证是最高性价比的一道防线。1.2 Spring Security 在 WebFlux 环境下的工作方式和 Servlet 有多大区别这是整合过程中最容易懵的一点。Spring Cloud Gateway 基于 Spring WebFlux底层是 Netty 和 Reactor跟传统 Spring MVC 的 Servlet 模型完全不同。Spring Security 针对两种模型分别提供了两套过滤器链路Servlet 环境下是 Spring Security Filter Chain基于Filter和DispatcherServlet的声明周期而 WebFlux 环境下则是一套WebFilter组成的链路核心入口是SecurityWebFilterChain。有 Servlet 经验的开发者刚开始往往找不到WebSecurityConfigurerAdapter因为这个类在 WebFlux 下压根不存在。你需要面对的是一个完全不同的编程模型不再有HttpServletRequest、HttpServletResponse取而代之的是ServerWebExchange和ServerHttpResponse。配置方式也从HttpSecurity换成了ServerHttpSecurity。这个区别不只是 API 名字不同整个异步模型都变了原来的同步过滤器思路在响应式链路里往往行不通。比如你要在过滤器里调用远程服务校验 TokenServlet 下可以简单同步调用但 WebFlux 下必须返回Mono或Flux否则会阻塞事件循环线程。1.3 方案选型JWT 网关统一校验还是网关转发到认证中心网关层做认证通常有两类主流做法我分别说下适用场景。第一类是 JWT 无状态方案。网关内置 JWT 解析逻辑客户端在登录时拿到 Token后续请求在 Header 里携带网关直接本地解析、验签、提取用户信息。这个方案的好处是性能好、链路短、不依赖认证服务的实时状态适合 Token 有效期短、并发量大的场景。缺点是无法立即失效如果用户被踢下线或者角色变更要等到 Token 过期才会生效需要引入黑名单机制弥补。第二类是 Token 转发认证中心校验。网关拿到 Token 后调用独立的认证服务确认合法性再放行请求。好处是可以实时控制 Token 状态、支持注销和踢人适合安全要求高、管理后台一类的系统。缺点是每次请求都增加一次远程调用网关容易成为瓶颈需要做缓存之类的优化。我个人的建议是如果系统刚起步、不想引入过多远程依赖先用 JWT 无状态方案把核心链路跑通等业务复杂度上来再逐步引入 Token 黑名单或认证中心校验。下面的实操部分围绕 JWT 方案展开同时会标明哪些环节可以替换为远程校验。2. 核心依赖配置与基础环境搭建2.1 引入依赖时容易踩的版本坑整合 Spring Cloud Gateway 和 Spring Security第一步当然是加依赖。但这里有个很常见的坑Spring Cloud Gateway 和 Spring Security 的版本不匹配会导致启动报错或者过滤器链不生效。以我手头的项目为例我使用的是 Spring Boot 2.7.x Spring Cloud 2021.0.x Spring Security 5.7.x 这一套组合兼容性相对稳定。如果你用的是 Spring Boot 3.x那对应的是 Spring Security 6.xAPI 又有一轮调整ServerHttpSecurity的 DSL 方法签名也变了。所以先确认版本矩阵再动手写配置。依赖方面我一般只加两个核心包spring-cloud-starter-gateway和spring-boot-starter-security。如果还要做 JWT 解析再加一个jjwt或者java-jwt。很多教程会让你额外引入spring-boot-starter-web这是绝对不能加的因为 Gateway 本身是 WebFlux 应用一旦引入 Web 模块两者会冲突导致启动失败或者路由不生效。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注意jjwt的api、impl、jackson三个模块要分开引入impl和jackson设置为runtime即可。版本号统一不要一个 0.11.5 一个 0.9.1否则会因为内部接口不兼容直接抛异常。2.2 路由配置的基本形态Spring Cloud Gateway 的路由配置写在application.yml里核心概念是Route、Predicate和Filter。一个路由由 ID、匹配条件和过滤器链组成。匹配条件一般用 Path 断言也可以组合 Header、Method、Query 等条件。下面是一个实际可用的配置片段我定义了三个路由用户服务、订单服务、以及一个不经过认证的公开接口。spring: application: name: gateway-service cloud: gateway: routes: - id: user-service uri: lb://user-service predicates: - Path/api/user/** filters: - StripPrefix1 - id: order-service uri: lb://order-service predicates: - Path/api/order/** filters: - StripPrefix1 - id: public-api uri: lb://public-service predicates: - Path/api/public/** filters: - StripPrefix1 default-filters: - DedupeResponseHeaderAccess-Control-Allow-Origin, RETAIN_FIRST main: web-application-type: reactiveStripPrefix1的作用是剥掉第一段路径比如请求/api/user/login转发到user-service时变成/login具体按你的服务端接口来定。DedupeResponseHeader是解决 CORS 头重复问题的如果你在前端调试时发现浏览器报多个Access-Control-Allow-Origin多半是网关和后端服务都加了 CORS 配置需要加这个过滤头去重。lb://前缀表示走注册中心负载均衡也就是 Nacos 或 Eureka 里的服务名。这一步要求网关本身注册到同一个注册中心里否则解析不到目标服务实例。2.3 启用 Spring Security 后的默认行为一旦引入spring-boot-starter-security不需要写任何配置Spring Security 的默认行为就会生效所有请求需要认证并弹出一个 HTTP Basic 登录框。这往往让第一次整合的同学措手不及——网关还没写认证逻辑先把所有接口锁死了。默认行为来自 Spring Security 的自动配置WebFlux 环境下同样如此。它会生成一个随机密码用户名是user密码打在启动日志里。如果你只是引入了依赖发现访问任何网关路由都返回 401不用怀疑配置问题这是安全机制在起作用。所以接下来的任务就是自定义SecurityWebFilterChain告诉 Spring Security 哪些请求放行、哪些必须认证以及认证逻辑怎么执行。3. SecurityWebFilterChain 配置详解与 Token 校验链路3.1 核心配置类应该怎么写在 WebFlux 环境下我们通过定义SecurityWebFilterChain的Bean来接管安全配置。一个基础版的配置类长这样Configuration EnableWebFluxSecurity public class GatewaySecurityConfig { private final JwtAuthenticationManager authenticationManager; private final SecurityContextRepository securityContextRepository; public GatewaySecurityConfig(JwtAuthenticationManager authenticationManager, SecurityContextRepository securityContextRepository) { this.authenticationManager authenticationManager; this.securityContextRepository securityContextRepository; } Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { return http .csrf().disable() .formLogin().disable() .httpBasic().disable() .authenticationManager(authenticationManager) .securityContextRepository(securityContextRepository) .authorizeExchange(exchanges - exchanges .pathMatchers(/api/public/**, /api/auth/login).permitAll() .anyExchange().authenticated() ) .build(); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }EnableWebFluxSecurity是必须的它会激活 WebFlux 环境下的安全自动配置。ServerHttpSecurity的 DSL 和 Servlet 下有相似之处但方法名略有不同例如csrf()直接返回配置器formLogin()和httpBasic()可以调用disable()禁掉默认的登录方式因为我们走的是 Token 认证。authenticationManager和securityContextRepository是核心下面会分别展开。pathMatchers用来声明公开路径多个路径用逗号分隔注意这里的匹配规则是PathPattern,跟 Gateway 路由里的Path断言语法不完全一样但基本概念差不多。3.2 自定义 AuthenticationManager 解析 JWTAuthenticationManager在 WebFlux 下的接口是ReactiveAuthenticationManager核心方法返回MonoAuthentication。我们需要实现一个JwtAuthenticationManager从 Token 中解析用户信息并生成一个已认证的Authentication对象。Component public class JwtAuthenticationManager implements ReactiveAuthenticationManager { private final JwtTokenParser jwtTokenParser; public JwtAuthenticationManager(JwtTokenParser jwtTokenParser) { this.jwtTokenParser jwtTokenParser; } Override public MonoAuthentication authenticate(Authentication authentication) { return Mono.just(authentication) .map(auth - jwtTokenParser.parse(auth.getCredentials().toString())) .map(claims - new UsernamePasswordAuthenticationToken( claims.getSubject(), null, Collections.singletonList(new SimpleGrantedAuthority(ROLE_USER)) )); } }authentication.getCredentials()就是前端传来的 Token 字符串解析成功后我们构造一个新的UsernamePasswordAuthenticationToken第二参数是密码这里存储了敏感信息统一传null。第三参数是权限列表可以从 JWT 的roles或authorities声明里提取也可以直接给一个默认角色方便后续做接口级权限控制。注意如果你在authenticate里出现了解析失败的情况比如 Token 过期、签名异常、格式非法,千万要抛异常或者返回Mono.error不要吞掉。因为在 Spring Security 里认证失败必须以异常的形式向外传递才能触发 401 响应。否则认证管理器会认为认证成功但 Authentication 对象里又没有有效用户信息后续逻辑直接乱套。3.3 SecurityContextRepository 的作用与实现这是整个整合中最关键、也最容易被忽略的组件。在 Servlet 环境下Spring Security 会把SecurityContext存到HttpSession里请求结束之后自动恢复。但网关是分布式的我们不能依赖 Session必须做到无状态每一个请求都从 Header 里取 Token解析出用户构造SecurityContext请求结束直接丢弃不保留任何会话状态。SecurityContextRepository就是负责这个“取上下文”和“存上下文”的接口。WebFlux 环境下需要实现ReactiveSecurityContextRepository。Component public class SecurityContextRepository implements ReactiveSecurityContextRepository { private final JwtAuthenticationManager authenticationManager; public SecurityContextRepository(JwtAuthenticationManager authenticationManager) { this.authenticationManager authenticationManager; } Override public MonoVoid save(ServerWebExchange exchange, SecurityContext context) { return Mono.empty(); } Override public MonoSecurityContext load(ServerWebExchange exchange) { String token resolveToken(exchange.getRequest()); if (token null || token.isEmpty()) { return Mono.empty(); } Authentication auth new UsernamePasswordAuthenticationToken(token, token); return authenticationManager.authenticate(auth) .map(SecurityContextImpl::new); } private String resolveToken(ServerHttpRequest request) { String bearerToken request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); if (StringUtils.hasText(bearerToken) bearerToken.startsWith(Bearer )) { return bearerToken.substring(7); } return null; } }load方法里做了几件事先从Authorization头里提取BearerToken然后构造一个临时的Authentication对象交给认证管理器解析。解析成功就封装成SecurityContextImpl返回解析失败自然会因为异常走 401。save方法直接返回Mono.empty()因为我们不需要保存上下文到任何地方这体现了无状态会话的设计思路。3.4 把用户信息透传到下游服务网关完成认证之后下游服务通常也需要知道当前用户是谁。常见做法是把用户信息放在 Header 里透传比如X-User-Id、X-User-Name、X-User-Roles。这一步需要用到 Gateway 的GlobalFilter在请求转发前把SecurityContext里的信息塞进请求头。Component public class UserContextFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .flatMap(authentication - { if (authentication null || !authentication.isAuthenticated()) { return chain.filter(exchange); } ServerHttpRequest request exchange.getRequest().mutate() .header(X-User-Id, authentication.getName()) .header(X-User-Roles, authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(,))) .build(); ServerWebExchange mutatedExchange exchange.mutate().request(request).build(); return chain.filter(mutatedExchange); }) .switchIfEmpty(chain.filter(exchange)); } Override public int getOrder() { return -100; } }ReactiveSecurityContextHolder.getContext()是 WebFlux 环境下获取当前用户上下文的标准入口它读取的是响应式调用链上的上下文变量。这里getOrder()设置为-100意思是让它在 Gateway 过滤器链的早期执行。注意GlobalFilter的执行时机和 Spring Security 的过滤链有时间差所以这里用了switchIfEmpty兜底确保拿不到用户上下文的时候照常转发请求而不是阻断。给下游透传 Header 时建议自定义X-开头的内部 Header不要直接复写原有的Authorization头这样下游可以保留原生 Token 做二次校验也可以去掉这个逻辑改为只传必要的用户 ID减少敏感信息的传递面。4. 常见问题与排查经验4.1 登录接口被安全过滤器拦截一直 401这是最先遇到的问题。明明在authorizeExchange里配置了/api/auth/login为permitAll可是请求还是返回 401。排查方向先看路径是否匹配pathMatchers用的是 Ant 风格匹配/api/auth/login精确匹配没问题但如果你的 Controller 映射在/api/user/login而网关路由是Path/api/user/**加StripPrefix1那转发到用户服务后的实际路径是/login但网关层面的匹配还是要用原始请求路径/api/user/login此时安全配置里需要写/api/user/login而不是转发后的/login。Spring Security 的拦截发生在 Gateway 路由转发之前它面对的是客户端原始请求路径。所以安全配置的permitAll路径必须基于原始路径来写和 Controller 里的RequestMapping不一致也没关系。我见过不少同学把两条路径搞混调一晚上都查不出原因。4.2 Token 解析成功但访问受限接口依然 403403 Forbidden说明身份认证已经通过但权限不足。最常见的原因是JwtAuthenticationManager里没有赋予任何角色而受限接口又有PreAuthorize(hasRole(ADMIN))之类的权限注解。此时需要从 JWT 中提取角色字段并转换为SimpleGrantedAuthority。还要注意一点hasRole(ADMIN)底层会自动拼接ROLE_前缀所以你在 JWT 里塞的权限值若是ADMIN生成的GrantedAuthority应该是ROLE_ADMIN两者要对应好。很多项目踩坑在权限值大小写不一致比如存了admin而代码匹配ADMIN也会 403。4.3 引入了 spring-boot-starter-web 导致网关起不来这类问题表现是启动时报Spring MVC found on classpath, which is incompatible with Spring Cloud Gateway之类的错误。原因是 Gateway 必须运行在 WebFlux 模式下而spring-boot-starter-web会把应用强制切换成 Servlet 模式。解决办法是从依赖里排除 Web 模块同时确保网关服务没有使用RestController等 MVC 注解来暴露额外接口。如果确实需要在网关上写一些自定义接口比如健康检查、登出接口可以直接用 WebFlux 的RouterFunction方式定义或者单独拆成一个管理服务。4.4 CORS 配置重复导致前端拿不到响应头前后端分离的项目中网关层几乎都要配置 CORS。如果网关配了后端微服务也配了浏览器会报 CORS 头重复。常见现象是前端访问接口时 Network 面板里出现两条Access-Control-Allow-Origin浏览器直接拦截响应。解法是在网关路由配置里加一个DedupeResponseHeaderAccess-Control-Allow-Origin, RETAIN_FIRST并把 CORS 的全局配置统一放在网关层。后端服务建议去掉各自的 CORS 配置保持逻辑收敛在网关。当然也可以选择保留后端配置、去掉网关的但这就违背了“网关统一入口”的初衷后期维护成本更高。4.5 自定义 Filter 里操作了阻塞调用导致请求线程卡死WebFlux 是响应式模型所有操作都不能阻塞事件循环线程。如果你在GlobalFilter里直接调用了RestTemplate、HttpClient的同步方法或者使用了Thread.sleep在高并发下会发生严重的线程饥饿表现为系统吞吐量骤降、请求大量超时。正确做法是使用WebClient的响应式调用或者把阻塞操作放到单独的线程池中执行最好连连接池都使用响应式版本。另一点是解析 JWT 的库本身如果是同步的其内部计算量很小对事件循环的影响有限可以接受。但如果解析逻辑里还要查数据库、查 Redis记得改成响应式客户端或者通过缓存来规避阻塞。5. 生产环境加固与扩展建议5.1 加一层网关限流别让认证成为唯一防线认证通过了不代表可以无限请求。网关层加限流可以用 Spring Cloud Gateway 自带的RequestRateLimiter过滤器底层基于 Redis 和令牌桶算法。我一般对登录接口配置比较严格的限流比如每秒 5 个请求防止暴力破解对普通业务接口配置更宽松的阈值比如每用户每秒 20 个请求。- id: auth-login uri: lb://user-service predicates: - Path/api/auth/login filters: - StripPrefix1 - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 5 redis-rate-limiter.burstCapacity: 10 key-resolver: #{userKeyResolver}userKeyResolver需要自己实现用来识别请求来源。对于登录接口通常取客户端 IP 作为 Key对于已认证接口可以优先取用户 ID这样限流更精准。别小看这一步网关被刷爆往往不是被恶意攻击而是某个客户端死循环重试没有限流的话一下子就把后端打挂了。5.2 Token 黑名单与刷新机制JWT 无状态方案最大的软肋是不能主动失效。我建议在 Redis 里维护一个黑名单Key 可以是token:blacklist:{jti}或者直接存用户 ID 加上签发时间。网关在JwtAuthenticationManager解析 Token 成功后再查一次 Redis确认不在黑名单里才放行。当然这会带来一次 Redis 查询的开销但相比每次调用认证中心成本已经低很多。实际操作中我通常会在用户注销时把 Token 的jti加入黑名单并设置与 Token 过期时间一致的 TTL避免 Redis 无限增长。用户修改密码或踢人下线时可以直接把该用户所有有效的jti加入黑名单或者用版本号机制让旧 Token 即使验签通过也因版本过期而失效。5.3 从“网关统一认证”到“接口级权限”的演进网关做完身份认证后权限控制可以分两级粗粒度控制在网关做比如路径匹配/**/admin/**需要ROLE_ADMIN角色细粒度控制在业务服务做通过方法级注解PreAuthorize实现。这种分层的好处是网关只管“能不能进”业务服务管“能干什么”职责清晰避免把网关的过滤器链搞得太臃肿。Spring Security 的authorizeExchange里也支持hasRole、hasAuthority等表达式可以基于正则路径做权限匹配。比如.authorizeExchange(exchanges - exchanges .pathMatchers(/api/admin/**).hasRole(ADMIN) .pathMatchers(/api/user/**).hasRole(USER) .anyExchange().authenticated() )注意如果这里限制角色JwtAuthenticationManager必须正确解析出角色并构造GrantedAuthority否则所有带 Admin 路径的请求都会 403。这个配置写起来简单但调试起来很费时间建议先统一给一个默认角色跑通链路再把具体角色映射慢慢加上。5.4 扩展思路动态路由与灰度发布网关整合 Spring Security 之后路由本身也可以做得更灵活。比如基于注册中心动态刷新路由或者从数据库、配置中心读取路由规则灰度发布时通过 Header 里的版本号把请求路由到不同服务实例。实现动态路由通常要继承RouteLocator重写getRoutes()方法从统一配置源里加载规则。灰度发布则可以用Weight断言或自定义GlobalFilter实现。这个扩展方向需要的基础能力都在前面搭建的网关体系上安全认证和路由链路已经打通后面加规则就只是数据驱动的事了。最后分享一点个人感触网关层的认证整合难点从来不是写一个过滤器而是理解整个链路里每一层的职责边界。Spring Security 管的是安全语义Gateway 管的是路由转发你把两者捏在一起时要清楚谁先谁后、谁负责哪一段出了问题才知道从哪一层查起。这套方案我在多个项目里验证过稳定性和可维护性都不错照着上面这组配置和代码走一遍再根据你的业务场景调整权限模型基本可以应付绝大多数微服务网关的认证需求。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →