场景设计:二维码扫码登录系统
本文讨论通用的“已登录手机为未登录 PC 授权”场景。二维码登录和 OAuth 2.0 Device Authorization Grant 思路相近,但具体实现仍应结合自身账号与会话体系。
1. 题目本质
扫码登录不是“二维码中放一个 Token,手机扫完就让电脑登录”。
它本质上是一个跨设备授权协议:
1 | |
这里有三个独立主体:
- PC 浏览器或桌面客户端:尚未登录;
- 手机 App:已经登录;
- 认证服务:负责状态和最终授权。
扫码只是把“PC 上的一次临时事务标识”安全地传给手机。最终身份绑定、用户确认和会话签发都必须发生在服务端。
2. 核心安全目标
系统至少要保证:
- 二维码不可预测;
- 二维码短期有效;
- 二维码只能成功使用一次;
- 扫码不等于登录成功,必须由用户明确确认;
- PC 最终会话不能直接包含在二维码中;
- 手机只能确认自己实际扫描到的那次事务;
- 攻击者截取二维码后不能长期重放;
- 二维码被替换时,用户能看到登录设备和地理风险信息;
- PC 轮询接口不能越权查询或领取其他二维码结果;
- 会话签发后应防止 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 | |
二维码内容不应直接包含:
- 用户 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 | |
Hash 内容:
1 | |
敏感 Token 建议保存哈希或 HMAC 摘要,而不是明文。
同时保留持久化审计记录:
1 | |
Redis 负责临时状态,数据库或日志平台负责审计。不要因为二维码状态有 TTL,就让安全事件也随 TTL 消失。
9. 原子状态转移
扫码和确认可能重复请求,也可能并发。状态更新必须使用原子比较并设置。
9.1 数据库写法
1 | |
9.2 Redis Lua 思路
1 | |
调用时明确期望状态和目标状态:
1 | |
原子消费 CONFIRMED -> CONSUMED 是防止同一个二维码给多个 PC 会话重复兑换的关键。
10. PC 如何获知状态变化
有三种常见方式。
10.1 短轮询
1 | |
优点:简单、容易穿过代理;缺点:大量无效请求。
优化:
- 服务端返回建议轮询间隔;
- 使用指数退避;
- 二维码过期后立即停止;
- 给状态接口限流;
- 相同状态返回轻量结果。
10.2 长轮询
请求在服务端等待一段时间,状态变化或超时才返回。
优点:比短轮询请求少;缺点:连接管理更复杂。
10.3 SSE / WebSocket
优点:状态变化实时推送;缺点:需要连接网关、重连和补偿机制。
推荐实践:
1 | |
推送只告诉 PC “状态变化了”,PC 仍通过受保护接口读取或兑换最终结果。不要在公共推送消息中直接携带正式会话凭证。
11. 为什么扫码后还要手机确认
如果“扫一下就登录”,攻击者可以采用二维码替换攻击:
- 攻击者在自己的电脑生成登录二维码;
- 把二维码伪装成活动码、菜单码或支付优惠码;
- 诱导受害者扫描;
- 受害者手机已登录;
- 攻击者电脑获得受害者账号。
所以手机必须展示清楚:
1 | |
并要求用户点击“确认登录”。对于高风险场景还可以增加:
- 生物识别;
- 设备 PIN;
- 二次口令;
- 数字匹配;
- 风险提示。
12. 防止二维码截图和转发攻击
二维码本身不可避免地可以被截图。系统不能假设“能扫到二维码的人一定站在电脑旁边”。
可采用多层缓解:
12.1 短 TTL
例如 60~120 秒,过期后自动刷新。
12.2 显示 PC 上下文
手机确认页展示:
- 浏览器/客户端类型;
- 操作系统;
- 大致位置;
- IP 风险;
- 应用名称;
- 请求时间。
12.3 数字匹配
PC 显示一个短数字,手机确认时要求核对:
1 | |
这能提升用户对“正在授权哪台设备”的感知。
12.4 浏览器绑定
创建二维码时,PC 生成随机 browser_secret,服务端仅保存摘要。兑换时 PC 必须证明持有该 secret:
1 | |
即使攻击者只窃取二维码或 poll token,没有原浏览器 secret 也不能完成兑换。
不要把浏览器绑定只做成 User-Agent 或 IP 绑定。User-Agent 可伪造,IP 也可能因 NAT、移动网络或 VPN 变化。
13. 防止 Session Fixation
二维码挑战和最终登录 Session 必须分离。
错误流程:
1 | |
如果攻击者提前知道或植入该 sessionId,可能劫持登录。
正确流程:
1 | |
最终 Cookie 应设置:
1 | |
并配置空闲超时、绝对超时和主动注销能力。
14. API 设计示例
14.1 创建二维码事务
1 | |
请求:
1 | |
响应:
1 | |
14.2 手机扫码
1 | |
1 | |
响应只返回待确认的 PC 上下文,不返回 PC 会话凭证。
14.3 手机确认
1 | |
1 | |
14.4 PC 查询状态
1 | |
响应:
1 | |
不要在状态接口中暴露完整用户信息。最多展示经用户许可的头像和脱敏昵称。
14.5 PC 兑换会话
1 | |
1 | |
服务端原子消费二维码事务,并下发全新会话。
15. 幂等语义
15.1 重复扫码
- 同一用户、同一设备重复扫码:返回当前状态;
- 不同用户扫描同一个已 SCANNED 二维码:拒绝,不允许覆盖第一个用户;
- 已 CONFIRMED 后再次扫码:返回已处理;
- 已 EXPIRED:提示刷新二维码。
15.2 重复确认
同一用户重复确认应返回相同结果。
15.3 重复兑换
第一次兑换成功后状态为 CONSUMED。后续兑换不能再次签发新会话,最多返回“已使用”。
如果第一次响应丢失,PC 是否还能恢复?有两种策略:
- 兑换接口本身使用 PC 幂等键,短时间内可返回相同会话结果;
- 第一次签发后保存一次性安全回执,原浏览器可恢复。
不能为了恢复方便,长期保留可反复兑换的 code。
16. 风控设计
可采集和判断:
- PC IP 与手机常用地区差异;
- 新设备登录;
- 匿名代理、Tor、机房 IP;
- 手机账号近期风险;
- 同一二维码被多个设备扫描;
- 同一 PC 短时间创建大量二维码;
- 同一账号短时间确认多个远距离设备;
- App 完整性和 Root/Jailbreak 风险;
- 异常 User-Agent 或自动化行为。
风险处置:
1 | |
17. 限流设计
至少按以下维度限流:
- IP 创建二维码频率;
- 设备创建二维码频率;
qrId状态查询频率;- 手机账号扫码频率;
- 手机设备确认频率;
- 失败兑换次数;
- 风险网段总量。
示例:
1 | |
限流响应不应泄漏过多内部风控规则。
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. 延伸追问
- 二维码被拍照发到群里,怎样降低风险?
- 为什么扫码和确认必须是两个状态?
- PC 兑换成功但响应丢失,如何幂等恢复?
- 如何实现数字匹配?
- 手机和 PC 在不同国家时是否允许登录?
- 如何支持桌面客户端而不是浏览器 Cookie?
- Redis 主从切换导致状态回退怎么办?
- 怎样支持 OAuth/OIDC 体系中的扫码登录?
参考资料
- RFC 8628 OAuth 2.0 Device Authorization Grant: https://datatracker.ietf.org/doc/html/rfc8628
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- NIST SP 800-63B: https://pages.nist.gov/800-63-4/sp800-63b.html