场景设计:二维码扫码登录系统

本文讨论通用的“已登录手机为未登录 PC 授权”场景。二维码登录和 OAuth 2.0 Device Authorization Grant 思路相近,但具体实现仍应结合自身账号与会话体系。

1. 题目本质

扫码登录不是“二维码中放一个 Token,手机扫完就让电脑登录”。

它本质上是一个跨设备授权协议:

1
2
3
4
PC 发起一次临时登录事务
手机上的已认证用户确认这次事务
服务端将用户身份绑定到该事务
PC 使用一次性凭证兑换自己的正式会话

这里有三个独立主体:

  • PC 浏览器或桌面客户端:尚未登录;
  • 手机 App:已经登录;
  • 认证服务:负责状态和最终授权。

扫码只是把“PC 上的一次临时事务标识”安全地传给手机。最终身份绑定、用户确认和会话签发都必须发生在服务端。

2. 核心安全目标

系统至少要保证:

  1. 二维码不可预测;
  2. 二维码短期有效;
  3. 二维码只能成功使用一次;
  4. 扫码不等于登录成功,必须由用户明确确认;
  5. PC 最终会话不能直接包含在二维码中;
  6. 手机只能确认自己实际扫描到的那次事务;
  7. 攻击者截取二维码后不能长期重放;
  8. 二维码被替换时,用户能看到登录设备和地理风险信息;
  9. PC 轮询接口不能越权查询或领取其他二维码结果;
  10. 会话签发后应防止 Session Fixation。

3. 状态机设计

二维码登录必须有明确状态机。一个常见状态如下:

stateDiagram-v2
    [*] --> CREATED: PC 创建二维码
    CREATED --> SCANNED: 已登录手机扫码
    SCANNED --> CONFIRMED: 用户确认登录
    SCANNED --> REJECTED: 用户拒绝
    CONFIRMED --> CONSUMED: PC 兑换正式会话
    CREATED --> EXPIRED: 超时未扫码
    SCANNED --> EXPIRED: 超时未确认
    CONFIRMED --> EXPIRED: 超时未兑换
    REJECTED --> [*]
    CONSUMED --> [*]
    EXPIRED --> [*]

状态转移必须单向、原子且可审计。

推荐状态:

状态 含义
CREATED 二维码已创建,等待扫码
SCANNED 手机已扫码,等待用户确认
CONFIRMED 用户已确认,等待 PC 一次性兑换
REJECTED 用户拒绝此次登录
CONSUMED PC 已成功兑换会话,事务结束
EXPIRED 二维码或兑换凭证已过期

4. 总体架构

flowchart LR
    PC[PC 浏览器/客户端] --> GW[API Gateway]
    PHONE[已登录手机 App] --> GW

    GW --> AUTH[认证服务]
    AUTH --> QR[(二维码事务存储<br/>Redis)]
    AUTH --> USER[用户与设备服务]
    AUTH --> RISK[登录风控服务]
    AUTH --> SESSION[会话服务]
    AUTH --> AUDIT[安全审计]

    PC --> CHANNEL[轮询 / SSE / WebSocket]
    CHANNEL --> AUTH

    SESSION --> SESSION_STORE[(Session Store)]

5. 完整交互流程

sequenceDiagram
    autonumber
    participant PC as PC 客户端
    participant Auth as 认证服务
    participant Redis as 二维码事务存储
    participant Phone as 已登录手机
    participant Risk as 风控服务
    participant Session as 会话服务

    PC->>Auth: POST /qr-login/challenges
    Auth->>Auth: 生成 qrId、browserBinding、过期时间
    Auth->>Redis: 保存 CREATED 状态
    Auth-->>PC: qrContent + pollToken + expireAt
    PC->>PC: 渲染二维码

    Phone->>Auth: POST /qr-login/scan(qrContent)
    Auth->>Redis: 原子 CREATED -> SCANNED
    Auth->>Risk: 查询 PC 设备、IP、地理和风险信息
    Risk-->>Auth: 登录上下文
    Auth-->>Phone: 展示“某浏览器正在请求登录”
    Auth-->>PC: 状态变为 SCANNED

    Phone->>Auth: POST /qr-login/confirm(qrId)
    Auth->>Redis: 原子 SCANNED -> CONFIRMED,绑定 userId
    Auth-->>Phone: 确认成功

    PC->>Auth: POST /qr-login/exchange(pollToken, browserProof)
    Auth->>Redis: 原子 CONFIRMED -> CONSUMED
    Auth->>Session: 为 userId 创建新 PC 会话
    Session-->>Auth: Set-Cookie / access token
    Auth-->>PC: 登录成功

