LI YAO'S BLOG

有趣的人生, 一半家长里短, 一半山川胡海

Github快捷登录配置

Github快捷登录配置

这是第三方登录系列的第三篇。前两篇分别接入了 Google 和 Gitee,这一篇接入 GitHub 登录。GitHub 登录在面向开发者的产品中几乎是标配,而且它和 Gitee 一样使用标准的 OAuth2 授权码模式,如果你已经看过《Gitee快捷登录配置》,这一篇会非常轻松——流程完全一样,只是换了接口地址和字段名。

1
2
Google:前端直接拿到 ID Token,后端只负责校验
Gitee / GitHub:前端只能拿到授权码 code,由后端用 code + Client Secret 换取 Access Token,再调 API 获取用户信息

本文主要讲的是”使用 GitHub 账号登录网站”,不是调用 GitHub 的仓库、Issue 等 REST API。另外要注意,GitHub 在国内部分网络环境下访问不稳定,如果你的用户主要在国内,建议把 GitHub 登录作为辅助登录方式,主推 Gitee 或手机号登录。

一、整体登录流程

GitHub 快捷登录的核心流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
用户点击"使用 GitHub 登录"
↓
前端跳转到 GitHub 授权页 https://github.com/login/oauth/authorize
↓
用户在 GitHub 登录并同意授权
↓
GitHub 重定向回你配置的回调地址,URL 上带着 code 和 state
↓
前端回调页把 code 发给后端
↓
后端用 code + Client Secret 向 GitHub 换取 Access Token
↓
后端用 Access Token 调用 GitHub API 获取用户信息
↓
后端根据 GitHub 用户唯一 ID 查询或创建本地用户
↓
后端生成自己系统的登录态,比如 Session、JWT、Sa-Token
↓
用户登录成功

原则依然不变:

1
2
GitHub 只负责证明"这个 GitHub 用户是谁"
你自己的系统负责决定"这个用户在我系统里是谁、有什么权限、登录态怎么维护"

二、几个核心概念

授权码模式的核心概念在《Gitee快捷登录配置》中已经详细讲过(Client ID、Client Secret、授权码 code、state 防 CSRF),这里只说 GitHub 特有的几点。

1. scope 权限范围

做快捷登录推荐申请:

1
2
read:user    读取用户公开资料
user:email 读取用户邮箱(包括设为私密的邮箱)

多个 scope 用空格分隔。如果一个 scope 都不传,也能拿到用户的公开信息,但拿不到私密邮箱。

2. GitHub 用户唯一 ID

GitHub 用户信息接口返回的字段中:

1
2
3
id:数字,永远不变,作为绑定标识
login:用户名,用户可以修改,不能作为唯一标识
email:邮箱,大概率是 null(见下文),不能作为唯一标识

3. email 大概率拿不到

这是 GitHub 登录最容易踩的坑:很多用户在 GitHub 设置中勾选了 “Keep my email addresses private”,此时 /user 接口返回的 email 字段是 null。

正确做法是申请 user:email 权限,再单独调用 /user/emails 接口,从邮箱列表中取 primary = true 且 verified = true 的那一条。

4. Access Token 的有效期

GitHub OAuth App 换到的 Access Token 是长期有效的,没有 refresh_token,只有用户主动撤销授权或 token 一年未使用时才会失效。

顺带澄清一个容易混淆的点:可过期 token + refresh_token 机制是 GitHub Apps 才有的功能(User-to-server token expiration),OAuth App 的设置里没有这个选项。

不过做快捷登录时不用关心这个差异:换到用户信息、完成绑定之后,这个 token 就可以丢弃了。

三、在 GitHub 创建 OAuth App

1. 进入开发者设置

登录 GitHub,点击右上角头像,进入:

1
Settings → Developer settings → OAuth Apps → New OAuth App

或者直接访问:

1
https://github.com/settings/developers

注意选择的是 OAuth Apps,不是 GitHub Apps。GitHub Apps 功能更强大但配置复杂,做网站快捷登录用 OAuth App 就够了。

