概述
本模块实现了 GitHub OAuth2 Authorization Code Flow(授权码模式),即点击”GitHub”按钮 → 跳转 GitHub 授权 → 回调登录的标准三方登录方式。
与 Device Flow(扫码登录)的区别:
| 特性 |
标准重定向流 |
Device Flow(扫码) |
| 用户操作 |
点按钮 → 跳 GitHub → 授权 → 跳回 |
扫二维码 → 手机确认 |
| 回调方式 |
GitHub 直接回调后端 redirect_uri |
前端轮询后端,后端轮询 GitHub |
| 适用场景 |
PC 浏览器 |
大屏/无法跳转的环境 |
| 复杂度 |
低(标准 OAuth) |
中(需要轮询) |
完整流程
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
| ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ 前端 (浏览器) │ │ 后端 (8081) │ │ GitHub API │ │ localhost:8082 │ │ │ │ │ └────────┬────────┘ └────────┬─────────┘ └────────┬────────┘ │ │ │ │ ① 点击 "GitHub" 按钮 │ │ │ 跳转到: │ │ │ /shop/oauth/github │ │ ├──────────302─────────────► │ │ │ │ │ │ │ ② 构造授权 URL │ │ │ (含 client_id, scope, │ │ │ redirect_uri, state) │ │ │ │ │ ③ 302 重定向 │ │ │◄────── 302 ───────────────┤ │ │ │ │ │ ④ 浏览器跳转到 GitHub │ │ │ https://github.com/ │ │ │ login/oauth/authorize │ │ ├─────────────────────────────────────────────────────► │ │ │ │ │ ⑤ 用户在 GitHub 上 │ │ │ 确认授权 │ │ │ │ │ │ ⑥ GitHub 回调后端 │ │ │ GET /shop/oauth/callback │ │ │ /github?code=xxx&state=yyy│ │ ├──────────────────────────► │ │ │ │ │ │ │ ⑦ 用 code 换 access_token│ │ │ POST /login/oauth/ │ │ │ access_token │ │ │ ───────────────────────► │ │ │ ◄── 返回 access_token │ │ │ │ │ │ ⑧ 获取 GitHub 用户信息 │ │ │ GET /user │ │ │ ───────────────────────► │ │ │ ◄── 返回 GitHub 用户 │ │ │ │ │ │ ⑨ 查 / 创建 ShopUser │ │ │ 生成 JWT token │ │ │ 生成 refresh token │ │ │ 用户信息 → JSON → Base64 │ │ │ │ │ ⑩ 重定向到前端 │ │ │ / │ ?token=xxx&refreshToken= │ │ │ yyy&user=base64... │ │ │◄────── 302 ───────────────┤ │ │ │ │ │ ⑪ 前端口令页面: │ │ │ 解析 URL query: │ │ │ - 保存 token / refresh │ │ │ - 解码 user Base64 → JSON │ │ │ - 存 localStorage │ │ │ - 跳转 /shop │ │ │ │ │
|
后端实现
1. OAuthService 接口
文件: src/main/java/com/.../service/OAuthService.java
1 2 3 4 5
| String authorize(String provider);
void callback(String provider, String code, String state, HttpServletResponse response) throws IOException;
|
2. OAuthServiceImpl 实现
文件: src/main/java/com/.../service/impl/OAuthServiceImpl.java
常量
1 2 3 4 5 6
| private static final String GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"; private static final String GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; private static final String GITHUB_USER_URL = "https://api.github.com/user"; private static final String GITHUB_EMAIL_URL = "https://api.github.com/user/emails"; private static final String REFRESH_KEY_PREFIX = "weblog:refresh:"; private static final long REFRESH_TOKEN_TTL_SECONDS = 7 * 24 * 3600;
|
authorize()
流程: 构造 GitHub OAuth 授权 URL 并返回
1 2 3 4 5 6 7 8
| public String authorize(String provider) { String state = UUID.randomUUID().toString().replace("-", ""); return GITHUB_AUTHORIZE_URL + "?client_id=" + oauthProperties.getClientId() + "&redirect_uri=" + URLEncoder.encode(oauthProperties.getRedirectUri(), StandardCharsets.UTF_8) + "&scope=read:user,user:email" + "&state=" + state; }
|
生成的 URL 示例:
1 2 3 4 5
| https://github.com/login/oauth/authorize ?client_id=xxx &redirect_uri=http://localhost:8081/shop/oauth/callback/github &scope=read:user,user:email &state=3f7a2b1c...
|
callback()
完整步骤:
1 2 3 4 5 6 7 8 9 10
| 1. 验证 provider = "github" 2. 调用 getGitHubAccessToken(code) → 换取 access_token 3. 用 access_token 调 GET /user → 获取 GitHub 用户信息 4. 用 access_token 调 GET /user/emails → 获取主邮箱(备用) 5. 查数据库 shop_user 表:WHERE oauth_provider='github' AND oauth_id=github_id 6. 如果不存在 → 创建新用户(用 UUID 作为 uuid) 7. 如果存在 → 更新昵称/头像 8. 生成 JWT token(subject = uuid)+ refresh token(存 Redis) 9. 将用户信息序列化 JSON → Base64(URL-safe 编码) 10. 302 重定向到前端:/#/shop/oauth/callback?token=xxx&refreshToken=yyy&user=base64
|
错误处理: 任何步骤失败 → 重定向到 /#/shop/auth?oauth_error=错误信息
getGitHubAccessToken()
文件: 同文件 380-410 行
1 2 3 4 5 6 7 8
| POST https://github.com/login/oauth/access_token Content-Type: application/x-www-form-urlencoded Accept: application/json
client_id=xxx &client_secret=xxx &code=xxx &redirect_uri=http://localhost:8081/shop/oauth/callback/github
|
Response:
1 2 3 4 5
| { "access_token": "gho_xxx", "token_type": "bearer", "scope": "read:user,user:email" }
|
重试机制: 内置 3 次重试,指数退避(1s → 2s → 4s),应对国内网络访问 GitHub 不稳定的情况。
getGitHubUser()
1 2 3
| GET https://api.github.com/user Authorization: Bearer gho_xxx Accept: application/json
|
Response(关键字段):
1 2 3 4 5 6 7
| { "id": 12345678, "login": "octocat", "name": "monalisa octocat", "avatar_url": "https://avatars.githubusercontent.com/u/12345678?v=4", "email": "octocat@github.com" }
|
getGitHubPrimaryEmail()
当 /user 返回的 email 为 null 时,调用 /user/emails 获取已验证的主邮箱:
1 2 3
| GET https://api.github.com/user/emails Authorization: Bearer gho_xxx Accept: application/json
|
Response:
1 2 3 4
| [ {"email": "octocat@github.com", "primary": true, "verified": true, "visibility": "public"}, {"email": "octocat@users.noreply.github.com", "primary": false, "verified": true, "visibility": null} ]
|
buildUserBase64()
将用户 ID、昵称、头像序列化为 JSON → URL-safe Base64:
1 2
| String json = objectMapper.writeValueAsString(userMap); return Base64.getUrlEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8));
|
注意: Base64.getUrlEncoder() 使用 - 和 _ 替代 + 和 /,前端 atob() 不认识这种变体,需手动替换。
3. OAuthController
文件: src/main/java/com/.../controller/OAuthController.java
| 端点 |
方法 |
说明 |
/shop/oauth/{provider} |
GET |
跳转到 OAuth 提供商授权页(302) |
/shop/oauth/callback/{provider} |
GET |
OAuth 回调处理 |
authorize 端点
1 2 3 4 5
| @GetMapping("/{provider}") public void authorize(@PathVariable String provider, HttpServletResponse response) throws IOException { String url = oAuthService.authorize(provider); response.sendRedirect(url); }
|
访问 http://localhost:8082/shop/oauth/github → Vite 代理到 http://localhost:8081/shop/oauth/github → 302 到 GitHub 授权页
callback 端点
1 2 3 4 5 6 7
| @GetMapping("/callback/{provider}") public void callback(@PathVariable String provider, @RequestParam String code, @RequestParam(required = false) String state, HttpServletResponse response) throws IOException { oAuthService.callback(provider, code, state, response); }
|
GitHub 授权后回调到 http://localhost:8081/shop/oauth/callback/github?code=xxx&state=yyy
4. 用户模型
表: shop_user
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| CREATE TABLE `shop_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `uuid` varchar(64) DEFAULT NULL COMMENT '用户唯一标识(JWT subject)', `nickname` varchar(64) DEFAULT NULL COMMENT '昵称', `avatar` varchar(512) DEFAULT NULL COMMENT '头像', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `password` varchar(256) DEFAULT NULL COMMENT '密码', `oauth_provider` varchar(20) DEFAULT NULL COMMENT 'OAuth 提供商', `oauth_id` varchar(128) DEFAULT NULL COMMENT 'OAuth 平台用户 ID', `status` int(11) DEFAULT '1', `register_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_phone` (`phone`), UNIQUE KEY `uk_oauth` (`oauth_provider`,`oauth_id`), UNIQUE KEY `uk_uuid` (`uuid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
OAuth 用户的 phone/password 为空,通过 oauth_provider + oauth_id 唯一标识。
UUID 作为 JWT 的 subject,不暴露 oauth_id 等外部平台 ID。
5. JWT Token 体系
生成
1
| String token = jwtTokenHelper.generateToken(uuid, "shop", accessTokenExpireTime);
|
- subject:
shop_user.uuid(随机 UUID,不暴露业务 ID)
- type:
shop(区分 admin 和 shop 用户)
- expire: 由
jwt.accessTokenExpireTime 配置
Refresh Token
1 2 3
| String refreshToken = UUID.randomUUID().toString().replace("-", ""); String refreshKey = "weblog:refresh:" + refreshToken; redisTemplate.opsForValue().set(refreshKey, uuid, 7, TimeUnit.DAYS);
|
- refresh token 存 Redis,7 天过期
- subject 存为 Redis value,用于刷新时验证
- 刷新端点在:
POST /shop/auth/refresh
6. 安全配置
文件: WebSecurityConfig.java
1 2
| .antMatchers("/shop/oauth/**").permitAll() .antMatchers("/shop/auth/**").permitAll()
|
OAuth 端点需要匿名访问,因为用户未登录时才能点击 GitHub 授权。
7. SSL 握手失败重试
请求 GitHub Token 端点时,RestTemplate 配置了 10s 连接超时、30s 读取超时,并添加重试:
1 2 3
| int maxRetries = 3; int retryDelayMs = 1000;
|
并通过系统属性强制 TLSv1.2 避免 TLS 1.3 在某些网络环境被中断:
1
| System.setProperty("https.protocols", "TLSv1.2");
|
前端实现
1. 登录页
文件: weblog-vue3/src/pages/frontend/shop-login.vue
GitHub 按钮
1 2 3 4
| <a :href="githubAuthUrl" class="..."> <svg class="w-5 h-5 mr-1"></svg> GitHub </a>
|
1
| const githubAuthUrl = `${window.location.origin}/shop/oauth/github`
|
- 直接使用
<a> 标签跳转(需要有 redirect_uri 白名单,不能用前端路由)
- URL 是
http://localhost:8082/shop/oauth/github → Vite 开发服务器代理到后端
OAuth 错误显示
1 2 3 4 5 6
| onMounted(() => { const oauthError = route.query.oauth_error if (oauthError) { ElMessage.error('GitHub 登录失败: ' + decodeURIComponent(oauthError)) } })
|
后端 callback 失败时会 302 到 /#/shop/auth?oauth_error=xxx,前端解析并弹窗显示。
自动登录
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
| onMounted(() => { tryAutoLogin() })
async function tryAutoLogin() { const token = getShopToken() const refreshToken = getShopRefreshToken() if (!token && !refreshToken) return
const payload = parseJwt(token) const now = Math.floor(Date.now() / 1000)
if (payload && payload.exp > now) { router.replace(redirect) return }
if (refreshToken) { const res = await axios.post('/shop/auth/refresh', { refreshToken }) if (res.data?.data) { setShopTokens(res.data.data, refreshToken) router.replace(redirect) } } }
|
2. 回调页
文件: weblog-vue3/src/pages/frontend/shop-oauth-callback.vue
GitHub 授权完成后,后端 302 到该页面(hash 路由参数形式):
1
| http://localhost:8082/#/shop/oauth/callback?token=xxx&refreshToken=yyy&user=base64...
|
页面逻辑:
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
| onMounted(() => { const token = route.query.token const refreshToken = route.query.refreshToken const userBase64 = route.query.user
if (!token || !refreshToken) { ElMessage.error('登录失败:缺少认证信息') router.replace('/shop/auth') return }
setShopTokens(token, refreshToken)
if (userBase64) { const standardBase64 = userBase64.replace(/-/g, '+').replace(/_/g, '/') const userStr = atob(standardBase64) const user = JSON.parse(userStr) localStorage.setItem('shop_user', JSON.stringify(user)) }
ElMessage.success('登录成功') router.replace('/shop') })
|
3. Token 管理
文件: weblog-vue3/src/composables/shopAuth.js
1 2 3 4 5 6 7
| setShopTokens(token, refreshToken)
getShopToken() getShopRefreshToken()
|
4. Vite 代理配置
1 2 3 4 5 6 7
| proxy: { '/shop/': { target: 'http://localhost:8081', changeOrigin: true } }
|
数据流详解
用户信息流
1 2 3 4 5 6 7 8
| GitHub API 后端 前端 ┌────────┐ ┌────────┐ ┌────────┐ │ id │ ──→ oauthId │ │ │ │ │ login │ ──→ nickname │ JSON │ → Base64 → │ JSON │ │ name │ ──→ nickname │ → │ │ → │ │ avatar │ ──→ avatar │ Base64 │ │ 存储 │ │ email │ ──→ email │ │ │ │ └────────┘ └────────┘ └────────┘
|
Token 流
1 2 3 4 5 6 7 8 9 10
| 后端 前端 ┌─────────────────────────────────┐ ┌────────────┐ │ JWT (subject=uuid, type=shop) │ ──→ │ localStorage │ │ Refresh Token (Redis, 7天) │ ──→ │ shop_token │ │ │ │ shop_refresh│ │ 每次请求携带 JWT (Authorization │ └────────────┘ │ Bearer header) │ │ → TokenAuthenticationFilter │ │ 验证 JWT 签名 + 过期 + 撤销检查 │ └─────────────────────────────────┘
|
错误异常流
1 2 3 4
| 流程中任何异常 → catch(Exception) → log.error → 302 到 /#/shop/auth?oauth_error=URL编码的错误信息
前端解析 route.query.oauth_error → ElMessage.error 弹窗
|
配置项
application.yml
1 2 3 4 5 6 7 8 9
| oauth: github: client-id: ${GITHUB_CLIENT_ID} client-secret: ${GITHUB_CLIENT_SECRET} redirect-uri: http://localhost:8081/shop/oauth/callback/github frontend-url: http://localhost:8082
jwt: accessTokenExpireTime: 7200000
|
| 配置 |
说明 |
oauth.github.client-id |
GitHub OAuth App 的 Client ID |
oauth.github.client-secret |
GitHub OAuth App 的 Client Secret(敏感!) |
oauth.github.redirect-uri |
GitHub 回调地址,需在 GitHub App 设置中添加白名单 |
oauth.github.frontend-url |
前端地址,用于回调成功后的重定向 |
jwt.accessTokenExpireTime |
JWT 过期时间(毫秒),默认 2 小时 |
回调地址白名单
在 GitHub OAuth App 设置页面,需将 redirect_uri 加入 Authorization callback URL:
1
| http://localhost:8081/shop/oauth/callback/github
|
踩坑记录
1. Vite 代理路径匹配
问题: 开发环境前端 8082,后端 8081,直接请求 /shop/oauth/github 需要代理
解决: Vite 配置 proxy: { '/shop/': { target: 'http://localhost:8081' } },前端 githubAuthUrl = window.location.origin + '/shop/oauth/github' → 浏览器请求 localhost:8082/shop/oauth/github → Vite 代理到 localhost:8081/shop/oauth/github
2. Hash 路由回调处理
问题: 后端回调重定向到 /#/shop/oauth/callback?token=xxx,但 Vue Router 是 hash 模式
解决: redirect URL 拼接为 frontendUrl + "/#/shop/oauth/callback?token=" + token。注意 # 前不能有 ?,否则 hash 会被当作 query parameter 的一部分。
3. Base64 URL 安全编码兼容
现象: 前端 atob() 解码用户信息时报错 Invalid character
原因: 后端 Base64.getUrlEncoder() 使用 URL-safe 字符集(- 和 _),前端 atob() 只认标准 Base64(+ 和 /)
解决:
1 2
| const standardBase64 = userBase64.replace(/-/g, '+').replace(/_/g, '/') const userStr = atob(standardBase64)
|
4. SSL 握手失败(国内网络)
现象: RestTemplate 调用 GitHub API 时报错 Remote host terminated the handshake
原因: 国内访问 GitHub 偶发 TLS 连接中断;部分代理/VPN 对 TLS 1.3 兼容性不好
解决:
- 强制 TLSv1.2:
System.setProperty("https.protocols", "TLSv1.2")
- 重试机制:
getGitHubAccessToken() 最多重试 3 次
5. OAuth 用户与手机号用户共存
注意: OAuth 注册的用户 phone / password 为空,登录时不能走手机号密码校验
实现:
shop_user 表的 phone / password 字段设可为空
- 唯一索引
uk_phone 只约束非空的 phone
- JWT 过滤器通过 uuid(非 phone)查找用户,兼容两种登录方式
- Token 签发时 type=
shop,与 admin 用户隔离
6. Session 与 token 的关系
注意: OAuth 登录后后端做 response.sendRedirect() 到前端并携带 token query,这是「URL 传参」,不需要 session。前端保存 token 到 localStorage 后,后续请求通过 Authorization: Bearer xxx 头携带。
测试指南
本地测试
- 确保 Vite 前端(8082)和后端(8081)都在运行
- 访问
http://localhost:8082/#/shop/auth
- 如果已登录,先清除 localStorage 中的
shop_token 和 shop_refresh_token
- 点击 “GitHub” 按钮
- 浏览器跳转到 GitHub 授权页,登录 GitHub 账号并授权
- GitHub 回调后端 → 后端处理 → 重定向到前端回调页
- 浏览器自动跳转到商城首页,右上角显示用户头像和昵称
直接 API 测试
1 2 3 4 5 6 7
| curl -v http://localhost:8081/shop/oauth/github 2>&1 | grep -i "location"
|