6. 二维码中应该放什么

推荐只放一个不可预测的短期 challenge URL 或 opaque token:

1
https://example.com/qr-login/scan?t=<random-opaque-token>

二维码内容不应直接包含:

  • 用户 ID;
  • PC 的正式 access token;
  • 可直接调用业务 API 的 bearer token;
  • 长期有效凭证;
  • 可被客户端自行修改并信任的设备信息;
  • 明文 sessionId。

可以包含:

  • 随机 challenge token;
  • 协议版本;
  • 可选应用标识;
  • 防止扫码器误识别的固定前缀。

所有真正状态都由服务端通过 challenge 查出。

7. Token 设计

建议至少区分三个凭证:

7.1 qr_token

放在二维码内,由手机提交。

特点:

  • 高随机性;
  • 短 TTL;
  • 只能用于扫码接口;
  • 不具备登录权限;
  • 使用后状态变为 SCANNED,但不直接消耗整个事务。

7.2 poll_token

只发给创建二维码的 PC,用于查询该事务状态或兑换。

特点:

  • 不展示在二维码中;
  • 与浏览器上下文绑定;
  • 不能用于手机确认;
  • 泄漏后也要受 browser binding 或 proof 限制。

7.3 exchange_code

用户确认后生成,或在服务端状态中隐式维护,用于 PC 一次性兑换正式会话。

特点:

  • 一次性;
  • 极短有效期;
  • 原子消费;
  • 只能在原浏览器上下文使用。

不要为了省事把三个角色合成一个万能 Token。权限过宽会使二维码泄漏直接演变为登录劫持。

8. Redis 数据结构

示例:

1
2
Key: qr:login:{qrId}
TTL: 120s

Hash 内容:

1
2
3
4
5
6
7
8
9
10
11
12
{
"status": "CREATED",
"qrTokenHash": "...",
"pollTokenHash": "...",
"browserBindingHash": "...",
"createdAt": "1785303000000",
"expiresAt": "1785303120000",
"scannedUserId": "",
"confirmedUserId": "",
"scanDeviceId": "",
"version": "1"
}

敏感 Token 建议保存哈希或 HMAC 摘要,而不是明文。

同时保留持久化审计记录:

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE qr_login_audit (
id BIGINT PRIMARY KEY,
qr_id VARCHAR(64) NOT NULL,
event_type VARCHAR(32) NOT NULL,
user_id BIGINT NULL,
pc_ip_hash VARCHAR(128) NULL,
mobile_device_id VARCHAR(128) NULL,
result VARCHAR(32) NOT NULL,
reason_code VARCHAR(64) NULL,
created_at TIMESTAMP NOT NULL
);

Redis 负责临时状态,数据库或日志平台负责审计。不要因为二维码状态有 TTL,就让安全事件也随 TTL 消失。

9. 原子状态转移

扫码和确认可能重复请求,也可能并发。状态更新必须使用原子比较并设置。

9.1 数据库写法

1
2
3
4
5
6
7
8
UPDATE qr_login_challenge
SET status = 'SCANNED',
scanned_user_id = :userId,
scanned_at = NOW(),
version = version + 1
WHERE qr_id = :qrId
AND status = 'CREATED'
AND expires_at > NOW();