2. 填写应用信息

1
2
3
4
Application name:应用名称,例如 Yao Admin,用户在授权页会看到
Homepage URL:应用主页,例如 https://yao-admin.com
Application description:可选
Authorization callback URL:授权回调地址

回调地址开发环境示例:

1
http://localhost:5173/oauth/github/callback

生产环境示例:

1
https://yao-admin.com/oauth/github/callback

注意事项:

1
2
3
1. 一个 OAuth App 只能填一个回调地址,开发和生产建议分别创建两个 OAuth App
2. GitHub 的回调匹配规则是前缀匹配,但协议、域名、端口必须完全一致
3. 这一点和 Gitee 不同:Gitee 一个应用可以配多行回调地址

3. 获取 Client ID 和 Client Secret

创建完成后进入应用详情页:

1
2
Client ID:页面上直接显示
Client Secret:点击 Generate a new client secret 生成

Client Secret 只在生成时完整显示一次,立刻保存到安全的地方。和 Gitee 一样,它只能保存在后端。

四、前端接入:跳转授权与回调页

前端逻辑和 Gitee 完全一致:登录按钮负责”跳过去”,回调页负责”接回来”。

1. 配置前端环境变量

.env.development:

1
2
VITE_GITHUB_CLIENT_ID=你的GitHubClientID
VITE_GITHUB_REDIRECT_URI=http://localhost:5173/oauth/github/callback

.env.production:

1
2
VITE_GITHUB_CLIENT_ID=生产环境GitHubClientID
VITE_GITHUB_REDIRECT_URI=https://yao-admin.com/oauth/github/callback

注意开发和生产是两个不同的 OAuth App,Client ID 不一样。

2. 登录按钮:拼接授权地址并跳转

GitHub 授权地址格式:

1
https://github.com/login/oauth/authorize?client_id=CLIENT_ID&redirect_uri=REDIRECT_URI&scope=read:user user:email&state=STATE

React 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function buildState(): string {
const state = crypto.randomUUID();
sessionStorage.setItem('github_oauth_state', state);
return state;
}

export default function GithubLoginButton() {
function handleClick() {
const params = new URLSearchParams({
client_id: import.meta.env.VITE_GITHUB_CLIENT_ID,
redirect_uri: import.meta.env.VITE_GITHUB_REDIRECT_URI,
scope: 'read:user user:email',
state: buildState()
});

window.location.href = `https://github.com/login/oauth/authorize?${params.toString()}`;
}

return (
<button onClick={handleClick}>
使用 GitHub 登录
</button>
);
}

3. 回调页:取出 code 并发给后端

回调页和 Gitee 版本只有两处不同:sessionStorage 的 key 和后端接口地址。

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
import { useEffect, useRef } from 'react';

export default function GithubCallback() {
const executed = useRef(false);

useEffect(() => {
// code 是一次性的,防止 React 18 开发模式下 useEffect 执行两次
if (executed.current) {
return;
}
executed.current = true;

const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const savedState = sessionStorage.getItem('github_oauth_state');

sessionStorage.removeItem('github_oauth_state');

if (!code) {
console.error('未获取到授权码');
window.location.href = '/login';
return;
}

if (!state || state !== savedState) {
console.error('state 校验失败,可能存在 CSRF 风险');
window.location.href = '/login';
return;
}

handleGithubLogin(code);
}, []);

async function handleGithubLogin(code: string) {
const res = await fetch('/api/auth/github/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ code })
});

if (!res.ok) {
throw new Error('GitHub 登录失败');
}

const data = await res.json();
localStorage.setItem('token', data.token);
window.location.href = '/';
}

return <div>正在登录,请稍候...</div>;
}

五、后端实现:换取 Access Token 并获取用户信息

后端流程和 Gitee 一样,但有三个 GitHub 特有的细节:

1
2
3
4
1. 换 token 接口默认返回 application/x-www-form-urlencoded 格式,
必须显式设置请求头 Accept: application/json 才会返回 JSON
2. 调用 GitHub API 必须携带 User-Agent 请求头,否则直接返回 403
3. email 为 null 时需要再调一次 /user/emails 接口

