AI摘要
北海のAI

GitHub OAuth 登录完整流程详解

一、前置准备:在 GitHub 创建 OAuth App

首先,需要在 GitHub 上创建一个 OAuth 应用:

  1. 访问 https://github.com/settings/developers
  2. 点击 “New OAuth App”
  3. 填写信息:
  4. 创建后获得:
    • Client ID: Ov23liXXXXXXXXXX(公开的)
    • Client Secret: xxxxxxxxxxxxxxxx(保密的,只给后端用)

二、完整流程图

sequenceDiagram
    actor U as 用户
    participant F as 前端
    participant B as 后端
    participant G as GitHub

    U->>F: ① 点击GitHub登录按钮
    F->>B: ② 浏览器跳转到后端OAuth接口
    B->>G: ③ 后端302重定向到GitHub授权页
    G-->>U: ④ 用户看到GitHub授权页面
    U->>G: ⑤ 用户点击"Authorize"
    G-->>F: ⑥ GitHub重定向回前端回调页
带上code参数 F->>B: ⑦ 前端把code发给后端 B->>G: ⑧ 后端用code换access_token G-->>B: ⑨ GitHub返回access_token B->>G: ⑩ 后端用token获取用户信息 G-->>B: ⑪ GitHub返回用户信息 B-->>F: ⑫ 后端返回JWT和用户信息 F-->>U: ⑬ 登录成功,跳转首页

三、每一步详细说明

步骤 ①:用户点击 GitHub 登录按钮

位置: src/components/modals/LoginModal.tsx

1
2
3
4
5
6
7
8
9
10
11
12
// 用户点击这个按钮
<button onClick={() => handleOAuthLogin('github')}>
<Github className="w-5 h-5" />
</button>

// 处理函数
const handleOAuthLogin = (provider: string) => {
// 生成跳转 URL
const oauthUrl = authService.getOAuthUrl(provider);
// 整个页面跳转到后端
window.location.href = oauthUrl;
};

authService.getOAuthUrl 做了什么:

1
2
3
4
5
6
7
// src/services/authService.ts
getOAuthUrl(provider: 'github'): string {
// 告诉后端:授权成功后,让 GitHub 重定向回这个地址
const redirectUri = encodeURIComponent(`${window.location.origin}/auth/callback`);
// 返回: http://localhost:8080/api/auth/oauth/github?redirect_uri=http://localhost:3000/auth/callback
return `${API_BASE_URL}/auth/oauth/${provider}?redirect_uri=${redirectUri}`;
}

此时浏览器地址栏变成:

1
http://localhost:8080/api/auth/oauth/github?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fcallback

步骤 ②③:后端接收请求并重定向到 GitHub

后端需要实现的接口: GET /api/auth/oauth/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
@GetMapping("/auth/oauth/{provider}")
public void initiateOAuth(
@PathVariable String provider,
@RequestParam("redirect_uri") String redirectUri,
HttpServletResponse response
) throws IOException {

// 1. 生成随机 state(防止 CSRF 攻击)
String state = UUID.randomUUID().toString();

// 2. 把 state 和 redirectUri 存起来(Redis 或 Session)
// 后面回调时要验证
redisTemplate.opsForValue().set(
"oauth_state:" + state,
redirectUri,
10, TimeUnit.MINUTES
);

// 3. 构建 GitHub 授权 URL
String githubUrl = "https://github.com/login/oauth/authorize"
+ "?client_id=" + GITHUB_CLIENT_ID // 你的 Client ID
+ "&redirect_uri=" + redirectUri // 授权后跳回哪里
+ "&scope=user:email" // 请求的权限
+ "&state=" + state; // 防 CSRF 的随机串

// 4. 302 重定向到 GitHub
response.sendRedirect(githubUrl);
}

此时浏览器地址栏变成:

1
https://github.com/login/oauth/authorize?client_id=Ov23liXXXX&redirect_uri=http://localhost:3000/auth/callback&scope=user:email&state=abc123

步骤 ④⑤:用户在 GitHub 页面授权

用户看到 GitHub 的授权页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌─────────────────────────────────────────────┐
│ GitHub │
│ │
│ Authorize Lucid │
│ │
│ Lucid by your-username │
│ wants to access your account │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ ✓ Read your email addresses │ │
│ │ ✓ Read your profile information │ │
│ └─────────────────────────────────────┘ │
│ │
│ [ Authorize your-username ] │
│ │
│ Authorizing will redirect to │
│ http://localhost:3000 │
│ │
└─────────────────────────────────────────────┘

用户点击 “Authorize” 按钮。


步骤 ⑥:GitHub 重定向回前端

GitHub 验证用户身份后,重定向到你设置的 callback URL:

浏览器地址栏变成:

1
http://localhost:3000/auth/callback?code=7e8f9a0b1c2d3e4f&state=abc123
  • code: 授权码(一次性的,10分钟内有效)
  • state: 之前传给 GitHub 的随机串(用于验证)

步骤 ⑦:前端回调页面处理

位置: src/components/auth/OAuthCallback.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export const OAuthCallback: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const loginWithOAuth = useStore((state) => state.loginWithOAuth);

useEffect(() => {
const handleCallback = async () => {
// 从 URL 获取参数
const code = searchParams.get('code'); // "7e8f9a0b1c2d3e4f"
const state = searchParams.get('state'); // "abc123"

// 调用后端 API,把 code 发过去
await loginWithOAuth('github', code, state);

// 登录成功,跳转首页
navigate('/');
};

handleCallback();
}, []);

return <div>正在登录...</div>;
};

