news 2026/10/31 3:48:14

扫码登录全链路解析:二维码票据、状态机与安全边界

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
扫码登录全链路解析:二维码票据、状态机与安全边界

1. 扫码登录解决的到底是什么问题

1.1 用户不是记不住密码,是不愿意在键盘上敲

做过PC端登录改造的人大概都遇过这个场景:用户在手机App里天天登录着,一切到电脑前,就得重新回忆那串大小写混合、带特殊符号、还要求不能和上次重复的密码。输错三次触发验证码,验证码看不清再刷新,刷新完发现密码又锁了。绝大多数"找回密码"工单,本质上不是用户忘了密码,而是用户压根不想在键盘上打密码。二维码扫描登录就是从这个痛点里长出来的方案:把身份认证的动作,从"输入凭证"迁移到"已登录设备的一次确认"。用户手机上本来就有一个已经完成认证的会话,PC端只需要借用这个会话的信任,就完成了登录。

这个方案的价值不只是体验。从安全角度看,扫码登录把密码输入这个高风险动作从PC端彻底移除了,键盘记录器、钓鱼页面伪造表单这类攻击手法直接失效,因为PC端从头到尾没有出现过任何密码。从产品角度看,它把用户从"账号密码体系"推到"设备账号体系",为后面的多端同步、设备管理、安全风控都留了口子。这篇文章想讲清楚的就是这套机制背后的完整链路:二维码里到底装了什么、服务端维护了一个什么样的状态机、手机端和PC端各自扮演什么角色、为什么主流实现都选择了相似的分工方式,以及我在这类系统里踩过的那些坑。

这篇文章适合三类人看。第一类是正在给自家产品做PC端登录改造的后端和前端工程师,想知道轮询接口该怎么设计、状态枚举怎么定;第二类是对身份认证体系感兴趣、想搞明白"扫一下就这么登录了"背后逻辑的技术爱好者;第三类是做安全测试或者架构评审的同学,关心这套流程有哪些可被利用的边界。我会按"设计思路—核心机制—落地代码—安全边界—故障排查"的顺序往下走,中间会给出可以直接抄的参数表和代码骨架。

1.2 一次扫码涉及三个角色、两条信道

先把参与方摆清楚,后面所有的设计选择都是围绕这三个角色之间的关系展开的。

第一个角色是待登录设备,通常是一台浏览器打开的PC页面。它的特点是显示能力好、输入能力差、本身没有任何已认证的会话。它是整个流程的发起方,也是最终要拿到登录态的一方。

第二个角色是已登录设备,通常是用户手里那台装着App的手机。它的特点是已经持有有效的身份凭证、有摄像头、有生物识别或锁屏密码可以做二次确认。它是整个流程的授权方。

第三个角色是认证服务端,它夹在两者中间,负责生成临时票据、维护会话状态、校验双方身份、最终签发正式令牌。它是唯一同时信任两端的角色,也是整个流程可信性的根基。

三个角色之间其实只走两条信道。一条是PC端与服务端之间的信道,走的是浏览器环境,能力受限但可控,用来拉取二维码、轮询状态、最终换取令牌。另一条是手机端与服务端之间的信道,走的是App内已经鉴权的接口,用来上报扫描事件和确认事件。注意,手机端和PC端之间没有任何直接通信,所有的信息交换都必须经过服务端中转。这一点非常关键,它保证了整个流程的可审计性——每一次状态跃迁都在服务端留下了记录,任何异常都能被追溯。

理解了这个"三方两信道"的结构,后面所有的设计就有了依据:为什么二维码要有有效期?因为第一条信道是不可信环境,票据暴露在公网上。为什么手机端要二次确认?因为扫描动作本身不能等同于授权,手机可能在包里被误触发。为什么PC端拿到的是令牌而不是用户密码?因为认证服务端只应该下发最小权限的凭证。这些设计决策都不是拍脑袋定的,而是从信道信任模型里推导出来的。

1.3 和传统登录方式比,它换掉了哪一环

把扫码登录和账号密码登录摆在一起对比,会看得更清楚。传统的账号密码登录,本质是"你知道什么"(something you know),凭证是静态的、可被复制的、可被猜测的。短信验证码登录是"你拥有什么"(something you have),凭证动态但依赖运营商通道,有延迟、有成本、有被拦截风险。而扫码登录严格来说不是一种新的认证因子,它是把一次已有的强认证会话,通过一次物理邻近性证明,扩展到另一台设备上。

这里的"物理邻近性"是它最微妙也最有价值的部分。用户能在手机上看到二维码并完成扫描,这个动作隐含了一个前提:这台手机和这台电脑在物理空间上足够接近,接近到人可以同时操作两者。这个隐含前提在绝大多数正常场景下成立,也正好构成了它的安全边界——如果一个攻击者拿到了二维码截图,但人不在旁边,他就无法完成"手机上确认"这一步。当然这个边界是有漏洞的,后面第 4 章会展开讲。