1. 配置 application.yml

1
2
3
4
5
github:
oauth:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
redirect-uri: ${GITHUB_REDIRECT_URI}

2. Controller 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/auth/github")
public class GithubAuthController {

private final GithubAuthService githubAuthService;

public GithubAuthController(GithubAuthService githubAuthService) {
this.githubAuthService = githubAuthService;
}

@PostMapping("/login")
public LoginResponse login(@Valid @RequestBody GithubLoginRequest request) {
return githubAuthService.login(request.getCode());
}
}

GithubLoginRequest 和 Gitee 版本一样,只有一个 code 字段,不再重复。

3. Token 响应对象

设置了 Accept: application/json 后,GitHub 换 token 接口返回:

1
2
3
4
5
{
"access_token": "gho_xxxxxxxxxxxxxxxx",
"token_type": "bearer",
"scope": "read:user,user:email"
}
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
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

@JsonIgnoreProperties(ignoreUnknown = true)
public class GithubTokenResponse {

@JsonProperty("access_token")
private String accessToken;

@JsonProperty("token_type")
private String tokenType;

private String scope;

public String getAccessToken() {
return accessToken;
}

public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}

public String getTokenType() {
return tokenType;
}

public void setTokenType(String tokenType) {
this.tokenType = tokenType;
}

public String getScope() {
return scope;
}

public void setScope(String scope) {
this.scope = scope;
}
}

4. 用户信息响应对象

GitHub /user 接口返回的主要字段:

1
2
3
4
5
6
7
8
{
"id": 9876543,
"login": "handsomeliyao",
"name": "LI YAO",
"avatar_url": "https://avatars.githubusercontent.com/u/9876543",
"email": null,
"html_url": "https://github.com/handsomeliyao"
}
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
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

@JsonIgnoreProperties(ignoreUnknown = true)
public class GithubUserInfo {

private Long id;

private String login;

private String name;

@JsonProperty("avatar_url")
private String avatarUrl;

private String email;

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public String getLogin() {
return login;
}

public void setLogin(String login) {
this.login = login;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public String getAvatarUrl() {
return avatarUrl;
}

public void setAvatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
}

public String getEmail() {
return email;
}

public void setEmail(String email) {
this.email = email;
}
}

5. 邮箱列表响应对象

/user/emails 接口返回数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
{
"email": "yao@example.com",
"primary": true,
"verified": true,
"visibility": "private"
},
{
"email": "9876543+handsomeliyao@users.noreply.github.com",
"primary": false,
"verified": true,
"visibility": null
}
]
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
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

@JsonIgnoreProperties(ignoreUnknown = true)
public class GithubEmail {

private String email;
private Boolean primary;
private Boolean verified;

public String getEmail() {
return email;
}

public void setEmail(String email) {
this.email = email;
}

public Boolean getPrimary() {
return primary;
}

public void setPrimary(Boolean primary) {
this.primary = primary;
}

public Boolean getVerified() {
return verified;
}

public void setVerified(Boolean verified) {
this.verified = verified;
}
}

6. 调用 GitHub 接口的 Service

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
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import java.util.Collections;
import java.util.List;

@Service
public class GithubApiService {

private static final String TOKEN_URL = "https://github.com/login/oauth/access_token";
private static final String USER_URL = "https://api.github.com/user";
private static final String EMAILS_URL = "https://api.github.com/user/emails";

private final String clientId;
private final String clientSecret;
private final String redirectUri;
private final RestTemplate restTemplate;

public GithubApiService(@Value("${github.oauth.client-id}") String clientId,
@Value("${github.oauth.client-secret}") String clientSecret,
@Value("${github.oauth.redirect-uri}") String redirectUri) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.redirectUri = redirectUri;
this.restTemplate = new RestTemplate();
}

public String getAccessToken(String code) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 关键:不加这个头,GitHub 返回的是 urlencoded 字符串而不是 JSON
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));

MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("client_id", clientId);
params.add("client_secret", clientSecret);
params.add("code", code);
params.add("redirect_uri", redirectUri);

HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);

GithubTokenResponse response = restTemplate.postForObject(TOKEN_URL, request, GithubTokenResponse.class);

if (response == null || response.getAccessToken() == null) {
throw new RuntimeException("获取GitHub AccessToken失败");
}

return response.getAccessToken();
}

public GithubUserInfo getUserInfo(String accessToken) {
HttpEntity<Void> request = new HttpEntity<>(buildApiHeaders(accessToken));

GithubUserInfo userInfo = restTemplate.exchange(
USER_URL,
HttpMethod.GET,
request,
GithubUserInfo.class
).getBody();

if (userInfo == null || userInfo.getId() == null) {
throw new RuntimeException("获取GitHub用户信息失败");
}

// email 为 null 时,再调邮箱接口取主邮箱
if (userInfo.getEmail() == null) {
userInfo.setEmail(getPrimaryEmail(accessToken));
}

return userInfo;
}

private String getPrimaryEmail(String accessToken) {
HttpEntity<Void> request = new HttpEntity<>(buildApiHeaders(accessToken));

List<GithubEmail> emails = restTemplate.exchange(
EMAILS_URL,
HttpMethod.GET,
request,
new ParameterizedTypeReference<List<GithubEmail>>() {}
).getBody();

if (emails == null) {
return null;
}

return emails.stream()
.filter(e -> Boolean.TRUE.equals(e.getPrimary()) && Boolean.TRUE.equals(e.getVerified()))
.map(GithubEmail::getEmail)
.findFirst()
.orElse(null);
}

private HttpHeaders buildApiHeaders(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
// 关键:GitHub API 要求必须携带 User-Agent,否则返回 403
headers.set("User-Agent", "yao-admin");
headers.set("X-GitHub-Api-Version", "2022-11-28");
return headers;
}
}

六、数据库表设计

继续复用系列前两篇中的两张表:

1
2
sys_user:用户表
sys_oauth_account:第三方账号绑定表

GitHub 登录时的绑定数据:

1
2
provider = github
provider_user_id = GitHub 用户信息中的 id(转成字符串保存)

到这里这张表已经同时支撑了 google、gitee、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
import cn.dev33.satoken.stp.StpUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class GithubAuthService {

private static final String PROVIDER_GITHUB = "github";

private final GithubApiService githubApiService;
private final UserRepository userRepository;
private final OAuthAccountRepository oAuthAccountRepository;

public GithubAuthService(GithubApiService githubApiService,
UserRepository userRepository,
OAuthAccountRepository oAuthAccountRepository) {
this.githubApiService = githubApiService;
this.userRepository = userRepository;
this.oAuthAccountRepository = oAuthAccountRepository;
}

@Transactional(rollbackFor = Exception.class)
public LoginResponse login(String code) {
// 1. code 换 access_token
String accessToken = githubApiService.getAccessToken(code);

// 2. access_token 换用户信息(内部已处理 email 为 null 的情况)
GithubUserInfo githubUserInfo = githubApiService.getUserInfo(accessToken);

// 3. 查询绑定关系
OAuthAccount oauthAccount = oAuthAccountRepository.findByProviderAndProviderUserId(
PROVIDER_GITHUB,
String.valueOf(githubUserInfo.getId())
);

User user;

if (oauthAccount != null) {
user = userRepository.findById(oauthAccount.getUserId());
if (user == null) {
throw new RuntimeException("绑定的本地用户不存在");
}
} else {
user = createUser(githubUserInfo);

OAuthAccount newOAuthAccount = new OAuthAccount();
newOAuthAccount.setUserId(user.getId());
newOAuthAccount.setProvider(PROVIDER_GITHUB);
newOAuthAccount.setProviderUserId(String.valueOf(githubUserInfo.getId()));
newOAuthAccount.setEmail(githubUserInfo.getEmail());
newOAuthAccount.setNickname(githubUserInfo.getName());
newOAuthAccount.setAvatar(githubUserInfo.getAvatarUrl());
oAuthAccountRepository.save(newOAuthAccount);
}

if (user.getStatus() == 0) {
throw new RuntimeException("当前用户已被禁用");
}

// 4. 生成本系统登录态
StpUtil.login(user.getId());
String token = StpUtil.getTokenValue();

return new LoginResponse(
token,
new LoginResponse.UserInfo(
user.getId(),
user.getNickname(),
user.getEmail(),
user.getAvatar()
)
);
}

private User createUser(GithubUserInfo githubUserInfo) {
User newUser = new User();
newUser.setEmail(githubUserInfo.getEmail());
newUser.setNickname(githubUserInfo.getName() != null ? githubUserInfo.getName() : githubUserInfo.getLogin());
newUser.setAvatar(githubUserInfo.getAvatarUrl());
newUser.setStatus(1);
userRepository.save(newUser);
return newUser;
}
}

