ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot 3.X OAuth2认证服务与资源服务落地指南

Spring Boot 3.X OAuth2认证服务与资源服务落地指南

先说一句实话:OAuth2 这个名词劝退了很多人,特别是到了 Spring Boot 3.X 之后,包名换了、配置方式变了,网上很多例子里夹杂着版本不符的写法,照着抄经常报一些奇怪的错。我自己也是在做一个前后端分离项目时,被"认证服务"+"资源服务"这套结构狠狠教育了一轮,才把整个链路彻底打通。这篇文章就把我调试通过的 Spring Boot 3.X 下的 OAuth2 认证服务与资源服务方案完整记录下来,包含配置代码、完整请求链路、以及我踩过之后才明白的几个关键细节,希望能帮你少走弯路。

整个方案解决的是微服务架构里最基础也最头痛的问题:用户只登录一次,凭一个 token 访问多个业务服务,同时业务服务又不需要自己维护用户密码。文章适合刚接触 Spring Security 6 的新人,也适合已经在做单体应用、正准备把认证拆出来独立部署的团队参考。我会尽量把"为什么这么配"也讲清楚,而不是只丢一堆代码。

1. 先想清楚:认证服务、资源服务、客户端三者的分工边界

1.1 小区物业和门禁的类比

OAuth2 里有几个角色,名字容易绕晕,我用生活场景解释一下。认证服务(也就是官方术语里的 Authorization Server,日常大家也习惯叫认证服务)相当于小区物业中心,它负责核实你的身份,然后给你发一张临时访客卡;资源服务相当于每一栋楼的门禁系统,它不看你的身份证,只认访客卡上的有效标记和权限范围;客户端应用则相当于你自己——你要进哪栋楼,就去物业中心拿卡,再拿卡去刷门禁。

这里有个关键认知:资源服务完全不关心你是谁,只关心这张卡是不是物业中心签发的、卡上写了哪些楼层的权限。对应到技术上就是,资源服务只需要拿着公钥校验 token 的签名是否来自认证服务,然后读取 token 里的 scope 或自定义声明来判断能不能放行。正因为这样,多个资源服务可以共享同一个认证服务,每个业务系统不需要存用户密码,用户表也只存在于认证服务里。

1.2 授权码流程里最重要的是哪几步

OAuth2 有好几种授权模式,认证服务与资源服务分离之后,最常用也是官方最推荐的是授权码模式(Authorization Code)。整个链路的关键动作是:客户端先把浏览器重定向到认证服务,用户登录并同意授权;认证服务回调浏览器并带一个一次性授权码;客户端拿着授权码向认证服务换 access token;随后客户端带 token 访问资源服务。

我早期犯过一个错误,以为授权码是发给用户浏览器去访问资源服务的,后来才明白,授权码必须在客户端后端换取 token,过程中客户端还要拿自己的 client_id 和 client_secret 做一次身份认证。这样做的意义是,token 不会暴露在浏览器地址栏里,比直接把 token 通过重定向回传要安全得多。

这个模式下,认证服务根本不需要关心客户端业务怎么实现,只需要按标准协议把令牌签发出来;资源服务也不需要关心用户是怎么登录的,只要能从 token 中解析出合法身份即可。职责边界一旦清晰,代码结构就会跟着清晰。

2. 认证服务落地方案:用 Spring Authorization Server 而不是拼一个"假 OAuth2"

2.1 依赖选型和版本对应关系

Spring Boot 3.X 对应的 Spring Security 6.X,OAuth2 方面的官方做法是:认证服务使用 Spring Authorization Server,资源服务使用 Spring Security 的 Resource Server 模块。依赖坐标如下。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-authorization-server</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-web</artifactId> </dependency>

很多文章会把spring-boot-starter-oauth2-client也列进来,那个模块是给"需要调用第三方登录"的应用用的,比如接入 GitHub 或企业微信登录。这里搭认证服务,如果不需要访问外部 OAuth2 服务,就不要引入oauth2-client,否则两个模块的自动配置可能会互相干扰,特别容易出现登录路由被抢占、接口地址冲突的情况。