从工程实现看,扫码登录还有一个隐性优势:它天然支持多端会话管理。传统登录方式里,服务端很难知道"这个人现在登录了几台设备",只能靠额外的设备指纹去猜。而扫码登录的每一次授权都是一条明确的服务端记录,哪台设备、什么时间、通过哪台手机授权的,全部清清楚楚。这对后续做异地登录提醒、设备下线、异常行为分析都是现成的数据基础。

2. 流程拆解:一次扫码背后的状态机

2.1 二维码里装的不是账号,是一个临时票据

很多人第一反应会以为二维码里编码的是用户ID或者加密后的密码,这个理解是错的。二维码里装的通常就是一个短生命周期的随机票据标识,业界习惯叫 ticket、qrId 或者 sceneId。它的长度一般是 32 位左右的随机字符串,字符集用 base62 或者十六进制,目的是让熵足够大、无法被枚举,同时让二维码的图形密度保持在手机摄像头容易识别的范围内。

二维码承载的内容一般有两种形态。一种是纯标识型,二维码里就是一串随机串,手机端扫到之后直接把它当作参数去打服务端接口;另一种是带地址型,二维码里是一个完整的短链接,形如{协议}://{域名}/qr/authorize?ticket=xxxxxxxx,手机端扫码后既可以用内置的扫码器解析,也可以走链接跳转拉起指定的App。后者的好处是兼容性更好,用户用系统相机扫也能识别,能通过链接直接拉起App;坏处是二维码内容变长,图形变密,对低端机摄像头的识别成功率不友好。

我自己的选择是带地址但不带业务信息。二维码容量上限撑死也就不到 3KB,没必要塞复杂内容,把票据放在 query 参数里就够。业务信息、过期时间、签名这些全部由服务端在收到 ticket 之后去查,客户端拿到的永远只是"一个可以拿去问服务端的凭证"。这样做的直接好处是票据可以被随时作废——服务端删掉这条记录,二维码立刻就失效,不需要考虑已经印出去的二维码怎么回收。

票据本身的生成有几点要注意。第一,必须用密码学安全的随机源,别用时间戳或者普通的伪随机数,前者可预测,后者在种子泄露的情况下可推测。第二,必须全局唯一且不可复用,最好在存储层用唯一索引兜底,避免并发下生成出重复票据。第三,票据本身不携带任何用户信息,哪怕被截获,攻击者也只知道"有这么一张票",不知道票属于谁、会登录到哪个账号。这三条看起来是常识,但我在代码评审里见过太多用System.currentTimeMillis()拼一个随机数就当作票据的实现。

2.2 完整状态流转:五个状态、四次跃迁

扫码登录的核心就是一个状态机。这是整个系统里最值得画清楚的部分,因为所有的接口设计、前端展示、异常处理都是从它推导出来的。一个典型的状态集合是这样:

状态值状态名触发方含义
0WAITING服务端生成票据时二维码已下发,等待被扫描
1SCANNED手机端上报扫描已被某台手机扫到,但用户尚未确认
2CONFIRMED手机端上报确认用户已在手机上点了同意,PC端可换取令牌
3EXPIRED服务端定时/懒过期超过有效期,票据作废
4CANCELED手机端或PC端主动取消用户拒绝或被主动作废

状态跃迁的路径只有四条:WAITING → SCANNED、SCANNED → CONFIRMED、WAITING/SCANNED → EXPIRED、WAITING/SCANNED → CANCELED。已经到达 CONFIRMED 的票据绝对不能再回退,这是防重放的第一道闸门。同样,EXPIRED和CANCELED是终态,任何针对终态票据的写操作都应该被拒绝并记录一条审计日志——这条日志在排查异常时非常有用,它能告诉你"有人在尝试用一个已经消费过的票据"。

这里有个容易忽略的细节:SCANNED 状态不等于授权。很多早期实现图省事,手机扫到就直接把状态置为 CONFIRMED,PC端立刻登录成功。这等于把"扫码"这个动作直接当成了"同意",用户掏出手机只是想看看二维码里是什么,结果PC端啪一下登进去了。正确的做法是扫描归扫描、确认归确认,中间必须有一次显式的用户操作,而且这次操作要有足够强的意图表达——比如按钮上写清楚"登录XXX账号",而不是一个模糊的"确定"。

在存储层面,这个状态机最适合放进带 TTL 的键值存储里。key 用qr:ticket:{ticket},value 存一个精简结构,包含状态、创建时间、扫描设备的标识、确认用户的ID、以及一个随机生成的 PC 侧密钥。TTL 建议设在 120 到 300 秒之间,具体取值后面 3.1 会算。用 TTL 而不是定时任务扫过期,好处是不需要维护一个额外的调度器,坏处是过期是被动的——如果一张票在过期前一刻被确认了,你得保证在消费它的接口里做一次时间校验,别只依赖存储层的 TTL。

