Spring OAuth2 Client
这一课你将能做到
- 引入
spring-boot-starter-oauth2-client 并完成 GitHub 的 registration 配置(client-id/secret 走环境变量),跑通默认路径 /oauth2/authorization/github → /login/oauth2/code/github
- 写出 SecurityFilterChain 中
oauth2Login 的配置,说清框架替你做了哪些事:state 生成校验、code 交换、UserInfo 拉取
- 实现
OAuth2LoginSuccessHandler:映射/创建本地用户 → backend 签发本站 JWT → 302 回前端 callback,联邦登录与密码登录双轨并存
为什么要学
手写授权码流五步并不难,难的是每个细节都不出错:state 的生成与会话绑定、code 的一次性、redirect_uri 字节级一致、token 刷新、用户信息解析……Spring Security 6 的 OAuth2 Client 把这些全部收进框架,你只做两件事:填配置、写「登录成功之后」的业务钩子。
本站的角色定位要摆正:backend 对 GitHub 而言是 OAuth Client(去别人那登录);对自家前端而言,仍是那个「校验密码、签发自研 JWT」的认证服务。联邦登录成功后,不是把 GitHub 的 token 洒给前端,而是走和密码登录完全相同的收尾——查/建用户、发本站 JWT。这样前端 AuthProvider、后端鉴权过滤器一条都不用改。
面试角度:Security 6 的 lambda DSL、oauth2Login 与资源服务器/自研过滤器的共存、AuthenticationSuccessHandler 的作用时机,都是实战高频题。
先建立图像
用户点「GitHub 登录」(前端按钮只做跳转)
→ GET /oauth2/authorization/github ← Spring 授权入口(自动生成 302)
→ 用户在 GitHub 登录并同意
→ GET /login/oauth2/code/github?code&state ← Spring 回调端点(自动校验+换票)
→ Spring 拉取 UserInfo,组装 OAuth2User
→ 你的 OAuth2LoginSuccessHandler:
findOrCreate 本地用户 → jwtUtil.sign → 302 回前端 /login/callback?token=...
→ 前端写 cookie → 后续请求带本站 JWT → 与密码登录完全同轨
| 概念 | 人话 |
|---|
registration | 「我作为哪个 Client 登记在 IdP」:id/secret/scope/redirect |
provider | IdP 的 authorize/token/userinfo 端点在哪(GitHub 内置免配) |
OAuth2User | 框架从 IdP 拉回的用户属性 Map |
SuccessHandler | 框架换完票、拿到用户之后,交给你处理的钩子 |
跟着做
步骤 1:注册 GitHub OAuth App
GitHub → Settings → Developer settings → OAuth Apps → New OAuth App
Homepage URL: http://localhost:3000
Authorization callback URL:
http://localhost:8080/login/oauth2/code/github ← 与 Spring 默认路径一致
记下 Client ID,生成 Client Secret——两个值都放环境变量,绝不提交 git。
步骤 2:加依赖
<!-- 中文注释:Client 负责「去别人那登录」;与 Resource Server 是两个不同 starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
步骤 3:写 application.yml
spring:
security:
oauth2:
client:
registration:
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope: read:user,user:email
# redirect-uri 不写即用默认模板:{baseUrl}/login/oauth2/code/{registrationId}
GitHub 的 authorize/token/userinfo 端点是框架内置的 CommonOAuth2Provider,不需要配 provider 段。
步骤 4:SecurityFilterChain(Spring Security 6 语法)
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class OAuth2ClientSecurityConfig {
@Bean
public SecurityFilterChain oauth2Chain(HttpSecurity http,
OAuth2LoginSuccessHandler successHandler) throws Exception {
http
.securityMatcher("/oauth2/**", "/login/**")
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.oauth2Login(oauth -> oauth
.successHandler(successHandler)
.failureHandler((req, res, ex) ->
res.sendRedirect("/login?error=" + ex.getClass().getSimpleName()))
);
return http.build();
}
}
要点:用 securityMatcher 把 OAuth 登录链与自研 JWT 鉴权链分区——/admin/** 等业务接口继续走你现有的 JWT 过滤器链,互不干扰。
步骤 5:成功处理器——联邦登录的「收尾动作」
package com.example.auth;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpServletRequest; // Spring Boot 3:jakarta 命名空间
import jakarta.servlet.http.HttpServletResponse;
@Component
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
private final UserService userService;
private final JwtUtil jwtUtil;
private final String frontendUrl = "http://localhost:3000";
public OAuth2LoginSuccessHandler(UserService userService, JwtUtil jwtUtil) {
this.userService = userService;
this.jwtUtil = jwtUtil;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2User oauthUser = (OAuth2User) authentication.getPrincipal();
String externalId = oauthUser.getName(); // GitHub 默认把数字 id 放进 name,稳定
Map<String, Object> attrs = oauthUser.getAttributes();
// ① 映射/创建本地用户(provider + externalId 联合唯一)
User user = userService.findOrCreateFromOAuth("github", externalId, attrs);
// ② 签发本站自研 JWT——与密码登录同一把钥匙
String token = jwtUtil.sign(user.getId(), user.getRole());
// ③ 302 回前端 callback,前端写入与密码登录相同的会话
String redirect = frontendUrl + "/login/callback?token="
+ URLEncoder.encode(token, StandardCharsets.UTF_8);
response.sendRedirect(redirect);
}
}
步骤 6:前端只加一个入口按钮
Next 「GitHub 登录」按钮
→ window.location.href = `${API_BASE}/oauth2/authorization/github`
→ 授权完成后落到 /login/callback?token=...
→ AuthProvider 写入与密码登录相同的 cookie
前端全程不接触 GitHub 的 code/token,也不需要知道 client_secret。
步骤 7:验收与密码轨回归
# 中文注释:OAuth 配好后,密码登录必须仍然可用(双轨并存)
curl -i -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"你的密码"}'
# 带 JWT 访问受保护接口
curl -i http://localhost:8080/admin/tracks \
-H "Authorization: Bearer <本站JWT>"
浏览器完整走一遍 GitHub 登录:应最终带着本站 JWT 回到 platform 并显示已登录。
讲清楚要点
- Spring OAuth2 Client 实现的是 Client 角色:它帮你跳转、换票、拉用户,但不会把你的业务 API 变成 Resource Server——那是另一个 starter、另一条链(下一课)。
- state、code 交换、redirect 校验由框架代劳:这正是选框架的理由;你手写的版本十有八九会漏 state 一次性作废之类的细节。
- secret 只属于机密客户端,必须来自环境变量/密钥管理,禁止写进 application.yml 提交仓库。
- 回调路径约定为
/login/oauth2/code/{registrationId}:IdP 控制台的 Callback URL 必须与之逐字一致。
- IdP 用户不是本站用户:成功处理器必须做 findOrCreate,主键用 (provider, externalId),别用可变的 login/邮箱。
- 联邦成功后发本站 JWT:前端会话、后端鉴权、权限模型全部复用密码登录的既有轨道——这是本站双轨并存的架构关键。
你可能会懵的地方
-
oauth2Login 和 oauth2Client 是一回事吗?
oauth2Login() 用于登录场景(拿用户身份建本地会话);另有 oauth2Client() 用于「调用外部 OAuth API 时自动携带/刷新 token」。本课聚焦登录。
-
为什么本地 http 可以,生产必须 https?
多数 IdP 生产环境要求 HTTPS 的 redirect_uri;localhost 通常被例外放行。上生产前把 callback 换成 https 域名并在 IdP 登记生产地址(建议本地/生产分开两个 OAuth App)。
-
GitHub 返回的属性里没有 email?
需要申请 user:email scope,且部分用户主邮箱未公开,可能要额外调 /user/emails。主键仍用数字 id,email 只作资料并做好空值兜底。
-
和自研 JWT 过滤器冲突吗?
不冲突,前提是分区:OAuth 链只 match /oauth2/**、/login/**;业务接口走原 JWT 链。成功处理器发完本站 JWT 后,一切回归原轨道。
-
想拿 GitHub 的 access_token 去调 GitHub API 怎么办?
在成功处理器里从 OAuth2AuthorizedClientRepository 取授权客户端,或用 OAuth2AuthorizedClientManager;注意该 token 只用于调 GitHub,与本站会话无关。
错法对照
| 错法 | 问题 | 正解 |
|---|
| secret 写进 application.yml 提交仓库 | 泄露即身份被冒用 | ${ENV} + 本地/CI 注入 |
| 前端拿 client_secret 直接调 GitHub | 机密进了浏览器 | 只有 Java 端换票 |
| 成功后不建本地用户,每次直接透传 GitHub 身份 | 站内权限/资料无处安放 | findOrCreate + 发本站 JWT |
| callback 写成前端地址却让 Spring 接 code | code 到不了换票端点 | redirect 指向后端约定路径 |
| 关掉密码登录「只留 GitHub」 | IdP 故障即全站锁死 | 双轨并存,admin 保留密码应急 |
卡点急救
- 现象:
redirect_uri_mismatch。GitHub 后台的 Callback URL 必须与 Spring 实际回调路径逐字一致:http://localhost:8080/login/oauth2/code/github。检查端口、斜杠、registrationId 拼写(不是 clientId)。
- 现象:回调后 Whitelabel 404 或
authorization_request_not_found。多见于 session 丢失(跨域 cookie、负载均衡会话不粘)或重启后旧 state 失效。开发期清浏览器 cookie 重试;生产确认 session/Redis 会话共享正常。
- 现象:成功后前端拿到的 token 少了字符或解析失败。redirect 拼接时没做 URL 编码,JWT 里的
+/= 被吃掉。确认 URLEncoder.encode(token, StandardCharsets.UTF_8),前端取参后按对应方式解码。
面试常问
-
Spring Security 的 oauth2Login 帮你做了哪些事?
生成并校验 state(防 CSRF)、跳转 authorize、回调接 code、用 code+secret 换 token、调用 UserInfo 组装 OAuth2User,最后把 Authentication 交给你的 SuccessHandler。你只需要写成功后的业务(建号、发本站 JWT、跳转)。
-
联邦登录为什么要桥接回自己的会话(发本站 JWT)?
站内权限模型在本地库;本站 JWT 短寿命可控可撤销;前端与后端鉴权轨道零改动。让 OAuth 只负责「认人」,会话与授权仍是自己的——职责分离。
-
SecurityMatcher 分区是什么?为什么要分?
Spring Security 支持多条 SecurityFilterChain,按 securityMatcher 路由。OAuth 登录链与自研 JWT 鉴权链各管各的路径,避免一个过滤器「什么都想验」导致的 401 混乱——这是双轨并存项目的标准组织方式。
自测三问
- 写出从点击按钮到拿到本站 JWT 的完整路径序列。
- GitHub OAuth App 的 Callback URL 在默认配置下应填什么?
- 为什么联邦成功后要签发本站 JWT,而不是让前端长期持有 GitHub 的 access_token?
小结一句话
Spring OAuth2 Client = 配好 registration,框架跑授权码流,你在 SuccessHandler 里映射用户并签发本站 JWT——联邦登录从此只剩「成功之后」的业务。
延伸