loginWithOAuth 做了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/store/slices/authSlice.ts
loginWithOAuth: async (provider, code, state) => {
// 调用后端 API
const response = await authService.oauthCallback(provider, code, state);

// 保存 token
tokenManager.setTokens(response.accessToken, response.refreshToken);

// 更新用户状态
set((state) => {
state.user = response.user;
state.isAuthenticated = true;
});
}

authService.oauthCallback 做了什么:

1
2
3
4
5
6
7
8
9
10
11
// src/services/authService.ts
async oauthCallback(provider: string, code: string, state?: string): Promise<LoginResponse> {
// POST 请求到后端
const response = await apiClient.post(`/auth/oauth/${provider}/callback`, {
code, // "7e8f9a0b1c2d3e4f"
state, // "abc123"
redirectUri: `${window.location.origin}/auth/callback`,
});

return response.data.data;
}

步骤 ⑧⑨⑩⑪:后端用 code 换取用户信息

后端需要实现的接口: POST /api/auth/oauth/github/callback

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
@PostMapping("/auth/oauth/{provider}/callback")
public ApiResponse<LoginResponse> oauthCallback(
@PathVariable String provider,
@RequestBody OAuthCallbackRequest request // { code, state, redirectUri }
) {

// ========== 步骤 1: 验证 state(防止 CSRF) ==========
String savedRedirectUri = redisTemplate.opsForValue()
.get("oauth_state:" + request.getState());
if (savedRedirectUri == null) {
throw new AuthException("无效的 state 参数");
}
// 删除已使用的 state
redisTemplate.delete("oauth_state:" + request.getState());

// ========== 步骤 2: 用 code 换取 GitHub access_token ==========
// POST https://github.com/login/oauth/access_token
RestTemplate restTemplate = new RestTemplate();

HttpHeaders headers = new HttpHeaders();
headers.set("Accept", "application/json");

Map<String, String> body = new HashMap<>();
body.put("client_id", GITHUB_CLIENT_ID); // 你的 Client ID
body.put("client_secret", GITHUB_CLIENT_SECRET); // 你的 Client Secret(保密!)
body.put("code", request.getCode()); // 前端传来的授权码

ResponseEntity<Map> tokenResponse = restTemplate.postForEntity(
"https://github.com/login/oauth/access_token",
new HttpEntity<>(body, headers),
Map.class
);

String githubAccessToken = (String) tokenResponse.getBody().get("access_token");
// githubAccessToken = "gho_xxxxxxxxxxxxxxxxxxxx"

// ========== 步骤 3: 用 access_token 获取 GitHub 用户信息 ==========
// GET https://api.github.com/user
HttpHeaders userHeaders = new HttpHeaders();
userHeaders.set("Authorization", "Bearer " + githubAccessToken);

ResponseEntity<GitHubUser> userResponse = restTemplate.exchange(
"https://api.github.com/user",
HttpMethod.GET,
new HttpEntity<>(userHeaders),
GitHubUser.class
);

GitHubUser githubUser = userResponse.getBody();
// githubUser = { id: 12345, login: "zhangsan", email: "zhangsan@example.com", avatar_url: "..." }

// ========== 步骤 4: 查找或创建本地用户 ==========
User user = userRepository.findByGithubId(githubUser.getId());

if (user == null) {
// 新用户,创建账号
user = new User();
user.setUsername(githubUser.getLogin());
user.setEmail(githubUser.getEmail());
user.setAvatar(githubUser.getAvatarUrl());
user.setGithubId(githubUser.getId());
user = userRepository.save(user);
}

// ========== 步骤 5: 生成你自己系统的 JWT token ==========
String accessToken = jwtService.generateAccessToken(user);
String refreshToken = jwtService.generateRefreshToken(user);

// ========== 步骤 6: 返回给前端 ==========
return ApiResponse.success(new LoginResponse(
accessToken, // "eyJhbGciOiJIUzI1NiIs..."
refreshToken, // "dGhpcyBpcyBhIHJlZnJl..."
3600, // 过期时间(秒)
user // 用户信息
));
}

步骤 ⑫⑬:前端保存 token 并跳转

回到前端的 loginWithOAuth

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
loginWithOAuth: async (provider, code, state) => {
// 后端返回的数据
const response = await authService.oauthCallback(provider, code, state);
// response = {
// accessToken: "eyJhbGciOiJIUzI1NiIs...",
// refreshToken: "dGhpcyBpcyBhIHJlZnJl...",
// user: { uuid: "xxx", username: "zhangsan", email: "...", avatar: "..." }
// }

// 保存 token 到 localStorage
tokenManager.setTokens(response.accessToken, response.refreshToken);

// 更新 Zustand 状态
set((state) => {
state.user = response.user;
state.isAuthenticated = true;
});
}

然后 OAuthCallback 组件执行 navigate('/'),用户被跳转到首页,登录完成!


四、总结:谁做什么

角色 做的事情
前端 1. 跳转到后端 OAuth URL
2. 接收 GitHub 回调(拿到 code)
3. 把 code 发给后端
4. 保存后端返回的 JWT token
后端 1. 重定向到 GitHub 授权页
2. 用 code + client_secret 换 GitHub token
3. 用 GitHub token 获取用户信息
4. 创建/查找本地用户
5. 生成 JWT token 返回给前端
GitHub 1. 展示授权页面
2. 用户授权后返回 code
3. 用 code 换 access_token
4. 提供用户信息 API

安全原因

  • client_secret 只有后端知道,前端拿不到
  • code 是一次性的,用完就失效
  • state 防止 CSRF 攻击
  • GitHub 的 access_token 不暴露给前端,只在后端使用