9.2 Redis Lua 思路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
local status = redis.call('HGET', KEYS[1], 'status')
if not status then
return {0, 'NOT_FOUND_OR_EXPIRED'}
end

if status ~= ARGV[1] then
return {0, status}
end

redis.call('HSET', KEYS[1],
'status', ARGV[2],
'userId', ARGV[3],
'updatedAt', ARGV[4])
return {1, ARGV[2]}

调用时明确期望状态和目标状态:

1
2
3
CREATED -> SCANNED
SCANNED -> CONFIRMED
CONFIRMED -> CONSUMED

原子消费 CONFIRMED -> CONSUMED 是防止同一个二维码给多个 PC 会话重复兑换的关键。

10. PC 如何获知状态变化

有三种常见方式。

10.1 短轮询

1
PC 每 1~2 秒查询一次状态

优点:简单、容易穿过代理;缺点:大量无效请求。

优化:

  • 服务端返回建议轮询间隔;
  • 使用指数退避;
  • 二维码过期后立即停止;
  • 给状态接口限流;
  • 相同状态返回轻量结果。

10.2 长轮询

请求在服务端等待一段时间,状态变化或超时才返回。

优点:比短轮询请求少;缺点:连接管理更复杂。

10.3 SSE / WebSocket

优点:状态变化实时推送;缺点:需要连接网关、重连和补偿机制。

推荐实践:

1
SSE/WebSocket 加速通知 + 状态查询接口作为恢复兜底

推送只告诉 PC “状态变化了”,PC 仍通过受保护接口读取或兑换最终结果。不要在公共推送消息中直接携带正式会话凭证。

11. 为什么扫码后还要手机确认

如果“扫一下就登录”,攻击者可以采用二维码替换攻击:

  1. 攻击者在自己的电脑生成登录二维码;
  2. 把二维码伪装成活动码、菜单码或支付优惠码;
  3. 诱导受害者扫描;
  4. 受害者手机已登录;
  5. 攻击者电脑获得受害者账号。

所以手机必须展示清楚:

1
2
3
正在登录:Chrome on macOS
大致位置:新加坡
请求时间:2026-07-29 13:40

并要求用户点击“确认登录”。对于高风险场景还可以增加:

  • 生物识别;
  • 设备 PIN;
  • 二次口令;
  • 数字匹配;
  • 风险提示。

12. 防止二维码截图和转发攻击

二维码本身不可避免地可以被截图。系统不能假设“能扫到二维码的人一定站在电脑旁边”。

可采用多层缓解:

12.1 短 TTL

例如 60~120 秒,过期后自动刷新。

12.2 显示 PC 上下文

手机确认页展示:

  • 浏览器/客户端类型;
  • 操作系统;
  • 大致位置;
  • IP 风险;
  • 应用名称;
  • 请求时间。

12.3 数字匹配

PC 显示一个短数字,手机确认时要求核对:

1
2
PC: 4821
Phone: “确认电脑上显示的是 4821 吗?”

这能提升用户对“正在授权哪台设备”的感知。

12.4 浏览器绑定

创建二维码时,PC 生成随机 browser_secret,服务端仅保存摘要。兑换时 PC 必须证明持有该 secret:

1
browserProof = HMAC(browser_secret, qrId + nonce)

即使攻击者只窃取二维码或 poll token,没有原浏览器 secret 也不能完成兑换。

不要把浏览器绑定只做成 User-Agent 或 IP 绑定。User-Agent 可伪造,IP 也可能因 NAT、移动网络或 VPN 变化。

13. 防止 Session Fixation

二维码挑战和最终登录 Session 必须分离。

错误流程:

1
2
PC 先拿到一个固定 sessionId
手机扫码后服务端直接把这个 sessionId 标为已登录

如果攻击者提前知道或植入该 sessionId,可能劫持登录。

正确流程:

1
2
3
4
临时二维码事务完成
-> PC 使用一次性 exchange code
-> 会话服务生成全新的高熵 sessionId
-> 旧的匿名会话失效或重新绑定 CSRF 状态

