如何实践OAuth2

前言

从授权服务器的搭建、客户端的配置,到令牌的生成与验证,Spring Security OAuth2 等组件已将协议细节封装为可复用的接口与注解,让开发者无需深入钻研协议底层,即可快速实现 “用微信登录自研系统”“用 GitHub 账号接入企业平台” 等场景。

正好,我的项目需要引入第三方登录,我们以 github 第三方登录为例子,来在 Spring Security 中使用 OAuth2

至于为什么不用微信,QQ,钉钉等这种国内更常用的,你可以去看看,申请这些东西有多复杂,不仅需要整体的备案,还需要上线服务,显然,对于第三方登录的实践,国内平台是不太合适的

有空我也想使下 OSU!的三方登录))

先给大家看一下基本的项目结构

image-20251115145142724

OAuth2的第三方登录的业务应该如何设计

那么,最重要的部分就是 OAuth2 业务如何设计

在老式的设计中,开发人员可能会在 User 表里加 google_id, github_id 字段。

我个人认为这是极其糟糕的设计,因为你无法预知未来要接多少家第三方。

但是实际上,这种方法也可能是好的,如果在不会接入太多是三方登录的情况下,这样还会保证其数据库不会太多跨表查询

我个人倾向于采用“主账号 + 绑定关系”的模式:

  1. User 表 (主账号):只存储属于你平台的唯一身份(ID、昵称、头像、注册时间、状态)。
  2. UserConnection 表 (第三方关联):存储 user_id 与 provider (如 Google) + provider_user_id (OpenID) 的对应关系。

所以,我们需要两张核心表,也就是 平台主用户表 和 第三方绑定表,第三方绑定表是专门用于 OAuth2 关联的表。

那么,实际产生的业务应该如何设计呢?

如果用户是第一次来(纯新人),它没有该平台的账号,期望直接点“使用 GitHub 登录”进行三方登录

那么,Spring Security 应该引导用户去 GitHub 授权 -> 回调拿到 UserInfo。然后用 GitHub 返回的 sub (OpenID) 去 sys_user_social 查。结果发现查不到记录,那么此时就有两种策略

  • 静默注册:

    系统自动在 sys_user 创建一个新账号。

    • Username: 如果能获取到,就是用用户在第三方平台的用户名,如果获取不到,按照特定规则设计一个随机字符串作为临时的用户名。

    • Email: 使用 GitHub 返回的邮箱,如果有,这个通常能够获取到

    • Avatar: 使用 GitHub 头像。

    • Password: 空 (或者生成一个随机密码并不告诉用户)。

      • 那么问题来了,一直让用户的密码空着,这可以吗?如果纯OAuth2用户注册进入平台日后希望使用平台进行登录注册,怎么办?

      • 实际上,这是业务的设计特性,有密码代表用户可以通过“账号/邮箱 + 密码”的方式进行表单登录。无密码代表该账号不支持表单登录,仅支持第三方登录或验证码登录。

        因为在 Spring Security 层面,当用户尝试用空密码登录时,校验逻辑(PasswordEncoder.matches)会直接失败,天然保证了安全性。没有密码不代表不安全,而是代表该通道关闭。

      • 那么如果用户一开始嫌麻烦用了 GitHub 登录,后来觉得还是设置个密码稳妥,或者想在不支持 GitHub 登录的设备上登录,那么业务如何设计?其实通过通过“忘记密码”流程找回是最合适的。

    在 sys_user_social 插入一条记录,关联新生成的 user_id。

    执行登录

  • 强制绑定:

    跳转到一个中间页:“您是新用户,请完善账号绑定”或“关联已有账号”。

    这种体验较差,现在主流 App 多采用策略 A,进去后再引导绑定各种需要的内容。

那么,还有一个场景,就是用户之前有账号(邮箱注册的),现在点“使用 GitHub 登录”,那么如何保证,用户使用OAuth2登录进去的是对应用户希望登录的账号,邮箱匹配逻辑如何思考

首先。流程还是获取 GitHub UserInfo。发现用户是第一次使用OAuth2登录,查不到 GitHub 的绑定记录。此时,应该使用一些策略保证能够正确关联,我们通常先检查 GitHub 返回的 email 是否存在于 sys_user 表中。

  • 情况 A:Email 匹配成功(GitHub 邮箱 = 平台邮箱)。

    • 系统判定这是同一个人。

    • 自动在 sys_user_social 插入一条记录,将 GitHub ID 绑定到那个已存在的 user_id 上。

    • 执行登录。

  • 情况 B:Email 不匹配(或者 GitHub 没返回邮箱)。

    • 系统无法判断这俩是不是同一个人。

    • 处理:这会被视为一个新用户(走上面场景中静默注册的逻辑)。用户会获得一个新的账号

    • 结果:用户会有两个账号(一个邮箱账号,一个 GitHub 账号)。

那么这样肯定是不行的,这样会不小心为用户创建很多小号,也就是,我在平台注册用a@test.com,GitHub 用 b@test.com。

所以,应该有这样的一个逻辑,用户已登录,可以在“个人中心”手动绑定自己关联的第三方平台账号,实现手动合并

  • 用户已用账号 A 登录。
  • 点击“绑定Github”。用户选择了自己的Github邮箱进行绑定,其中,这里无论OAuth2提供商给的邮箱和用户在该平台注册的账号的邮箱是否相同,都不影响,因为这样就认为了用户强行绑定了这个OAuth2提供商账号就是自己的
  • OAuth 流程结束后,拿到 Github OpenID
  • 需要有一个校验,查 sys_user_social,看这个微Github OpenID 是否已经被其他用户绑定了?
    • 如果已绑定:提示“该 Github 账号已被其他账号占用,请先解绑”。
    • 如果未绑定:在 sys_user_social 插入 {user_id: A.id, provider: ‘wechat’, …}。

通过OAuth2.0实现Github第三方登录

在 Spring Security 5+ / 6+ 中,主要通过自定义 DefaultOAuth2UserService 来实现上述逻辑。

