GitHub OAuth 登录完整流程详解
一、前置准备:在 GitHub 创建 OAuth App
首先,需要在 GitHub 上创建一个 OAuth 应用:
访问 https://github.com/settings/developers
点击 “New OAuth App”
填写信息:
创建后获得:
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 ) => { const oauthUrl = authService.getOAuthUrl (provider); window .location .href = oauthUrl; };
authService.getOAuthUrl 做了什么:
1 2 3 4 5 6 7 getOAuthUrl (provider : 'github' ): string { const redirectUri = encodeURIComponent (`${window .location.origin} /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 { String state = UUID.randomUUID().toString(); redisTemplate.opsForValue().set( "oauth_state:" + state, redirectUri, 10 , TimeUnit.MINUTES ); String githubUrl = "https://github.com/login/oauth/authorize" + "?client_id=" + GITHUB_CLIENT_ID + "&redirect_uri=" + redirectUri + "&scope=user:email" + "&state=" + state; 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 ( ) => { const code = searchParams.get ('code' ); const state = searchParams.get ('state' ); 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 loginWithOAuth : async (provider, code, state) => { const response = await authService.oauthCallback (provider, code, state); 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 async oauthCallback (provider : string , code : string , state ?: string ): Promise <LoginResponse > { const response = await apiClient.post (`/auth/oauth/${provider} /callback` , { code, state, 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 } ) { String savedRedirectUri = redisTemplate.opsForValue() .get("oauth_state:" + request.getState()); if (savedRedirectUri == null ) { throw new AuthException ("无效的 state 参数" ); } redisTemplate.delete("oauth_state:" + request.getState()); 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); body.put("client_secret" , GITHUB_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" ); 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(); 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); } String accessToken = jwtService.generateAccessToken(user); String refreshToken = jwtService.generateRefreshToken(user); return ApiResponse.success(new LoginResponse ( accessToken, refreshToken, 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); tokenManager.setTokens (response.accessToken , response.refreshToken ); 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 不暴露给前端,只在后端使用