2.3 为什么分工是PC轮询、手机确认,而不是反过来

这条分工看起来理所当然,但背后的逻辑值得说清楚,因为它解释了为什么这套方案在浏览器环境里能跑通。

PC端之所以只能轮询,是因为浏览器拿不到服务端的主动推送。严格来说浏览器有 WebSocket、有 SSE、有 Server-Sent Events,但它们的兼容性和穿透性在企业内网、老旧浏览器、代理环境下都不够稳。而扫码登录这个场景的特点是低频、短时、用户在场——一个用户从打开登录页到扫码完成,平均也就十几秒,他不介意等。所以在"实现复杂度"和"实时性"之间,绝大多数团队选择了实现更简单、兼容性更好的轮询。

手机端之所以必须确认,是因为它是唯一可信的角色。手机上有已认证的会话、有锁屏密码或生物识别作为最后一道门。让手机承担"授权"动作,等于把这套系统里最敏感的决定权交给了一个本身已经受保护的设备。这个设计还有个副作用:授权动作天然带有用户在场的证明。用户必须在手机上亲手点一下,这一点的法律意义和审计意义都比"系统自动完成"要强得多。

还有一层考虑是风险的隔离。PC端是一个不可信环境——可能有恶意插件、可能被投屏、可能被人旁观。所以PC端在确认完成之前,拿到的信息应该尽可能少:只知道"还没被扫"、"被扫了但没确认"、"确认了可以拿了",仅此而已。它不应该知道是哪个账号扫的,不应该知道授权设备的型号,更不应该知道用户的其他信息。信息越少,被侧信道利用的空间就越小。这个原则在接口设计上体现得很直接:轮询接口的返回体里,账号信息只应该在 CONFIRMED 之后的换取令牌接口里出现,而且是加密之后作为令牌下发的。

3. 落地实现:从生成票据到拿到登录态

3.1 票据参数设计:有效期到底该设多少

参数取值不能拍脑袋,得从用户行为和攻击窗口两头夹。先看用户行为:一个正常的扫码流程,用户看到二维码、掏出手机、解锁、打开App、点扫码、对准、识别、确认,这个链路在熟练用户身上大约 8 到 15 秒,在不熟练用户身上可能 30 到 60 秒。再加上可能出现的识别失败重扫、App 冷启动、网络抖动,合理的上限应该在 2 分钟左右。

再看攻击窗口:票据的有效期越长,一张被偷拍的二维码可被利用的时间就越长。假设有效期内攻击者有概率拿到这张图,那有效期就是攻击窗口的长度。从这个角度,越短越好。

两头夹下来,120 秒是一个比较平衡的取值,比较激进的实现会用 60 秒,保守一点的用 300 秒。我给的建议是分层:默认 120 秒,如果检测到异常(同一 IP 段短时间内大量生成二维码、或者票据被扫描后长时间不确认)则动态缩短到 60 秒。前端展示的倒计时应该比服务端 TTL 少 5 到 10 秒,留一个缓冲,避免用户看到"还剩 1 秒"去扫,扫完发现接口返回过期。

轮询频率也要算。假设前端每 2 秒轮询一次,一个 120 秒的会话最多产生 60 次请求。这个数字本身不大,但乘以并发量就很可观了。我按一个中等规模的场景算一下:假设峰值每秒新增 2000 个登录会话,平均每个会话持续 40 秒就被完成或放弃,那稳态并发会话数是 2000 × 40 = 8 万,轮询接口的 QPS 就是 8 万 / 2 = 4 万。这个量级对一个普通业务的后端来说已经不算轻松了,如果每个请求还要查一次数据库,基本就崩了。

所以三个优化是必须做的:第一,状态查询走缓存不走数据库,票据本身就该放在内存型存储里,过期自动清理。第二,在 WAITING 状态用低频轮询、在 SCANNED 状态切到高频,因为用户扫完之后是最焦急的时候,需要立刻给他反馈,而扫之前他不着急。具体做法是前端在 WAITING 时 3 秒一次,收到 SCANNED 后改成 1 秒一次。第三,如果条件允许,用长轮询替代短轮询,服务端 hold 住请求最多 30 秒,状态一变立刻返回,这样 QPS 能从 4 万降到大约 1300(8 万 / 30 秒 × 长连接平均提前返回系数,实际在 2000 到 3000 之间)。这个数量级的差距,在真实业务里就是"要不要加机器"的区别。

3.2 三种获取结果的方式,各有各的适用场景

短轮询、长轮询、WebSocket 推送,这三种方式在扫码登录里都有人用,选型主要看你的基础设施和并发规模。