模块设计

首先,从我们的项目结构看,zhiyan-common-* 系列模块都是提供通用能力的,而 OAuth2 作为通用的认证协议,适合抽象到 common 层,然后auth模块依赖 zhiyan-common-oauth 最后在其中完成全部的OAuth2三方登录的内容

这样,OAuth2 的核心逻辑(协议交互、token 管理)与具体业务解耦且逻辑清晰

  • zhiyan-common-oauth: 纯技术实现,不涉及业务
  • zhiyan-auth: 业务逻辑,用户管理、绑定关系等

这样的设计易于之后扩展新的服务提供商

数据库表设计

那么,为了应对新出现的业务,我们需要设计新的数据库表,我们需要设计一个OAuth2用户绑定关系表

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE oauth2_user_connection (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
user_id BIGINT NOT NULL COMMENT '用户ID',
provider VARCHAR(50) NOT NULL COMMENT '提供商(wechat/alipay/dingtalk/github)',
provider_user_id VARCHAR(255) NOT NULL COMMENT '提供商用户ID',
access_token TEXT COMMENT '访问令牌',
refresh_token TEXT COMMENT '刷新令牌',
expire_time DATETIME COMMENT '过期时间',
created_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_provider_user (provider, provider_user_id),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OAuth2用户绑定关系表';

这个仅供参考,但是大部分还是通用的

我实际项目设计的表是这样的,所以说,实体类和其持久层照着写就差不多

配置类

实际上,开发 OAuth2 貌似不需要特意引入新的 OAuth2 依赖

但是,该有的配置类肯定要有

首先,肯定要定义有哪些内容需要写道配置文件中,根据这个写配置类,我习惯先写总配置属性,再写各个厂商的单独的自己的配置属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package hbnu.project.zhiyanbackend.auth.oauth.config.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
* OAuth2 总配置属性
* 关联所有第三方提供商的配置
*
* @author ErgouTree
*/
@Data
@ConfigurationProperties(prefix = "zhiyan.oauth2")
public class OAuth2Properties {

/**
* 是否启用OAuth2功能
*/
private boolean enabled = true;

/**
* 回调地址基础路径(不包含具体路径)
*/
private String callbackBaseUrl;

/**
* 前端回调页面URL(OAuth2登录成功后重定向到的前端页面)
* 注意:此配置已废弃,请使用下面的具体页面URL配置
*/
@Deprecated
private String frontendCallbackUrl;

/**
* 前端主页URL(登录成功后跳转)
*/
private String frontendHomeUrl;

/**
* 前端补充信息页面URL(需要补充信息时跳转)
*/
private String frontendSupplementUrl;

/**
* 前端绑定页面URL(需要绑定账号时跳转)
*/
private String frontendBindUrl;

/**
* 前端错误页面URL(登录失败时跳转)
* 目前设计为登录页
*/
private String frontendErrorUrl;

/**
* GitHub OAuth2 配置(关联独立的GitHub配置类)
*/
private GitHubOAuthProperties github = new GitHubOAuthProperties();

// 后续新增其他提供商(如微信、Google),直接在此处添加对应的配置类对象
// 例如:private WechatOAuthProperties wechat = new WechatOAuthProperties();
}

再写自动配置类,统一初始化 OAuth2 相关的核心 Bean,然后绑定自定义的 OAuth2 配置属性,再将所有实现OAuth2Provider接口的第三方登录提供商收集,方便后续根据提供商名称快速获取对应的实现类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package hbnu.project.zhiyanbackend.auth.oauth.config;

import hbnu.project.zhiyanbackend.auth.oauth.provider.OAuth2Provider;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import hbnu.project.zhiyanbackend.auth.oauth.config.properties.OAuth2Properties;
import org.springframework.web.client.RestTemplate;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
* OAuth2自动配置类
* 自动注册OAuth2相关的Bean
*
* @author ErgouTree
*/
@Slf4j
@AutoConfiguration
@EnableConfigurationProperties(OAuth2Properties.class)
@ConditionalOnProperty(prefix = "zhiyan.oauth2", name = "enabled", havingValue = "true", matchIfMissing = true)
public class OAuth2AutoConfiguration {

/**
* RestTemplate Bean 用户 Http 的请求
*/
@Bean
public RestTemplate oauth2RestTemplate() {
return new RestTemplate();
}

/**
* OAuth2ClientImpl 已经通过 @Service 注解自动注册
* 这里只需要提供 RestTemplate Bean,providers 会通过 List<OAuth2Provider> 自动注入
*/
@Bean
public Map<String, OAuth2Provider> oauth2Providers(List<OAuth2Provider> providers) {
Map<String, OAuth2Provider> providerMap = providers.stream()
.filter(OAuth2Provider::isEnabled)
.collect(Collectors.toMap(
OAuth2Provider::getProviderName,
provider -> provider,
(existing, replacement) -> existing
));

log.info("OAuth2模块初始化完成,已启用{}个提供商: {}",
providerMap.size(), providerMap.keySet());

return providerMap;
}
}

这个自动配置类是自定义 OAuth2 框架的初始化入口,负责注册 HTTP 工具 Bean、管理第三方登录提供商的实例映射。

模块化可插拔配置的管理就完成了,然后自己根据需要的内容,在配置文件中进行对应配置项目的编写就可以了

申请Github的OAuth app

https://github.com/settings/applications/new

image-20251115151529904

这里除了最后一项Authorization callback URL,其它内容对后续的代码处理都没有影响(用于用户点击第三方登录时展示网站的信息)

在本地实现 GitHub OAuth2 登录时,这两个地址的填写需结合你的项目启动端口和 Spring Security OAuth2 的默认回调路径来配置:

  • Homepage URL:填写你本地项目的主页地址,例如若你的项目在本地以 8080 端口启动,可填写 http://localhost:8080。等上线了再换回来真实的域名就可以

  • Authorization callback URL:这是回调地址,GitHub 在用户完成授权后,将用户重定向回你项目的地址,同时会附带授权码(code)。这个地址是 OAuth 2.0 授权流程的关键环节 —— 后端需要通过这个地址接收授权码,再换取用户的 Access Token,最终完成登录。

    这个地址存在一个填写规则,填写规则是:你的项目域名/login/oauth2/code/github,因为login/oauth2/code/github 是Spring Security OAuth2 的默认回调路径(github 是你注册的客户端标识),后端会自动监听这个路径来处理 GitHub 的授权回调。

    你需要在后端的这个路径,完成一个处理临时授权码code的接口

这样配置后,GitHub 会在用户授权后,将请求正确重定向到你本地项目的 OAuth2 回调处理端点,从而完成登录流程。

注册完毕获取 clientId 和 client Secret,这些内容是需要填写到配置文件的

通常情况下,本地和线上是会分开配置的

编写OAuth2组件之第三方登录提供商服务

这部分代码是做什么呢?他们需要构成一个符合设计模式的 OAuth2 客户端集成框架,在 OAuth2 业务逻辑中扮演着“驱动程序”的角色,负责与不同的第三方平台(如 GitHub、Google、Gitee 等)进行协议对接。

首先,编写一个OAuth2Provider (接口) ,定义标准,定义了一个 OAuth2 提供商必须具备哪些核心能力,用于屏蔽底层差异,对于业务层(如 LoginService)来说,它们都是一个 OAuth2Provider。这样编写符合开闭原则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package hbnu.project.zhiyanbackend.auth.oauth.provider;


import hbnu.project.zhiyanbackend.auth.model.dto.OAuth2UserInfoDTO;

/**
* OAuth2提供商接口
* 定义OAuth2提供商的通用行为
*
* @author ErgouTree
*/
public interface OAuth2Provider {

/**
* 获取提供商名称
*
* @return 提供商名称(如:github)
*/
String getProviderName();

/**
* 生成授权URL
*
* @param state 状态参数(用于防止CSRF攻击)
* @param redirectUri 回调地址
* @return 授权URL
*/
String getAuthorizationUrl(String state, String redirectUri);

/**
* 通过授权码获取访问令牌
*
* @param code 授权码
* @param redirectUri 回调地址
* @return 访问令牌
*/
String getAccessToken(String code, String redirectUri);

/**
* 通过访问令牌获取用户信息
*
* @param accessToken 访问令牌
* @return 用户信息
*/
OAuth2UserInfoDTO getUserInfo(String accessToken);

/**
* 是否启用
*
* @return true-启用,false-禁用
*/
boolean isEnabled();
}

然后,我们需要拥有一个AbstractOAuth2Provider (抽象基类),用于流程复用,它控制并实现了 OAuth2 标准流程中通用且重复的代码。因为 OAuth2 的授权码模式(Authorization Code Grant)流程在大多数平台下是标准的(拼接 URL -> 请求 Token -> 请求 UserInfo)。通过这个抽象类,我们不需要为每个新平台重写网络请求逻辑,实现了代码的 DRY (Don’t Repeat Yourself) 原则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
package hbnu.project.zhiyanbackend.auth.oauth.provider;

import hbnu.project.zhiyanbackend.auth.oauth.config.properties.OAuth2Properties;
import hbnu.project.zhiyanbackend.auth.exeption.OAuth2Exception;
import hbnu.project.zhiyanbackend.auth.model.dto.OAuth2UserInfoDTO;
import hbnu.project.zhiyanbackend.basic.utils.JsonUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.*;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import java.util.Collections;
import java.util.Map;
import java.util.Objects;

/**
* OAuth2提供商抽象基类
* 实现通用的OAuth2流程逻辑
*/
@Slf4j
public abstract class AbstractOAuth2Provider implements OAuth2Provider {

// 自定义的 OAuth2 配置属性类
protected final OAuth2Properties properties;

// HTTP 客户端工具
protected final RestTemplate restTemplate;

protected AbstractOAuth2Provider(OAuth2Properties properties, RestTemplate restTemplate) {
this.properties = properties;
this.restTemplate = restTemplate != null ? restTemplate : createRestTemplate();
}

/**
* 创建RestTemplate实例
*/
protected RestTemplate createRestTemplate() {
return new RestTemplate();
}

/**
* 生成授权的URL(通用实现)
* 这是 OAuth2 流程的第一步,生成第三方平台的授权页面 URL
*/
@Override
public String getAuthorizationUrl(String state, String redirectUri) {
// 1. 获取子类实现的配置项:授权基础URL、Client ID、授权范围,并且校验
String baseUrl = getAuthorizationUri();
String clientId = getClientId();
String scope = getScope();
if (StringUtils.isEmpty(baseUrl) || StringUtils.isEmpty(clientId)) {
throw new OAuth2Exception("OAuth2配置不完整:缺少authorizationUri或clientId");
}

// 3. 拼接授权URL,遵循OAuth2的授权码模式规范
StringBuilder url = new StringBuilder(baseUrl);
url.append("?client_id=").append(clientId); // 客户端ID,第三方平台标识你的应用
url.append("&redirect_uri=").append(redirectUri); // 授权成功后的回调URL
url.append("&state=").append(state); // 防CSRF的随机状态值,服务器生成一个随机字符串存入 Session/Cookie 并传给 GitHub,当 GitHub 回调时必须带回同样的 state。如果对不上,说明请求可能被伪造。
url.append("&response_type=code"); // 授权码模式,固定值为code

// 4. 如果有授权范围,添加到URL中
if (StringUtils.isNotEmpty(scope)) {
url.append("&scope=").append(scope);
}

log.debug("生成授权URL: {}", url.toString());
return url.toString();
}


/**
* 通过授权码获取访问令牌(通用实现)
* 用户授权后,第三方平台会把授权码(code)带回回调 URL,后端用这个code换取访问令牌(AccessToken)。
*/
@Override
public String getAccessToken(String code, String redirectUri) {
// 1. 获取子类实现的配置项:令牌接口URL、Client ID、Client Secret,并且校验
String tokenUri = getTokenUri();
String clientId = getClientId();
String clientSecret = getClientSecret();
if (StringUtils.isEmpty(tokenUri) || StringUtils.isEmpty(clientId) || StringUtils.isEmpty(clientSecret)) {
throw new OAuth2Exception("OAuth2配置不完整:缺少tokenUri、clientId或clientSecret");
}

try {
// 3. 构建请求参数(OAuth2授权码模式的标准参数)
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("client_id", clientId); // 客户端ID
params.add("client_secret", clientSecret); // 客户端密钥
params.add("grant_type", "authorization_code"); // 授权类型,固定为authorization_code
params.add("code", code); // 授权码
params.add("redirect_uri", redirectUri); // 回调URL,和授权时一致

// 4. 设置请求头
HttpHeaders headers = new HttpHeaders();
// HTML 表单默认的提交格式,提交键值对
// 使用了 APPLICATION_FORM_URLENCODED。这是 OAuth2 协议的要求,即通过表单格式发送 client_id 和 client_secret 来换取令牌。
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 期望收到JSON
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);

// 5. 发送POST请求到令牌接口
ResponseEntity<String> response = restTemplate.exchange(
tokenUri,
HttpMethod.POST,
request,
String.class
);

// 6. 处理响应
if(response.getStatusCode().is2xxSuccessful()){
String body = response.getBody();
log.debug("获取访问令牌响应: {}", body);
// 解析响应
return parseAccessToken(body);
} else {
String errorBody = response.getBody();
log.error("获取访问令牌失败 - 状态码: {}, 响应: {}", response.getStatusCode(), errorBody);
throw new OAuth2Exception("获取访问令牌失败: " + response.getStatusCode() + (errorBody != null ? " - " + errorBody : ""));
}
}catch (OAuth2Exception e){
log.error("获取访问令牌异常", e);
throw new OAuth2Exception("获取访问令牌失败: " + e.getMessage(), e);
}catch (Exception e) {
log.error("获取访问令牌异常", e);
throw new OAuth2Exception("获取访问令牌失败: " + e.getMessage(), e);
}
}


/**
* 解析访问令牌(子类可重写以支持不同的响应格式)
* 解析 AccessToken
*/
protected String parseAccessToken(String responseBody) {
try{
// 先尝试解析为JSON格式,因为Github返回JSON
Map<String, Object> json = JsonUtils.parseMap(responseBody);
String accessToken = (String) Objects.requireNonNull(json).get("access_token");
if (StringUtils.isNotEmpty(accessToken)) {
return accessToken;
}
}catch (OAuth2Exception e){
log.debug("响应不是JSON格式,尝试URL编码格式解析");
}

// 尝试解析URL编码格式(如:access_token=xxx&token_type=bearer)
String[] pairs = responseBody.split("&");
for (String pair : pairs) {
String[] keyValue = pair.split("=", 2);
if (keyValue.length == 2 && "access_token".equals(keyValue[0])) {
return keyValue[1];
}
}

throw new OAuth2Exception("无法从响应中解析访问令牌: " + responseBody);
}


/**
* 通过访问令牌获取用户信息(通用实现)
* 用获取到的 AccessToken 调用第三方平台的用户信息接口,获取用户的昵称、头像、唯一标识等信息。
*/
@Override
public OAuth2UserInfoDTO getUserInfo(String accessToken) {
// 1. 获取子类实现的用户信息接口URL
String userInfoUri = getUserInfoUri();
if (StringUtils.isEmpty(userInfoUri)) {
throw new OAuth2Exception("OAuth2配置不完整:缺少userInfoUri");
}

try {
// 2. 设置请求头:携带AccessToken(Bearer认证)
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken); // 等价于 headers.add("Authorization", "Bearer " + accessToken)
headers.setAccept(java.util.Collections.singletonList(MediaType.APPLICATION_JSON));

HttpEntity<Void> request = new HttpEntity<>(headers);

// 3. 发送GET请求到用户信息接口
ResponseEntity<String> response = restTemplate.exchange(
userInfoUri,
HttpMethod.GET,
request,
String.class
);

// 4. 处理响应
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
String body = response.getBody();
log.debug("获取用户信息响应: {}", body);
return parseUserInfo(body);
} else {
throw new OAuth2Exception("获取用户信息失败: " + response.getStatusCode());
}
} catch (OAuth2Exception e) {
throw e;
} catch (Exception e) {
log.error("获取用户信息异常", e);
throw new OAuth2Exception("获取用户信息失败: " + e.getMessage(), e);
}
}