Spring Authorization Server 一开始是独立于 Spring Boot 的,版本对应关系要特别注意:Spring Boot 3.0 配 Spring Authorization Server 1.0,Boot 3.1 配 1.1,Boot 3.2 配 1.2,Boot 3.3 配 1.3。Boot 3.4 以上推荐使用 1.4。我这里验证的环境是 Spring Boot 3.3.5 加 Authorization Server 1.3.1,起步就是这套组合。

2.2 认证服务的核心装配:安全链、客户端仓库、JWT 签名

认证服务本质上还是一个 Spring Security 应用,只是多了一套授权端点。核心配置类长这样。

@Configuration @EnableWebSecurity public class AuthorizationServerConfig { @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/oauth2/**", "/login", "/.well-known/**") .authorizeHttpRequests(authorize -> authorize .requestMatchers("/.well-known/openid-configuration", "/.well-known/jwks.json").permitAll() .anyRequest().authenticated() ) .csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/token")) .exceptionHandling(ex -> ex .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), new AntPathRequestMatcher("/oauth2/**")) ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .formLogin(Customizer.withDefaults()); return http.build(); } @Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient demoClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("demo-client") .clientSecret("{noop}demo-secret") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri("http://127.0.0.1:8081/login/oauth2/code/demo-client") .scope("read") .scope("write") .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofMinutes(30)) .refreshTokenTimeToLive(Duration.ofDays(7)) .reuseRefreshTokens(true) .build()) .clientSettings(ClientSettings.builder() .requireAuthorizationConsent(true) .build()) .build(); return new InMemoryRegisteredClientRepository(demoClient); } }

这段配置里有两个顺序很关键。第一个是@Order(1),因为项目里还会有第二个安全链处理普通请求,Spring Security 会按顺序匹配第一个符合规则的安全链,认证服务相关的 URL 必须优先被这个配置接到。第二个是.well-known端点要放行,因为资源服务和外部系统要靠这个地址发现公钥和配置信息。

客户端仓库在示例里用了内存实现,方便测试。clientSecret前面那个{noop}是 PasswordEncoder 的标记,表示明文存储,生产环境一定要换成 BCrypt,这个后面再说。

2.3 登录用户从哪来:内存 UserDetails 到数据库的演进

认证服务的登录链路还差一块,就是用户信息。最简单的实现是声明一个 UserDetailsService。

@Bean public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { UserDetails user = User.builder() .username("user1") .password(passwordEncoder.encode("123456")) .roles("USER") .build(); return new InMemoryUserDetailsManager(user); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }

内存用户只适合验证流程。我后来把用户表迁到了数据库,用 Spring Data JPA 实现了自己的UserDetailsService,逻辑不复杂:按用户名查用户,把密码交给 BCryptPasswordEncoder 校验,再把用户的启用状态、角色、权限一并塞进 UserDetails。

有一点值得提醒:认证服务里的用户体系可以和企业内部的 LDAP、统一身份平台打通,但在 Spring Security 里最终都要封装成 UserDetails 返回。Spring Authorization Server 不会直接操作你的用户表,它只认 UserDetailsService 的校验结果,这点理解到位之后,后续接任何外部认证源都只是在替换 UserDetailsService 的实现。

3. 资源服务接入:JWT 校验、JWK 端点与权限映射

3.1 资源服务安全链的关键配置

资源服务完全不认识用户密码,它只认 token。配置也有一个独立的 Spring Security 安全链。

@Configuration @EnableWebSecurity public class ResourceServerConfig { @Bean @Order(2) public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/api/**") .authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt .jwkSetUri("http://localhost:9000/.well-known/jwks.json") )) .exceptionHandling(ex -> ex .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) ) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); return http.build(); } }

注意这个安全链用securityMatcher("/api/**")限定了范围,资源服务里如果还有不需要认证的首页、静态资源,可以再加一个/public/**或/health的放行规则。BearerTokenAuthenticationEntryPoint和BearerTokenAccessDeniedHandler是处理 401 和 403 的标准组件,默认行为是返回WWW-Authenticate: Bearer头,这对前后端分离项目是合理的,前端看到 401 就知道该跳登录页了。

3.2 两种公钥获取方式的选择

JWT 是非对称签名时,资源服务怎么拿到公钥,有两种常见做法。

第一种就是上面示例中的jwkSetUri,资源服务启动后第一次遇到请求时,会去认证服务的/.well-known/jwks.json拉取公钥集合并缓存,默认缓存 5 分钟。这种方式的好处是支持密钥轮换,认证服务更新密钥之后,资源服务最多 5 分钟就能拿到新公钥。

第二种是在本地配置一个固定的验证公钥。

@Bean public JwtDecoder jwtDecoder() { return NimbusJwtDecoder.withPublicKey(publicKey).build(); }

这种方式要求认证服务签名的 RSA 公钥在资源服务这边有一份静态配置,适合认证服务与资源服务打包部署在同一个小环境、且密钥不经常变的情况。它的优点是启动即可用,不依赖认证服务在线,缺点是密钥一旦轮换,两边都要同步更新配置。

我在实际项目中偏向第一种,因为认证服务独立部署后,密钥轮换是迟早的事。第一次把资源服务先启动、认证服务后启动也没有关系,JwtDecoder 不会在启动时主动去拉公钥,只有真正请求到来时才拉取,并会缓存起来,不存在启动强依赖问题。

3.3 JWT 里的 scope 是怎么变成权限的

资源服务中最常踩的坑就是权限判断。很多人在 Controller 里写了@PreAuthorize("hasAuthority('admin')"),却发现请求怎么都进不来。这是因为 Spring Security 的 JWT 支持默认会把 token 里的 scope 声明转换成权限,并且权限名会加一个SCOPE_前缀。

也就是说,如果客户端请求的 scope 是read,资源服务里判断权限就要写成hasAuthority("SCOPE_read"),直接写hasAuthority("read")是永远不成立的。

@GetMapping("/api/hello") @PreAuthorize("hasAuthority('SCOPE_read')") public String hello(@AuthenticationPrincipal Jwt jwt) { return "Hello " + jwt.getSubject() + ", scopes=" + jwt.getClaimAsStringList("scope"); }

如果想自定义声明名,比如把权限放到permissions而不是scope,可以通过JwtGrantedAuthoritiesConverter和JwtAuthenticationConverter改映射逻辑。我这边的建议是,项目初期就确定好权限命名规范,不要在 token 解析规则上反复横跳,否则排查 403 会非常痛苦。

4. 端到端实测:从浏览器授权到调用资源接口的一次完整链路

4.1 发起授权请求与登录授权

配置全部就绪后,把认证服务跑在 9000 端口,资源服务跑在 8081 端口。第一步,浏览器地址栏直接访问授权端点。

http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-client&redirect_uri=http://127.0.0.1:8081/login/oauth2/code/demo-client&scope=read&state=xyz123

浏览器会先被重定向到登录页,输入 2.3 节里创建的 user1 / 123456。登录成功后,因为注册客户端时设置了requireAuthorizationConsent(true),会看到默认的授权同意页,点击 Approve 按钮之后,浏览器才会被重定向回回调地址。

这里的state参数是防 CSRF 的关键,认证服务原样带回,客户端校验时必须和发起时一致。Spring Authorization Server 的默认实现会处理一部分 state 校验逻辑,但业务侧最好自己也保存并比对一次。

此时地址栏大概长这样:

http://127.0.0.1:8081/login/oauth2/code/demo-client?code=u2XpK...&state=xyz123

code是一次性的授权码,有效期很短,一旦换取过 token 就作废。拿到它之后,真正的主角登场,客户端后端要拿它去换 access token。

4.2 换取令牌

授权码换令牌的请求必须走客户端后端,也就是用 client 的身份去换,而不是浏览器直接请求,因为 client_secret 不能暴露在浏览器里。我用 curl 模拟这个过程:

curl -X POST "http://localhost:9000/oauth2/token" \ -u demo-client:demo-secret \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=u2XpK..." \ -d "redirect_uri=http://127.0.0.1:8081/login/oauth2/code/demo-client"

-u demo-client:demo-secret会自动加上 Basic Auth 头,认证服务通过客户端认证方式 CLAIM 校验客户端身份,然后判定授权码合法,返回 JSON。

{ "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImRlbW8ta2V5LTEifQ.eyJzdWIiOiJ1c2VyMSIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsImF1ZCI6WyJkZW1vLWNsaWVudCJdLCJleHAiOjE3MTQ2Njk1MzEsInNjb3BlIjpbInJlYWQiXX0...", "token_type": "Bearer", "expires_in": 1799, "scope": "read", "refresh_token": "替换成实际token" }

把 access_token 粘贴到任意 JWT 解析工具里,能看到 payload 里的sub、iss、aud、exp、scope字段。资源服务就是靠验签加解析这些字段来完成身份认证的。

4.3 携带令牌访问资源接口

最后一步最简单,带 token 访问资源服务:

curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImRlbW8ta2V5LTEifQ..." \ "http://localhost:8081/api/hello"

返回内容是我在 Controller 里拼出来的 Hello 信息,里面有用户名和 scope 列表。如果没带 token,会拿到 401;带了 token 但 scope 不够,会拿到 403。整个链路到这一步就完整跑通了。

整个流程里最容易让人疑惑的是为什么客户端要保存 redirect_uri。因为授权码是按照特定的客户端、特定的回调地址签发的,换 token 时认证服务会比对回调地址是否一致,防止授权码被第三方截获后滥用。所以注册客户端时写的redirectUri和实际请求里的redirect_uri必须一字不差。

5. 开发期踩过的坑与排查清单

5.1 依赖冲突与错误引入 starter

一开始我在工程里同时引入了spring-boot-starter-oauth2-client和spring-boot-starter-oauth2-authorization-server,结果登录页行为变得很诡异,/login一直跳转到默认的第三方登录配置页面。排查后发现是客户端模块的自动配置抢先注册了 OAuth2 Login 相关组件。现在我的规范是:认证服务项目只引入 authorization-server starter,需要客户端登录的业务项目才引入 oauth2-client starter,两者基本不在同一个服务里同时出现。

5.2 redirect_uri 不匹配与明文 secret

有个典型低级错误是把注册时的回调地址写成http://localhost:8081/login/oauth2/code/demo-client,但真实项目里前端回调地址在远端服务器上,带了额外的路径前缀,导致授权成功后重定向回调被 Spring 拒绝,直接报 redirect_uri 不匹配。

另一个容易被忽略的坑是 client_secret 的格式。用{noop}前缀是让 Spring Security 以明文方式比较,一旦后续改成 BCrypt 加密,数据库中所有{noop}开头的 secret 都要同步编码,否则客户端认证会直接失败。建议从一开始就准备一个统一的 secret 加密封装,不要图省事全用{noop}。

5.3 密钥重启失效与 JWK 缓存导致的 401

认证服务每次重启都会重新生成 RSA 密钥对,之前的 token 用旧私钥签名,重启后 JWK 端点换了新公钥,资源服务缓存里还留着旧公钥,就会出现"同一个 token 一会儿能用、一会儿报 invalid_token"的情况。Spring Authorization Server 的默认实现里密钥确实是在内存中生成的,所以开发时可以接受,但上生产必须把密钥持久化到证书库或配置管理的 KeyStore 里。

资源服务那边的 JWK 缓存默认是 5 分钟,如果认证服务做了一次密钥轮换,未命中的新 token 在这 5 分钟里仍然可能验签失败。排查的时候记得先确认两边的密钥编号 kty 和 kid 是否一致,不要一上来就怀疑 token 本身有问题。

6. 上线前的加固改造:从 Demo 到可用系统

6.1 客户端信息和密钥持久化

内存版的 RegisteredClientRepository 重启即丢失,生产环境必须换成数据库实现。Spring Authorization Server 提供了JdbcRegisteredClientRepository,表结构官方文档里有初始化脚本,引入spring-boot-starter-jdbc和数据源就能跑。我个人更习惯用 JPA 自己实现一个仓库,直接沿用现有的数据库迁移流程,这样可以顺便对客户端做一些自定义字段,比如所属业务线、备注、启停状态。

RSA 密钥持久化这一块,我推荐用 KeyStore 文件保存。把私钥和公钥一起写入 KeyStore,每次启动从里面读取,密钥不会因为重启而更换。代码如下:

KeyStore keyStore = KeyStore.getInstance("JKS"); try (InputStream is = new FileInputStream("/path/to/demo.jks")) { keyStore.load(is, "storepass".toCharArray()); } Key key = keyStore.getKey("demo-key", "keypass".toCharArray());

这样资源服务那边只要保证 KeyStore 里证书对应的公钥没有被替换,token 验签就始终稳定。如果哪天真要主动轮换密钥,也只需要生成一对新密钥放入 KeyStore,并确认资源服务的 JWK 缓存过了刷新周期。

6.2 自定义 JWT 声明与 token 生命周期

默认的 JWT 只有sub、scope、iss、aud、exp,业务系统通常还需要用户 ID、角色列表、甚至租户信息。可以通过自定义OAuth2TokenCustomizer往 JWT 里添加声明。

@Bean public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() { return context -> { if (context.getPrincipal() instanceof UsernamePasswordAuthenticationToken authentication) { UserDetails user = (UserDetails) authentication.getPrincipal(); context.getClaims().claim("roles", user.getAuthorities().stream() .map(GrantedAuthority::getAuthority).toList()); } }; }

添加自定义声明之后有一个明显的好处:资源服务不用每一个接口都调认证服务查用户详情,直接解析 token 就能知道用户角色。代价是 token 里塞太多信息会增大体积,放在 HTTP Header 里也可能触及网关的 header 大小限制,所以只放最核心的字段。

token 生命周期也值得单独调。默认 access token 5 分钟、refresh token 60 分钟,生产环境一般要按业务重设。我之前给一个后台管理场景调成了 access 30 分钟、refresh 8 小时,两边系统同时做静默续期,用户体感就是"一直在线但不至于 token 太长时间有效"。

6.3 资源服务的异常响应与审计

默认的 Bearer 401 响应是不带 JSON 体的,前端拿响应头判断还可以,但如果统一网关要聚合错误信息,最好把认证异常响应改成 JSON 格式。做法是自定义一个 AuthenticationEntryPoint 返回标准错误结构。

审计也很重要。认证服务至少应该记录:谁在什么时候通过了登录、哪个客户端申请了哪个 scope、是否授权成功。资源服务则应该记录关键接口的访问者和访问结果。这些东西在生产环境会省掉大量扯皮时间,排查"谁动了我的数据"的时候,token 日志就是唯一的线索。

我在实际项目里的做法是在认证服务里通过 AOP 统一打印事件日志,带上 traceId,资源服务侧则把 JWT 的sub和jti打印到业务日志中,两边日志一对比,链路就很清晰。

7. 最后再聊聊几个源码里才会看到的细节

如果把 Spring Authorization Server 的默认配置类源码翻一遍,会发现认证服务能跑通是很多默认行为叠加的结果。比如 token 端点的地址是/oauth2/token,授权端点地址是/oauth2/authorize,这些都在自动配置里按协议约定注册好了。我们自定义安全链的时候,不能轻易把这些路径改掉,否则客户端 SDK 或前端的 OAuth2 库可能找不到端点。

另外,授权同意页面默认是 Spring 内置的,样式比较简陋,但功能完整。想自定义的话,需要配置同意页路由并覆盖/oauth2/consent相关的 Controller 逻辑,同时把authorizationEndpoint.consentPage("/custom-consent")指到自己的页面。这部分改动量不小,建议先把标准流程走通,确认整个链路没问题后再去美化。

资源服务那边同样有个小细节:JWT 里的aud是客户端 ID,不是资源服务 ID,如果你有多个资源服务共用一个认证服务,不要在aud上做服务隔离,而应该在 scope 或自定义 claim 上做。我见过有人在aud里写死资源服务名称,结果客户端换一个资源服务就要重新发起授权,非常难维护。

最后再分享一个小技巧:调试阶段把 JwtDecoder 的JWKSet缓存调短一点,或者禁用缓存,这样每次请求都会去认证服务拉取最新公钥,密钥变更后立刻生效。我开发时会把NimbusJwtDecoder的setJwtValidator日志级别调到 DEBUG 看详细校验过程,错误信息比默认日志友好太多。

这套认证服务与资源服务的组合跑通之后,扩展起来非常顺。新业务服务只要加一个安全链配置,就能接入同一套用户认证体系,不会再出现每个服务各自维护一套密码表的情况。真到微服务数量多起来,再去考虑网关层统一透传 token 也不迟。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进