Spring Cloud Gateway整合Spring Security实现JWT统一认证与动态权限管理
1. 为什么要在网关层做权限管理1.1 从一次线上故障说起去年我们团队做了一次微服务拆分把原来的单体应用拆成了十几个服务。拆分完成后认证授权这块出了大问题每个服务都要自己解析Token、自己查权限代码重复不说还经常出现某个服务忘了加权限校验的情况。更麻烦的是前端调用一个页面可能要请求五六个后端服务每个服务都独立校验一次Token性能损耗相当可观。后来我们决定把权限校验统一收口到网关层。这个思路其实很朴素所有外部请求都必须经过网关网关校验通过了才转发给后面的业务服务业务服务只关心业务逻辑不用再操心认证授权的事。这就像一栋大楼的保安系统所有访客必须在大堂登记验证验证通过后才能进入各个楼层楼层内部不再单独设卡。Spring Cloud Gateway是Spring Cloud生态里的响应式网关基于WebFlux实现性能比传统的Zuul 1.x好很多。Spring Security则是Spring生态里最成熟的安全框架。把这两个整合起来就能在网关层实现统一的用户认证和权限管理。1.2 网关层权限管理的核心优势把权限管理放在网关层好处是多方面的。首先是统一入口所有请求都从网关过不会出现漏网之鱼。其次是减少重复代码业务服务不需要引入安全框架不需要写权限校验逻辑专注业务本身。第三是性能优化Token解析和权限查询只在网关做一次后面的服务直接信任网关传递的用户信息。还有一个容易被忽略的好处是协议转换。网关可以把外部的JWT Token转换成内部服务之间传递的简单Header比如把用户ID和用户名放到请求头里传给下游服务。这样下游服务不需要理解JWT的细节也不需要共享密钥降低了耦合度。当然网关层做权限管理也有它的边界。它主要解决的是认证你是谁和粗粒度授权你能不能访问这个接口的问题。至于细粒度授权你能不能操作这条数据比如用户A只能修改自己的订单这种数据级别的权限还是得在业务服务里做。网关层适合做能不能进这个门的判断不适合做进门后能拿什么东西的判断。1.3 整体架构设计思路我们的整体架构是这样的客户端携带JWT Token访问网关网关上的Spring Security过滤器链负责解析Token、验证签名、检查过期时间然后从Token中提取用户角色和权限信息与当前请求的路径进行匹配判断是否有权限访问。校验通过后网关把用户信息放到请求头里转发给下游服务。这里有个关键设计决策权限规则存在哪里。我们试过三种方案。第一种是硬编码在配置文件里简单但改权限要重启网关。第二种是存在数据库里灵活但每次请求都要查库性能有影响。第三种是存在Redis里兼顾灵活性和性能。最终我们选了第三种权限变更时通过消息通知网关刷新本地缓存。另一个决策是Token的格式。我们用的是JWT因为它是自包含的网关不需要每次请求都去认证服务器验证。JWT里放了用户ID、用户名、角色列表和权限列表。签名算法用的是RS256非对称加密网关只需要公钥就能验证认证服务器持有私钥负责签发。2. 核心组件与依赖配置2.1 版本选型与依赖关系版本兼容性是整合过程中最容易踩坑的地方。Spring Cloud Gateway和Spring Security的版本必须和Spring Boot版本匹配否则会出现各种奇怪的类找不到或者方法不兼容的问题。我们用的是这套组合Spring Boot 2.7.x、Spring Cloud 2021.0.x、Spring Cloud Gateway 3.1.x、Spring Security 5.7.x。这个组合经过大量项目验证比较稳定。如果你用Spring Boot 3.x那对应的Spring Security就是6.x配置方式会有一些变化比如WebSecurityConfigurerAdapter被移除了改用组件化的配置方式。依赖方面网关服务需要引入这几个核心包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-data-redis-reactive/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency注意网关是响应式应用不能用传统的spring-boot-starter-web否则会冲突。Redis也要用响应式的>Configuration EnableWebFluxSecurity public class GatewaySecurityConfig { Bean public SecurityWebFilterChain securityWebFilterChain( ServerHttpSecurity http, JwtAuthenticationFilter jwtFilter, GatewayAuthorizationManager authorizationManager) { return http .csrf().disable() .cors().configurationSource(corsConfigurationSource()) .and() .securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) .authorizeExchange() .pathMatchers(/auth/login, /auth/refresh).permitAll() .pathMatchers(/actuator/health).permitAll() .anyExchange().access(authorizationManager) .and() .addFilterAt(jwtFilter, SecurityWebFiltersOrder.AUTHENTICATION) .exceptionHandling() .authenticationEntryPoint(new JwtAuthenticationEntryPoint()) .accessDeniedHandler(new JwtAccessDeniedHandler()) .and() .build(); } }这里有几个关键点需要解释。csrf().disable()是因为我们用JWT做认证不依赖CookieCSRF攻击的前提是浏览器自动携带CookieJWT放在Header里手动携带不存在CSRF问题。securityContextRepository设为NoOp是因为我们不需要在Session里保存安全上下文每个请求都从Token重新构建。addFilterAt把我们的JWT过滤器放在AUTHENTICATION这个位置这是Spring Security过滤器链里专门处理认证的位置。放在这里的好处是后面的授权过滤器能拿到我们设置好的认证信息。3.2 JWT认证过滤器的实现细节JWT过滤器是整个权限管理的入口它的职责是从请求头里提取Token、验证Token有效性、解析用户信息、构建认证对象、放入安全上下文。Component public class JwtAuthenticationFilter implements WebFilter { private final JwtTokenValidator tokenValidator; private final ReactiveRedisTemplateString, String redisTemplate; Override public MonoVoid filter(ServerWebExchange exchange, WebFilterChain chain) { String token extractToken(exchange.getRequest()); if (token null) { return chain.filter(exchange); } return tokenValidator.validate(token) .flatMap(claims - { String userId claims.getSubject(); ListString authorities claims.get(authorities, List.class); // 检查Token是否在黑名单里用于登出场景 return redisTemplate.hasKey(token:blacklist: claims.getId()) .flatMap(isBlacklisted - { if (Boolean.TRUE.equals(isBlacklisted)) { return Mono.error(new InvalidTokenException(Token已失效)); } UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userId, null, authorities.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()) ); return chain.filter(exchange) .contextWrite(ReactiveSecurityContextHolder .withAuthentication(authentication)); }); }) .onErrorResume(e - { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); }); } }这里有个响应式编程的坑需要特别注意安全上下文的传递。在传统的Servlet应用里SecurityContextHolder用的是ThreadLocal同一个线程里随处可取。但在响应式应用里一个请求可能在不同线程间切换ThreadLocal就不管用了。必须用ReactiveSecurityContextHolder通过contextWrite把认证信息写入响应式上下文后面的过滤器才能通过ReactiveSecurityContextHolder.getContext()拿到。另一个坑是异常处理。响应式流里的异常不会自动被Spring Security的异常处理器捕获必须在过滤器里自己处理。我们这里用onErrorResume捕获异常直接设置响应状态码并结束响应。如果不处理异常会一直往上抛最后变成一个500错误而不是期望的401。3.3 自定义授权管理器的逻辑Spring Security默认的授权管理器是基于hasRole、hasAuthority这些表达式的但我们的权限规则是动态的存在Redis里所以需要自定义一个ReactiveAuthorizationManager。Component public class GatewayAuthorizationManager implements ReactiveAuthorizationManagerAuthorizationContext { private final PermissionRuleCache ruleCache; Override public MonoAuthorizationDecision check(MonoAuthentication authentication, AuthorizationContext context) { ServerWebExchange exchange context.getExchange(); String path exchange.getRequest().getPath().value(); String method exchange.getRequest().getMethodValue(); // 从缓存获取该路径需要的权限 ListString requiredAuthorities ruleCache.getRequiredAuthorities(path, method); if (requiredAuthorities.isEmpty()) { // 没有配置权限规则默认放行 return Mono.just(new AuthorizationDecision(true)); } return authentication .filter(Authentication::isAuthenticated) .map(auth - { SetString userAuthorities auth.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toSet()); boolean granted userAuthorities.containsAll(requiredAuthorities); return new AuthorizationDecision(granted); }) .defaultIfEmpty(new AuthorizationDecision(false)); } }权限匹配的逻辑是全部满足用户必须拥有规则里要求的所有权限才能访问。比如某个接口要求[ORDER_READ, ORDER_WRITE]用户只有ORDER_READ是不够的。这种设计适合需要多重权限的敏感操作。路径匹配用的是Ant风格的通配符/api/order/**能匹配/api/order/123和/api/order/123/detail。匹配的时候要注意顺序精确路径要放在通配路径前面否则精确路径的规则会被通配路径覆盖。我们在缓存里用的是LinkedHashMap按配置的顺序存储匹配时从头遍历找到第一个匹配的就返回。4. 权限数据的缓存与动态刷新4.1 为什么选择Redis作为权限存储权限数据的特点是读多写少。每个请求都要查权限规则但权限规则的变更频率很低可能几天才改一次。这种场景最适合用缓存。我们对比过几种方案。放在配置文件里改权限要重启网关不可接受。放在数据库里每次请求查库QPS高的时候数据库压力大。放在本地缓存里每个网关实例各存一份改权限要通知所有实例实现复杂。最终选Redis是因为它兼顾了集中存储和高速读取而且有现成的发布订阅机制来做变更通知。Redis里的数据结构很简单就是一个Hashkey是gateway:permission:rulesfield是路径模式value是权限列表的JSON字符串。网关启动时全量加载之后通过订阅gateway:permission:changed频道来接收变更通知。4.2 本地缓存与Redis的二级缓存设计虽然Redis很快但每个请求都走一次网络IO还是有开销的。我们在网关本地又加了一层Caffeine缓存形成二级缓存结构本地Caffeine缓存 - Redis - 数据库。本地缓存的过期时间设得比较短比如5分钟。这样即使Redis的变更通知丢了最多5分钟后本地缓存也会自动失效重新从Redis加载。这是一种兜底机制保证最终一致性。Component public class PermissionRuleCache { private final CacheString, ListString localCache; private final ReactiveRedisTemplateString, String redisTemplate; public PermissionRuleCache(ReactiveRedisTemplateString, String redisTemplate) { this.redisTemplate redisTemplate; this.localCache Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.MINUTES) .maximumSize(1000) .build(); } public ListString getRequiredAuthorities(String path, String method) { String cacheKey method : path; // 先查本地缓存 ListString cached localCache.getIfPresent(cacheKey); if (cached ! null) { return cached; } // 本地没有查Redis这里为了简化用了阻塞方式实际应该用响应式 String rulesJson redisTemplate.opsForHash() .get(gateway:permission:rules, cacheKey) .block(); if (rulesJson null) { return Collections.emptyList(); } ListString authorities JSON.parseArray(rulesJson, String.class); localCache.put(cacheKey, authorities); return authorities; } }注意上面的代码为了展示逻辑用了block()但在实际的响应式应用里在过滤器链中调用block()会导致线程阻塞严重时会让整个网关失去响应。正确的做法是把整个权限查询链路都做成响应式的返回MonoListString。4.3 权限变更的实时通知机制权限变更的流程是这样的管理后台修改权限规则 - 写入数据库 - 发布消息到Redis频道 - 网关订阅到消息 - 清除本地缓存。网关端的订阅代码Component public class PermissionChangeListener implements InitializingBean { private final ReactiveRedisTemplateString, String redisTemplate; private final PermissionRuleCache ruleCache; Override public void afterPropertiesSet() { redisTemplate.listenToChannel(gateway:permission:changed) .doOnNext(message - { // 收到变更通知清空本地缓存 ruleCache.clearLocalCache(); }) .subscribe(); } }这里有个细节我们收到通知后是清空整个本地缓存而不是只清除变更的那条规则。原因是消息里只带了有变更的信号没带具体变更内容。这样做的好处是消息体小传输快坏处是清空后第一次请求会有一点点延迟需要重新从Redis加载。考虑到权限变更频率很低这个代价完全可以接受。5. 实操过程中的踩坑记录5.1 响应式编程的常见陷阱整合过程中最大的坑都来自响应式编程模型。我整理了几个最容易出问题的地方。第一个坑是在响应式链路里调用阻塞方法。比如用redisTemplate.opsForValue().get()这种非响应式的API或者用Thread.sleep()或者调用传统的JDBC。这些操作会阻塞当前线程而WebFlux的线程池很小默认是CPU核数几个阻塞操作就能把线程池占满导致整个网关无法响应新请求。排查这类问题可以用BlockHound工具它能在检测到阻塞调用时抛出异常。第二个坑是上下文丢失。响应式编程里没有ThreadLocal所有需要跨操作符传递的数据都要通过contextWrite写入上下文用Mono.deferContextual读取。我们一开始把认证信息放在ThreadLocal里结果发现有时候能拿到有时候拿不到排查了很久才意识到是线程切换导致的。第三个坑是异常处理的位置。在响应式流里onErrorResume必须放在正确的位置才能捕获到异常。如果放在flatMap之前flatMap里抛出的异常就捕获不到。我们的经验是在过滤器链的最外层包一个onErrorResume兜底处理所有未捕获的异常。5.2 Token刷新与黑名单的配合JWT的一个固有问题是无法主动失效。Token一旦签发在过期之前一直有效。用户登出后Token理论上还能用这是个安全隐患。我们的解决方案是维护一个黑名单。用户登出时把Token的IDJWT里的jti字段加入Redis黑名单过期时间设为Token的剩余有效期。网关每次验证Token时先检查jti是否在黑名单里。private MonoBoolean isTokenBlacklisted(String jti) { return redisTemplate.hasKey(token:blacklist: jti) .map(Boolean.TRUE::equals); }这个方案有个性能问题每个请求都要查一次Redis。优化方法是把黑名单也做本地缓存但黑名单是不断增长的本地缓存所有黑名单不现实。我们的折中方案是本地缓存最近5分钟的黑名单因为大部分登出后的异常访问都发生在短时间内。Token刷新方面我们用的是双Token机制AccessToken有效期短比如30分钟RefreshToken有效期长比如7天。AccessToken过期后用RefreshToken换新的AccessToken。RefreshToken只能使用一次用过之后就失效换发新的RefreshToken。这样即使RefreshToken泄露攻击者也只能用一次。5.3 跨域与预检请求的处理网关作为所有请求的入口跨域问题也在这里处理。Spring Cloud Gateway支持CORS配置但和Spring Security一起用时要注意顺序CORS过滤器必须在Security过滤器之前执行否则预检请求OPTIONS方法会被Security拦截返回401。Bean public CorsWebFilter corsWebFilter() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOriginPatterns(Collections.singletonList(*)); config.setAllowedMethods(Arrays.asList(GET, POST, PUT, DELETE, OPTIONS)); config.setAllowedHeaders(Collections.singletonList(*)); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsWebFilter(source); }同时在Security配置里要放行OPTIONS请求.pathMatchers(HttpMethod.OPTIONS).permitAll()注意setAllowedOriginPatterns(*)和setAllowCredentials(true)一起用时Spring会动态返回请求的Origin而不是返回*。这是浏览器的安全要求携带凭证的请求不能使用通配符Origin。5.4 性能压测与调优数据上线前我们做了一轮压测用的是4核8G的机器网关单实例。测试场景是模拟1000并发用户持续请求每个请求都带JWT Token。第一轮测试结果不太理想QPS只有3000左右平均响应时间80ms。排查后发现瓶颈在JWT验签上RS256验签是CPU密集型操作每次请求都要做一次。优化方案是把验签结果缓存起来同一个Token在有效期内只验一次。缓存key是Token的MD5值value是解析后的Claims。加上这层缓存后QPS提升到了8000响应时间降到25ms。第二轮优化是权限匹配。原来的实现是每次请求都遍历所有权限规则规则多了之后匹配变慢。改成用前缀树Trie存储规则后匹配时间从O(n)降到O(路径长度)QPS进一步提升到12000。最终压测数据单实例QPS 12000P99响应时间45msCPU使用率70%内存占用稳定在2G左右。这个性能对于大多数业务场景已经足够了如果不够可以水平扩展网关实例。6. 常见问题速查与排查思路6.1 认证失败的排查路径认证失败是最常见的问题表现是返回401。排查的时候按这个顺序来排查项检查方法常见原因Token是否存在检查请求头Authorization字段前端忘记带Token或者Header名字写错Token格式是否正确检查是否以Bearer 开头格式不对解析失败Token是否过期解析Token的exp字段客户端时间不准或者Token确实过期签名是否有效用公钥验签公钥配置错误或者Token被篡改Token是否在黑名单查Redis用户已登出我遇到过一次很奇怪的问题本地测试一切正常部署到测试环境就401。排查了半天发现是测试环境的机器时间比标准时间慢了5分钟导致Token刚签发就被判定为过期。所以服务器时间同步是个容易被忽略但很重要的检查项。6.2 授权失败的排查路径授权失败返回403说明认证通过了但权限不够。排查思路首先确认用户的权限列表是否正确。可以在网关日志里打印解析出来的authorities看看和预期是否一致。如果权限列表是空的说明Token里没放权限信息或者解析出了问题。然后确认路径匹配是否正确。我们的规则匹配是从上到下遍历找到第一个匹配的就返回。如果规则配置的顺序不对可能会出现精确路径被通配路径覆盖的情况。比如先配置了/api/**需要USER权限后配置了/api/admin/**需要ADMIN权限那么访问/api/admin/users时第一个规则就匹配上了只要求USER权限ADMIN权限的规则永远不会生效。最后确认权限名称是否一致。大小写敏感ORDER_READ和order_read是不同的权限。我们踩过一次坑数据库里存的是小写Token里放的是大写导致怎么都匹配不上。6.3 性能问题的定位方法网关的性能问题通常表现为响应变慢或者QPS上不去。定位方法先看CPU使用率。如果CPU高可能是JWT验签或者权限匹配消耗太多。验签的优化前面说了加缓存。权限匹配的优化是用更高效的数据结构。再看内存使用率。如果内存持续增长不下降可能是缓存没有设置上限或者有内存泄漏。Caffeine缓存一定要设置maximumSize否则会无限增长。最后看线程状态。如果大量线程处于BLOCKED状态说明有阻塞调用。用jstack导出线程栈找到阻塞的位置。响应式应用里出现阻塞调用是严重问题必须修复。还有一个容易忽略的点是日志级别。DEBUG级别的日志在高并发下会产生大量IO严重影响性能。生产环境一定要把日志级别调到INFO或WARN并且避免在过滤器里打印大对象。6.4 几个实用的调试技巧第一个技巧是在网关加一个调试端点输入Token返回解析后的用户信息和权限列表。这个端点在排查问题时非常有用能快速确认Token本身有没有问题。第二个技巧是用curl模拟请求。有时候前端的问题会让人误以为是后端的问题用curl直接调网关能排除前端的干扰curl -X GET http://localhost:8080/api/order/list \ -H Authorization: Bearer eyJhbGciOiJSUzI1NiIs... \ -H Content-Type: application/json -v第三个技巧是在Redis里直接查看权限规则确认规则有没有正确加载redis-cli HGETALL gateway:permission:rules第四个技巧是临时关闭权限校验。在排查是认证问题还是授权问题时可以临时把授权管理器改成全部放行看请求能不能通。如果通了说明是授权问题不通说明是认证问题。这个操作只能在测试环境做生产环境千万别这么干。7. 一些延伸思考与扩展方向7.1 从网关权限到服务网格网关层做权限管理在单体微服务架构里是够用的但如果服务数量继续增长网关可能会成为瓶颈。这时候可以考虑把权限管理下沉到服务网格层用Sidecar代理来做认证授权。每个服务旁边跑一个Sidecar所有进出服务的流量都经过Sidecar权限校验在Sidecar里做。这样网关只负责路由不负责安全职责更单一。不过服务网格的复杂度比网关高很多运维成本也大。我的建议是服务数量在50个以下网关方案足够了超过50个再考虑服务网格。7.2 细粒度权限的补充方案前面说过网关层适合做粗粒度授权细粒度授权还是要在业务服务里做。但两者可以配合网关校验通过后把用户ID和权限列表放到请求头里传给下游服务下游服务用这些信息做数据级别的权限判断。比如订单服务收到请求后从请求头里拿到用户ID然后查数据库确认这个订单是不是属于这个用户的。这种判断网关做不了因为网关不知道订单和用户的归属关系。7.3 审计日志的重要性权限管理不只是拦住不该进的还要记录谁进了哪里。我们在网关里加了一个审计日志过滤器记录每个请求的用户、路径、方法、时间、结果。这些日志写到独立的日志文件里定期归档。审计日志在排查安全事件时非常有用。比如发现某个账号在短时间内大量访问敏感接口通过审计日志能快速定位到具体的时间和操作。审计日志也是合规要求很多行业标准都要求保留一定期限的操作日志。日志的存储要注意脱敏Token、密码这些敏感信息绝对不能记到日志里。我们只记录用户ID和用户名不记录Token内容。7.4 多租户场景的适配如果系统需要支持多租户权限管理会更复杂一些。每个租户有自己的角色和权限体系同一个路径在不同租户下可能要求不同的权限。我们的做法是在JWT里加一个tenantId字段权限规则的key变成tenantId:method:path。网关做权限匹配时先从Token里取出租户ID然后用租户ID路径去查规则。这样不同租户的权限规则互不干扰。多租户还会带来数据隔离的问题这个就不在网关层解决了属于业务服务的职责。网关只负责把租户ID传下去业务服务根据租户ID做数据过滤。这套方案我们已经在生产环境跑了快一年支撑了日均千万级的请求量整体比较稳定。中间也遇到过一些坑比如Redis集群切换时权限缓存短暂失效、JWT密钥轮换时的平滑过渡等但都有对应的解决方案。权限管理是个持续迭代的事情没有一劳永逸的方案关键是把基础架构搭好后面根据业务需求逐步完善。