ARTICLE DETAIL

资讯详情

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

Spring Boot OAuth2客户端实战:授权码流程与Token管理全解析

Spring Boot OAuth2客户端实战:授权码流程与Token管理全解析 要聊spring-boot-starter-oauth2-client我得先坦白一件事当我第一次把这个依赖加进项目时以为它就是“接一个第三方登录按钮”而已。后来发现它背后牵扯的是整个 OAuth2 授权流程、Session 管理、Token 刷新、以及对外部授权服务器的信任模型。说白了这玩意儿不单纯是个“登录开关”而是一整套客户端侧的 OAuth2 登录与资源访问基础设施。这篇文章我会用实际项目经验来讲不贴官方文档翻译重点说清楚三件事这个 starter 到底帮你做了什么基于 Spring Security 6 的项目里应该怎么配才能少踩坑以及当年我折腾了一整天才搞明白的几个问题。适合刚接手 Spring Boot 团队项目的后端同学也适合那些“接口已经能跑但完全说不清授权码小屋”的朋友。1. 整体设计与思路拆解1.1 OAuth2 客户端在 Spring 生态里的定位先退一步。OAuth2 不是一个“登录组件”它是一个授权协议。它解决的原始问题是让应用 A客户端在经过用户同意后访问托管在服务方 B授权服务器上的用户资源。最典型的场景有两种一种是“社交登录”比如用户点“用 GitHub 登录”你的应用去 GitHub 同意并换取用户信息另一种是“前后端分离 网关”前端要访问第三方 API或者你的后端要代表用户去调另一个系统的接口。在 Spring 生态里spring-boot-starter-oauth2-client就是客户端这一侧的“全家桶”。它和spring-boot-starter-security配合后会帮你完成自动生成/login、/oauth2/authorization/{registrationId}、/login/oauth2/code/{registrationId}这些关键端点管理授权码流程里的状态参数state防止 CSRF 攻击维护 OAuth2 的AuthorizedClient也就是用户授权后拿到的 token 存哪、怎么取自动刷新过期的 access token前提是授权服务器下发了 refresh token把认证后的用户信息放进了SecurityContext和常规的Principal对接。这是个非常重的自动装配。我在项目里见过很多人直接依赖它默认行为结果出问题根本无从下手因为不清楚它到底改写了 Spring Security 的哪个过滤器链。1.2 为什么非要用 starter而不是手写 OAuth2 请求你可能觉得 OAuth2 流程无非是“重定向到授权页、拿 code、换 token、拉用户信息”手写也就几百行。但你真去实现一遍你会碰到这些破事状态参数的处理。每个授权请求你得生成一个随机 state存在 Session 里回调时比对。这个逻辑每个 OAuth2 服务方都要求但具体细节很琐碎。Token 的存储和关联。拿到 token 之后你得知道它是哪个用户在哪个客户端注册下的 token同时要处理 Session 会话中“匿名用户”和“已认证用户”的切换。刷新机制。Access token 有效期通常一小时你不可能让用户每过一小时重新授权一次。合理做法是后台定期用 refresh token 换新的 access token并且要处理并发刷新。用户信息服务。不同授权服务器的用户信息字段不一样你需要写一堆适配代码默认情况下可以用NimbusJwtDecoder解析 ID Token但如果不配置sub和名字映射很容易翻车。starter 的价值就是把这套在 Spring Security 5 时代需要你手动整合的“OAuth2 Login”和“OAuth2 Client”两大模块打包成了容易自动配置的依赖。你只需要声明“我有一个客户端”并填好配置它就用OAuth2ClientAuthenticationProcessingFilterSecurity 5 的旧名字或 Security 6 里重构的组合过滤器把事情办了。1.3 从 Security 5 到 Security 6设计上的关键变化这是我必须单独拿出来强调的因为网上一搜全是 Security 5 的陈旧写法。Spring Security 6 对 OAuth2 客户端的实现做了底层重构主要变化包括OAuth2AuthorizationRequestRedirectFilter、OAuth2AuthorizationCodeGrantFilter等合并到了OAuth2ClientAuthenticationFilter部分类名直接废弃。你去看 5.x 的调试图参考意义已经不大了。默认启用了HttpSessionSecurityContextRepository和RequestAttributeSecurityContextRepository双层机制Session 并发情况下 Token 刷新更容易出现并发写。oauth2Login()DSL 和oauth2Client()DSL 并存。注意这是两个不同的入口oauth2Login()面向“用户在前端登录”oauth2Client()更偏向“后端代表用户请求资源”。两者能同时注册但你得清楚自己主要用哪个。自动配置类从OAuth2ClientAutoConfiguration演进但核心还是看OAuth2ClientProperties注册信息仍然通过spring.security.oauth2.client.registration.*注入。基于这套新架构我建议你从两个思维模型去理解项目里的配置入口是registration落点是authorized client repository。前者定义你能跟哪些授权服务器打交道后者定义你拿到 token 后挂在哪个用户身上。2. 核心细节解析与基础配置2.1 依赖引入与项目结构以 Maven 为例最新的 Spring Boot 3.3 / 3.4 项目里典型依赖长这样dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-client/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会传递引入spring-security-oauth2-client和spring-security-oauth2-jose。jose里包含nimbus-jose-jwt它是 JWT 编解码的核心库ID Token 的校验全靠它。如果你的项目用的是 Jakarta EE 9Spring Boot 3.x 没问题如果还在 Spring Boot 2.7记得把spring-security-oauth2-client版本对齐到 5.7两者 API 差异很大。2.2 以“用 GitHub 登录”为例的最简配置下面这份配置是初学者最快见效的。我以 GitHub 为授权服务器注册信息如下spring: security: oauth2: client: registration: github: client-id: your_client_id client-secret: your_client_secret scope: - read:user - user:email provider: github: authorization-uri: https://github.com/login/oauth/authorize token-uri: https://github.com/login/oauth/access_token user-info-uri: https://api.github.com/user user-name-attribute: id配合一个最基础的 Security 配置类Configuration EnableWebSecurity public class SecurityConfig { Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/, /public/**).permitAll() .anyRequest().authenticated() ) .oauth2Login(withDefaults()); return http.build(); } }这段配置之后启动应用访问任何受保护接口时会自动跳到 GitHub 授权页同意后回跳然后你在 Controller 里就能直接拿PrincipalGetMapping(/me) public MapString, Object me(AuthenticationPrincipal OAuth2User user) { return Map.of( name, user.getName(), email, user.getAttribute(email), avatar, user.getAttribute(avatar_url) ); }我特别提醒一个新手坑GitHub 在user-info-uri返回的 JSON 里主键字段是id不是login也不是sub。如果你没配置user-name-attribute: id默认会找sub找不到就抛出OAuth2UserAuthenticationException。Google 的默认主键是sub这点也是两家最容易搞混的地方。2.3 注册多个客户端时的默认行为实际项目里一般不会只用一家授权服务器。我还见过把 GitHub、Google、企业微信配在一起的。此时配置结构变成spring: security: oauth2: client: registration: github: ... google: client-id: ... client-secret: ... scope: openid, profile, email wechat: ...一旦有多个 registration默认的登录首页会是 Spring Security 自动生成的/login页面。如果你想自定义登录页有两个选择用HttpSecurity里的oauth2Login(oauth2 - oauth2.loginPage(/custom-login))指定自己页面在自定义登录页里把链接指到/oauth2/authorization/{registrationId}。比如我在项目里常用的是第二种前端不用非得跟 Spring 的后端页面耦合a href/oauth2/authorization/githubGitHub 登录/a a href/oauth2/authorization/googleGoogle 登录/a这个/oauth2/authorization/{registrationId}端点会自动触发授权码跳转同时生成 state 参数写入会话。它不是 Security 6 才有的但 Security 6 里对应的处理已经挪到OAuth2AuthorizationRequestRedirectFilter并且在某些代理环境下需要额外处理重定向 URI 的拼接。2.4 授权服务器是自定义时的 Provider 配置用现成的 GitHub、Google 当然省事但企业内部系统或者自研平台通常要对接自己的授权服务器。这里我强烈建议直接复用 Spring Authorization Server或者你的授权服务器能发布 OIDC 元数据。因为有了 OIDC 元数据地址配置就极简spring: security: oauth2: client: provider: my-oidc: issuer-uri: https://sso.example.com registration: my-oidc: client-id: client-a client-secret: secret-a authorization-grant-type: authorization_code scope: openid, profileSpring 会自动从https://sso.example.com/.well-known/openid-configuration抓取authorization_endpoint、token_endpoint、jwks_uri等地址。如果授权服务器没实现 OIDC 元数据你就得像我前面那样手工写authorization-uri、token-uri、user-info-uri。这是我踩过坑的地方——某次我对接一个不完全符合 OIDC 规范的平台它连.well-known都没有默认配置直接启动失败。3. 实操过程与核心环节实现3.1 核心流程拆解从点击登录到进入系统要真正用好这个 starter脑子里得有一条清晰的时序线。我画过不下十遍现在用文字推演一遍完整流程你对照源码看会特别清晰用户访问/oauth2/authorization/github浏览器 GET 请求到达OAuth2AuthorizationRequestRedirectFilter组装一个OAuth2AuthorizationRequest里面包含clientId、scope、state、redirectUri等参数并把请求对象存到 Session默认属性名为OAuth2AuthorizationRequest.class.getName()加.CLIENT前缀浏览器 302 到授权服务器用户登录并授权授权服务器把用户重定向回redirect_uri路径通常形如/login/oauth2/code/github?code...state...OAuth2AuthorizationCodeGrantFilter校验 state核对 session 中保存的值与回调参数一致不一致直接拦截用 code 向 token-uri 发 POST换取 access token、refresh token、ID Token如果 scope 里有 openid通过 user-info-uri 拉取用户信息构建OAuth2User如果配置了oauth2Login()此时用户被标记为已认证OAuth2LoginAuthenticationFilter把认证结果写进SecurityContext重定向回savedRequest就是你最初想访问的页面。我在第 5 步上翻车次数最多。尤其是前后端分离项目如果用户是通过前端网关跳转的回调时state对应的 Session 很可能因为网关改了域名或跨域而丢了。这个不是 starter 的问题但你得提前知道否则排查会绕大圈。3.2 通过配置实现“登录后跳转不同页面”默认成功后跳回savedRequest是对的。但很多业务不是这样用户点了登录可能从 A 页面过来成功后却必须回到 B 页面或者用户直接在地址栏输入受保护接口下载一个导出文件希望登录后直接触发下载。这时候要用OAuth2AuthenticationSuccessHandler。我的做法是实现一个自定义 Handler继承SavedRequestAwareAuthenticationSuccessHandler从 request 里取前端传的redirect参数Component public class CustomOAuth2SuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { String target request.getParameter(redirect); if (StringUtils.hasText(target)) { getRedirectStrategy().sendRedirect(request, response, target); } else { super.onAuthenticationSuccess(request, response, authentication); } } }然后在 Security 配置里手动替换.oauth2Login(oauth2 - oauth2 .successHandler(customOAuth2SuccessHandler) )这样可以做到“登录后回到指定的前端路由”对单页应用尤其友好。注意不要直接用HttpServletResponse.sendRedirect(target)直接用的话如果 target 是/api/xxx这种相对路径还算能跑但一旦带协议前缀就会绕过 Spring 的 redirect 校验触发开放重定向告警。3.3 拿到 Token 后调用受保护资源上面的流程解决的是“登录”。真实项目很少只要登录更多是“登录后要代表用户操作数据”。比如用户登录后你想读取他的 GitHub 仓库列表或者访问另一个内部微服务的用户画像接口。这时候你需要的不是OAuth2User而是完整的OAuth2AccessToken。Spring 提供了OAuth2AuthorizedClientService和OAuth2AuthorizedClientManager。最直接的办法是在 Controller 或 Service 里注入Service public class GitHubRepoService { private final RestClient restClient; private final OAuth2AuthorizedClientService authorizedClientService; public GitHubRepoService(RestClient.Builder builder, OAuth2AuthorizedClientService authorizedClientService) { this.restClient builder.build(); this.authorizedClientService authorizedClientService; } public String fetchReposForCurrentUser(Authentication authentication) { OAuth2AuthorizedClient client authorizedClientService.loadAuthorizedClient( github, authentication.getName() ); if (client null) { throw new IllegalStateException(未找到已授权的 GitHub 客户端); } return restClient.get() .uri(https://api.github.com/user/repos) .header(HttpHeaders.AUTHORIZATION, Bearer client.getAccessToken().getTokenValue()) .retrieve() .body(String.class); } }loadAuthorizedClient的第一个参数是registrationId第二个参数是 principalName。注意authentication.getName()不一定等于 GitHub 的login。如果没有用OAuth2User作为 principal而是自定义了 principal那么这里拿到的底层名字可能不对。稳妥做法是在登录 Handler 里直接取OAuth2User.getAttribute(id)存成你的业务用户 ID后续统一用它做 principal name。3.4 Token 刷新与持久化策略OAuth2AuthorizedClientService默认实现是InMemoryOAuth2AuthorizedClientServicetoken 存内存里。你要是重启服务用户的 token 全丢只能重新登录。这在开发环境无所谓生产环境通常是两种出路自己实现OAuth2AuthorizedClientService把 token 加密后存 Redis使用JdbcOAuth2AuthorizedClientServiceSpring 提供了标准建表语句。如果你有刷新令牌还需要开启刷新逻辑。默认情况下一旦 access token 过期RestClient里手动塞的 token 就失效。但如果你通过OAuth2AuthorizedClientManager发起请求配置好oauth2Client()后Spring 有可能会触发OAuth2AuthorizationCodeGrantFilter之外的DefaultOAuth2AuthorizedClientManager自动刷新。我的经验是别过度依赖自动刷新尤其跨容器、多实例部署时刷新令牌的并发和幂等处理很麻烦。一个折中方案是把自动刷新关掉自己在定时任务或请求入口处显式刷新并把刷新后的新 token 回写进 Redis。这样你清楚 token 状态排查也简单。要关闭自动刷新可以自定义OAuth2AuthorizedClientProvider的refreshTokenGrantEnabled或直接重写DefaultOAuth2AuthorizedClientManager的逻辑。3.5 Spring Security 6 的认证服务器端配合这届热词里明显是拿 Spring Security 6 自带的授权服务器组件和客户端配对。实际项目中最快搭一个授权服务器的依赖是dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-authorization-server/artifactId /dependency如果授权服务器和客户端在同一个应用里你要注意两点授权服务器默认不监听/login它负责/oauth2/authorize、/oauth2/token这些端点你如果同时定义了oauth2Login()和OAuth2AuthorizationServerConfigurer会存在回调地址被自身拦截的可能。这时给授权请求指定redirect_uri时要非常明确不要走默认拼接。我在一个 demo 里试过把客户端和授权服务器放一起结果/login/oauth2/code/my-auth被 Security 过滤器当成受保护接口处理了一直 401。最终方案是把两套配置拆成不同SecurityFilterChain或直接拆成两个应用。生产上我强烈建议拆开因为授权服务器的 Session 和客户端的 Session 是两套概念放一起只会互相干扰。4. 常见问题与排查技巧实录4.1 问题速查表现象常见原因解决思路启动报No bean named springSecurityFilterChainsecurity 配置类没被扫描到或EnableWebSecurity没加确认配置类在启动类子包下加上注解再重启跳转到授权服务器后登录成功但回调报Invalid state回调域名/端口与发起时不一致Session 丢失或 state 参数被网关改写确认代理转发头正确开发环境把 cookie 的SameSite调成Lax或NoneSecure用户信息里拿不到email没有申请对应 scope或授权服务器未返回该字段检查scope配置手动调用 user-info-uri 验证返回体使用 Google 登录报Invalid ID Token客户端配置的 redirect-uri 与 Google 控制台不一致Google Cloud Console 里精确配置 redirect-uri不要用通配符token 失效后接口 401自动刷新实际没触发或刷新返回了新 token 但未回写存库明确测试刷新流程。在拦截器或 manager 里打印 token 续期日志检查持久化存储部署到 HTTPS 环境后回调地址变成httpforwarded headers 没配好开启server.forward-headers-strategy: framework或用Tomcat的RemoteIpValve4.2 一个我会排查到底的经典问题state参数不一致某个同事把 GitHub 登录配好后在本地跑得好好的一到联调环境就报Invalid state。我查了两天最后发现原因是反向代理把x-forwarded-host传对了但x-forwarded-proto一直传的是http导致回调地址拼成http://而 GitHub 回调时我们又被 301 跳到https://端口和域名链路上 Session 也掉了。解决办法要么是让网关正确透传x-forwarded-protohttps要么在应用侧强制设置回调协议。我后来在 Nginx 层加了proxy_set_header X-Forwarded-Proto $scheme;再配合应用内server: forward-headers-strategy: framework之后 state 校验就稳定了。你要是用云负载均衡尤其注意它们会用X-Forwarded-Port有时还会附加X-Original-Host得把全套转发头都调试到位。4.3 自定义用户信息映射避免默认字段崩溃GitHub、Google、企业微信的 user-info 结构完全不一样。你如果通过OAuth2UserService自定义拉取逻辑写起来要小心。我举个例子企业微信返回的用户 JSON 里用户 ID 是userid没有sub字段。如果直接让默认的OAuth2UserService解析弹簧里会拿sub当主键直接 NPE。所以在这种情况下要自定义一个OAuth2UserServiceOAuth2UserRequest, OAuth2Userpublic class WeComOAuth2UserService extends DefaultOAuth2UserService { Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User user super.loadUser(userRequest); MapString, Object attributes new HashMap(user.getAttributes()); // 企业微信用 userid 作为主键 return new DefaultOAuth2User( List.of(new SimpleGrantedAuthority(ROLE_USER)), attributes, userid ); } }然后配置到oauth2Login(oauth2 - oauth2.userInfoEndpoint(u - u.userService(weComOAuth2UserService)))。这个细节就是我看源码才发现的。如果你不写自定义映射很多文档和教程都不带你看到这层。4.4 全链路日志排查法遇到 OAuth2 问题我最先做的事永远是开 DEBUG 日志logging: level: org.springframework.security: DEBUG org.springframework.security.oauth2: TRACETRACE会把每次请求的 header 都打出来包括重定向链路里携带的 code 和 state。生产环境别开 TRACE因为会打到 token 值。开发环境赶紧开一次最容易看出是哪一步掉的链子。我还会在OAuth2AuthorizedClientService里加一个临时切面把每次loadAuthorizedClient的返回值打出来。如果发现刷新后的 token 与原 token 相比没有变化那就说明根本没触发刷新问题不在网络层而在刷新逻辑本身。5. 实操心得与扩展方向5.1 我实际用下来的三个心得第一starter 默认行为适合 demo不适合生产。生产里至少要做三件事替换InMemoryOAuth2AuthorizedClientService、加密存储 client-secret、给 token 加个业务层面的过期提醒。我见过留默认集成上线当天用户量一大内存直接溢出。别小看这个。第二多实例部署时必须让 state 校验依赖共享存储。默认 state 存在 Session 里Session 若放本地内存负载均衡就会让用户在 A 实例发起授权回调落到 B 实例state 比对必失败。要么 Session 外置到 Redis要么把授权请求对象持久化到 Redis 自定义OAuth2AuthorizationRequestRepository。我觉得后者更可控因为 Session 外置会带来全量会话膨胀问题。第三不要硬编码 token 到业务请求里。很多时候业务代码里只是想要“带 token 调接口”但直接注入OAuth2AccessToken会让依赖变得隐晦。更好的做法是用RestClient的拦截器统一加 Authorization 头根据当前认证信息查找对应的 token。这也是社区里很多人推荐的ServletOAuth2AuthorizedClientExchangeFilterFunction用法Bean RestClient restClient(RestClient.Builder builder, OAuth2AuthorizedClientManager authorizedClientManager) { ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2 new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager); return builder .baseUrl(https://api.github.com) .apply(oauth2.oauth2Configuration()) .build(); }这样业务层只需要调restClient.get().uri(/user/repos).attributes(oauth2.authorization(github)).retrieve()token 的存活和刷新由客户端模块自己管。5.2 和 Spring Authorization Server 组合的完整链路建议如果你要在企业内部搭一套“自研究授权服务器 客户端”的完整链路我建议这样分层独立授权服务应用用spring-boot-starter-oauth2-authorization-server只负责用户认证、签发 token、发布.well-known。业务后端应用用spring-boot-starter-oauth2-client通过issuer-uri配置指到授权服务。认证存储redis保存 token 持久化信息。统一登录入口前端点击/oauth2/authorization/{registrationId}成功后携带Authentication进入业务后端。我周围太多团队一上来就想拿这套搞定单点登录实际上 OAuth2 不是单点登录协议本身它解决的是授权问题。真的要实现跨应用 SSO还得靠 Session 共享或 OIDC 的 ID Token 为用户构建应用会话。5.3 后续可以继续扩展的三个方向如果你把这套 starter 吃透了下一步值得钻研的有spring-security-oauth2-client和 WebFlux 的配合响应式项目里同样有WebClient的 OAuth2 支持但配置项和线程模型完全不同别把 servlet 的思路直接搬过去。PKCE与公共客户端移动端或单页应用不适合存 client-secret公开客户端需要启用 PKCE。Security 6 对authorization_code PKCE支持比 5 完善很多值得专门验证。JWT与Introspection的组合当你的资源服务器和客户端不在同一个应用时要区分“客户端拿 token”和“资源服务器验 token”两条链路这也是 OAuth2 架构里最容易混的部分。我个人实际体会是OAuth2 最难的永远不是写代码而是理清几个参与方的边界。毕竟 authorization result里面的坑比我见过的任何业务代码都多。你这个项目只要不是特别复杂把spring-boot-starter-oauth2-client理明白之后后面的路会顺非常多。
返回列表