1. 项目整体思路拆解:为什么微服务要拥抱OAuth2
1.1 从一次登录说起
做后端开发这些年,但凡涉及到用户登录、权限控制,几乎躲不开Spring Cloud Security和OAuth2这两个词。很多刚接触微服务的同学会有个疑惑:单体应用里我用Session+Cookie管理登录状态挺顺手,怎么一拆成微服务就非得引入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> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> <version>2.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.x | 5.7.x / 5.8.x | 1.0.x |
| 3.0.x | 6.0.x | 1.1.x |
| 3.1.x | 6.1.x | 1.1.x / 1.2.x |
| 3.2.x | 6.2.x | 1.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 Server
3.1 环境准备和依赖引入
我这里直接用Spring Boot 3.2.4 + Spring Cloud 2023.0.1来做演示,业务场景设定为一个极简社区平台,包含一个用户中心和内容服务。实际上我们只需要两个工程:认证服务用于签发Token,内容服务用于扮演资源服务器。用户中心的职责可以先并进认证服务里,保持最小可运行状态。
先创建认证服务,pom.xml核心依赖这样写:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-authorization-server</artifactId> <version>1.3.0</version> </dependency> <dependency> <groupId>com.nimbusds</groupId> <artifactId>nimbus-jose-jwt</artifactId> </dependency>nimbus-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,它替代了旧框架里的ClientDetails:
@Bean 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签不出Token:
@Bean public JWKSource<SecurityContext> 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_id=community-app&response_type=code&scope=read&redirect_uri=http://localhost:8081/login/oauth2/code/community,流程分四步:
- SAS发现未登录,跳转到/login登录页。
- 登录成功后,询问用户是否授权community-app访问read、write权限,这一步就是资源所有者的授权环节。
- 点击同意后,浏览器重定向到redirect_uri,并携带一个授权码,形如
?code=xxxxxx。 - 前端应用拿授权码向后端换取Token,这个步骤需要拿到client_id和client_secret,在测试时可以直接用curl模拟:
curl -X POST "http://localhost:8080/oauth2/token" \ -u "community-app:secret123" \ -d "grant_type=authorization_code" \ -d "redirect_uri=http://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. 资源服务器实操:让每个微服务都认识JWT
4.1 资源服务最小依赖和配置
授权服务器发得出Token,业务服务认不认又是另一回事。接下来把内容服务改造成资源服务器,让它在网关下游能独立验证JWT。
内容服务的pom.xml核心依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 token,JWT验签不通过。多数原因是授权服务器重启后重新生成了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.security=DEBUG,OAuth2的每一步重定向和Token签发信息都会打印出来,问题定位会快很多。
有一个细节我强烈建议养成习惯:给不同环境配置不同的JWK Set URI。本地联调用localhost,测试环境用内网域名,生产环境必须走HTTPS。如果URI配置错了,资源服务器启动不报错,首次验签时才开始拉公钥,第一笔业务请求才会暴露问题,排查起来特别隐蔽。
5.3 生产环境必须补强的四个加固点
授权服务器默认基于内存的客户端和用户信息,只适合学习和原型验证,生产环境至少要补四块东西:客户端注册信息和用户信息持久化到数据库;HTTPS全局开启,KeyClock在令牌传输中明文面临劫持风险;引入Token吊销机制,用户修改密码后能立刻让旧Token失效;刷新令牌做轮换,降低长期有效凭证被窃取的潜在损失。
角色权限模型也要提前设计。OAuth2的scope是给客户端看的权限粒度,而用户角色是给资源服务器做业务鉴权用的。比如scope=read只代表允许读接口,但用户是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的官方文档,会发现很多抽象概念都有了落点。