先说一句实话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 而不是拼一个假 OAuth22.1 依赖选型和版本对应关系Spring Boot 3.X 对应的 Spring Security 6.XOAuth2 方面的官方做法是认证服务使用 Spring Authorization Server资源服务使用 Spring Security 的 Resource Server 模块。依赖坐标如下。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-authorization-server/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-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.0Boot 3.1 配 1.1Boot 3.2 配 1.2Boot 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_typecodeclient_iddemo-clientredirect_urihttp://127.0.0.1:8081/login/oauth2/code/demo-clientscopereadstatexyz123浏览器会先被重定向到登录页输入 2.3 节里创建的 user1 / 123456。登录成功后因为注册客户端时设置了requireAuthorizationConsent(true)会看到默认的授权同意页点击 Approve 按钮之后浏览器才会被重定向回回调地址。这里的state参数是防 CSRF 的关键认证服务原样带回客户端校验时必须和发起时一致。Spring Authorization Server 的默认实现会处理一部分 state 校验逻辑但业务侧最好自己也保存并比对一次。此时地址栏大概长这样http://127.0.0.1:8081/login/oauth2/code/demo-client?codeu2XpK...statexyz123code是一次性的授权码有效期很短一旦换取过 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_typeauthorization_code \ -d codeu2XpK... \ -d redirect_urihttp://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 OAuth2TokenCustomizerJwtEncodingContext 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 也不迟。