/**
* 解析用户信息(子类必须实现)
* 用于将第三方平台返回的用户信息字符串解析为统一的OAuth2UserInfoDTO对象
*/
protected abstract OAuth2UserInfoDTO parseUserInfo(String responseBody);


// ==================== 抽象方法(子类必须实现) ====================

/**
* 获取Client ID
*/
protected abstract String getClientId();

/**
* 获取Client Secret
*/
protected abstract String getClientSecret();

/**
* 获取授权范围
*/
protected abstract String getScope();

/**
* 获取授权URI
*/
protected abstract String getAuthorizationUri();

/**
* 获取Token URI
*/
protected abstract String getTokenUri();

/**
* 获取用户信息URI
*/
protected abstract String getUserInfoUri();
}

最后,为Github平台编写具体的实现,进行特性的定制,GithubOAuth2Provider,因为每个平台的返回数据格式(JSON 结构)、特殊的权限控制(如 GitHub 隐藏邮箱)都不同。此类专门解决这些“最后一步”的差异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
package hbnu.project.zhiyanbackend.auth.oauth.provider;

import hbnu.project.zhiyanbackend.auth.oauth.config.properties.OAuth2Properties;
import hbnu.project.zhiyanbackend.auth.exeption.OAuth2Exception;
import hbnu.project.zhiyanbackend.auth.model.dto.OAuth2UserInfoDTO;
import hbnu.project.zhiyanbackend.basic.utils.JsonUtils;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
* GitHub OAuth2提供商实现
*
* @author ErgouTree
*/
@Slf4j
@Component
public class GithubOAuth2Provider extends AbstractOAuth2Provider {

// 提供商名
private static final String PROVIDER_NAME = "github";

/**
* 构造函数注入
* OAuth2Properties 通过 @EnableConfigurationProperties 注册,只有一个Bean实例
*/
public GithubOAuth2Provider(OAuth2Properties properties, RestTemplate restTemplate) {
super(properties, restTemplate);
}

// 下面两个都是实现OAuth2Provider接口(父类实现的接口)定义的方法,用于提供商的标识和启用状态判断。
@Override
public String getProviderName() {
return PROVIDER_NAME;
}

@Override
public boolean isEnabled() {
return properties.getGithub().isEnabled();
}

// 这些方法是父类定义的抽象方法,返回 GitHub 平台的 OAuth2 配置项,所有配置都来自OAuth2Properties
@Override
protected String getClientId() {
return properties.getGithub().getClientId();
}

@Override
protected String getClientSecret() {
return properties.getGithub().getClientSecret();
}

@Override
protected String getScope() {
return properties.getGithub().getScope();
}

@Override
protected String getAuthorizationUri() {
return properties.getGithub().getAuthorizationUri();
}

@Override
protected String getTokenUri() {
return properties.getGithub().getTokenUri();
}

@Override
protected String getUserInfoUri() {
return properties.getGithub().getUserInfoUri();
}


/**
* 解析GitHub用户信息
* 该方法重写了父类的抽象方法,用于将 GitHub 用户信息接口返回的 JSON 字符串解析为统一的OAuth2UserInfoDTO
*/
@Override
protected OAuth2UserInfoDTO parseUserInfo(String responseBody) {
try {
Map<String, Object> userMap = JsonUtils.parseMap(responseBody);

OAuth2UserInfoDTO userInfo = OAuth2UserInfoDTO.builder()
.provider(PROVIDER_NAME)
// GitHub用户的唯一ID,额外说一下,这是后续用户绑定的核心字段
.providerUserId(String.valueOf(Objects.requireNonNull(userMap).get("id")))
.username((String) userMap.get("login"))
.nickname((String) userMap.get("name"))
.avatarUrl((String) userMap.get("avatar_url"))
.email((String) userMap.get("email"))
.build();

// 注意:邮箱获取逻辑在getUserInfo方法中处理,因为此时还没有accessToken
return userInfo;
} catch (Exception e) {
log.error("解析GitHub用户信息失败", e);
throw new OAuth2Exception("解析用户信息失败: " + e.getMessage(), e);
}
}


/**
* 获取GitHub用户邮箱(需要user:email权限)
*/
private String getUserEmail(String accessToken) {
try {
// 1. 从配置中获取邮箱接口URL
String emailUri = properties.getGithub().getUserEmailUri();
if (StringUtils.isEmpty(emailUri)) {
return null;
}

// 2. 设置请求头:携带AccessToken
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
headers.setAccept(java.util.Collections.singletonList(MediaType.APPLICATION_JSON));
HttpEntity<Void> request = new HttpEntity<>(headers);

// 3. 发送GET请求到邮箱接口
ResponseEntity<String> response = restTemplate.exchange(
emailUri,
HttpMethod.GET,
request,
String.class
);

// 4. 处理响应:解析邮箱列表
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
List<Map<String, Object>> emails = JsonUtils.parseMapList(response.getBody());
// 优先返回主邮箱(primary=true且verified=true)
for (Map<String, Object> email : emails) {
Boolean primary = (Boolean) email.get("primary");
Boolean verified = (Boolean) email.get("verified");
if (Boolean.TRUE.equals(primary) && Boolean.TRUE.equals(verified)) {
return (String) email.get("email");
}
}
// 如果没有主邮箱,返回第一个已验证的邮箱
for (Map<String, Object> email : emails) {
Boolean verified = (Boolean) email.get("verified");
if (Boolean.TRUE.equals(verified)) {
return (String) email.get("email");
}
}
}
} catch (Exception e) {
log.warn("获取GitHub用户邮箱失败: {}", e.getMessage());
}
return null;
}