最终 Cookie 应设置:

1
2
3
4
Secure
HttpOnly
SameSite=Lax/Strict(根据业务选择)
合理的 Path/Domain

并配置空闲超时、绝对超时和主动注销能力。

14. API 设计示例

14.1 创建二维码事务

1
POST /api/v1/qr-login/challenges

请求:

1
2
3
4
5
{
"clientType": "WEB",
"clientName": "Chrome on macOS",
"browserPublicKey": "optional-public-key"
}

响应:

1
2
3
4
5
6
7
{
"qrId": "ql_01K...",
"qrContent": "https://example.com/qr-login/scan?t=...",
"pollToken": "...",
"expiresAt": 1785303120000,
"pollIntervalMs": 1500
}

14.2 手机扫码

1
2
POST /api/v1/qr-login/scan
Authorization: Bearer <mobile-access-token>
1
2
3
4
{
"qrToken": "...",
"mobileDeviceId": "device-xxx"
}

响应只返回待确认的 PC 上下文,不返回 PC 会话凭证。

14.3 手机确认

1
2
3
POST /api/v1/qr-login/{qrId}/confirm
Authorization: Bearer <mobile-access-token>
Idempotency-Key: <request-id>
1
2
3
4
5
{
"action": "APPROVE",
"displayCode": "4821",
"biometricProof": "optional"
}

14.4 PC 查询状态

1
2
GET /api/v1/qr-login/{qrId}/status
Authorization: QR-Poll <pollToken>

响应:

1
2
3
4
{
"status": "SCANNED",
"expiresAt": 1785303120000
}

不要在状态接口中暴露完整用户信息。最多展示经用户许可的头像和脱敏昵称。

14.5 PC 兑换会话

1
2
POST /api/v1/qr-login/{qrId}/exchange
Authorization: QR-Poll <pollToken>
1
2
3
4
{
"browserProof": "...",
"nonce": "..."
}

服务端原子消费二维码事务,并下发全新会话。

15. 幂等语义

15.1 重复扫码

  • 同一用户、同一设备重复扫码:返回当前状态;
  • 不同用户扫描同一个已 SCANNED 二维码:拒绝,不允许覆盖第一个用户;
  • 已 CONFIRMED 后再次扫码:返回已处理;
  • 已 EXPIRED:提示刷新二维码。

15.2 重复确认

同一用户重复确认应返回相同结果。

15.3 重复兑换

第一次兑换成功后状态为 CONSUMED。后续兑换不能再次签发新会话,最多返回“已使用”。

如果第一次响应丢失,PC 是否还能恢复?有两种策略:

  1. 兑换接口本身使用 PC 幂等键,短时间内可返回相同会话结果;
  2. 第一次签发后保存一次性安全回执,原浏览器可恢复。

不能为了恢复方便,长期保留可反复兑换的 code。

16. 风控设计

可采集和判断:

  • PC IP 与手机常用地区差异;
  • 新设备登录;
  • 匿名代理、Tor、机房 IP;
  • 手机账号近期风险;
  • 同一二维码被多个设备扫描;
  • 同一 PC 短时间创建大量二维码;
  • 同一账号短时间确认多个远距离设备;
  • App 完整性和 Root/Jailbreak 风险;
  • 异常 User-Agent 或自动化行为。

风险处置:

1
2
3
低风险 -> 普通确认
中风险 -> 数字匹配/生物识别
高风险 -> 拒绝或要求密码/MFA

17. 限流设计

至少按以下维度限流:

  • IP 创建二维码频率;
  • 设备创建二维码频率;
  • qrId 状态查询频率;
  • 手机账号扫码频率;
  • 手机设备确认频率;
  • 失败兑换次数;
  • 风险网段总量。

示例:

1
2
3
同一二维码查询:不低于服务端给出的 pollInterval
同一 IP 创建二维码:滑动窗口限流
同一账号确认新设备:风险分级