短轮询是最容易实现的:前端setInterval每 2 秒打一次状态查询接口,服务端直接读缓存返回当前状态。优点是实现零成本、排查零成本、对网关和负载均衡完全透明。缺点是请求量大、有无用请求(状态没变也照打)、实时性有最多 2 秒的延迟。适合日活几万以内、或者内部系统这种量级。

长轮询是短轮询的优化版:请求打过来之后,服务端不立刻返回,而是挂起 20 到 30 秒,期间如果状态发生跃迁就立即返回,超时未变则返回一个空结果让前端重连。它的实现要点是必须用异步非阻塞的方式挂起请求,千万别用Thread.sleep占着线程池的线程,一个 200 线程的池子在长轮询下能撑住的并发连 2000 都不到。用 Servlet 3.0 的异步支持,或者响应式框架的Mono/Flux,才是正确姿势。另外要注意网关的超时配置必须大于长轮询的 hold 时间,否则网关先断,前端会收到一堆 504。

WebSocket是实时性最好的:二维码生成的时候就把连接建好,服务端状态一变直接推。优点是零轮询、延迟最低、还能顺带做心跳检测用户是否还在页面上。缺点是连接要保持,对连接数敏感,而且需要网关支持协议升级,某些企业代理环境下会被拦。还有一个隐性成本:连接断开的检测和处理,用户切到别的标签页、笔记本合盖、网络切换,都会导致连接断开,前端必须有重连逻辑,而重连过程中可能错过一次状态推送,所以还得保留一个兜底的查询接口。我个人的倾向是:首次接入用短轮询,量上来之后升级到长轮询,WebSocket 只在连接数可控且已有基础设施的情况下才上。

3.3 手机端确认与令牌签发:一步都不能省

手机端的动作拆成两步,第一步是扫描上报,第二步是用户确认。这两步都不能省,而且第二步必须带用户身份。

扫描上报接口的职责很轻:接收 ticket,校验票据存在且状态是 WAITING,把状态改成 SCANNED,记录扫描者的标识和扫描时间,返回一个"你正在登录 XXX 账号,是否确认"的提示页。注意这里返回的账号信息是给手机端自己看的,不能透传给 PC 端。同时这个接口要带上频率限制,防止有人写脚本批量扫描票据去做状态探测。

确认接口的职责就重了:它要校验当前用户是否有权授权这张票、票据是否还在有效期内、状态是否确实是 SCANNED、这张票是否已经被确认过。四个条件全过之后,才把状态改成 CONFIRMED,并写入授权用户 ID,同时生成一个一次性的兑换码。PC 端拿到 CONFIRMED 状态后,用 ticket 加上自己在生成时拿到的那个 PC 侧密钥,去调换取令牌的接口,服务端校验两者匹配,才下发正式的访问令牌。

这里的关键设计是两段式兑换。PC 端在生成二维码时,除了拿到 ticket,还会拿到一个只能自己知道的 secret(存在内存或者 sessionStorage 里,不放进二维码)。确认之后,PC 端必须同时提供 ticket 和 secret 才能换到令牌。这个设计防的是"票据被偷看"——就算攻击者通过某种方式看到了二维码里的 ticket,他也没有 secret,换不到令牌。代价是刷新页面之后 secret 丢了,二维码得重新生成,但这个代价可以接受。

还有一个必须做的动作:票据的一次性消费。换取令牌成功后,立刻把这条票据记录标记为已消费或者直接删除。这样即使 ticket 和 secret 同时泄露,也只能用一次。我给的做法是在换取接口里先做一次原子性的"取并删"操作,比如 Redis 的GETDEL,或者用 Lua 脚本保证读和删的原子性。别用先 GET 再 DELETE 两步,中间有并发窗口。

3.4 代码骨架:服务端三个接口的最小实现

下面这段是基于常见实践写的服务端骨架,用 Java 表达,重点看状态校验的顺序和原子性处理,不要照抄框架细节。

