ARTICLE DETAIL

资讯详情

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

Spring Cloud微服务OAuth2实战:授权服务器与资源服务器搭建指南

Spring Cloud微服务OAuth2实战:授权服务器与资源服务器搭建指南 1. 项目整体思路拆解为什么微服务要拥抱OAuth21.1 从一次登录说起做后端开发这些年但凡涉及到用户登录、权限控制几乎躲不开Spring Cloud Security和OAuth2这两个词。很多刚接触微服务的同学会有个疑惑单体应用里我用SessionCookie管理登录状态挺顺手怎么一拆成微服务就非得引入OAuth2这一套复杂的东西我举个直白的例子。单体架构就像一个小公司前台、财务、仓库都在同一间办公室员工刷卡进门保安扫一眼工牌就知道你是哪个部门的能去哪些区域。但微服务架构不一样服务被拆成了几十个独立部门分散在不同楼层甚至不同园区你不能再指望门口保安认识每一个员工。这时候就需要一套统一的通行证机制发证机关管发证各个门禁只验证通行证本身是否有效、有没有对应区域的权限——这就是OAuth2授权框架最核心的价值。放到Spring Cloud体系里这套通行证机制通常是这么组合的Spring Cloud Security负责把安全策略统一纳入微服务治理体系OAuth2则定义通行证的签发、验证、吊销标准。两者结合能解决三个实际问题用户只需要登录一次就能访问网关后面的所有微服务也就是Single Sign-On单点登录。各个微服务不用各自维护Session只校验Token的合法性和权限范围服务本身变得无状态方便水平扩展。第三方应用接入时可以精确控制它能访问哪些资源、能操作多长时间不用把用户名密码直接交给对方。如果你现在接手的是一个Spring Cloud项目大概率会遇到两种技术选型老项目可能还在用Spring Security OAuth2那个旧的starter新项目则应该直接上Spring Authorization Server。这个差异背后有一段演进历史我在第三节详细讲这里先不展开。1.2 核心概念最容易混淆的四个角色OAuth2里有四个角色我在面试和带新人时发现超过一半的人会把“授权服务器”和“资源服务器”搞混。我建议用生活场景来记OAuth2角色生活类比在Spring Cloud中的位置资源所有者Resource Owner房产证的户主真正拥有数据的人就是最终用户客户端Client租客想进门使用房间的人前端应用、第三方App授权服务器Authorization Server物业公司负责核验身份、发放门禁卡独立的认证服务资源服务器Resource Server房间里的保险柜根据门禁卡决定打开哪层抽屉各个业务微服务这里特别要注意授权服务器和资源服务器绝对是两个独立职责。授权服务器管的是“你是谁”“你能拿什么权限”资源服务器管的是“你拿来的这张卡我认不认”“你够不够格打开这个接口”。在Spring Cloud环境下我强烈建议把授权服务器拆成一个独立服务而不是和业务服务混在一起原因有三点第一授权服务器的安全等级需要最高独立部署能减少被业务漏洞波及的风险第二Token签发是高频操作独立部署方便做扩容和限流第三多个资源服务器共享同一个授权服务器才能实现“一次登录、处处访问”。Token本身又有两种形态这个坑也要提前避开。一种是不透明Token就是随机字符串资源服务器拿到后还得回授权服务器查一次“这个Token有效吗”多一次网络调用另一种是JWT把用户信息、过期时间、权限列表直接编码进Token里资源服务器本地就能验签和解码不用回调。现在主流方案几乎都是JWT后面我会专门演示实现过程。2. 技术选型深度对比Spring Security OAuth2旧栈vs Spring Authorization Server新栈2.1 版本演进背后的逻辑我知道很多老项目的pom.xml里还躺着这样几个依赖dependency groupIdorg.springframework.security.oauth/groupId artifactIdspring-security-oauth2/artifactId version2.3.8.RELEASE/version /dependency还有配套的spring-security-oauth2-client和spring-security-oauth2-jose。这套旧技术栈在GitHub上已经明确进入维护模式官方不再增加新功能只会修一修严重的安全漏洞。原因其实可以理解旧项目把授权服务器、客户端、资源服务器的逻辑硬塞进一个框架里架构上已经很吃力Spring Security 5.x之后又全面转向了基于SecurityFilterChain的链式配置旧OAuth2 starter难以跟上这轮重构。取而代之的是Spring Authorization Server简称SAS这是Spring官方钦定的下一代OAuth2授权服务器实现。它从Spring Security 5.1时代开始孵化在Spring Security 6.0时代正式成熟。我查过Spring Initializr上的依赖列表新项目可以直接勾选Spring Authorization Server已经看不到旧OAuth2 starter的入口了。这基本宣告了技术风向用新不用旧。从Spring Boot版本来看SAS也有对应的兼容关系Spring Boot版本对应Spring Security版本建议SAS版本2.7.x5.7.x / 5.8.x1.0.x3.0.x6.0.x1.1.x3.1.x6.1.x1.1.x / 1.2.x3.2.x6.2.x1.2.x / 1.3.x我自己的推荐是如果你在搭新项目直接Spring Boot 3.2 Spring Security 6.2 SAS 1.3这套组合既能享受最新特性社区资料也算丰富。2.2 迁移或者选型时必须避开的三个认知误区误区一以为SAS是旧OAuth2 starter的简单升级版。实际二者包名不同、配置模型不同、ClientDetails接口被RegisteredClient替代配置写法几乎要推倒重来。误区二以为SAS把客户端、资源服务器都涵盖进去了。实际上SAS只做授权服务器客户端和资源服务器仍然用Spring Security本身配置需要另外设计。误区三认死理只相信一种授权模式。OAuth2有四种授权模式但实际微服务架构里用得最多的是客户端模式Client Credentials和授权码模式Authorization Code密码模式在Security 6里已经被标注为不推荐新项目尽量别再用了。我在选型这个问题上的实际经验是存量老项目如果运行稳定不必急着迁移但凡是新项目一律用SAS。很多教程还在教旧OAuth2的写法学完之后接手的却是新项目从依赖坐标到配置类全部对不上越学越乱。直接学SAS才是符合当前技术趋势的正路。3. 授权服务器实操从零搭建Spring Authorization Server3.1 环境准备和依赖引入我这里直接用Spring Boot 3.2.4 Spring Cloud 2023.0.1来做演示业务场景设定为一个极简社区平台包含一个用户中心和内容服务。实际上我们只需要两个工程认证服务用于签发Token内容服务用于扮演资源服务器。用户中心的职责可以先并进认证服务里保持最小可运行状态。先创建认证服务pom.xml核心依赖这样写dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-oauth2-authorization-server/artifactId version1.3.0/version /dependency dependency groupIdcom.nimbusds/groupId artifactIdnimbus-jose-jwt/artifactId /dependencynimbus-jose-jwt是SAS底层做JWT签名验签的库虽然SAS会传递引用但我习惯显式声明避免版本冲突。数据库这块我先不加用内存模式保证示例能跑通生产环境你再换成JDBC或者JPA持久化。3.2 核心配置类逐段讲解SAS的授权服务器配置核心是一个自动装配的SecurityFilterChain我给你看一个可以直接跑通的最小配置Configuration EnableWebSecurity public class AuthorizationServerConfig { Bean Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { http.securityMatcher(/oauth2/**, /login, /error) .authorizeHttpRequests(authorize - authorize .anyRequest().authenticated() ) .exceptionHandling(ex - ex .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint(/login), new MediaTypeRequestMatcher(MediaType.APPLICATION_JSON) ) ) .oauth2ResourceServer(oauth2 - oauth2.jwt(Customizer.withDefaults())) .formLogin(form - form.loginPage(/login).permitAll()); return http.build(); } Bean Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize - authorize .anyRequest().authenticated() ) .formLogin(form - form.loginPage(/login).permitAll()); return http.build(); } }这段代码有两个过滤器链容易让人困惑我解释一下。第一个过滤器链用securityMatcher限定只处理/oauth2/**、/login等路径这是授权服务器自己收发的路径权限要求是全部必须登录同时开启了formLogin作为用户登录的入口。第二个过滤器链兜底其他所有请求。这种拆分是SAS推荐的边界隔离写法避免授权端点被普通请求干扰。接着是核心的RegisteredClient它替代了旧框架里的ClientDetailsBean public RegisteredClientRepository registeredClientRepository() { RegisteredClient registeredClient RegisteredClient.withId(community-client) .clientId(community-app) .clientSecret({noop}secret123) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .redirectUri(http://localhost:8081/login/oauth2/code/community) .scope(read) .scope(write) .tokenSettings(TokenSettings.builder() .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) .accessTokenTimeToLive(Duration.ofMinutes(30)) .refreshTokenTimeToLive(Duration.ofHours(12)) .build()) .build(); return new InMemoryRegisteredClientRepository(registeredClient); }这里有两个细节值得展开。第一clientSecret我用{noop}前缀意思是明文存储不加密这是为了演示方便生产环境必须用BCrypt加密写法是{bcrypt}加密文或者直接用DelegatingPasswordEncoder统一管理。第二tokenSettings里我选了SELF_CONTAINED格式也就是JWT自包含格式资源服务器拿到后能本地验签不用回调认证服务。如果不设置这个SAS默认也是SELF_CONTAINED但版本更迭后默认值时有变动显式写出来最保险。3.3 授权码模式完整跑通流程现在只剩最后一块拼图JWKSource也就是JWT签名密钥的来源。没有它SAS签不出TokenBean public JWKSourceSecurityContext jwkSource() { RSAKey rsaKey generateRsaKey(); JWKSet jwkSet new JWKSet(rsaKey); return (jwkSelector, securityContext) - jwkSelector.select(jwkSet); } private static RSAKey generateRsaKey() { KeyPair keyPair generateRsa(); RSAPublicKey publicKey (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey (RSAPrivateKey) keyPair.getPrivate(); return new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); } private static KeyPair generateRsa() { KeyPairGenerator keyPairGenerator KeyPairGenerator.getInstance(RSA); keyPairGenerator.initialize(2048); return keyPairGenerator.generateKeyPair(); }完成上面这些配置后启动认证服务浏览器访问http://localhost:8080/oauth2/authorize?client_idcommunity-appresponse_typecodescopereadredirect_urihttp://localhost:8081/login/oauth2/code/community流程分四步SAS发现未登录跳转到/login登录页。登录成功后询问用户是否授权community-app访问read、write权限这一步就是资源所有者的授权环节。点击同意后浏览器重定向到redirect_uri并携带一个授权码形如?codexxxxxx。前端应用拿授权码向后端换取Token这个步骤需要拿到client_id和client_secret在测试时可以直接用curl模拟curl -X POST http://localhost:8080/oauth2/token \ -u community-app:secret123 \ -d grant_typeauthorization_code \ -d redirect_urihttp://localhost:8081/login/oauth2/code/community \ -d code替换为实际授权码响应里会返回access_token、refresh_token、scope、expires_in等字段。把access_token解码来看你会发现payload里包含了iss、sub、aud、scope、exp等标准声明其中sub是用户唯一标识scope是权限范围。这一步跑通授权服务器的核心链路就算是通了。4. 资源服务器实操让每个微服务都认识JWT4.1 资源服务最小依赖和配置授权服务器发得出Token业务服务认不认又是另一回事。接下来把内容服务改造成资源服务器让它在网关下游能独立验证JWT。内容服务的pom.xml核心依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/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配置类Configuration EnableWebSecurity public class ResourceServerConfig { Bean public SecurityFilterChain resourceServerSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize - authorize .requestMatchers(/api/public/**).permitAll() .requestMatchers(/api/content/**).hasAuthority(SCOPE_read) .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 - oauth2 .jwt(jwt - jwt.decoder(jwtDecoder())) ); return http.build(); } Bean public JwtDecoder jwtDecoder() { return NimbusJwtDecoder .withJwkSetUri(http://localhost:8080/oauth2/jwks) .build(); } }关键点有一处必须说明白jwtDecoder配置了jwkSetUri它的作用是定期从授权服务器拉取JWK公钥集合。注意验签时资源服务器拿到的公钥来自授权服务器的jwks端点而不是自己本地生成RSA密钥对。两个服务要用同一套密钥体系靠的就是这个JWKS端点。我在初学阶段犯过错误在资源服务器里也生成了一对RSA密钥结果验签永远失败后来才搞清楚公钥必须由授权服务器统一发布。4.2 测试资源服务器的鉴权效果写一个简单的测试接口RestController RequestMapping(/api/content) public class ContentController { GetMapping(/list) public String list() { return content list, current user: SecurityContextHolder.getContext().getAuthentication().getName(); } }不加Token访问返回401。加上Token访问有两种方式用curl的Header方式curl -H Authorization: Bearer 替换为access_token http://localhost:8081/api/content/list用Postman的话在Authorization页签里Type选择Bearer Token粘贴Token进去即可。如果一切配置正确接口会返回当前用户名和内容列表。如果返回403多半是scope权限问题检查资源服务器的hasAuthority是否和Token里携带的scope对应。Token里scope是read那你必须写SCOPE_read注意SCOPE_前缀是Spring Security的硬性约定。4.3 从网关视角看Token如何流转搭建Spring Cloud Gateway做统一入口时最关键的一点是网关本身不应该拦截并解析业务Token只做路由转发Token透传给下游服务验签。否则网关和资源服务器都去解析一遍Token职责就重复了。如果一定要在网关层做统一登录校验我习惯在GlobalFilter里写一个简洁的断言只检查Authorization Header是否存在且以Bearer开头不做全文解析。这样既避免未登录请求穿透到业务层又保持了下游验签机制的独立性。粒度更细的权限判断一律交给资源服务器完成。这套分工在微服务治理里很实用也符合安全分层的原则。5. 常见问题与排查技巧实录5.1 认证链路最常踩的六个坑第一坑签名验证失败。日志报Invalid tokenJWT验签不通过。多数原因是授权服务器重启后重新生成了RSA密钥而资源服务器的JWK缓存还是旧公钥。解决办法是配置密钥持久化把RSAKey的私钥保存到文件或数据库而不是每次启动随机生成。我在演示代码里用的是临时密钥生产项目千万不要直接抄抄了就要承担重启后所有已签发Token立即失效的后果。第二坑ClientSecret加密格式不匹配。如果用了{noop}明文但全局PasswordEncoder是BCrypt启动时会直接爆异常。统一用DelegatingPasswordEncoder就能兼容多种格式这也是为什么演示代码里明确写{noop}的原因——让你看到这个前缀的作用。第三坑redirect_uri不匹配。授权码模式下注册的redirectUri必须和请求中的redirect_uri完全一致不能多一个斜杠不能改端口连大小写都要一致。排查时优先看SAS控制台的完整报错提示它会明确列出注册值和实际值。第四坑scope权限不足导致403。Token里有scope但鉴权写成了hasRole完全不搭界。Spring Security的JWT鉴权里scope映射到authority时自动带SCOPE_前缀这点特别容易被忽略。第五坑CORS跨域问题。前后端分离后前端拿Token调接口会先发OPTIONS预检请求。我遇到过因为预检请求没有Authorization Header被安全链拦截返回401前端白白踩坑半小时。解决办法是给需要跨域的接口统一配置CORS放过预检请求。第六坑用户信息获取失败。很多教程会在认证服务里用Principal获取当前用户但客户端模式没有用户概念。如果你同时承担了客户端模式业务服务内部调用不能用Principal取用户信息得通过client_id关联调用方身份。5.2 调试OAuth2链路的三板斧我在实际调试时有三件顺手工具第一件JWT官网解码器把Token粘贴进去一眼看懂header和payload结构排查scope和exp异常时非常快第二件curl带-v参数发起请求完整查看响应头和状态码能快速判断是401还是403第三件在SAS配置里临时把日志级别调到DEBUG具体配置是logging.level.org.springframework.securityDEBUGOAuth2的每一步重定向和Token签发信息都会打印出来问题定位会快很多。有一个细节我强烈建议养成习惯给不同环境配置不同的JWK Set URI。本地联调用localhost测试环境用内网域名生产环境必须走HTTPS。如果URI配置错了资源服务器启动不报错首次验签时才开始拉公钥第一笔业务请求才会暴露问题排查起来特别隐蔽。5.3 生产环境必须补强的四个加固点授权服务器默认基于内存的客户端和用户信息只适合学习和原型验证生产环境至少要补四块东西客户端注册信息和用户信息持久化到数据库HTTPS全局开启KeyClock在令牌传输中明文面临劫持风险引入Token吊销机制用户修改密码后能立刻让旧Token失效刷新令牌做轮换降低长期有效凭证被窃取的潜在损失。角色权限模型也要提前设计。OAuth2的scope是给客户端看的权限粒度而用户角色是给资源服务器做业务鉴权用的。比如scoperead只代表允许读接口但用户是VIP还是普通会员得靠JWT里的角色声明来体现。我习惯的做法是在授权服务器生成Token时通过自定义OAuth2TokenCustomizer把数据库里查到的角色列表塞进JWT的claims里资源服务器再从claims里解析角色做业务判断。这一步如果漏掉后面做接口级权限控制时会很痛苦。6. 扩展延伸从最小示例到微服务安全基线6.1 基于SAS构建统一认证中心的模块划分如果要把这套示例扩展成生产级的统一认证中心我建议至少拆成四个模块sas-authorization-server负责Token签发sas-resource-server作为通用的资源服务安全SDK封装JWT解码和权限注解解析sas-gateway负责路由转发和登录态兜底检查sas-common负责工具类和常量定义。资源服务器通过依赖sas-resource-server这个SDK业务代码里只需要写注解就能完成权限控制比如PreAuthorize(hasAuthority(SCOPE_read))。这里有个架构层面的经验让资源服务器以SDK方式接入比每个业务服务单独配置一份完整安全逻辑要可靠得多。一方面配置口径统一不会出现一个服务配错JWK地址全部鉴权失效的事故另一方面升级安全策略时只改SDK一处发布后所有接入方自动生效。我在实际项目里把所有资源服务器的安全配置收敛成一个starter团队里新服务接入时只需引入依赖和配两行地址省心很多。6.2 OAuth2在Spring Cloud体系里的最终形态最终跑在云上的Spring Cloud安全体系大致是这样一副画面用户打开前端应用前端引导用户到授权服务器登录授权服务器发放JWT前端把JWT存在HttpOnly的Cookie或内存中访问业务接口时通过网关转发JWT网关只做路由和限流业务资源服务器本地验签并解析权限。授权服务器和资源服务器互不通信唯一的偶合就是JWKS公钥拉取。我会特别提醒自己做架构评审时关注一个点授权服务器必须是可水平扩展的不能用单机内存保存授权码和Token状态。SAS本身支持把RegisteredClient、Authorization、AuthorizationConsent持久化到数据库用Spring JDBC那一套通用表结构就能做。否则一到高并发登录场景内存模式会迅速达到瓶颈。如果你愿意再往前走一步可以考虑引入分布式会话把登录态完全从授权服务器中解耦出去但这是后话了。最后分享一个我个人的判断OAuth2这套规范看起来复杂但它解决的是“可信身份在多系统间的传递”这种必须由标准方案解决的根本问题。自己发明一套Token方案看起来很省事等业务系统多起来单点登录、第三方接入、权限审计全部要返工的时候才知道当初为什么应该一开始就拥抱标准方案。这篇内容覆盖的授权服务器和资源服务器上手路径就是我认为最平滑的入门方式。你如果照着跑通了再回去看Spring Security 6的官方文档会发现很多抽象概念都有了落点。
返回列表