/**
* 重写getUserInfo以支持邮箱获取
*/
@Override
public OAuth2UserInfoDTO getUserInfo(String accessToken) {
// 调用父类方法,获取基础用户信息(此时不含有效邮箱)
OAuth2UserInfoDTO userInfo = super.getUserInfo(accessToken);
// 设置accessToken以便后续获取邮箱
userInfo.setAccessToken(accessToken);

// GitHub的email可能为null(如果用户设置了隐私),需要单独获取
if (StringUtils.isEmpty(userInfo.getEmail())) {
userInfo.setEmail(getUserEmail(accessToken));
}

return userInfo;
}
}

可以看到 Github 比较特殊,所以,你获取到邮箱的策略需要进行修改,来确保能够应对不同的情况

编写OAuth2组件之Client通信层

Client 这部分差不多就是核心调度层(Manager层),编写这部分是希望屏蔽不同三方平台带来的差异,对外给你的 Controller 或 Service提供统一的操作接口,属于 OAuth2 基础设施。

Controller 层只需要调用 OAuth2Client 的方法,而不需要关心具体是去连 GitHub 还是 Google,也不用关心 State 怎么存取。

首先,Client 中需要有什么呢?分析一下,OAuth2 登录必须做的其实只有两件核心大事,拆解成接口中这样的两个方法

  • getAuthorizationUrl:
    • 作用:这是第一步。用户点击“GitHub登录”时,后端需要生成一个 URL(比如 https://github.com/login/oauth/authorize?client_id=xxx…)给前端,让前端跳转过去。
  • getUserInfoByCode:
    • 作用:第二、三步。用户在 GitHub 授权后,跳回你的平台带了一个 code。这个方法负责用 code 换 token,再用 token 换 用户信息。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
package hbnu.project.zhiyanbackend.auth.oauth.client;

import hbnu.project.zhiyanbackend.auth.exeption.OAuth2Exception;
import hbnu.project.zhiyanbackend.auth.model.dto.AuthorizationResultDTO;
import hbnu.project.zhiyanbackend.auth.model.dto.OAuth2UserInfoDTO;
import hbnu.project.zhiyanbackend.auth.oauth.provider.OAuth2Provider;
import hbnu.project.zhiyanbackend.redis.utils.RedisUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;

import static java.util.Collections.emptyMap;

/**
* OAuth2客户端实现类
* 封装OAuth2登录流程的核心逻辑
*
* @author ErgouTree
*/
@Slf4j
@Service
public class OAuth2ClientImpl implements OAuth2Client {

// Spring 会自动把所有实现了 OAuth2Provider 接口的类(比如 GithubProvider)注入到这个列表中
private final Map<String, OAuth2Provider> providers;

/**
* 构造函数注入providers List,并转换为Map
*/
public OAuth2ClientImpl(List<OAuth2Provider> providers) {
if (providers != null && !providers.isEmpty()) {
this.providers = providers.stream()
// 过滤掉配置文件里没开启的平台
.filter(OAuth2Provider::isEnabled)
.collect(Collectors.toMap(
OAuth2Provider::getProviderName, // 提供商名
provider -> provider, // 对应的实现类对象
(existing, replacement) -> existing
));
} else {
this.providers = emptyMap();
}
}

/**
* State缓存前缀
*/
private static final String STATE_CACHE_PREFIX = "oauth2:state:";

/**
* State过期时间(秒)
*/
private static final long STATE_EXPIRE_SECONDS = 600L; // 10分钟

/**
* 生成授权URL
*/
@Override
public AuthorizationResultDTO getAuthorizationUrl(String providerName, String redirectUri) {
// 根据名字找到对应的 Provider
OAuth2Provider provider = getProvider(providerName);

// 生成state(用于防止CSRF攻击),这其实是一个安全令牌,这个令牌存入redis
String state = generateState();

// 生成授权URL,调用具体的 Provider 去拼接那个长长的 URL
String authorizationUrl = provider.getAuthorizationUrl(state, redirectUri);

log.info("生成OAuth2授权URL - 提供商: {}, state: {}", providerName, state);
// 返回URL和State给前端,前端去跳转
return new AuthorizationResultDTO(authorizationUrl, state);
}

/**
* 通过授权码获取用户信息
* 这方法是用户授权完跳回你的网站 callback 接口时调用的。它拿到了最关键的 用户信息 (DTO),这个数据没有被存储起来,拿到数据后,Service 层会做对应的事情
*/
@Override
public OAuth2UserInfoDTO getUserInfoByCode(String providerName, String code, String state, String redirectUri) {
// 检查 state 是否合法,防止别人伪造请求回调
validateState(state);

// 获取对应的 Provider
OAuth2Provider provider = getProvider(providerName);

// 获取访问令牌,也就是用OAuth2提供商提供的code换取accessToken,这一步会由 Provider 发 HTTP 请求给第三方
String accessToken = provider.getAccessToken(code, redirectUri);
log.debug("获取访问令牌成功 - 提供商: {}", providerName);

// 获取用户信息,也就是用 accessToken 换 UserInfo,这一步也会由 Provider 发 HTTP 请求给第三方
OAuth2UserInfoDTO userInfo = provider.getUserInfo(accessToken);
log.info("获取用户信息成功 - 提供商: {}, 用户ID: {}", providerName, userInfo.getProviderUserId());

return userInfo;
}

/**
* 获取OAuth2提供商
*/
private OAuth2Provider getProvider(String providerName) {
if (StringUtils.isEmpty(providerName)) {
throw new OAuth2Exception("OAuth2提供商名称不能为空");
}

OAuth2Provider provider = providers.get(providerName.toLowerCase());
if (provider == null) {
throw new OAuth2Exception("不支持的OAuth2提供商: " + providerName);
}

if (!provider.isEnabled()) {
throw new OAuth2Exception("OAuth2提供商未启用: " + providerName);
}

return provider;
}

/**
* 生成state并缓存
*/
private String generateState() {
String state = UUID.randomUUID().toString().replace("-", "");
String cacheKey = STATE_CACHE_PREFIX + state;

// 缓存state(10分钟过期)
RedisUtils.setCacheObject(cacheKey, System.currentTimeMillis(), Duration.ofSeconds(STATE_EXPIRE_SECONDS));

return state;
}

/**
* 验证state
* 这是 OAuth2 协议中防止 CSRF(跨站请求伪造)攻击 的标准做法。
* 如果你不校验 State,黑客可以构造一个恶意的回调链接诱导用户点击,从而绑定错误的账号。
*/
private void validateState(String state) {
if (StringUtils.isEmpty(state)) {
throw new OAuth2Exception("State参数不能为空");
}

String cacheKey = STATE_CACHE_PREFIX + state;
// 去 Redis 查有没有这个 state
Object cached = RedisUtils.getCacheObject(cacheKey);

if (cached == null) {
throw new OAuth2Exception("State已过期或无效");
}

// 验证后删除state,一次性使用,防止重放攻击
RedisUtils.deleteObject(cacheKey);
}
}

编写服务层

然后针对我们对 OAuth2 的底层实现,为其针对性的编写一个服务来处理这些业务

只演示处理 OAuth2 登录注册,绑定第三方账号与解绑,这些才是核心业务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
/**
* 处理 OAuth2 登录/注册
*
* 流程:
* 1. 查询 UserConnection,看第三方账号是否已绑定
* 2. 已绑定 -> 直接登录
* 3. 未绑定 -> 尝试邮箱匹配
* a. 匹配成功 -> 自动绑定 + 登录
* b. 匹配失败 -> 静默注册 + 自动绑定 + 登录
*/
@Override
public R<OAuth2LoginResponseDTO> handleOAuth2Login(OAuth2UserInfoDTO oauth2UserInfo) {
log.info("处理OAuth2登录 - 提供商: {}, 用户ID: {}, 邮箱: {}", oauth2UserInfo.getProvider(), oauth2UserInfo.getProviderUserId(), oauth2UserInfo.getEmail());

try {
// 1. 验证OAuth2用户信息
validateOAuth2UserInfo(oauth2UserInfo);

String provider = oauth2UserInfo.getProvider();
String providerUserId = oauth2UserInfo.getProviderUserId();

// 2. 查询是否已有绑定关系
Optional<UserConnection> connectionOpt = connectionRepository.findByProviderAndProviderUserIdAndIsUnboundFalse(provider, providerUserId);
if(connectionOpt.isPresent()) {
// 场景 1: 已绑定,直接登录
UserConnection connection = connectionOpt.get();
log.info("第三方账号已绑定 - 用户ID: {}, 提供商: {}", connection.getUserId(), provider);

// 更新绑定信息(头像、用户名等可能变化)
updateConnectionInfo(connection, oauth2UserInfo);
connectionRepository.save(connection);

// 执行登录
return doLogin(connection.getUserId(), oauth2UserInfo);
}

// 3. 未绑定,尝试邮箱匹配
if (StringUtils.isNotBlank(oauth2UserInfo.getEmail())) {
Optional<User> userByEmailOpt = userRepository.findByEmailAndIsDeletedFalse(oauth2UserInfo.getEmail());
if (userByEmailOpt.isPresent()) {
// 场景 2: 邮箱匹配成功,自动绑定
User existingUser = userByEmailOpt.get();
log.info("邮箱匹配成功,自动绑定 - 用户ID: {}, 邮箱: {}, 提供商: {}", existingUser.getId(), existingUser.getEmail(), provider);
// 检查用户状态
if (Boolean.TRUE.equals(existingUser.getIsLocked())) {
return R.fail("账户已被锁定,请联系管理员");
}
// 创建绑定关系
UserConnection newConnection = createConnection(existingUser.getId(), oauth2UserInfo);
connectionRepository.save(newConnection);

// 更新用户信息(头像等)
updateUserFromOAuth2(existingUser, oauth2UserInfo);
userRepository.save(existingUser);

// 执行登录
return doLogin(existingUser.getId(), oauth2UserInfo);
}
}
// 场景 3: 邮箱不匹配或没有邮箱,静默注册新用户
log.info("静默注册新用户 - 提供商: {}, 提供商用户ID: {}", provider, providerUserId);
User newUser = createUserFromOAuth2(oauth2UserInfo);
User savedUser = userRepository.save(newUser);

// 分配默认角色
assignDefaultRole(savedUser.getId());

// 创建绑定关系
UserConnection newConnection = createConnection(savedUser.getId(), oauth2UserInfo);
connectionRepository.save(newConnection);

// 执行登录
return doLogin(savedUser.getId(), oauth2UserInfo);
}catch (ServiceException e){
log.error("OAuth2 登录失败: {}", e.getMessage());
return R.fail(e.getMessage());
} catch (Exception e) {
log.error("OAuth2 登录异常", e);
return R.fail("登录失败,请稍后重试");
}
}


/**
* 已登录用户手动绑定第三方账号
*/
@Override
@Transactional
public R<Void> bindOAuth2Account(Long userId, OAuth2UserInfoDTO oauth2UserInfo) {
log.info("手动绑定第三方账号 - 用户ID: {}, 提供商: {}", userId, oauth2UserInfo.getProvider());

try{
// 1. 验证用户是否存在
Optional<User> userOpt = userRepository.findByIdAndIsDeletedFalse(userId);
if (userOpt.isEmpty()) {
return R.fail("用户不存在");
}

User user = userOpt.get();
if (Boolean.TRUE.equals(user.getIsLocked())) {
return R.fail("账户已被锁定");
}

String provider = oauth2UserInfo.getProvider();
String providerUserId = oauth2UserInfo.getProviderUserId();

// 2. 检查该第三方账号是否已被其他用户绑定
Optional<UserConnection>existingConnectionOpt = connectionRepository.findByProviderAndProviderUserIdAndIsUnboundFalse(provider, providerUserId);
if (existingConnectionOpt.isPresent()) {
UserConnection existingConnection = existingConnectionOpt.get();
if (!existingConnection.getUserId().equals(userId)) {
return R.fail("该 " + provider + " 账号已被其他用户绑定,请先解绑");
}
// 已绑定到当前用户,无需重复绑定
log.info("该账号已绑定到当前用户 - 用户ID: {}, 提供商: {}", userId, provider);
return R.ok(null, "该账号已绑定");
}

// 3.检查当前用户是否已绑定过该提供商
boolean alreadyBound = connectionRepository.existsByUserIdAndProviderAndIsUnboundFalse(userId, provider);
if (alreadyBound) {
return R.fail("您已绑定过 " + provider + " 账号,请先解绑旧账号");
}

// 4. 创建绑定关系
UserConnection newConnection = createConnection(userId, oauth2UserInfo);
connectionRepository.save(newConnection);

// 5.更新用户信息
updateUserFromOAuth2(user, oauth2UserInfo);
userRepository.save(user);

log.info("手动绑定成功 - 用户ID: {}, 提供商: {}, 提供商用户ID: {}", userId, provider, providerUserId);
return R.ok(null, "绑定成功");
}catch (ServiceException e){
log.error("手动绑定失败 - 用户ID: {}, 提供商: {}", userId, oauth2UserInfo.getProvider(), e);
return R.fail("绑定失败,请稍后重试");
}
}


/**
* 解绑第三方账号
*/
@Override
@Transactional
public R<Void> unbindOAuth2Account(Long userId, String provider) {
log.info("解绑第三方账号 - 用户ID: {}, 提供商: {}", userId, provider);

try{
// 1.查询用户
Optional<User> userOpt = userRepository.findByIdAndIsDeletedFalse(userId);
if (userOpt.isEmpty()) {
return R.fail("用户不存在");
}
User user = userOpt.get();

// 2.查询绑定关系
Optional<UserConnection> connectionOpt = connectionRepository.findByUserIdAndProviderAndIsUnboundFalse(userId, provider);
if (connectionOpt.isEmpty()) {
return R.fail("未绑定该 " + provider + " 账号");
}

// 3.安全检查,如果用户没有密码或者邮箱,且只剩下一个绑定,就不允许解绑
if (!user.hasPassword() | !user.hasEmail()) {
long connectionCount = connectionRepository.countByUserId(userId);
if (connectionCount <= 1) {
return R.fail("因为您尚未设置密码或者邮箱,且这是唯一的登录方式,解绑后将无法登录。请先设置密码后再解绑。");
}
}

// 4. 标记为解绑(软删除)
UserConnection connection = connectionOpt.get();
connection.setIsUnbound(true);
connectionRepository.save(connection);

log.info("解绑成功 - 用户ID: {}, 提供商: {}", userId, provider);
return R.ok(null, "解绑成功");
} catch (Exception e) {
log.error("解绑失败 - 用户ID: {}, 提供商: {}", userId, provider, e);
return R.fail("解绑失败,请稍后重试");
}
}

编写OAuth2 Controller层

那么,首先,为了精简,只留下了两个接口,分别用于第三方平台授权后重定向回前端和用于用户手动绑定第三方账号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
@Slf4j
@RestController
@RequestMapping("/zhiyan/auth/oauth2")
@RequiredArgsConstructor
@Tag(name = "OAuth2第三方登录", description = "GitHub等第三方登录相关接口")
public class OAuth2Controller {

@Resource
private OAuth2Client oAuth2Client;
private final OAuth2Service oAuth2Service;
private final OAuth2Properties oAuth2Properties;
private final ObjectMapper objectMapper;
private final OrcidOAuth2Provider orcidOAuth2Provider;
private final UserRepository userRepository;
private final UserConnectionRepository userConnectionRepository;

/**
* OAuth2 回调接口(第三方平台授权后跳转)
*
* 核心逻辑:
* 1. 获取 OAuth2 用户信息
* 2. 调用 OAuth2Service.handleOAuth2Login 处理登录/注册
* 3. 重定向到前端(携带 token 或错误信息)
*/
@GetMapping("/callback/{provider}")
@Operation(summary = "OAuth2 回调", description = "第三方平台授权成功后的回调接口")
public void callback(
@Parameter(description = "OAuth2 提供商名称", example = "github", required = true)
@PathVariable String provider,
@Parameter(description = "授权码", required = true)
@RequestParam String code,
@Parameter(description = "状态参数(用于防 CSRF 攻击)", required = true)
@RequestParam String state,
HttpServletResponse response) {
log.info("OAuth2 回调请求 - 提供商: {}, code: {}, state: {}", provider, code, state);

try {
// 1. 构建回调 URL
String redirectUri = buildCallbackUrl(provider);

// 2. 通过授权码获取用户信息
OAuth2UserInfoDTO userInfo = oAuth2Client.getUserInfoByCode(provider, code, state, redirectUri);
log.info("获取 OAuth2 用户信息成功 - 提供商: {}, 用户ID: {}, 邮箱: {}",
provider, userInfo.getProviderUserId(), userInfo.getEmail());

// 3. 处理登录/注册
R<OAuth2LoginResponseDTO> loginResult = oAuth2Service.handleOAuth2Login(userInfo);

// 4. 重定向到前端
String redirectUrl = buildSuccessRedirectUrl(loginResult);
log.info("重定向到前端页面: {}", redirectUrl);
response.sendRedirect(redirectUrl);
} catch (Exception e) {
log.error("OAuth2 回调处理失败 - 提供商: {}, 错误: {}", provider, e.getMessage(), e);
// 重定向到前端错误页面
try {
String errorUrl = buildErrorRedirectUrl(provider, e.getMessage());
response.sendRedirect(errorUrl);
} catch (Exception ex) {
log.error("重定向到错误页面失败", ex);
}
}
}


/**
* 已登录用户手动绑定第三方账号
*
* 流程:
* 1. 用户点击"绑定 GitHub"
* 2. 跳转到 /oauth2/authorize/github
* 3. 授权完成后回调到 /oauth2/bind/callback/{provider}
* 4. 调用此接口完成绑定
*/
@GetMapping("/bind/callback/{provider}")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "手动绑定第三方账号回调", description = "已登录用户绑定第三方账号的回调接口")
public void bindCallback(
@Parameter(description = "OAuth2 提供商名称", example = "github", required = true)
@PathVariable String provider,
@Parameter(description = "授权码", required = true)
@RequestParam String code,
@Parameter(description = "状态参数", required = true)
@RequestParam String state,
HttpServletResponse response) {
log.info("手动绑定回调 - 提供商: {}", provider);

try{
// 1. 获取当前用户ID
Long userId = SecurityUtils.getUserId();
if (userId == null) {
throw new IllegalStateException("用户未登录");
}

// 2. 获取 OAuth2 用户信息
String redirectUri = buildBindCallbackUrl(provider);
OAuth2UserInfoDTO userInfo = oAuth2Client.getUserInfoByCode(provider, code, state, redirectUri);

// 3.执行绑定
R<Void> bindResult = oAuth2Service.bindOAuth2Account(userId, userInfo);

// 4.重定向到前端
String redirectUrl;
if(R.isSuccess(bindResult)){
redirectUrl = buildBindSuccessRedirectUrl(provider);
} else {
redirectUrl = buildBindErrorRedirectUrl(provider, bindResult.getMsg());
}
response.sendRedirect(redirectUrl);
}catch (ControllerException | IOException e){
log.error("手动绑定失败 - 提供商: {}", provider, e);
try {
String errorUrl = buildBindErrorRedirectUrl(provider, e.getMessage());
response.sendRedirect(errorUrl);
} catch (Exception ex) {
log.error("重定向失败", ex);
}
}
}
}

唯一需要注意的就是这个回调地址,在这个后端接口的地址中,处理了临时授权码code换你平台登录的Token方法

image-20251224193511977