// 1. 生成二维码票据 @PostMapping("/qr/generate") public QrGenerateResp generate() { String ticket = RandomUtil.base62(32); // 密码学安全随机 String secret = RandomUtil.base62(32); // PC 侧私有密钥 QrSession s = new QrSession(); s.setTicket(ticket); s.setSecret(secret); s.setState(WAITING); s.setCreateAt(now()); // TTL 120 秒,value 存整个会话对象 redis.opsForValue().set("qr:ticket:" + ticket, s, Duration.ofSeconds(120)); // 二维码内容只放 ticket,不放 secret String content = qrBaseUrl + "?ticket=" + ticket; return new QrGenerateResp(ticket, secret, content, 120); } // 2. PC 端轮询状态(只返回状态,不返回账号信息) @GetMapping("/qr/state") public QrStateResp state(@RequestParam String ticket) { QrSession s = redis.opsForValue().get("qr:ticket:" + ticket); if (s == null) return QrStateResp.expired(); return QrStateResp.of(s.getState()); // 0/1/2,注意 no-store } // 3. 手机端扫描上报 @PostMapping("/qr/scan") public void scan(@RequestParam String ticket, @CurrentUser Long uid) { String key = "qr:ticket:" + ticket; // 用 Lua 保证「检查状态 + 改状态」的原子性 Long ret = redis.execute(SCAN_LUA, Arrays.asList(key), String.valueOf(uid)); if (ret == 0) throw new BizException("二维码已失效"); } // 4. 手机端确认授权 @PostMapping("/qr/confirm") public void confirm(@RequestParam String ticket, @CurrentUser Long uid) { String key = "qr:ticket:" + ticket; Long ret = redis.execute(CONFIRM_LUA, Arrays.asList(key), String.valueOf(uid)); if (ret == 0) throw new BizException("二维码状态异常"); } // 5. PC 端兑换令牌(ticket + secret 双因子,一次性消费) @PostMapping("/qr/exchange") public TokenResp exchange(@RequestParam String ticket, @RequestParam String secret) { String key = "qr:ticket:" + ticket; QrSession s = redis.opsForValue().get(key); // 先读 if (s == null || s.getState() != CONFIRMED) throw new BizException("未授权"); if (!s.getSecret().equals(secret)) throw new BizException("凭证不匹配"); redis.delete(key); // 再删,生产中应用 GETDEL return tokenService.issue(s.getAuthorizedUid()); // 签发正式令牌 }

前端的轮询逻辑同样有讲究,核心是别用固定间隔死轮,要根据状态动态调整,并且处理好页面隐藏时的暂停。