限流响应不应泄漏过多内部风控规则。

18. 多机房和一致性

二维码事务生命周期短,但状态要求单向且不可重复消费。

推荐:

  • qrId 中带路由信息;
  • 同一个二维码始终路由到固定地域/分片;
  • 状态写入单主或共识存储;
  • 读取可本地化,但最终确认和兑换回源主分片;
  • 不让两个地域各自独立把同一二维码从 CONFIRMED 改为 CONSUMED。

跨地域多活不是两个机房各发一次 Session。

19. 故障场景

19.1 手机确认成功,PC 没收到推送

PC 通过轮询/重连后查询状态,仍可兑换。

19.2 Redis 故障

  • 暂停创建新二维码;
  • 已有二维码提示刷新;
  • 不应绕过状态机直接登录;
  • 若使用持久化状态表,可切换到降级存储,但要保证原子消费。

认证系统宁可暂时不可用,也不能“为了可用性默认放行”。

19.3 会话签发成功,响应丢失

使用 PC 侧幂等兑换机制恢复,不得再次任意消费 challenge。

19.4 用户确认时账号被封禁

确认和兑换阶段都应校验账号状态。不能只在扫码时校验一次。

19.5 二维码过期与确认同时发生

由原子状态转移裁决:只有一个操作能成功。客户端时间不参与最终判断,以服务端时间为准。

20. 日志与审计

记录:

  • challenge 创建;
  • 扫码;
  • 确认/拒绝;
  • 风控决策;
  • 兑换;
  • 会话签发;
  • 失败原因;
  • 设备和 IP 的脱敏标识。

不要记录:

  • 完整二维码 Token;
  • poll token;
  • exchange code;
  • 正式 access token;
  • 明文 sessionId。

可以记录哈希前缀或内部 traceId 进行关联。

21. 常见错误回答

错误一:二维码中直接放 access token

截图或日志泄漏就等于账号泄漏。

错误二:手机扫完直接登录

容易受到二维码替换和诱导扫码攻击。

错误三:只用一个 Token 做扫码、轮询和兑换

权限过大,任何一端泄漏都会扩大攻击面。

错误四:状态用普通 GET + SET 更新

并发扫码、确认、过期和兑换可能互相覆盖,应使用 CAS、Lua 或事务条件更新。

错误五:最终复用匿名 Session

可能引发 Session Fixation,登录后应签发全新会话。

错误六:只依赖 WebSocket 推送

断线会让 PC 永远停在旧状态,必须提供可恢复查询路径。

22. 面试总结话术

扫码登录是一个跨设备授权状态机。PC 先创建短期 challenge,二维码只包含不可预测的扫码 Token;手机在已登录状态下扫描,将 challenge 从 CREATED 原子推进到 SCANNED,并展示 PC 设备、位置和数字匹配信息,用户明确确认后变为 CONFIRMED。PC 持有独立的 poll token 和浏览器绑定 secret,使用一次性兑换接口把状态原子推进到 CONSUMED,再由会话服务生成全新的 Session。整个过程需要短 TTL、一次性消费、幂等、防重放、防二维码替换、Session Fixation 防护、风控和审计。SSE/WebSocket 用于加速通知,轮询查询用于恢复。

23. 延伸追问

  1. 二维码被拍照发到群里,怎样降低风险?
  2. 为什么扫码和确认必须是两个状态?
  3. PC 兑换成功但响应丢失,如何幂等恢复?
  4. 如何实现数字匹配?
  5. 手机和 PC 在不同国家时是否允许登录?
  6. 如何支持桌面客户端而不是浏览器 Cookie?
  7. Redis 主从切换导致状态回退怎么办?
  8. 怎样支持 OAuth/OIDC 体系中的扫码登录?

参考资料


场景设计:二维码扫码登录系统
https://allendericdalexander.github.io/2026/07/29/archtect/examples/04-qr-code-scan-login/
作者
AtLuoFu
发布于
2026年7月29日
许可协议