ARTICLE DETAIL

资讯详情

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

微服务认证授权实战:OAuth2+JWT与Spring Cloud Gateway落地指南

微服务认证授权实战:OAuth2+JWT与Spring Cloud Gateway落地指南 1. 整体架构设计思路与选型分析1.1 微服务认证授权的3个核心问题做微服务第一道坎往往不是服务拆分而是登录状态怎么跨服务保持。单体时代一个Session走天下到了微服务这里请求要经过网关、用户服务、订单服务、商品服务每个服务都得知道当前调用的人是谁、能干什么。我见过不少团队在这个环节翻车要么每个服务各自做一套登录逻辑要么把Session往Redis里一塞就以为解决了结果分布式环境下一堆会话同步问题。拆开来看微服务认证授权其实就三个问题第一个是身份的确认——你说是你是谁怎么证明第二个是权限的校验——确认你是张三之后你能不能访问订单接口、能不能删除商品第三个是信任的传递——用户服务验证通过后订单服务怎么相信这个结果是可信的而不是伪造的。这三个问题层层递进缺一个环节整个认证链路就断了。为什么很多团队最终都走到了OAuth2 JWT这套组合上我个人的理解是OAuth2解决的是授权流程的标准化它规范了令牌怎么发、怎么刷新、怎么吊销JWT解决的是令牌本身的自包含性把用户身份和权限信息直接编码进令牌里服务端不需要查数据库就能完成校验。两者各管一段组合起来正好覆盖上面三个问题。另一个原因是微服务架构下无状态设计几乎是刚需——服务实例可以随意扩缩容、重启、迁移但Session状态一旦存在某个实例上就处处受制。1.2 为什么是OAuth2 JWT而不是Session或纯Token先泼一盆冷水OAuth2 JWT不是银弹如果你的系统只有单体和十来个接口Session Cookie反而更简单。但在微服务场景下这套组合的收益体现得很明显。拿Session方案来说客户端拿着Cookie访问服务A服务A得把SessionId拿到Session服务去换用户信息如果Session是存储在Redis里请求每经过一个服务就要查一次Redis延迟和依赖都在增加一旦Redis抖动所有服务跟着遭殃。纯Token方案比Session好一些但问题在于Token校验逻辑要么写在每个服务里要么靠网关统一处理。如果Token只是一个随机字符串网关校验时还得调用用户服务确认有效性这又是同步调用搞不好还会把用户服务打成热点。JWT就不一样了它的Payload里直接携带用户ID、角色、过期时间网关本地就能完成签名校验不依赖任何远程调用。OAuth2的加入则把令牌怎么发、怎么换、怎么刷新这些流程标准化了不需要每个团队自己拍脑袋设计一套发Token的接口。再从扩展性角度说说。微服务架构下前端可能有Web、App、小程序多个入口后端有十几个服务第三方开放API也在考虑范围内。OAuth2的客户端模式、授权码模式分别应对机器间调用和用户授权场景一套框架吃下所有类型的接入方。这是我最终选择这套方案的核心理由——它解决的不只是当下而是给后续接入方留出了标准通道。1.3 令牌结构设计JWT里该放什么不该放什么JWT由三部分组成Header、Payload、Signature分别用Base64Url编码后用点号拼接。很多新手把JWT当成存东西的储物柜什么数据都往Payload里塞。我见过有人把用户手机号、身份证号甚至家庭住址直接塞进去的这是大忌。JWT的Payload虽然经过Base64编码但并没有加密任何拿到Token的人用工具就能解码看到里面的明文内容。所以Payload只能放非敏感信息放用户ID、用户名、角色、权限标识这类就够了。关于Claims的设计我推荐的微服务通用结构长这样{ sub: U10086, user_name: zhangsan, authorities: [ROLE_ADMIN, ORDER_READ], scope: [read, write], client_id: order-web, exp: 1735689600, iat: 1735686000, jti: d3f2a1b6-9c8e-4f7d-8a6b-2c1e0d9f8a7b }这几个字段各有讲究。sub是主体标识放用户唯一IDauthorities放角色和权限scope是OAuth2规范里的授权范围区分read/write这样的粗粒度权限exp和iat是过期时间和签发时间控制令牌生命周期jti是唯一ID用于吊销令牌时将指定Token拉黑。还有个容易被忽略的点aud审计字段最好也加上标识这个Token是给哪个客户端用的防止Token在多个客户端之间串用。签名这块我建议用RS256而非HS256。HS256是对称签名签发和校验用的是同一个密钥一旦密钥从某个服务泄露整个系统的Token都能被伪造RS256是公私钥对授权服务器持有私钥签发各微服务只拿公钥验签即使某个资源服务器的公钥泄露攻击者也造不出合法Token。这属于安全设计上的最小化泄露影响后面安全章节我会再展开说。2. 授权服务器与资源服务器的核心实现2.1 自建授权服务器Spring Boot 3的配置迁移要点Spring Security OAuth2这块在Spring Boot 3里改动很大网上搜到的教程大量还是基于Spring Boot 2.x的旧写法照着抄大概率编译都过不去。最大的变化是原来的EnableAuthorizationServer注解和AuthorizationServerEndpointsConfigurer这套在Spring Security 5.x时代就被标记废弃到Spring Boot 3里彻底移除取而代之的是独立的spring-authorization-server项目。在Spring Boot 3.2项目里引入授权服务器的依赖后核心配置长这样# application.yml spring: application: name: auth-server server: port: 9000Configuration EnableAuthorizationServer public class AuthorizationServerConfig { // 注意Boot 3 里已经没有这个注解了需要走下面的新方式 }上面这段是错误示例Boot 3里要用Configuration 定义SecurityFilterChain的方式配合OAuth2AuthorizationServerConfigurer来实现。正确的配置骨架如下Configuration public class AuthorizationServerConfig { Bean Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfigurer authorizationServerConfigurer new OAuth2AuthorizationServerConfigurer(); http.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher()) .with(authorizationServerConfigurer, (authorizationServer) - {}) .authorizeHttpRequests((authorize) - authorize.anyRequest().authenticated()) .csrf(csrf - csrf.ignoringRequestMatchers( authorizationServerConfigurer.getEndpointsMatcher())); return http.build(); } Bean Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests((authorize) - authorize.anyRequest().authenticated()) .formLogin(Customizer.withDefaults()); return http.build(); } }这里有两个坑值得单独说。第一个是Order的顺序问题授权服务器的端点匹配器必须优先级最高否则登录页的过滤器链会先把/oauth2/token这类接口拦下来直接给你返回302重定向到登录页。第二个是CSRF白名单token端点属于POST接口且是匿名可访问的不排除CSRF保护会直接403。这两个坑我当年各踩了一次排查的时候一度怀疑是依赖版本冲突最后才发现是过滤器链顺序的问题。2.2 JWT签名与Token增强自定义Claims授权服务器默认生成的JWT里只有sub、aud、exp等OAuth2标准字段实际业务里我们需要把用户ID、角色扩展进去。在Spring Authorization Server里这个扩展点叫TokenCustomizer它运行在Token生成之后、签名之前允许你往Claims里塞自定义数据。Component public class JwtTokenCustomizer implements OAuth2TokenCustomizerJwtEncodingContext { Override public void customize(JwtEncodingContext context) { if (context.getTokenType().getValue().equals(OAuth2TokenType.ACCESS_TOKEN.getValue())) { // 从认证信息里提取用户详情 Authentication principal context.getPrincipal(); if (principal instanceof UsernamePasswordAuthenticationToken) { UserDetail userDetail (UserDetail) principal.getPrincipal(); context.getClaims().claim(user_id, userDetail.getUserId()); context.getClaims().claim(user_name, userDetail.getUsername()); context.getClaims().claim(authorities, userDetail.getAuthorities()); } } } }需要特别提醒的是不要在customize里做太重的操作。这个回调在每次发Token时执行如果里面调用远程接口查用户权限授权接口的响应时间会明显劣化。我的实践是用户权限在登录时已经加载到内存中的用户对象里这里只做内存数据的拷贝绝不发RPC调用。2.3 资源服务器Spring Security 6 的规则配置迁移资源服务器的安全配置在Spring Security 6里同样有大改动。旧版的antMatchers([/api/**]).hasRole(ADMIN)写法被移除统一改用requestMatchers配合authorizeHttpRequests。下面是我在订单服务里用的配置你可以直接参考Configuration EnableWebSecurity public class ResourceServerConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.oauth2ResourceServer(oauth2 - oauth2 .jwt(jwt - jwt.decoder(jwtDecoder())) ); http.authorizeHttpRequests(authorize - authorize .requestMatchers(/api/orders/**).hasAnyAuthority(ROLE_USER, ROLE_ADMIN) .requestMatchers(/api/admin/**).hasAuthority(ROLE_ADMIN) .anyRequest().authenticated() ); return http.build(); } Bean public JwtDecoder jwtDecoder() { // 指向授权服务器的公钥端点 NimbusJwtDecoder jwtDecoder NimbusJwtDecoder .withJwkSetUri(http://auth-server:9000/oauth2/jwks).build(); // 建议加上过期时间校验和 issuer 校验 JwtValidators validator JwtValidators.createDefaultWithIssuer(http://auth-server:9000); jwtDecoder.setJwtValidator(validator); return jwtDecoder; } }这里有个关键点JwtDecoder的配置决定了你的服务信任哪些Token。withJwkSetUri指向授权服务器的JWKS端点Spring会定期拉取授权服务器的公钥列表用于验签。如果你的资源服务器和授权服务器之间有网络隔离这个端点不通那么所有请求都会401。生产环境务必配置好网络策略或者把公钥静态配置到本地。requestMatchers的粒度设计我建议按接口前缀 角色来分而不是每个接口单独写一条规则。实际上权限的细化管理放到方法级PreAuthorize注解上更灵活SecurityFilterChain里只管粗粒度的路由拦截这个方法我用了很久规则不至于蔓延成一片难维护的代码。3. 网关统一认证与令牌转发实操3.1 网关层做认证 vs 每个服务各自认证架构上有一个必须想清楚的问题校验JWT放在哪一层方案A是网关统一校验方案B是每个服务自己校验。我强烈推荐方案A理由很实际网关是请求的必经之路在这里做认证可以避免重复代码更重要的是网关还能顺带完成令牌解析后把用户信息放进请求头转发给下游下游服务根本不需要关心Token怎么验签只需要信任网关传过来的X-User-Id头。但方案A也有一个前置条件网关和下游服务之间的链路必须可信。如果服务之间存在暴露的端口外部请求可以直接绕过网关打到你某个服务上那网关验签就形同虚设了。所以在部署上要确保服务只暴露给网关不直接暴露公网。这是很多团队忽略的一个安全死角。网关过滤器的职责拆分我自己遵循三条原则放行白名单登录接口、健康检查、静态资源校验JWT签名、过期时间非法Token直接拦截解析出用户信息后以Header方式传递给下游3.2 基于Spring Cloud Gateway实现JWT过滤器用一个GlobalFilter配合GatewayFilter实现全局过滤。代码我给一个简版但完整的实现Component public class JwtAuthGlobalFilter implements GlobalFilter, Ordered { private final JwtDecoder jwtDecoder; public JwtAuthGlobalFilter(JwtDecoder jwtDecoder) { this.jwtDecoder jwtDecoder; } Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path exchange.getRequest().getURI().getPath(); // 白名单直接放行登录接口、token刷新接口、swagger等 if (isWhitelist(path)) { return chain.filter(exchange); } String token resolveToken(exchange.getRequest()); if (token null) { return unauthorized(exchange); } try { Jwt jwt jwtDecoder.decode(token); // 校验通过把用户信息放入Header传给下游服务 ServerHttpRequest mutatedRequest exchange.getRequest().mutate() .header(X-User-Id, jwt.getClaimAsString(user_id)) .header(X-User-Name, jwt.getClaimAsString(user_name)) .header(X-User-Authorities, String.join(,, jwt.getClaimAsStringList(authorities))) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (JwtException e) { return unauthorized(exchange); } } Override public int getOrder() { return -100; // 保证最先执行 } }有个细节很多人容易忽略修改请求头时要注意下游服务的信任边界。如果你直接把用户信息放进Header传给下游那这个Header就成了一个新的攻击面。如果下游服务直接暴露在不受信任的网络中攻击者完全可以跳过网关自己拼接X-User-Id来伪造身份。所以下游服务除了信任网关传过来的Header本人建议再做一层轻量校验比如校验内部服务间的调用凭证或者确保网络层面做死隔离。这个问题你不一定会马上遇到但等你开放对外接口或者多环境互通时就会明白。网关过滤器的性能也要留意。JwtDecoder.decode是整个过滤链路中比较重的操作尤其是RSA验签。如果网关承载的QPS很高可以引入JWT解码结果的本地缓存对相同Token在有效期内直接返回解析结果能省下不少CPU。Spring Cloud Gateway的默认实现没有自动缓存需要自己做我这里提一句具体能不能用上取决于你们的流量规模。3.3 服务间调用时如何透传身份网关把用户信息放进Header之后订单服务请求用户服务时需要把用户信息继续传递下去。这里最容易犯的错误是Feign客户端默认会丢掉请求头导致下游服务拿不到用户身份。我推荐直接用Feign的RequestInterceptor把关键Header透传Bean public RequestInterceptor userContextRequestInterceptor() { return template - { ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes ! null) { HttpServletRequest request attributes.getRequest(); template.header(X-User-Id, request.getHeader(X-User-Id)); template.header(X-User-Name, request.getHeader(X-User-Name)); template.header(X-User-Authorities, request.getHeader(X-User-Authorities)); } }; }同时要配上Hystrix或Sentinel的上下文传播策略否则在隔离线程池里RequestContextHolder取不到请求对象Header透传就会失效。这个坑我印象很深当时排查了半天最后发现是信号量隔离模式下线程上下文丢了导致下游服务收到空Header直接给返回401。如果你用Spring Cloud OpenFeign 线程池隔离记得把RequestContextHolder的策略配置成弱引用或者手动传值。4. Token续签、安全风险与排查实录4.1 JWT的安全隐患与常见攻击手法JWT看起来简单但安全上的坑一点不比Session少。我梳理几个最典型的攻击手法你对照检查自己的实现。算法混淆攻击Algorithm Confusion。很多JWT库支持从Token头部读取alg字段动态决定验签算法。攻击者可以构造一个alg为none的Token或者把签名算法改成HS256然后用授权服务器的公钥当作HMAC的对称密钥来签Token。防法很简单解码时固定使用RS256不要信任Token头里的alg声明。Spring Security的NimbusJwtDecoder默认只支持配置的算法但你如果自己封装了解析逻辑就要特别留意这一点。密钥泄露导致的伪造。这是最致命的一种。像网络上公开过的Nacos默认JWT密钥导致身份认证绕过的案例本质上就是内置默认密钥被长期使用且从未更换。任何使用对称签名或者固定密钥的JWT系统密钥一旦流入公开仓库或泄露到日志中等于把整个系统的认证机制拱手让人。内部系统的JWT密钥我建议至少每季度轮换一次并在配置中心做多版本共存的无缝切换。过期Token与库存Token攻击。JWT一旦签发在没有服务端记录的情况下即使它已经过期只要有人盗取了仍然可以在有效期内使用。所以敏感操作前置校验不够还建议结合Redis做Token状态管理把吊销的jti拉黑这样能弥补JWT不可吊销的先天短板。关于Payload中敏感信息的问题虽然前面提过一次这里必须再强调JWT的Payload只是Base64Url编码不是加密。抓包工具和在线解码器都能直接读出内容。所以手机号、身份证号、银行卡这些字段万万不能往Claims里放如果业务上确实需要一种补救办法是只放脱敏值或哈希值真实数据仍然通过服务端接口查询。4.2 Token续签方案对比Refresh Token与滑动过期JWT的过期时间设短了用户频繁重新登录体验差设长了Token泄露的风险窗口又大。业界的通行做法是短Access Token 长Refresh Token双Token方案。Access Token设置15-30分钟Refresh Token设置7-30天。Access Token过期后前端用Refresh Token换新的Access TokenRefresh Token也过期了才需要重新登录。实现Refresh Token时授权服务器的配置是这样的Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient client RegisteredClient.withId(UUID.randomUUID().toString()) .clientId(gateway-client) .clientSecret({noop}gateway-secret) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofMinutes(30)) .refreshTokenTimeToLive(Duration.ofDays(7)) .reuseRefreshTokens(true) .build()) .build(); return new InMemoryRegisteredClientRepository(client); }这里有个参数值得展开reuseRefreshTokens表示刷新时是否复用旧Refresh Token。设为true用户体验好但安全性差一个被盗的Refresh Token可以无限续命设为false更安全但用户可能遇到刷新令牌失效的偶发问题比如并发两个请求同时刷新后一个用了已经被失效的刷新令牌。我建议是开false同时前端对刷新请求做全局串行化这样能兼顾安全和稳定。除了双Token方案还有一个叫滑动过期的实践也可以考虑在用户操作活跃期间每次请求都顺延Access Token的过期时间。实现上通常是每次请求后返回一个新的Token给前端前端用新Token替换旧的。好处是用户一直操作就不会掉线坏处是签发的Token数量多而且Token撤吊销的诉求会更强烈。两种方案怎么选主要看你们的业务容忍度ToB内部系统更适合滑动过期ToC高并发公网系统我更推荐双Token。4.3 微服务认证链路常见问题速查表我把这几年遇到的认证授权问题整理成一个速查表排查时照着对就行。现象可能原因排查方向所有接口返回401网关JwtDecoder配置的JWKS端点无法访问检查网关到授权服务器的网络连通性和/oauth2/jwks是否可访问个别服务返回401下游服务自身的JwtDecoder配置缺失或issuer不匹配检查资源服务器的jwtDecoderBean是否注入接口偶发401并发刷新Token触发旧Token失效检查reuseRefreshTokens配置及前端刷新策略登录后携带Token请求仍被拦截白名单配置把Token校验过滤掉了检查网关过滤器的白名单路径是否包住了需要鉴权的接口网关放行Header但下游拿到空值线程上下文丢失或服务间调用没有透传Header检查Feign的RequestInterceptor和线程池隔离策略授权服务器配置正确但Token是明文的没有配置JWT编码器授权服务器使用了默认的Opaque Token检查OAuth2TokenGenerator是否注入了JwtEncoder刚换的密钥导致旧Token全部失效密钥迭代时没有做多版本共存用配置中心管理密钥版本新旧密钥同时接受验签方法级PreAuthorize不生效EnableGlobalMethodSecurity没有开启或版本迁移后注解失效检查是否添加EnableMethodSecurity注意Boot 3改名4.4 日志与监控实践认证授权链路出了问题最大痛点往往不是代码而是排查链路太长。我建议在所有服务里对认证相关事件打印结构化日志至少要包含这几个字段traceId、userId、clientId、tokenId(jti)、uri、requestMethod、status。用MDC把这些都贯穿起来后面配合链路追踪系统比如SkyWalking或Zipkin才能快速定位是哪一环掉了链子。网关层的审计日志尤其重要。谁在什么时候用什么Token调了哪个接口这是安全审计的基础。我见过不少团队因为日志里没存Token的jti出了安全问题之后连哪个Token被滥用了都查不出来只能靠猜。审计日志建议同步到独立的日志平台并设置保存周期这算是我们踩过之后沉淀下来的硬规则了。5. 工程落地的经验与扩展建议5.1 分阶段落地的路径规划如果团队之前没有OAuth2 JWT的基础我不建议一步到位把方案铺满所有服务。我的建议是分三步走第一步先做一个最小闭环授权服务器 网关 一个业务服务。打通登录拿Token - 网关验签 - 服务拿用户信息这条主链路把基础设施跑通。第二步沉淀公共组件把JWT解析、Header透传、用户上下文封装成公共库统一版本管理让各业务服务接入的成本降到最低。第三步逐步扩展接入更多服务完善权限模型再做Token吊销、动态权限等增强功能。这套节奏的好处是每步都有可验证的结果而且基础组件成熟之前不会在几十个服务里推倒重来。5.2 权限模型的沉淀认证解决你是谁授权解决你能干什么。OAuth2的scope适合做粗粒度授权read/write业务服务内部通常还要用RBAC模型做精细控制。我的分层做法是网关注册时校验scope保证客户端只能调用自己有权限的API范围服务内部通过PreAuthorize校验authority控制角色对具体接口的访问数据行级权限在Service层自己实现不走Spring Security注解这个分层的好处是把权限控制的职责拆开网关管粗粒度、服务管细粒度、业务层管数据边界不会把所有规则堆到一个地方规则变更的影响面也最小。权限数据尽量从配置中心动态加载避免改权限还要发版重部署。5.3 关于JWT使用的几句心里话说句掏心窝子的话JWT确实是好东西但围绕JWT的误解也最多。有人把JWT吹成无状态认证的终极方案但实际落地时为了吊销Token、处理Token续签还是绕不开Redis这类外部存储。正是因为这样我建议你在设计阶段就想清楚你们要的无状态是到什么程度是完全不依赖存储还是允许有状态地管理需要被吊销的Token后者通常是更务实的选择。另外JWT方案落地时最大的成本往往不在功能开发而在安全意识和配置细节。同样的代码密钥写在配置文件和由配置中心统一管理完全两个风险等级过期时间设5分钟和设30分钟对用户体验和安全性的影响差异巨大。这部分没有标准答案只有基于你业务风险的反复权衡。这套架构我先后在三个项目里落地过从最早的单体Spring Security改造到目前支撑日均百万级请求的多服务集群整体稳定性和扩展性都经受住了验证。如果你正在规划微服务的认证授权体系希望这篇文章能帮你少走一些弯路。最后再补充一个小技巧全链路联调时可以在网关侧加一个模拟Token的调试模式只允许在测试环境开启当开发环境没有授权服务器可用时用固定签名的测试Token直接放行能大幅提高前后端并行开发的效率。当然这个开关一定要做好环境隔离千万别带上线。
返回列表