一个二维码同时支持微信扫码和App扫码登录
同时提供微信登录和 App 登录时,登录页通常会放两个二维码:一个给微信扫,一个给自家 App 扫。
实现很直接,体验却有点别扭。用户拿起手机之后,还要先读说明、选应用,扫错了再返回重试。这个选择没有提供额外价值,完全可以由扫码端自己处理。
做法是让两个入口共用微信公众号的带参数二维码。微信扫描后,按公众号原有的关注或扫码流程处理;App 读到约定格式后,不打开默认 URL,直接进入扫码登录。网页只等登录结果,不区分是哪个应用完成了扫码。
同一个码,两种识别方式
这类二维码仍以微信公众号的带参数二维码为基础。
网页登录页打开后,服务端为本次登录生成一个临时场景值,再用这个场景值申请公众号二维码。微信能识别它,App 的扫码模块也能读出二维码中的 URL。
完整流程如下:
网页创建一次性登录会话
→ 生成临时场景值
→ 通过微信接口申请带参数二维码
→ 网页展示二维码
微信扫码
→ 关注公众号或触发扫码事件
→ 微信把场景值和用户 OpenID 发给公众号服务器
→ 服务端确认登录
App 扫码
→ 识别二维码 URL 是否符合约定格式
→ 符合时不打开 URL
→ 携带当前 App 登录凭证请求确认
→ 服务端确认登录
网页
→ 轮询同一条登录会话
→ 发现已确认
→ 完成登录
不管从哪边扫码,最后更新的都是同一条登录会话。网页只维护一套状态。
微信侧用到的接口
微信侧只需要公众号的“生成带参数的二维码”接口:
POST https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=ACCESS_TOKEN
临时二维码使用 QR_STR_SCENE,常用参数有:
expire_seconds:二维码有效时间。action_name:临时字符串场景值使用QR_STR_SCENE。action_info.scene.scene_str:本次登录对应的场景值。
微信会返回 ticket、expire_seconds 和 url。ticket 可以用于换取二维码图片,url 是二维码解析后的地址。保存哪个字段、由谁转发二维码图片,可以按项目现有架构处理。
伪代码可以写得很简单:
创建网页登录会话:
生成一个随机、短期有效的 scene
保存 login_id、scene、过期时间和 pending 状态
申请微信公众号二维码:
调用“生成带参数的二维码”接口
action_name 使用 QR_STR_SCENE
scene_str 使用刚才生成的 scene
保存二维码映射:
把微信返回的 ticket、url 与 login_id 关联
把二维码展示信息和 login_id 返回给网页
scene 只用来标记这次登录。用户 ID、App token、Cookie 等长期凭证不能写进二维码。二维码可能被截图或转发,拿到其中的内容不应该直接获得登录权限。
微信扫码后的登录处理
未关注公众号的人扫描带参数二维码,微信会先展示关注页面。关注完成后,公众号服务器会收到 subscribe 事件,EventKey 中带有场景值,并带 qrscene_ 前缀。
已经关注公众号的人扫描时,公众号服务器会收到 SCAN 事件,可以从 EventKey 读取场景值。
服务端处理时,把两种事件统一成同一个 scene:
收到微信事件:
先校验微信回调签名
如果事件是 subscribe:
从 EventKey 中读取场景值
去掉 qrscene_ 前缀
如果事件是 SCAN:
直接读取 EventKey
按场景值查询登录会话
检查会话是否存在、是否过期、是否已经使用
使用 FromUserName 中的 OpenID 查找站内用户
身份满足登录条件后,把会话改为已确认
记录登录用户和 wechat 渠道
关注公众号只解决了微信身份识别。站内是否自动创建账号、是否要求绑定手机号、是否还需要一次确认,取决于产品自己的账号规则。
OpenID 只在当前公众号下有效。如果公众号、开放平台 App 或其他微信应用需要共用账号,要按实际的开放平台绑定关系处理身份,不能直接比较不同应用下的 OpenID。
App 扫码时,不要直接打开 URL
App 的普通扫码逻辑通常是:读到 URL,然后交给 WebView 或系统浏览器打开。要复用公众号二维码,需要在“打开 URL”之前多做一次判断。
App 读取扫码结果:
如果不是约定的微信公众号二维码格式:
继续使用原来的扫码处理
如果符合约定格式:
不打开 URL
显示登录确认信息
用户确认后,把完整二维码内容发给登录服务
同时携带 App 当前登录 token
格式判断不能只看微信域名。公众号二维码、微信文章和其他微信链接都可能使用微信域名,只判断域名很容易误伤正常扫码。
App 可以先按域名和路径做初步识别,再把扫码得到的完整内容交给服务端。服务端根据事先保存的 URL、ticket 或摘要查找登录会话,确认这个码是不是由本系统生成、是否仍在有效期内。
App 没必要解析微信二维码 URL 的内部结构。只要完整内容能和创建登录会话时保存的数据对应上即可。以后二维码格式有变化,也不用跟着修改客户端的场景值解析逻辑。
App 登录确认的伪代码如下:
App 提交扫码登录确认:
携带二维码完整内容
携带当前 App 登录 token
如果现有协议必须传 user_id,也一并携带
服务端收到请求:
验证 token
从 token 得到真实用户身份
如果请求里还有 user_id:
检查 user_id 是否与 token 对应的用户一致
根据二维码内容查找登录会话
检查会话是否存在、是否过期、是否已经使用
把会话改为已确认
记录登录用户和 app 渠道
用户 ID 应由服务端从 token 中取得。客户端传来的 user_id 只能作为附加信息,不能单独证明用户身份。
App 端最好保留一次确认。扫码后显示即将登录的产品、浏览器或设备,用户确认之后再提交。否则扫到别人屏幕上的二维码时,自己的账号会直接登录到对方浏览器。
网页端只负责轮询
网页拿到二维码后,定时查询 login_id 对应的状态即可。它不关心最后是微信回调确认的,还是 App 确认的。
网页开始轮询:
查询 login_id 的登录状态
如果状态是 pending:
等待一段时间后继续查询
如果状态是 scanned:
提示“已扫码,等待确认”
如果状态是 confirmed:
领取当前浏览器的登录结果
停止轮询并进入登录后的页面
如果状态是 expired:
停止轮询
提示二维码已过期,并允许刷新
状态可以按业务复杂度增减。一套比较完整的状态是:
pending → scanned → confirmed → consumed
└────────────────────────→ expired
pending 表示等待扫码,scanned 表示手机已识别但还没确认,confirmed 表示身份已经通过,consumed 表示网页已经领取登录结果。超过有效时间后进入 expired。
轮询不需要很频繁。登录页只会存在一小段时间,短间隔轮询通常够用。项目本来就有长连接能力时,也可以改用 SSE 或 WebSocket。
网页领取登录结果时,服务端应该给当前浏览器设置登录态,或者发放只能使用一次的授权码。不要把 App token、微信 OpenID 或长期有效的站内 token 直接返回给轮询接口。
几个不能省的安全检查
登录页只放一个码,服务端该做的验证并不会减少。二维码可能被复制,微信回调、App 确认和网页轮询也可能同时发生。
至少要处理下面几件事:
- 二维码和登录会话都要短期有效,二者的过期时间保持一致。
- 同一条登录会话只能确认一次,也只能被网页领取一次。
- 确认和领取都要处理并发,不能靠“先查状态、再改状态”这种容易产生时间差的写法。
- 登录结果要绑定创建二维码时的浏览器会话,不能只凭 login_id 领取。
- 微信事件必须校验回调签名,App 请求必须验证登录 token。
- 日志可以记录 login_id、渠道和状态变化,但不要记录完整 token。
二维码内容本身不具备登录权限。微信用户身份来自签名有效的事件回调,App 用户身份来自通过验证的 token。二维码只负责把手机端的确认结果关联到当前网页登录会话。
最后
这套实现没有改变微信和 App 各自的登录方式,只是让它们共用同一个二维码和同一条网页登录会话。微信继续处理关注或扫码事件,App 识别约定格式后接管,网页照常轮询。
登录页因此只需要展示一个码。用微信扫,走公众号登录;用 App 扫,走 App 登录。选择由扫码端处理,不再交给用户。
请先 登录后发表评论 ~