概述
本模块实现了 GitHub Device Authorization Flow(设备授权流),提供”扫码登录”体验。
与标准 OAuth 重定向流的区别:
| 特性 |
标准重定向流 |
Device Flow(扫码) |
| 用户在 PC 上 |
点按钮 → 跳 GitHub → 授权 → 跳回 |
显示二维码 + user_code |
| 用户在手机上 |
不需要 |
扫二维码 → 输入 code → 授权 |
| 回调方式 |
GitHub 直接回调 redirect_uri |
前端轮询后端 → 后端轮询 GitHub |
| 适用场景 |
PC 浏览器 |
PC 大屏展示、无法跳转的环境 |
完整流程
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
| ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ 前端 (浏览器) │ │ 后端 (8081) │ │ GitHub API │ │ localhost:8082 │ │ │ │ │ └────────┬────────┘ └────────┬─────────┘ └────────┬────────┘ │ │ │ │ ① POST /device/init │ │ ├──────────────────────────► │ │ │ │ ② POST device/code │ │ │ ───────────────────────► │ │ │ │ │ │ ③ 返回 device_code │ │ │ + user_code │ │ │ + verification_uri │ │ │◄───────────────────────── │ │ │ │ │ ④ 返回 {deviceCode, │ │ │ userCode, │ │ │ verificationUri} │ │ │◄──────────────────────────┤ │ │ │ │ │ ⑤ 渲染二维码(≈ verification_uri) │ │ 显示 user_code: DDB3-23AD │ │ │ │ │ ⑥ 用户用手机扫描二维码 │ │ │ 或直接访问 github.com/login/device │ │ 输入 user_code 完成授权 │ │ │ │ │ │ ⑦ GET /device/status │ 每 5s 轮询 │ │ ?deviceCode=xxx │ │ ├──────────────────────────► │ │ │ │ ⑧ POST /login/oauth/ │ │ │ access_token │ │ │ ───────────────────────► │ │ │ ◄── authorization_pending│ │ ⑨ "等待扫码中..." │ │ │◄──────────────────────────┤ │ │ ════════════════════════════════════ │ │ 用户手机在 GitHub 上确认授权 │ │ ════════════════════════════════════ │ │ │ │ │ ⑩ 再次轮询 │ │ ├──────────────────────────► │ │ │ │ ⑪ POST /login/oauth/ │ │ │ access_token │ │ │ ───────────────────────► │ │ │ ⑫ 返回 access_token │ │ │◄───────────────────────── │ │ │ │ │ │ ⑬ GET /user (获取用户信息)│ │ │ ───────────────────────► │ │ │ ◄── 返回 GitHub 用户 │ │ │ │ │ │ ⑭ 查/创建 ShopUser │ │ │ 生成 JWT + refresh token │ │ │ │ │ ⑮ 返回 token + 用户信息 │ │ │◄──────────────────────────┤ │ │ │ │ │ ⑯ 保存 token 到 localStorage │ │ 跳转到 /shop │ │
|
后端实现
1. OAuthService 接口扩展
文件: src/main/java/com/.../service/OAuthService.java
新增两个接口方法:
1 2 3 4 5
| Response initDeviceFlow();
Response checkDeviceFlow(String deviceCode);
|
2. OAuthServiceImpl 实现
文件: src/main/java/com/.../service/impl/OAuthServiceImpl.java
constants
1 2 3 4 5
| private static final String GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"; 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 DEVICE_KEY_PREFIX = "weblog:device:";
|
initDeviceFlow()
1 2 3 4 5 6 7 8 9 10
| 1. POST https://github.com/login/device/code Body: client_id, scope=read:user,user:email → 返回 device_code, user_code, verification_uri, interval, expires_in
2. 将 device_code 状态存入 Redis: KEY: weblog:device:{deviceCode} VALUE: { deviceCode, status: "pending", expiresIn } TTL: expires_in (GitHub 指定的过期时间)
3. 返回前端: deviceCode, userCode, verificationUri, interval
|
Request:
1 2 3 4 5
| POST https://github.com/login/device/code Content-Type: application/x-www-form-urlencoded Accept: application/json
client_id=xxx&scope=read:user,user:email
|
Response:
1 2 3 4 5 6 7
| { "device_code": "3584d83530jskajsjas46af8289938c8ef79f9dc5", "user_code": "WDJB-MJJJ", "verification_uri": "https://github.com/login/device", "expires_in": 900, "interval": 5 }
|
checkDeviceFlow()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 1. 从 Redis 获取设备流状态 2. 如果 status 已为 "success",直接返回结果(清除 Redis key) 3. 如果 status 为 "expired"/"denied",返回错误(清除 Redis key) 4. 否则 POST https://github.com/login/oauth/access_token Body: client_id, client_secret, device_code, grant_type=urn:ietf:params:oauth:grant-type:device_code
GitHub 可能返回: - authorization_pending → 用户未操作,返回 status: pending - slow_down → 需要放慢轮询,返回 status: pending - expired_token → 标记为 expired,返回错误 - access_denied → 标记为 denied,返回错误 - access_token → 授权成功!
5. 授权成功后: a. 用 access_token 获取 GitHub 用户信息 (GET https://api.github.com/user) b. 用 access_token 获取主邮箱 (GET https://api.github.com/user/emails) c. 查找 ShopUser (by oauth_provider=github, oauth_id=github_id) d. 不存在则创建新用户,存在则更新昵称/头像 e. 生成 JWT token + refresh token f. 返回 token, refreshToken, user 给前端
|
3. OAuthController 端点
文件: src/main/java/com/.../controller/OAuthController.java
| 端点 |
方法 |
说明 |
/shop/oauth/device/init |
POST |
初始化设备流,返回二维码数据 |
/shop/oauth/device/status |
GET |
轮询设备流状态,参数:deviceCode |
4. RestTemplate 配置
文件: src/main/java/com/.../config/RestTemplateConfig.java
1 2 3 4 5 6 7
| @Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(10000); factory.setReadTimeout(30000); return new RestTemplate(factory); }
|
5. 安全配置
在 WebSecurityConfig.java 中确保 /shop/oauth/** 路径允许匿名访问:
1
| .antMatchers("/shop/oauth/**").permitAll()
|
6. SSL 握手失败重试
由于国内访问 GitHub 偶发 SSL 握手失败(Remote host terminated the handshake),getGitHubAccessToken() 内置重试机制:
1 2 3
| int maxRetries = 3; int retryDelayMs = 1000;
|
同时强制走 TLSv1.2 避免某些代理/VPN 对 TLS 1.3 握手兼容性问题:
1
| System.setProperty("https.protocols", "TLSv1.2");
|
前端实现
1. 扫码页面
文件: weblog-vue3/src/pages/frontend/shop-oauth-qrcode.vue
页面结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| ┌─ 弹窗/居中容器 ──────────────────────┐ │ │ │ GitHub 扫码登录 │ │ 使用 GitHub App 扫描二维码完成登录 │ │ │ │ ┌───────────────┐ │ │ │ │ │ │ │ 二维码图片 │ │ │ │ 220x220 │ │ │ │ │ │ │ └───────────────┘ │ │ │ │ 输入以下代码 │ │ ┌──────────────┐ │ │ │ DDB3-23AD │ │ │ └──────────────┘ │ │ │ │ 或访问 https: │ device 输入以上代码完成授权 │ │ │ │ ⟳ 等待扫码中... │ │ │ │ [取消] [刷新二维码] │ └──────────────────────────────────────┘
|
关键逻辑
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
| async function initDevice() { loading.value = true const res = await fetch('/shop/oauth/device/init', { method: 'POST' }) const json = await res.json() const data = json.data
deviceCode.value = data.deviceCode userCode.value = data.userCode verificationUri.value = data.verificationUri
loading.value = false await nextTick()
await QRCode.toCanvas(qrCanvas.value, data.verificationUri, { width: 220, margin: 2 })
startPolling() }
async function pollDevice() { const res = await fetch('/shop/oauth/device/status?deviceCode=' + deviceCode.value) const json = await res.json()
if (json.data?.token) { setShopTokens(json.data.token, json.data.refreshToken) router.replace('/shop') return } scheduleNextPoll() }
|
注意事项
loading 切换时机:必须先 loading = false 让 canvas 渲染到 DOM,再用 nextTick() 等待,否则 qrCanvas ref 为 null,QR 码画不上去
- 轮询间隔从 GitHub 返回的
interval 字段获取(通常 5s)
- 二维码用
qrcode npm 包(v1.5.4)的 QRCode.toCanvas() 渲染到 <canvas> 元素
2. 登录页入口
文件: weblog-vue3/src/pages/frontend/shop-login.vue
在 GitHub 按钮旁边添加扫码入口:
1 2 3 4 5 6 7
| <el-button @click="goQrcode">扫码</el-button>
<script> function goQrcode() { router.push('/shop/auth/qrcode') } </script>
|
同时增加 OAuth 错误显示逻辑:
1 2 3 4 5
| if (route.query.oauth_error) { ElMessage.error('GitHub 登录失败: ' + route.query.oauth_error) router.replace('/shop/auth') }
|
3. 路由配置
文件: weblog-vue3/src/router/index.js
1 2 3 4 5
| { path: '/shop/auth/qrcode', component: ShopOAuthQRCode, meta: { title: '扫码登录' } }
|
踩坑记录
1. GitHub OAuth App 须开启 Device Flow
现象: 调用 POST /login/device/code 返回 400
1
| {"error":"device_flow_disabled","error_description":"Device Flow must be explicitly enabled for this App"}
|
解决: 去 GitHub Settings → Developer settings → OAuth Apps → 选择你的 App → 勾选 “Enable Device Flow” → Save
2. RestTemplate 在 4xx 响应时抛异常
现象: GitHub 返回 HTTP 400,但 restTemplate.postForEntity() 在读取响应体之前就抛出 HttpClientErrorException
解决: 在 initDeviceFlow() 的 catch 中先捕获 HttpClientErrorException,从 e.getResponseBodyAsString() 解析错误 JSON 体
3. SSL 握手失败(国内网络问题)
现象: 访问 https://github.com/login/oauth/access_token 时 Java 报错
1
| javax.net.ssl.SSLHandshakeException: Remote host terminated the handshake
|
原因:
- 国内访问 GitHub 网络不稳定,中间设备可能中断 TLS 连接
- 某些代理/VPN 设备对 TLS 1.3 握手兼容性不好
解决:
- 强制使用 TLSv1.2:
System.setProperty("https.protocols", "TLSv1.2")
- 添加重试机制:
getGitHubAccessToken() 最多重试 3 次,指数退避
4. 前端 Canvas ref 为 null 导致 QR 码空白
现象: 二维码区域显示了(有边框),但里面没有二维码图案
原因: 代码中 <canvas ref="qrCanvas"> 在 v-if="loading" 条件下不存在。API 返回数据后,代码先改了 deviceCode 等变量但没改 loading,执行 nextTick() 时 DOM 里还是加载状态,canvas 没渲染出来,qrCanvas.value = null
解决: 绘制 QR 码前先 loading.value = false 让 DOM 切换到二维码视图,再 await nextTick() 确保 canvas 已就绪。
5. OAuth callback 中 base64 URL 安全编码问题
现象: 前端 atob() 解码用户信息时报错
原因: 后端 Base64.getUrlEncoder() 使用了 URL 安全的 Base64(- 和 _ 替代 + 和 /),前端 atob() 不认识这种变体
解决: 解码前替换字符:
1 2
| const standardBase64 = data.user.replace(/-/g, '+').replace(/_/g, '/') const userStr = atob(standardBase64)
|
配置项
application.yml
1 2 3 4 5 6
| oauth: github: client-id: Ov23li0zJcTQdkklkswc client-secret: xxxxxxxxx redirect-uri: http://localhost:8081/shop/oauth/callback/github frontend-url: http://localhost:8082
|
相关依赖
1 2 3 4 5 6 7 8
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
|
测试指南
手动测试
- 确保 Vite 前端运行在
localhost:8082,后端运行在 localhost:8081
- 访问
http://localhost:8082/#/shop/auth
- 点击”扫码”按钮
- 页面显示二维码和
user_code
- 用手机扫描二维码,或访问
https://github.com/login/device 输入 user_code
- 在手机上确认授权
- 浏览器自动跳转到商城首页,登录成功
后端 API 直接验证
1 2 3 4 5
| curl -X POST http://localhost:8081/shop/oauth/device/init
curl "http://localhost:8081/shop/oauth/device/status?deviceCode=xxx"
|