对比一下会发现,GithubAuthService 和 GiteeAuthService 几乎逐行相同。如果你的系统要接入更多平台,可以进一步抽象出一个 OAuthLoginTemplate,把”查绑定 → 建用户 → 生成登录态”的公共流程提取出来,每个平台只实现”换 token + 拿用户信息”两个方法。

八、前后端联调

1. 检查回调地址

确认 GitHub OAuth App(开发环境那个)配置的回调地址是:

1
http://localhost:5173/oauth/github/callback

前端 VITE_GITHUB_REDIRECT_URI 和后端 GITHUB_REDIRECT_URI 保持同一个值。

2. 测试登录流程

1
2
3
4
5
1. 打开登录页,点击"使用 GitHub 登录"
2. 浏览器跳转到 github.com 授权页,登录并点击 Authorize
3. 浏览器回到 /oauth/github/callback?code=xxx&state=yyy
4. 回调页把 code 发给后端,后端返回本系统 token
5. 前端保存 token,跳转首页

3. 国内网络问题

后端服务器如果部署在国内,调用 github.com 和 api.github.com 可能超时。排查时先在服务器上直接测试连通性:

1
curl -I https://api.github.com

如果不通,可选方案:

1
2
3
1. 后端服务器使用海外节点(例如香港、新加坡、东京的云服务器)
2. 给后端的 RestTemplate 配置代理
3. 评估是否真的需要 GitHub 登录,国内用户主推 Gitee 登录

九、生产环境部署注意事项

1. 生产环境单独创建 OAuth App

GitHub 一个 OAuth App 只能配一个回调地址,所以:

1
2
Dev OAuth App  → http://localhost:5173/oauth/github/callback
Prod OAuth App → https://yao-admin.com/oauth/github/callback

两套 Client ID 和 Client Secret 分别配置到对应环境。

2. 必须使用 HTTPS

生产回调地址必须是 HTTPS。

3. Client Secret 保护

1
2
3
1. 只通过环境变量注入,不进代码仓库
2. GitHub 支持同时存在两个 Client Secret,轮换时先生成新的、再删除旧的,可以平滑过渡
3. 怀疑泄露时立即在 OAuth App 设置中 Revoke

4. Nginx 配置

和前两篇一致:/ 指向前端静态文件并配置 try_files $uri $uri/ /index.html;,/api/ 反向代理到后端。/oauth/github/callback 是前端路由,不需要额外配置。

十、常见问题

1. 换 token 拿到的响应解析失败

最常见的原因:没有设置 Accept: application/json 请求头。

GitHub 这个接口默认返回的是:

1
access_token=gho_xxx&scope=read%3Auser&token_type=bearer

这是 urlencoded 字符串,不是 JSON。加上 Accept 头之后才返回 JSON。

2. 调用 API 返回 403

检查是否携带了 User-Agent 请求头。GitHub API 强制要求所有请求带 User-Agent,值随便填(建议填你的应用名),不带就是 403。

