Featured image of post 扫码登录的完整设计

扫码登录的完整设计

扫码登录看似简单,背后是一套涉及前端轮询、后端状态机、长连接和安全控制的完整设计。本文讲透

写在前面

PC 浏览器扫手机端二维码自动登录——12306、知乎、京东、网易邮箱、微信网页版……这种交互在国内已经是标配。

它看着简单——但背后是一套涉及前端轮询、后端状态机、长连接、安全控制的完整设计新人接这块时常常忽略关键细节,做出来要么不安全、要么体验差。

本文从协议、状态机、API 设计、安全考虑几个层面把扫码登录讲清楚。


一、整体流程

四个核心环节:

  1. PC 请求二维码
  2. 手机扫描(标记 sceneId 状态变化)
  3. 手机确认登录(颁发 PC 端 token)
  4. PC 拿到 token 完成登录

二、状态机

sceneId 是整个流程的核心标识——它在后端经历几个状态:

为什么"扫描"和"确认"要分两步?

  • 手机扫到的是任意二维码,不一定是登录用的——必须显式确认才算同意登录
  • 防止"路边贴的恶意二维码骗你扫"——扫描后用户能看到"是否登录到 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 分钟能写完,做对要考虑安全、体验、跨实例同步十几个点。

工程要点:

  1. 状态机:PENDING → SCANNED → CONFIRMED——不要跳过 SCANNED
  2. 二维码 URL 带签名
  3. PC 端用 SSE 等状态变化
  4. 手机端必须已登录 + 显式确认
  5. sceneId UUID + 短期 TTL
  6. 跨实例用 Redis 共享状态

掌握这套,你做的"扫码登录"在体验和安全上能直接对标主流产品。

使用 Hugo 构建
主题 StackJimmy 设计