尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Cloud微服务权限校验实战:OAuth2与JWT完整指南

Spring Cloud微服务权限校验实战:OAuth2与JWT完整指南 1. 为什么分布式架构下的权限校验必须重做先聊个我实际遇到过的场景。单体应用时代权限校验无非就是session里存个用户ID再配合拦截器判断一下角色代码写起来也就几十行。可一旦系统拆成微服务问题立刻变味了——用户登录后请求A服务A服务拿着token去调用B服务B服务怎么确认这个token是真的如果每个服务都自己去解析tokentoken的签发算法、密钥、过期策略又该由谁来统一管理这时候如果没有一套标准的授权机制服务之间的信任关系很快就会乱成一锅粥。我最初接手Spring Cloud项目时第一版方案很简单粗暴网关层统一校验JWT校验通过后把用户信息塞进Header往下游传。听起来没毛病但实际跑起来全是坑。比如某个服务需要根据用户角色做接口级别的权限控制它拿什么来判断Header里的字符串能直接信吗如果被其他内部服务伪造怎么办更麻烦的是当第三方系统要接入我们的开放接口时总不能给它也发一套内部JWT吧。这些问题逼着我重新审视Spring Cloud体系里真正的权限校验方案——OAuth2。OAuth2的核心价值在于它把认证和授权这两件事彻底分开了。认证解决的是你是谁授权解决的是你能干什么。在分布式环境下这种分离尤为关键认证中心只管发令牌各个业务服务只需要认令牌、查权限不需要自己维护一套用户密码体系。这就好比小区大门保安确认你的访客身份后给你一张临时通行卡每栋楼的单元门只认卡不认人。单元门不需要知道你叫什么也不需要打电话给业主确认只需要验证这张卡是不是物业发的、有没有过期。另一个关键点是Spring Cloud微服务体系里已经有了一整套基础设施——注册中心、配置中心、网关、熔断器OAuth2要做的不是另起炉灶而是把这套资源服务器Resource Server机制嵌入到已有的服务调用链路里。网关做令牌转发与初步校验业务服务通过Spring Security OAuth2的Resource Server配置完成token的二次校验每个服务通过解析token拿到用户身份和权限范围再配合方法级别的PreAuthorize实现精细授权。这才是Spring Cloud分布式权限校验的完整闭环。接下来我会结合自己实操过的项目从OAuth2核心模型、Spring Cloud的架构设计、授权服务与资源服务的配置、网关Token校验、常见踩坑这几个维度完整拆一遍。内容偏实战适合已经掌握Spring Boot基础、准备给Spring Cloud项目补上权限校验能力的同学。2. 先搞清楚OAuth2的四个角色和令牌流转流程2.1 OAuth2的四个角色到底对应系统的哪个模块OAuth2协议定义了四个角色资源所有者Resource Owner、客户端Client、授权服务器Authorization Server、资源服务器Resource Server。很多初学者卡在这四个名词上其实用生活场景套一下就很清晰。资源所有者就是用户本人他拥有数据资源客户端是第三方应用比如一个移动App或者前端网页它想替用户访问资源授权服务器是认证中心负责验证用户身份并发放令牌资源服务器是真正存数据、提供API的后端服务。在Spring Cloud体系里这四个角色都有明确落点。资源所有者和客户端一般在自己公司的前端应用侧授权服务器对应独立的Auth服务比如spring-cloud-auth资源服务器则对应一堆业务微服务——订单服务、用户服务、商品服务。这里很多人容易混淆的点是网关算不算资源服务器从Spring Cloud Gateway的定位来看它更偏向API入口做路由转发和统一鉴权入口最终的业务资源还是由各个微服务自己保护。所以更合理的做法是网关负责一轮粗校验token是否存在、是否过期业务服务做细校验权限角色是否匹配。2.2 授权码模式是核心但内部服务调用常用客户端模式OAuth2有四种授权模式授权码模式authorization_code、简化模式implicit、密码模式password、客户端模式client_credentials。在Spring Cloud分布式项目里对外给第三方用的90%以上是授权码模式对内服务间调用的客户端模式最常见密码模式因为直接把用户名密码暴露给客户端现在已经基本被淘汰在Spring Security 5.x里也不推荐使用了。授权码模式的完整流程是用户访问客户端应用客户端把用户重定向到授权服务器授权服务器展示登录页用户输入账号密码完成认证认证通过后授权服务器返回一个授权码注意这里不是token客户端拿着授权码再向后端换取access_token。这个先换code再换token的设计目的是保证用户密码只经过授权服务器不经过客户端避免客户端截获密码。客户端模式就简单得多适合服务间发起无用户参与的调用比如定时任务服务去调用订单服务拉取数据。它只需要client_id和client_secret就能直接换取token不需要用户参与。我在实际项目中一般会为每个内部服务单独分配一套client_id和client_secret一是便于追踪调用来源二是不同服务分配不同的scope限制访问范围。2.3 Token本身的门道JWT与Opaque Token的取舍OAuth2协议本身不规定token格式市面上常见的两种是JWT和不透明字符串。JWT的全称是JSON Web Token它把用户信息、权限范围、过期时间全部编码进一个自带签名的字符串里资源服务器拿到后只需要本地验签不需要再回头访问授权服务器确认token是否有效。不透明token就只是一串随机字符串资源服务器拿到token后必须调用授权服务器的一个接口一般是/oauth/check_token来验证token是否有效、获取用户信息。每来一个请求就要远程调一次认证服务在高并发下这个接口容易成为性能瓶颈。我在Spring Cloud项目里一般优先选JWT。原因有三个一是验签本地化不产生额外的网络开销二是JWT自带用户信息下游服务解析后就能拿到userId、userName、scope省去了再查一次用户表三是JWT本身有签名只要密钥不泄露伪造几乎不可能。但JWT也有缺点最典型的是无法主动失效——用户登出后token在过期前仍然有效。这个问题的日常解法是维护一个黑名单缓存把登出的token扔进去网关和资源服务校验时先查一下黑名单。3. Spring Cloud分布式环境下的鉴权链路设计3.1 一张链路图理清所有请求走向在Spring Cloud项目里一次请求从进来到访问业务数据一般经历这么几个环节客户端App/浏览器携带access_token请求到Spring Cloud GatewayGateway的GlobalFilter做第一轮校验——token是否存在、格式是否正确、是否在黑名单里第一轮校验通过后Gateway再调用授权服务器的/oauth/check_token或直接本地解析JWT确定用户身份身份确定后Gateway把userId、username、authorities等用户信息放进请求Header转发给下游业务微服务业务微服务通过Spring Security的EnableResourceServer或更现代的EnableWebSecurity配置对指定路径做权限拦截如果用户想申请一个第三方应用的访问令牌或者想获取新的token才会直接跟授权服务器交互换取access_token和refresh_token。这个链路的关键设计是分层校验一验到底。网关负责粗验证保证无效token根本进不了内网服务负责细鉴权根据不同的角色和权限范围决定是否放行。两个环节一粗一细互相补充而不是重复造轮子。我见过有的团队把全部校验都堆在网关上服务内部完全不设防结果一旦有内部服务被绕过网关直接暴露比如测试环境忘记关端口数据就裸奔了。3.2 网关层Token校验到底应该验什么先说结论网关层不建议做太多业务相关的权限判断它应该只做最小必要校验——token是否携带、token是否过期、token格式是否为合法JWT、是否命中黑名单。真正的角色权限判断全部留给下游业务服务。为什么这么设计一是网关是流量入口如果在这里做复杂逻辑会拖慢整体响应速度二是权限规则经常变化把业务规则放在网关层会导致网关频繁发版违背了网关作为基础设施层应该保持稳定的原则三是网关如果做了细粒度鉴权业务服务就容眉毛胡子一把抓失去独立的安全边界。我是这样做的在Spring Cloud Gateway里写一个AuthGlobalFilter实现GlobalFilter和Ordered接口order设为0保证最早执行。过滤器里先从Header取出Authorization Bearer token去掉Bearer前缀后尝试通过JwtDecoder解析解析失败直接返回401解析成功后把Jwt.getClaims()里的user_id、user_name、authorities写入请求Header用ServerHttpRequest.mutate()构建新请求再放行。代码大约长这样Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private final JwtDecoder jwtDecoder; public AuthGlobalFilter(JwtDecoder jwtDecoder) { this.jwtDecoder jwtDecoder; } Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String token resolveToken(exchange.getRequest()); if (StringUtils.hasText(token)) { 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)) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (JwtException e) { // token无效返回401 return unauthorized(exchange); } } return chain.filter(exchange); } }这段代码有个隐藏的细节jwtDecoder.decode如果抛异常说明token签名不对或者格式坏了这时候直接返回401即可。但如果只是token过期呢JwtDecoder默认只验签不校验过期时间需要额外调用JwtTimestampValidator或者自己手动判断jwt.getExpiresAt()。我当时就在这个细节上踩过坑——网关放行了过期token业务服务验签通过后才发现过期白白消耗了一次请求往返。3.3 下游服务如何安全信任上游传递的用户信息这里有一个很多初学者容易犯的错误网关把userId放在Header里往后传业务服务收到后直接把信任了不去校验这个userId是不是伪造的。如果某个内部服务被直接暴露到外网攻击者伪造一个X-User-Id: 999的Header就能冒充任意用户。正确的思路是业务服务也要做token验证。Spring Security OAuth2的Resource Server机制天然支持这个场景——即使网关已经把用户信息放进Header业务服务也应该再解析一次请求头里的原始token从中提取真实的userId。如果发现Header里的userId和token解析出来的userId不一致说明Header被篡改过直接拒绝。另外服务间调用如果用的是FeignFeign默认不会自动携带token。需要在RequestInterceptor里从当前请求上下文取出token再放到新的Feign请求头里。这一步很多团队都会忽略结果就是服务A通过Feign调服务B时B根本不知道调用方是谁权限校验直接失效。Configuration public class FeignClientInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { // 从Spring Security上下文获取当前token Authentication authentication SecurityContextHolder.getContext().getAuthentication(); if (authentication ! null authentication.getCredentials() ! null) { String token authentication.getCredentials().toString(); template.header(Authorization, Bearer token); } } }注意这里有个前提上游服务必须在SecurityContext里保存了原始token。在使用JWT场景下JwtAuthenticationConverter会自动把JWT解析成JwtAuthenticationTokencredentials是token字符串所以上面的代码能正常取到。如果你用的是不透明token那就要换一种方式从请求头里拿。4. 授权服务与资源服务的核心实现4.1 授权服务搭建的关键步骤授权服务是整个权限体系的中枢它负责接收用户名密码、验证身份、颁发token、管理token的刷新与失效。我搭建授权服务用的是Spring Boot 2.7 Spring Security OAuth2 Authorization Server这个组合是Spring官方后来主推的方式替代旧的EnableAuthorizationServer。依赖方面需要注意版本兼容我当时用的是spring-boot-starter-parent 2.7.18对应spring-security-oauth2-authorization-server 0.4.5这个搭配相对稳定。授权服务的核心类是AuthorizationServerConfiguration它主要配置三个东西一个RegisteredClientRepository的Bean用于注册客户端信息一个JWKSource用于生成和签名JWT一个AuthorizationServerSecurityConfiguration用于暴露token端点并配置其安全策略。下面是一个配置客户端的最小示例Configuration EnableWebSecurity public class AuthorizationServerConfig { Bean Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .tokenEndpoint(tokenEndpoint - tokenEndpoint .accessTokenRequestConverter(new CustomAuthenticationConverter()) .authenticationProvider(customAuthenticationProvider) .accessTokenResponseHandler(customAccessTokenResponseHandler) ); return http.build(); } Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient registeredClient RegisteredClient.withId(internal-service) .clientId(order-service) .clientSecret({noop}order-service-secret) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUri(http://localhost:8080/login/oauth2/code/gateway) .scope(read) .scope(write) .build(); return new InMemoryRegisteredClientRepository(registeredClient); } }配置客户端时有一点要特别留意Spring Security 5之后默认使用DelegatingPasswordEncoder密码必须以{noop}、{bcrypt}等前缀标注加密方式。如果只写order-service-secret而没有前缀启动时会报There is no PasswordEncoder mapped for the id null。这个报错我遇到过不下三次每次都是因为新写一个环境忘了加前缀。4.2 资源服务的Spring Security配置与权限注解资源服务那边要做的事相对简单引入spring-boot-starter-oauth2-resource-server配置JWT解码器然后通过EnableGlobalMethodSecurity(prePostEnabled true)开启方法级鉴权之后就能在接口上直接使用PreAuthorize(hasAuthority(SCOPE_read))这类注解。资源服务配置核心是向Spring Security声明JWT验签的公钥来源。如果我们用的是对称密钥直接配置spring.security.oauth2.resourceserver.jwt.secret即可如果是非对称密钥把公钥配置在spring.security.oauth2.resourceserver.jwt.public-key-location即可如果密钥托管在授权服务器的/oauth2/jwks端点则配置jwk-set-uri资源服务启动时会定期从该端点拉取公钥。生产环境我一般用jwk-set-uri方式授权服务切公钥对时业务服务不用重启无缝衔接。Configuration EnableWebSecurity EnableGlobalMethodSecurity(prePostEnabled true) public class ResourceServerConfig { Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize - authorize .antMatchers(/public/**).permitAll() .anyRequest().authenticated()) .oauth2ResourceServer(oauth2 - oauth2.jwt(Customizer.withDefaults())); return http.build(); } Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter converter new JwtGrantedAuthoritiesConverter(); // 默认从scope字段提取权限前缀SCOPE_ converter.setAuthorityPrefix(SCOPE_); JwtAuthenticationConverter jwtConverter new JwtAuthenticationConverter(); jwtConverter.setJwtGrantedAuthoritiesConverter(converter); return jwtConverter; } }这里最容易被忽略的是权限前缀问题。默认情况下JwtGrantedAuthoritiesConverter会把JWT里的scope字段解析成SCOPE_read、SCOPE_write这种权限标识。如果你的数据库或者用户权限模型里用的是ROLE_ADMIN这种角色标识两者对不上PreAuthorize(hasRole(ADMIN))就永远拦截不了。我当时为了让两套体系兼容写了一个自定义的Converter把scopes、roles、permissions三种来源统一映射成一套权限标识。4.3 自定义授权逻辑从数据库加载用户权限默认的认证方式是查内存用户表但真实项目肯定要对接数据库。我通常会实现一个UserDetailsService从数据库查出用户信息、角色、权限然后封装成UserDetails返回。同时还要实现一个AuthenticationProvider在其中校验密码、检查账户状态、记录登录日志。有一种常见需求是数据库中的权限需要实时生效——比如管理员后台改了某个用户的角色希望立即生效。如果用JWTtoken里的权限是登录时签发的改数据库并不能让已签发的token立刻失效。这个矛盾我实践中是用短token 刷新机制方案解决的access_token有效期设成30分钟refresh_token设成7天前端拿到access_token过期后自动用refresh_token换新token新token里自然带了最新的角色权限。这样权限变更最多30分钟内生效对大多数业务足够。如果业务对权限实时性要求极高那就得走每次请求查权限路线。JWT只存userId不存权限列表服务拿userId去Redis或者数据库查实时权限。这种方式灵活性高但每一次请求都多一次远程IO性能损耗比较明显需要权衡。5. 网关、Nacos与OAuth2的协同实践5.1 网关动态路由与鉴权白名单配置Spring Cloud Gateway结合Nacos做动态路由是现在的主流玩法路由信息放在Nacos配置中心网关从配置中心拉取修改后动态刷新。但路由刷新不等于鉴权规则刷新——如果你把白名单路径写在过滤器代码里每加一个公开接口都得重新发版这在生产环境极度不方便。我的方案是路由规则和鉴权白名单都放到Nacos配置里Gateway通过RefreshScope和NacosConfigListener动态感知配置变化。白名单配置大意如下auth: whitelist: - /auth/login - /auth/refresh - /oauth2/token - /actuator/health网关过滤器每来一个请求先判断请求路径是否命中白名单命中则直接放行否则走token校验。这块有个隐藏的坑是/auth/login这种POST接口如果也在网关后面同时网关过滤器又强制校验token那用户根本没法登录因为登录的时候还没有token。所以白名单里必须包含登录、刷新token、OAuth2授权端点、健康检查等不需要认证的路径。5.2 网关集群、限流与OAuth2令牌的冲突处理热词里有人问Spring Cloud Gateway能做集群吗答案当然是可以。集群部署时多个网关实例共享同一份路由配置但token校验是本地JWT解析不依赖共享session所以天然支持水平扩容。唯一需要注意的是如果用了限流本地限流比如RequestRateLimiter默认用本地内存存储会导致流量分配不均——请求打到不同网关实例限流计数互相不共享。这种情况下需要把限流数据放到Redis使用RedisRateLimiter。还有一个容易出问题的点是网关集群多个实例的过滤器日志很难串联排查。我通常会在网关层生成一个traceId放在Header里往下传同时配合Zipkin或SkyWalking做链路追踪。这样即使一个请求跨了多个服务节点也能按traceId把整个调用链串起来排错效率高不少。5.3 服务间调用与分布式锁、分布式事务的关系顺带说一下热词里很火的分布式锁和分布式事务。在分布式权限体系下分布式锁的典型场景是用户登录时防止并发创建账号或者刷新token时防止并发写缓存。Redis分布式锁目前最稳的写法是SET key value NX EX timeout释放锁时用Lua脚本判断value是否一致再DEL避免误删别人的锁。分布式事务则常常和权限连带出现——比如创建订单时扣减库存、累积积分这些操作跨了用户服务、库存服务、积分服务一个失败就要全部回滚。Spring Cloud Alibaba体系里Seata是主流方案AT模式对于业务代码侵入性小。但要注意Seata的全局事务管理会把服务间的连接状态绑定到全局事务ID上如果这个过程中有OAuth2的token远程校验调用它也会被纳入事务上下文一旦token校验失败导致局部回滚全局事务也会跟着回滚——这有时候是好事有时候会引起莫名其妙的滚回。我现在处理这类场景的原则是事务内不调用无关的远程鉴权服务只在进入业务逻辑前完成认证。6. 线上实战中踩过的坑登录403、token失效、权限串号6.1 login oauth error 403背后往往是CSRF或客户端认证没配好热词里有一条很典型的报错login oauth error: request failed with status code 403 press enter to retry。我第一次在Spring Security OAuth2登录流程里遇到403时第一反应是账号密码错了但试了几次都是403才意识到问题出在CSRF防护上。Spring Security默认开启了CSRF防护而OAuth2的token端点默认是POST请求。如果授权服务器配置里没有关闭CSRFPOST请求会因为缺少CSRF token被拦下来返回403。还有一个常见原因是客户端信息没走HTTP Basic认证导致Spring Security不认识这个client也会返回403。排查这类403建议先看响应体有没有WWW-Authenticate头如果有说明是认证问题如果没有多半是CSRF或过滤器顺序问题。我的授权服务器配置里是这样处理的http.csrf(csrf - csrf.disable()) .authorizeHttpRequests(authorize - authorize .antMatchers(/oauth2/token, /oauth2/authorize, /login, /error).permitAll() .anyRequest().authenticated());注意/oauth2/authorize如果是GET请求且需要走登录页它本身也应该permitAll否则用户没登录之前连授权页面都进不去。登录页和登录接口处理完Spring Security的默认表单登录逻辑会接管后续的认证跳转。6.2 token过期后刷新并发问题JWT过期后前端自动用refresh_token换新token这个逻辑在高并发场景下有个并发隐患多个请求同时发现access_token过期同时发起刷新请求有可能会拿到多个新token旧的refresh_token被重复使用甚至被撤销。更隐蔽的问题是刷新token本身也可能过期。我见过一个线上事故用户token过期后刷新失败前端循环重试打爆了授权服务器的token端点。后来我在前端加了刷新token互斥锁全局只允许一个刷新请求在途其余请求在刷新期间等待刷新完成后用新token重放。后端也做了刷新token的复用检测——如果同一个refresh_token短时间内被使用两次直接判定异常并将其标记为失效同时强制用户重新登录。6.3 权限串号JWT里丢了audience导致服务间鉴权串味权限串号是我踩过最深的一个坑。当时有两个业务服务订单服务和积分服务都接入了同一个授权服务器。用户登录后访问订单服务token里的aud受众字段没有做限制导致积分服务也能接受订单服务的token。正常情况下这不算大问题但当积分服务内部有敏感接口时一个订单服务的token居然能调积分服务的管理员接口——权限范围完全乱套了。修复方式是给每个客户端分配独立的scope同时配置JwtDecoder校验audience。Spring Security OAuth2的JWT认证器支持.jwtAuthenticationConverter()自定义校验逻辑我在里面加了一个断言token的aud必须包含当前服务的clientId两者不匹配直接抛异常。这样即使一个token被泄露到别的服务也调不动那些接口。6.4 常见问题速查表问题现象可能原因排查与解决建议网关返回401JWT签名密钥不一致、token过期、token格式错误先检查网关与授权服务器是否使用同一套JWK密钥再确认token是否过期资源服务返回403权限不足、scope不匹配、hasRole前缀不对检查JWT的scope字段确认权限注解用的前缀是否为SCOPE_Feign调用丢失token未配置RequestInterceptor实现Feign拦截器从SecurityContext取出token放入请求头刷新token频繁失败refresh_token过期、被复用、客户端secret错误检查refresh_token有效期开启复用检测确认client配置新增公开接口需重启网关白名单写死在过滤器代码里把白名单移到Nacos配置结合RefreshScope动态刷新Redis分布式锁误删释放锁时未校验ownership使用Lua脚本原子性校验和删除key7. 扩展总结从OAuth2出发看分布式系统的思维转变当我真正把OAuth2接入Spring Cloud项目之后最大的感触是分布式架构下的权限校验难点不在于OAuth2协议本身而在于思维的切换——从单机信任切换到无状态信任。单机时代容器里的session天然可信但分布式环境下服务实例可能随时扩缩容session复制不现实于是只能把所有信任信息写进token里用签名保证不可伪造再用过期时间保证时效性。理解了这一点再看JWT、看OAuth2的各个角色一切都顺理成章。我个人在实际项目里形成了一套比较固定的落地套路授权服务独立部署用数据库存储客户端信息和用户信息JWT统一签发网关只做粗校验和路由转发白名单动态配置业务服务全部启用Resource Server再配合PreAuthorize做方法级鉴权Feign强制传递token防止内部调用失去身份Redis里维护token黑名单和分布式锁解决登出和并发问题。这套组合到目前为止跑得比较稳。另外建议大家在动手之前一定先想清楚一个问题这个系统有没有第三方接入需求如果只是自己公司几个微服务互相调用OAuth2的client_credentials模式就够如果有外部开发者接入才需要考虑完整的授权码模式。一开始就把协议复杂度拉满后面的维护成本会成倍上升。后续还可以在几个方向上继续扩展一是结合Spring Authorization Server升级到OAuth2.1协议拥抱PKCE等新特性二是把权限模型从RBAC升级到ABAC支持基于属性的动态授权三是引入更细粒度的策略决策点把权限校验从代码注解迁移到独立的权限服务。每一步的推进都需要在理解OAuth2底层原理的基础上来做否则很容易被各种框架封装弄晕头。
返回列表