3. 换 token 返回 bad_verification_code

1
2
3
4
5
1. code 已经被使用过(code 是一次性的)
2. code 已过期(有效期 10 分钟)
3. React 18 开发模式 useEffect 执行两次,第二次用的是已消费的 code
4. Client ID 和 Client Secret 与生成 code 的 OAuth App 不匹配,
常见于开发环境误用了生产的凭证

4. email 是 null

用户开启了邮箱隐私保护,这在 GitHub 用户中非常普遍。按本文第五节的方式申请 user:email scope 并调用 /user/emails 接口兜底。如果两边都拿不到(用户没有验证过邮箱),就允许 email 为空,登录后引导用户补填。

5. 回调地址不匹配报错 redirect_uri_mismatch

GitHub 的回调校验规则:

1
2
3
4
5
1. 协议、域名、端口必须和 OAuth App 配置完全一致
2. 路径允许是配置路径的子路径(前缀匹配)
3. localhost 和 127.0.0.1 视为不同域名
4. 例外:回环地址(127.0.0.1 / localhost)不要求端口一致,
本地调试换端口不用改 OAuth App 配置,官方推荐用 127.0.0.1 而不是 localhost

6. 授权页显示的应用信息能改吗

可以。OAuth App 设置中可以上传应用 Logo、修改应用名称和主页。授权页展示的信息越完整,用户的信任度越高。

7. 用户撤销授权后会怎样

用户可以在 GitHub 的 Settings → Applications 中随时撤销授权。撤销后:

1
2
3
1. 已发放的 Access Token 立即失效
2. 不影响你系统中已生成的登录态和绑定关系
3. 用户下次点击 GitHub 登录时,会重新走一遍授权页

十一、最终落地清单

GitHub 平台需要完成:

1
2
3
1. Settings → Developer settings → OAuth Apps → New OAuth App
2. 填写应用名称、主页、回调地址(开发和生产分别创建)
3. 生成并保存 Client Secret

前端需要完成:

1
2
3
4
5
1. 配置 VITE_GITHUB_CLIENT_ID 和 VITE_GITHUB_REDIRECT_URI
2. 登录按钮拼接授权地址并跳转,scope 用 read:user user:email,带上随机 state
3. 回调页校验 state、取出 code,防止 useEffect 重复执行
4. 调用后端 /api/auth/github/login
5. 保存后端返回的本系统 token

后端需要完成:

1
2
3
4
5
6
7
8
9
10
1. 配置 client-id、client-secret、redirect-uri
2. POST https://github.com/login/oauth/access_token 换取 access_token,
记得加 Accept: application/json
3. GET https://api.github.com/user 获取用户信息,
记得加 User-Agent 和 Authorization: Bearer
4. email 为 null 时调用 GET https://api.github.com/user/emails 兜底
5. 根据 id 查询 sys_oauth_account 绑定关系
6. 查询或创建本地用户
7. 生成自己系统的登录态
8. 返回 token 和用户信息

十二、总结

GitHub 快捷登录和 Gitee 是同一套授权码模式,核心三步不变:

1
2
3
1. 前端跳转 https://github.com/login/oauth/authorize 拿授权码 code
2. 后端用 code + Client Secret 换 Access Token
3. 后端调用用户信息接口,根据 id 完成本地用户绑定和登录

GitHub 特有的几个坑:

1
2
3
4
5
1. 换 token 必须加 Accept: application/json,否则返回的不是 JSON
2. 调 API 必须加 User-Agent,否则 403
3. email 大概率是 null,要用 /user/emails 接口兜底
4. 一个 OAuth App 只能配一个回调地址,开发生产要分开建
5. 国内服务器访问 GitHub API 可能不稳定

至此,Google、Gitee、GitHub 三种快捷登录全部接入完成。回头看会发现:sys_oauth_account 绑定表 + “先查绑定、再建用户、最后生成自己系统登录态”的流程是通用骨架,任何新平台都只是往这个骨架上挂一个新的 provider 而已。

赞赏一杯咖啡