let timer = null; let interval = 3000; async function poll(ticket) { const resp = await fetch(`/qr/state?ticket=${ticket}`, { cache: 'no-store' }); const data = await resp.json(); if (data.state === 2) { // CONFIRMED clearInterval(timer); const tr = await fetch('/qr/exchange', { method: 'POST', body: new URLSearchParams({ ticket, secret: localStorage.getItem('qr_secret') }) }); const token = await tr.json(); location.href = '/home'; // 令牌已写入 Cookie 或内存 return; } if (data.state === 1 && interval !== 1000) { // 已扫描,提速 clearInterval(timer); interval = 1000; timer = setInterval(() => poll(ticket), interval); } if (data.state === 3 || data.state === 4) { // 过期或取消 clearInterval(timer); showExpiredAndRefresh(); } } // 页面隐藏时暂停轮询,避免用户切走后还在空转 document.addEventListener('visibilitychange', () => { if (document.hidden) clearInterval(timer); else timer = setInterval(() => poll(ticket), interval); });

这段代码里有两个点值得单独说。第一个是cache: 'no-store',状态查询接口如果不加这个头,某些浏览器或者中间的代理层会缓存 GET 响应,导致前端拿到的永远是第一次的结果,二维码看着一直不动。这个坑我踩过,排查了半天才发现是 CDN 缓存了状态接口。第二个是visibilitychange的处理,一个用户开了五个标签页,每个都在轮询,服务端压力直接翻五倍,而这个用户可能只是在另一个标签页里干活。暂停轮询这个动作能省下相当一部分无效请求。

4. 安全边界:这套流程最容易被撬开的几个点

4.1 二维码被截图转发,怎么防

这是扫码登录最经典的攻击场景,行业内一般叫它"扫码钓鱼"。攻击者的手法很直接:想办法拿到一张有效的登录二维码图片,转发给受害者,骗受害者扫一下并确认,然后攻击者在自己那台设备上完成登录。整个过程中,受害者的手机确实完成了授权,服务端的所有校验都通过,日志上看不出任何异常。

这个攻击之所以成立,是因为扫码登录隐含的"物理邻近性"假设被打破了。二维码被转发之后,"能扫到"不再意味着"在旁边"。要防它,单一的招数都不够,得叠几层。

第一层是确认页上的信息要足够吓人。手机端在显示确认按钮之前,必须明确告诉用户"你正在登录一台位于 XX 城市的 Windows 设备",把设备类型、大致归属地、浏览器信息都亮出来。用户如果没在电脑前操作,看到这条信息就会警觉。这一层不解决技术问题,但它把攻击成本从"骗人扫一下"提高到了"骗人扫一下并且无视明显的异常提示"。

第二层是压缩确认窗口。票据在 SCANNED 状态之后,给一个很短的确认时限,比如 60 秒,超时自动转为 CANCELED。攻击者转发图片、等受害者扫码、再等受害者点确认,这个链路很容易超过时限。正常用户扫完马上就点了,不受影响。

第三层是绑定生成端的环境指纹。生成二维码的时候,服务端记录下请求的来源 IP 段、User-Agent 的关键特征。兑换令牌的时候做一次比对,如果差异过大就要求额外的验证。这一层会有误伤(用户换网络、换浏览器),所以只能作为风控信号而不是硬拦截。

第四层,也是最有效的一层,限制票据的可转移性。生成二维码时,服务端下发一个 HttpOnly 的 Cookie 作为会话标识,兑换令牌时必须带上这个 Cookie。攻击者转发的只是图片,拿不到受害者的 Cookie,所以就算受害者扫了并确认了,攻击者那台设备也换不到令牌。这个做法牺牲了一点兼容性(跨域、隐私模式会有问题),但防钓鱼效果是实打实的。

4.2 票据的生命周期管理,比想象中更容易出事

票据是这套系统的核心资产,它的生命周期里有几个点特别容易出问题。

生成环节的枚举风险。如果票据的随机性不够,攻击者可以批量生成候选票据去探测状态,一旦命中一个 CONFIRMED 的票据,就能尝试兑换。防这个的办法很简单:票据长度不低于 32 位、字符集不小于 62、使用密码学安全随机源。这样暴力枚举的空间是 62^32,天文数字,实际不可行。顺便说一句,生成接口本身也要限流,按 IP 和设备指纹做,防止有人刷爆存储。

存储环节的信息泄露。票据记录里会存授权用户 ID,这个字段是敏感的。如果存储被未授权访问,等于泄露了"某张票对应某个用户"。所以存储层的访问控制要做严,别把缓存实例暴露在公网,也别在日志里打印完整的票据对象。我见过有实现直接把整个会话对象序列化打进日志,里面带着用户 ID 和手机号,这个在合规审查里是硬伤。

消费环节的重放风险。前面说过票据必须一次性消费,但实际实现里经常出问题。常见错误有三个:一是用"先查后删"的两步操作,并发下两张请求都能查到,都能兑换;二是删除失败但没抛异常,接口照样返回成功;三是把消费标记写在了另一个存储里,主存储的票据没删,下次还能用。正确的做法是用原子操作,Redis 的GETDEL或者 Lua 脚本,一步搞定读和删,失败就整个失败。

过期环节的时钟问题。如果服务端是多实例部署,各机器的时钟可能有偏差,导致 A 机器认为票据没过期、B 机器认为过期了。这个问题的根源是把过期判断交给了本地时钟。解决办法是统一用存储层的 TTL 作为唯一权威,业务代码里不要再自己做时间比较,或者所有实例都对接同一个时间源。

4.3 三类攻击面,逐一对照

把上面的分析收拢成一张表,方便做架构评审的时候逐条对。

攻击类型攻击路径主要防护手段残留风险
二维码转发钓鱼截取图片转发给受害者诱导确认确认页展示设备信息、短确认窗口、绑定生成端 Cookie用户无视提示仍然确认
票据枚举批量猜测 ticket 探测状态32 位以上随机票据、生成接口限流几乎为零
票据重放复用已确认的票据换取令牌原子性一次性消费、ticket + secret 双因子若 secret 同时泄露则失效
中间人窃听在 PC 与服务端之间截获流量全链路 HTTPS、Cookie 加 Secure 与 SameSite客户端被植入根证书
点击劫持用透明 iframe 覆盖二维码区域诱导点击二维码区域加 X-Frame-Options、用图片渲染老旧浏览器兼容问题
扫码后信息泄露轮询接口返回过多账号信息状态接口只返回状态枚举,账号信息只在兑换后下发实现疏忽

表格里最后一列是我特意加的。安全方案没有"完全防住"这回事,只有"把攻击成本抬高到不划算"。做架构评审的时候,与其争论某个攻击能不能防住,不如把残留风险明确写出来,让业务方知道自己在承担什么。

5. 实战踩坑与排查速查表

5.1 六种典型故障,对应的定位思路

这套系统上线之后,我遇到过的问题基本集中在"状态不同步"和"请求打不通"两类。整理成速查表,遇到现象直接对。

现象最可能的原因排查动作
二维码刚出来就显示过期服务端 TTL 与前端倒计时不一致,或前端倒计时用了本地时间对比接口返回的 expireIn 与前端展示值,检查是否有客户端时钟偏移
手机扫了,PC 端毫无反应状态接口被缓存,或状态枚举前后端不一致看响应头有没有 Cache-Control,抓包比对返回的 state 值
PC 卡在"已扫描"不动手机端确认接口失败,或确认后没写回存储查手机端网络请求,查服务端确认接口的错误日志
兑换令牌报"凭证不匹配"secret 丢失(刷新页面、开了新标签),或跨域导致 Cookie 没带上检查 secret 的存储位置,检查跨域配置和 SameSite
多标签页互相踢下线同一 ticket 被多个页面并发兑换,一次性消费只成功一个让每个标签页独立生成二维码,或做页面间广播
高峰期状态接口大面积超时轮询 QPS 打满,或长轮询占满了线程池看接口 QPS 曲线与线程池活跃数,检查是否误用了阻塞式挂起

表格里第二条特别值得展开。状态接口被缓存这个问题,我第一次遇到的时候完全没往缓存上想,因为本地开发环境一切正常,一上生产就不行。后来发现是前置的代理层对 GET 请求做了默认缓存,而我们的接口又没显式声明不可缓存。修复很简单,响应头加上Cache-Control: no-store, no-cache, must-revalidate,同时把状态查询改成 POST(虽然不符合 REST 风格,但能绕开绝大多数中间层的 GET 缓存策略)。这个坑的教训是:任何返回实时状态的接口,都必须显式声明不可缓存,别指望中间层猜你的意图。

第三条也常见。手机端确认成功之后,服务端要写回状态,这个写回如果失败了(存储抖动、超时),PC 端就永远等不到。我的做法是在确认接口里做重试,并且把"确认成功但写回失败"这个情况单独打一条告警日志。另外前端要有兜底:超过 30 秒还停在 SCANNED,就提示用户"如果已确认请刷新重试",并给一个手动刷新的按钮。别让用户对着一个不动的界面干等。

5.2 轮询风暴的治理,从三个角度下手

轮询风暴是这类系统最常见的性能问题,尤其在早晚高峰。治理思路分三层。

第一层是减少请求数量。前面提到的页面隐藏暂停、WAITING 低频 SCANNED 高频,这两个改动能砍掉一大半无效请求。还有一个容易被忽略的点:二维码过期之后,前端必须停止轮询。我见过有实现过期了还在每秒打一次接口,用户把登录页开着去吃饭,回来发现后台打了几千次请求。加一个过期判断,clearInterval一下的事。

第二层是降低单次请求的成本。状态查询接口里不能有任何数据库操作、不能有复杂的序列化、不能打业务日志。理想情况下它就是一次内存读取加一次枚举返回,响应时间在 1 毫秒以内。如果状态数据量小,甚至可以把整个会话对象缓存在本地内存里做二级缓存,只在状态跃迁时才同步。注意二级缓存会带来一致性问题,得用发布订阅或者短 TTL 来兜。

第三层是横向削峰。如果前面两层做完 QPS 还是高,那就上长轮询,把"轮询次数"换成"连接数"。长轮询的关键是异步挂起,用 Servlet 的AsyncContext或者响应式框架,一个线程能挂几千个请求。同时要注意网关的空闲超时和负载均衡的连接数上限,这两个地方经常成为新的瓶颈。还有一个细节:长轮询的连接在网关侧断开时,服务端要能感知到并释放资源,否则会积压一堆僵尸连接。

最后分享一个我在实际项目里用的小技巧。给状态查询接口加一个"版本号"参数,前端每次带上自己已知的状态版本,服务端如果发现版本没变,直接返回 304 或者一个极简的"未变化"响应,连 JSON 序列化都省了。这个改动在请求量大的时候能省下可观的 CPU,实现成本也就十几行代码。当然前提是前端要正确处理这个响应,别把它当成错误。

5.3 一些不太好归类但很实用的经验

关于二维码的渲染。别用前端 canvas 现画,用服务端生成图片返回。原因有三个:一是尺寸和纠错等级可控,低端机识别率高;二是可以防止"二维码被覆盖"这类点击劫持;三是缓存友好。纠错等级建议用 M 级,太高会让图形变密,反而降低识别率。图片尺寸别低于 200×200,太小了手机对焦困难。

关于多端登录的冲突。用户可能同时开着手机App和PC网页,扫码登录之后两边的会话是什么关系?我的建议是两套会话独立维护,PC端的令牌和手机端的令牌互不干扰,但都归属于同一个用户。这样任何一端退出不会影响另一端,符合用户预期。如果业务上要求"一端登录挤掉另一端",那要在签发令牌的时候做全局的单点控制,这个逻辑最好放在统一的会话服务里,别散落在各个业务模块。

关于测试环境的坑。本地开发的时候,手机和电脑通常在同一个局域网,二维码里如果写的是localhost,手机根本扫不开。常见的做法是把二维码内容改成局域网 IP,但这会带来跨域和 Cookie 域名的问题。我一般会在开发环境加一个开关,把二维码内容指向一个固定的测试域名,然后在 hosts 里做映射。另外测试环境的 HTTPS 证书往往是自签的,手机端会拦,得提前把证书装好,否则调试半天以为是代码问题。

关于灰度发布。这套流程涉及前后端和移动端三方,任何一方先上线都可能出问题。比较稳的顺序是:先上服务端,新老接口并存;再上PC端,走新的生成和轮询接口,但兑换接口保留旧逻辑的兼容分支;最后上移动端,新版本才支持扫码确认。整个过程要保留一个"强制走老流程"的降级开关,出问题能一键切回。我在一个项目里就是因为移动端灰度没控制好,一批旧版本用户扫了新二维码但客户端不认识,直接白屏,回滚花了半小时。

关于监控指标。至少要有这么几个:二维码生成量、扫码成功率(SCANNED / 生成量)、确认成功率(CONFIRMED / SCANNED)、端到端耗时分布、状态接口的 P99 延迟、过期率。这几个指标能覆盖绝大多数异常:生成量突增说明有人刷接口,扫码成功率骤降说明二维码识别出了问题,确认成功率掉说明确认接口有故障,端到端耗时变长说明某一环卡住了。我当时就是靠"确认成功率掉了 15 个点"这条告警,提前发现了一个存储集群的写入抖动。

6. 这套机制还能怎么用

扫码登录本质上是"用一台已认证设备授权另一台设备"的通用模式,把它抽象出来,能复用的场景其实不少。

最常见的是扫码支付确认,逻辑几乎一样,只是最后一步不是签发登录令牌而是发起一笔交易,安全要求更高,通常还要加支付密码或者生物识别。另一类是扫码加好友、扫码进群,这个是扫码后的业务动作不同,状态机可以简化成"扫到即执行",不需要二次确认。还有一类是设备配网,智能硬件扫二维码把自己的标识上报,服务端把设备和账号绑定,本质上是把"设备"当成了那个待登录的PC端。

还有一个稍微冷门但很有用的场景:扫码完成跨端的数据同步。比如用户在手机上编辑了一半的文档,想在电脑上接着写,扫个码把编辑会话搬过去。这个场景里,二维码承载的不是登录凭证,而是一个会话的交接票据,服务端要保证交接过程中数据的一致性,比登录要复杂一些。

我在实际做这套东西的过程中最深的一点体会是:扫码登录的复杂度不在二维码本身,而在状态机的一致性上。二维码的生成和渲染都是成熟方案,随便找个库就能用,真正花时间的是"状态在什么时机变、由谁来变、变了之后怎么保证两端都能看到"。很多线上问题的根因,都是某个状态跃迁没有被正确持久化,或者两端对同一个状态的理解不一致。所以如果你的团队要接这套东西,我建议把三分之一的时间花在把状态机画清楚、把每个状态的入口和出口都列出来、把异常情况下应该落到哪个状态定死,剩下的时间再动手写代码。这个顺序反了,后面排查问题的成本会翻好几倍。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 3:47:11

图神经网络新趋势:动态图、表情识别与图语言模型的前沿进展

这周刷 arXiv 的感觉和年初明显不一样。2026 年第 39 周,图神经网络方向的投稿量又上了一个台阶,但真正值得读的,不是那些把旧方法换个数据集刷分的,而是几条主线开始出现“收敛”迹象的工作:动态图从单纯改聚合方式走…

作者头像 李华
网站建设 2026/10/31 3:46:46

Unity网络游戏开发:客户端架构与优化实践

1. Unity网络基础客户端开发概述在游戏开发领域,网络功能已成为现代游戏的标配功能。Unity作为全球最流行的游戏引擎之一,其网络模块的设计直接影响着千万开发者的工作效率。网络客户端代码作为连接玩家与游戏世界的桥梁,需要处理连接管理、数…

作者头像 李华
网站建设 2026/10/31 3:46:20

CSS滚动条定制:从浏览器差异到标准化实践

1. 前端开发者的"眼中钉":浏览器默认滚动条 作为一名从业十年的前端开发者,我至今记得第一次用CSS定制滚动条时的震撼。那是在2015年,客户要求将丑陋的灰色滚动条改成与品牌色一致的玫红色。当时只能依赖非标准的 ::-webkit-scro…

作者头像 李华
网站建设 2026/10/31 3:46:17

双指针算法实战:从两数之和到移动零

1. 双指针算法入门:从两题简单题开始掌握核心思想双指针算法是解决数组和链表问题的利器,尤其适合处理需要同时遍历或比较多个元素的情况。今天我们就通过LeetCode上两道简单但极具代表性的题目——167.两数之和II和283.移动零,来深入理解双指…

作者头像 李华
网站建设 2026/10/31 3:45:28

Docker容器启动失败分析与最佳实践

1. 容器启动失败的行业现状与核心痛点最近在帮几家中小型公司做容器化改造时,发现一个令人震惊的现象:超过90%的生产环境Docker容器存在启动失败风险。上周排查的一个典型案例中,某电商平台在流量高峰期间有近40%的订单服务容器无法正常启动&…

作者头像 李华
网站建设 2026/10/31 3:44:43

Nuxt下a-menu菜单高亮与路由状态同步完整方案

1. 点击跳转只是三行代码&#xff0c;真正难啃的是“回来之后还认得路”先说一个我见过很多次的现象&#xff1a;有人在 Nuxt 项目里接了 Ant Design Vue&#xff0c;<a-menu>的点击跳转二十分钟就写完了&#xff0c;然后在群里问“为什么刷新页面菜单就全灭了”。这件事…

作者头像 李华