写在前面
PC 浏览器扫手机端二维码自动登录——12306、知乎、京东、网易邮箱、微信网页版……这种交互在国内已经是标配。
它看着简单——但背后是一套涉及前端轮询、后端状态机、长连接、安全控制的完整设计。新人接这块时常常忽略关键细节,做出来要么不安全、要么体验差。
本文从协议、状态机、API 设计、安全考虑几个层面把扫码登录讲清楚。
一、整体流程
sequenceDiagram
participant Browser as PC 浏览器
participant Server as 后端
participant Phone as 手机 App
Browser->>Server: GET /qr/create
Server->>Server: 生成 sceneId + 二维码
Server-->>Browser: { sceneId, qrUrl, expiresIn }
Browser->>Browser: 展示二维码
Browser->>Server: 长轮询 / SSE 等待状态
Phone->>Phone: 扫描二维码
Phone->>Server: POST /qr/scan { sceneId, token }
Server->>Server: 标记 sceneId 为 SCANNED
Server-->>Browser: SCANNED(手机已扫,等确认)
Phone->>Server: POST /qr/confirm { sceneId, token }
Server->>Server: 标记 sceneId 为 CONFIRMED + 颁发 PC 端 token
Server-->>Browser: CONFIRMED + token
Browser->>Browser: 设置 cookie/storage 跳转登录页四个核心环节:
- PC 请求二维码
- 手机扫描(标记 sceneId 状态变化)
- 手机确认登录(颁发 PC 端 token)
- PC 拿到 token 完成登录
二、状态机
sceneId 是整个流程的核心标识——它在后端经历几个状态:
stateDiagram-v2
[*] --> PENDING: 创建二维码
PENDING --> SCANNED: 手机扫描
SCANNED --> CONFIRMED: 手机确认
SCANNED --> CANCELLED: 手机取消
PENDING --> EXPIRED: 超时
SCANNED --> EXPIRED: 超时
CONFIRMED --> [*]: PC 拿到 token
CANCELLED --> [*]
EXPIRED --> [*]为什么"扫描"和"确认"要分两步?
- 手机扫到的是任意二维码,不一定是登录用的——必须显式确认才算同意登录
- 防止"路边贴的恶意二维码骗你扫"——扫描后用户能看到"是否登录到 xxx 网站"的确认页
存储用 Redis 自然——key 是 sceneId,value 是 status + 关联数据,TTL 5 分钟。
三、API 设计
1. 创建二维码
1
2
| GET /api/qr/create
Response: { sceneId, qrPayload, expireSeconds }
|
qrPayload 是二维码内容——通常包含:
1
| https://your-app.com/qrlogin?scene=<sceneId>&t=<timestamp>&sign=<signature>
|
带签名防止伪造——后端校验签名才允许处理。
2. PC 端轮询/订阅状态
方式 A:长轮询
1
2
| GET /api/qr/wait?sceneId=xxx&from=PENDING
(后端阻塞,直到状态变化或 30 秒超时返回)
|
PC 端循环调用,每次带上"我现在知道的状态"——后端只在状态变化时返回。
方式 B:SSE(推荐)
1
2
3
4
5
6
7
8
9
10
11
| GET /api/qr/stream?sceneId=xxx
Accept: text/event-stream
event: status
data: {"status": "PENDING"}
event: status
data: {"status": "SCANNED"}
event: status
data: {"status": "CONFIRMED", "token": "..."}
|
SSE 比长轮询省连接、省服务端线程,强烈推荐。
方式 C:WebSocket
适合"长连接 + 双向通信"场景——但做扫码登录是杀鸡用牛刀。
3. 手机扫描
1
2
3
| POST /api/qr/scan
Body: { sceneId }
Headers: Authorization: Bearer <user_token>
|
手机已登录用户扫码后,App 调这个接口——后端把 sceneId 标记 SCANNED,记录是哪个 user 扫的。
返回内容包括"PC 端要登录哪个账号"信息,让 App 显示二次确认页面:
1
2
| 是否使用账号 zhangsan@example.com 登录到 PC 端?
[确认] [取消]
|
4. 手机确认
1
2
| POST /api/qr/confirm
Body: { sceneId }
|
后端:
- 校验 sceneId 状态是 SCANNED
- 校验当前 user 和扫描时的 user 一致
- 颁发 PC 端 token
- 把 sceneId 状态改为 CONFIRMED + 关联 token
PC 端的 SSE 收到 CONFIRMED 状态 + token,完成登录。
5. 手机取消
1
2
| POST /api/qr/cancel
Body: { sceneId }
|
把 sceneId 状态改为 CANCELLED——PC 端收到通知后展示"已取消"。
四、安全考虑
扫码登录是登录入口——安全比体验更重要。
1. sceneId 必须不可猜
用 UUID v4 或 nanoid——不要用自增 ID。攻击者枚举 sceneId 可能尝试劫持别人的登录会话。
2. 二维码 URL 带签名
1
| https://app.com/qrlogin?scene=xxx&t=1700000000&sign=hmac(secret, scene+t)
|
App 扫码后先校验签名——拒绝伪造的二维码。
3. PC 端和手机要校验"会话一致性"
PC 端轮询时带一个浏览器随机 token——手机确认时如果发现 token 不一致,说明可能被中间人劫持。
1
2
3
4
5
6
7
8
9
10
| PC: GET /qr/create
Browser生成 browserNonce 存 cookie
qrPayload 中包含 browserNonce 的 hash
Phone: 扫码后 POST /qr/scan
后端记录 nonce hash
PC: GET /qr/stream
带上 browserNonce
后端校验 nonce hash 一致才返回 token
|
4. 二维码必须短时间过期
5 分钟内不被扫描就 EXPIRED——避免有人贴在公共场所"骗扫"。
5. 已扫描状态也要超时
SCANNED 状态如果 1 分钟内没确认 → EXPIRED——避免"用户扫了但没确认就走了,攻击者后续接管"。
6. 手机端必须已登录
扫描接口要求用户已登录——不允许游客状态扫码。否则任何人扫了都能"以游客身份登录 PC 端",毫无意义。
7. 防 CSRF
PC 端的 sceneId 是会话级的——一个 sceneId 只能绑定一个浏览器 session。换浏览器轮询同一个 sceneId 应该被拒绝。
8. 异地登录提醒
PC 端登录成功后发送邮件/短信通知用户——异地登录是常见的"安全感"配置。
五、UX 上的几个细节
1. 扫描后的状态展示
UX 不要只显示"等待扫描"和"登录成功"——SCANNED 状态要专门展示:
1
2
3
| [二维码图] [二维码灰色 + 中央对勾] [跳转中...]
请扫码登录 扫描成功,请在手机上确认 登录成功
|
让用户知道"手机端在做什么",不会以为系统卡住。
2. 二维码刷新
5 分钟过期后自动刷新——而不是显示"过期请重新打开"。
3. 手机和 PC 一定不能在同一浏览器
PC 浏览器扫描 PC 浏览器二维码——必须拒绝。手机端 App 才有资格扫——通过 User-Agent 简单判断。
4. 取消的反馈
用户在手机上"取消"后——PC 应该立刻看到"已取消",并提供"重新生成二维码"按钮。
六、技术实现要点
1. SSE 比长轮询好
1
2
3
4
5
6
7
8
9
10
11
12
| const evt = new EventSource(`/api/qr/stream?sceneId=${sceneId}`);
evt.addEventListener('status', e => {
const data = JSON.parse(e.data);
if (data.status === 'CONFIRMED') {
localStorage.setItem('token', data.token);
location.href = '/dashboard';
evt.close();
}
});
evt.onerror = () => {
// 重连
};
|
记得在 nginx 配置 proxy_buffering off,否则消息要等 buffer 满才推送。
2. 后端用 Redis 维护状态
1
2
3
| String sceneId = UUID.randomUUID().toString();
SceneState state = new SceneState("PENDING", null, System.currentTimeMillis());
redis.setex("qr:" + sceneId, 300, JSON.toJSONString(state));
|
状态变更用 Lua 脚本保证原子性:
1
2
3
4
5
6
7
8
9
10
| local key = KEYS[1]
local expectStatus = ARGV[1]
local newStatus = ARGV[2]
local current = redis.call('GET', key)
if current == false then return -1 end
local state = cjson.decode(current)
if state.status ~= expectStatus then return 0 end
state.status = newStatus
redis.call('SETEX', key, 300, cjson.encode(state))
return 1
|
3. 跨进程通信
后端可能多实例——手机端命中实例 A,PC 端命中实例 B。状态变更需要广播:
- 简单:后端只读 Redis 状态——所有实例数据一致
- 进阶:用 Redis Pub/Sub 主动推送实例 B 触发 SSE
4. 二维码生成
后端不一定要生成图片——返回二维码内容字符串,前端用 qrcode.js 渲染:
1
2
| import QRCode from 'qrcode';
QRCode.toCanvas(canvasEl, qrPayload);
|
省了后端 PNG 编码、节省带宽。
七、移动 App 端的实现要点
1. 调用系统相机扫码
iOS / Android 都有原生 API——读取二维码内容。
2. 内容解析
1
2
3
4
5
6
7
8
| // 二维码内容
https://app.com/qrlogin?scene=abc&t=...&sign=...
// App 端:
- 校验是不是自家域名
- 校验签名
- 校验时间(防重放)
- 调用 /qr/scan 接口
|
如果二维码不是自家——不要打开浏览器跳转 URL,那等于把扫码结果给到不可信的网站。
3. 显示确认页面
不要扫描后直接跳过——展示明确的"是否登录到 PC?“对话框,用户主动点确认才下一步。
4. 异常处理
- 网络断开 → 提示重试
- 二维码过期 → 提示用户刷新 PC 端
- 重复扫描 → 提示"该二维码已被扫描”
八、扩展场景
1. 关注公众号即登录(微信)
二维码生成时带上"场景值"——用户扫码并关注公众号后,公众号收到事件回调——等同于"扫描+确认"一次完成。
适合"既要登录又要涨粉"的场景。
2. 跨端会话同步
PC 扫码登录后,手机端是不是也算"已登录"? 通常是——扫码本身就证明了用户身份。
3. 桌面客户端扫码
很多桌面 App(如 Microsoft Teams、Slack)也支持二维码登录——逻辑和 Web 完全相同。
4. 离线扫码
部分场景下手机断网时扫描——可以先在本地校验签名 + 缓存意图,联网后再上报后端。
小结
把全文压一句:
扫码登录的核心是『二维码 + sceneId + 状态机 + 双端协同』——看似 5 分钟能写完,做对要考虑安全、体验、跨实例同步十几个点。
工程要点:
- 状态机:PENDING → SCANNED → CONFIRMED——不要跳过 SCANNED
- 二维码 URL 带签名
- PC 端用 SSE 等状态变化
- 手机端必须已登录 + 显式确认
- sceneId UUID + 短期 TTL
- 跨实例用 Redis 共享状态
掌握这套,你做的"扫码登录"在体验和安全上能直接对标主流产品。