如何实践OAuth2
前言
从授权服务器的搭建、客户端的配置,到令牌的生成与验证,Spring Security
OAuth2
等组件已将协议细节封装为可复用的接口与注解,让开发者无需深入钻研协议底层,即可快速实现
“用微信登录自研系统”“用 GitHub 账号接入企业平台” 等场景。
正好,我的项目需要引入第三方登录,我们以 github
第三方登录为例子,来在 Spring Security 中使用 OAuth2
至于为什么不用微信,QQ,钉钉等这种国内更常用的,你可以去看看,申请这些东西有多复杂,不仅需要整体的备案,还需要上线服务,显然,对于第三方登录的实践,国内平台是不太合适的
有空我也想使下 OSU!的三方登录))
先给大家看一下基本的项目结构
image-20251115145142724
OAuth2的第三方登录的业务应该如何设计
那么,最重要的部分就是 OAuth2 业务如何设计
在老式的设计中,开发人员可能会在 User 表里加 google_id, github_id
字段。
我个人认为这是极其糟糕的设计,因为你无法预知未来要接多少家第三方。
但是实际上,这种方法也可能是好的,如果在不会接入太多是三方登录的情况下,这样还会保证其数据库不会太多跨表查询
我个人倾向于采用“主账号 + 绑定关系”的模式:
- User 表
(主账号):只存储属于你平台的唯一身份(ID、昵称、头像、注册时间、状态)。
- 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@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;
@Data @ConfigurationProperties(prefix = "zhiyan.oauth2") public class OAuth2Properties {
private boolean enabled = true;
private String callbackBaseUrl;
@Deprecated private String frontendCallbackUrl;
private String frontendHomeUrl;
private String frontendSupplementUrl;
private String frontendBindUrl;
private String frontendErrorUrl;
private GitHubOAuthProperties github = new GitHubOAuthProperties();
}
|
再写自动配置类,统一初始化 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;
@Slf4j @AutoConfiguration @EnableConfigurationProperties(OAuth2Properties.class) @ConditionalOnProperty(prefix = "zhiyan.oauth2", name = "enabled", havingValue = "true", matchIfMissing = true) public class OAuth2AutoConfiguration {
@Bean public RestTemplate oauth2RestTemplate() { return new RestTemplate(); }
@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;
public interface OAuth2Provider {
String getProviderName();
String getAuthorizationUrl(String state, String redirectUri);
String getAccessToken(String code, String redirectUri);
OAuth2UserInfoDTO getUserInfo(String accessToken);
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;
@Slf4j public abstract class AbstractOAuth2Provider implements OAuth2Provider {
protected final OAuth2Properties properties;
protected final RestTemplate restTemplate;
protected AbstractOAuth2Provider(OAuth2Properties properties, RestTemplate restTemplate) { this.properties = properties; this.restTemplate = restTemplate != null ? restTemplate : createRestTemplate(); }
protected RestTemplate createRestTemplate() { return new RestTemplate(); }
@Override public String getAuthorizationUrl(String state, String redirectUri) { String baseUrl = getAuthorizationUri(); String clientId = getClientId(); String scope = getScope(); if (StringUtils.isEmpty(baseUrl) || StringUtils.isEmpty(clientId)) { throw new OAuth2Exception("OAuth2配置不完整:缺少authorizationUri或clientId"); }
StringBuilder url = new StringBuilder(baseUrl); url.append("?client_id=").append(clientId); url.append("&redirect_uri=").append(redirectUri); url.append("&state=").append(state); url.append("&response_type=code");
if (StringUtils.isNotEmpty(scope)) { url.append("&scope=").append(scope); }
log.debug("生成授权URL: {}", url.toString()); return url.toString(); }
@Override public String getAccessToken(String code, String redirectUri) { 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 { MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); params.add("client_id", clientId); params.add("client_secret", clientSecret); params.add("grant_type", "authorization_code"); params.add("code", code); params.add("redirect_uri", redirectUri);
HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
ResponseEntity<String> response = restTemplate.exchange( tokenUri, HttpMethod.POST, request, String.class );
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); } }
protected String parseAccessToken(String responseBody) { try{ 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编码格式解析"); }
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); }
@Override public OAuth2UserInfoDTO getUserInfo(String accessToken) { String userInfoUri = getUserInfoUri(); if (StringUtils.isEmpty(userInfoUri)) { throw new OAuth2Exception("OAuth2配置不完整:缺少userInfoUri"); }
try { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(accessToken); headers.setAccept(java.util.Collections.singletonList(MediaType.APPLICATION_JSON));
HttpEntity<Void> request = new HttpEntity<>(headers); ResponseEntity<String> response = restTemplate.exchange( userInfoUri, HttpMethod.GET, request, String.class );
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); } }
protected abstract OAuth2UserInfoDTO parseUserInfo(String responseBody);
protected abstract String getClientId();
protected abstract String getClientSecret();
protected abstract String getScope();
protected abstract String getAuthorizationUri();
protected abstract String getTokenUri();
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;
@Slf4j @Component public class GithubOAuth2Provider extends AbstractOAuth2Provider {
private static final String PROVIDER_NAME = "github";
public GithubOAuth2Provider(OAuth2Properties properties, RestTemplate restTemplate) { super(properties, restTemplate); }
@Override public String getProviderName() { return PROVIDER_NAME; }
@Override public boolean isEnabled() { return properties.getGithub().isEnabled(); }
@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(); }
@Override protected OAuth2UserInfoDTO parseUserInfo(String responseBody) { try { Map<String, Object> userMap = JsonUtils.parseMap(responseBody);
OAuth2UserInfoDTO userInfo = OAuth2UserInfoDTO.builder() .provider(PROVIDER_NAME) .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();
return userInfo; } catch (Exception e) { log.error("解析GitHub用户信息失败", e); throw new OAuth2Exception("解析用户信息失败: " + e.getMessage(), e); } }
private String getUserEmail(String accessToken) { try { String emailUri = properties.getGithub().getUserEmailUri(); if (StringUtils.isEmpty(emailUri)) { return null; }
HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(accessToken); headers.setAccept(java.util.Collections.singletonList(MediaType.APPLICATION_JSON)); HttpEntity<Void> request = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange( emailUri, HttpMethod.GET, request, String.class );
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { List<Map<String, Object>> emails = JsonUtils.parseMapList(response.getBody()); 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; }
@Override public OAuth2UserInfoDTO getUserInfo(String accessToken) { OAuth2UserInfoDTO userInfo = super.getUserInfo(accessToken); userInfo.setAccessToken(accessToken); 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;
@Slf4j @Service public class OAuth2ClientImpl implements OAuth2Client {
private final Map<String, OAuth2Provider> providers;
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(); } }
private static final String STATE_CACHE_PREFIX = "oauth2:state:";
private static final long STATE_EXPIRE_SECONDS = 600L;
@Override public AuthorizationResultDTO getAuthorizationUrl(String providerName, String redirectUri) { OAuth2Provider provider = getProvider(providerName);
String state = generateState();
String authorizationUrl = provider.getAuthorizationUrl(state, redirectUri);
log.info("生成OAuth2授权URL - 提供商: {}, state: {}", providerName, state); return new AuthorizationResultDTO(authorizationUrl, state); }
@Override public OAuth2UserInfoDTO getUserInfoByCode(String providerName, String code, String state, String redirectUri) { validateState(state);
OAuth2Provider provider = getProvider(providerName);
String accessToken = provider.getAccessToken(code, redirectUri); log.debug("获取访问令牌成功 - 提供商: {}", providerName);
OAuth2UserInfoDTO userInfo = provider.getUserInfo(accessToken); log.info("获取用户信息成功 - 提供商: {}, 用户ID: {}", providerName, userInfo.getProviderUserId());
return userInfo; }
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; }
private String generateState() { String state = UUID.randomUUID().toString().replace("-", ""); String cacheKey = STATE_CACHE_PREFIX + state;
RedisUtils.setCacheObject(cacheKey, System.currentTimeMillis(), Duration.ofSeconds(STATE_EXPIRE_SECONDS));
return state; }
private void validateState(String state) { if (StringUtils.isEmpty(state)) { throw new OAuth2Exception("State参数不能为空"); }
String cacheKey = STATE_CACHE_PREFIX + state; Object cached = RedisUtils.getCacheObject(cacheKey);
if (cached == null) { throw new OAuth2Exception("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
|
@Override public R<OAuth2LoginResponseDTO> handleOAuth2Login(OAuth2UserInfoDTO oauth2UserInfo) { log.info("处理OAuth2登录 - 提供商: {}, 用户ID: {}, 邮箱: {}", oauth2UserInfo.getProvider(), oauth2UserInfo.getProviderUserId(), oauth2UserInfo.getEmail());
try { validateOAuth2UserInfo(oauth2UserInfo);
String provider = oauth2UserInfo.getProvider(); String providerUserId = oauth2UserInfo.getProviderUserId();
Optional<UserConnection> connectionOpt = connectionRepository.findByProviderAndProviderUserIdAndIsUnboundFalse(provider, providerUserId); if(connectionOpt.isPresent()) { UserConnection connection = connectionOpt.get(); log.info("第三方账号已绑定 - 用户ID: {}, 提供商: {}", connection.getUserId(), provider);
updateConnectionInfo(connection, oauth2UserInfo); connectionRepository.save(connection);
return doLogin(connection.getUserId(), oauth2UserInfo); }
if (StringUtils.isNotBlank(oauth2UserInfo.getEmail())) { Optional<User> userByEmailOpt = userRepository.findByEmailAndIsDeletedFalse(oauth2UserInfo.getEmail()); if (userByEmailOpt.isPresent()) { 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); } } 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{ 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();
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, "该账号已绑定"); }
boolean alreadyBound = connectionRepository.existsByUserIdAndProviderAndIsUnboundFalse(userId, provider); if (alreadyBound) { return R.fail("您已绑定过 " + provider + " 账号,请先解绑旧账号"); }
UserConnection newConnection = createConnection(userId, oauth2UserInfo); connectionRepository.save(newConnection);
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{ Optional<User> userOpt = userRepository.findByIdAndIsDeletedFalse(userId); if (userOpt.isEmpty()) { return R.fail("用户不存在"); } User user = userOpt.get();
Optional<UserConnection> connectionOpt = connectionRepository.findByUserIdAndProviderAndIsUnboundFalse(userId, provider); if (connectionOpt.isEmpty()) { return R.fail("未绑定该 " + provider + " 账号"); }
if (!user.hasPassword() | !user.hasEmail()) { long connectionCount = connectionRepository.countByUserId(userId); if (connectionCount <= 1) { return R.fail("因为您尚未设置密码或者邮箱,且这是唯一的登录方式,解绑后将无法登录。请先设置密码后再解绑。"); } }
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;
@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 { String redirectUri = buildCallbackUrl(provider);
OAuth2UserInfoDTO userInfo = oAuth2Client.getUserInfoByCode(provider, code, state, redirectUri); log.info("获取 OAuth2 用户信息成功 - 提供商: {}, 用户ID: {}, 邮箱: {}", provider, userInfo.getProviderUserId(), userInfo.getEmail());
R<OAuth2LoginResponseDTO> loginResult = oAuth2Service.handleOAuth2Login(userInfo);
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); } } }
@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{ Long userId = SecurityUtils.getUserId(); if (userId == null) { throw new IllegalStateException("用户未登录"); }
String redirectUri = buildBindCallbackUrl(provider); OAuth2UserInfoDTO userInfo = oAuth2Client.getUserInfoByCode(provider, code, state, redirectUri);
R<Void> bindResult = oAuth2Service.bindOAuth2Account(userId, userInfo);
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