图解小程序静默登录那些事

4,974 阅读25分钟

1. 背景

之前,作者在文章《小程序静默登录方案设计》中提到过,小程序可以通过微信官方提供的登录能力方便地获取微信提供的用户身份标识,快速建立小程序内的用户体系。

「静默登录」 ,开发者通过调用 wx.login 获取到 code ,将其发送到开发者后端,开发者后端通过接口去微信后端换取到 openidunionidsessionKey后,然后把自定义登录态 auth-token返回给前端,就已经完成登录行为了。

理论上,开发者后端可以通过 openid识别用户,也能通过unionid关联同主体的多个小程序、公众号、app,实现数据互通,从而为每一个用户创建独一无二的业务身份(比如,用户id),在「微信生态」中建立成熟用户体系。

然而,对于复杂的跨端应用,比如一个包含pch5小程序三端的电商应用,不同渠道注册的uid是不同的,用户登录后难以对各个渠道的交易、促销、收藏等数据进行整合。因此,要实现跨端的用户体系数据互通,就需要提供一个唯一的用户标识——手机号。这便是作者在文章《小程序用户登录方案设计》所阐述的内容。

那么,为什么写这篇文章呢,原因有三:

  1. 由于设计上的缺陷,用户登录和静默登录统一分装成一个Session类实例,与基于fly.js封装的http-clientHTTP 请求库存在循环引用问题,虽然小程序方早就提出了循环引用导致的堆栈溢出问题的解决方案,但循环引用仍是一枚潜在的“炸弹”,需要尽早解除风险。
  2. 通过对前端监控上报数据和后端监控数据的分析,发现静默登录的成功率和耗时存在提升的空间,本文也将阐述这方面的优化方案。
  3. 开发人员写文章的最终目的是将技术方案阐述得通俗易懂且生动完整,之前的两篇文章作者主要关注了“怎么做”,而忽略了“为什么”,期间也与不少读者交流过登录流程的种种细节,受益良多,收集了这些问题,作者想重新讲一遍小程序登录的故事。

2. 登录方案架构设计

用户登录架构

「登录」方案架构如上图所示,将所有登录相关功能抽象到  「service 层」,供  「业务层」  调用。该  「service 层」  主要分为以下两个模块:

  1. libs模块: 提供登录相关的类方法供「业务层」调用,为了解决循环引用的问题,将原本的session类实例拆分成sessionauthLogin两个类session类主要提供存取用户信息、判断当前用户身份的方法,authLogin类主要提供静默登录相关方法。该模块是本文重点阐述内容。

  2. ui模块基础组件封装了user-containerphone-container,分别是获取「微信授权用户信息」和获取「微信授权手机号」的纯 UI 单元组件,给通用组件使用。拿到授权数据后需要发送给服务端进行存储,也需要执行一些跳转逻辑判断,这些都抽象成行为类封装在 auth-flow 中,供通用组件使用。现阶段授权登录有弹窗或跳转登录页面两种形式,封装了通用组件auth-flow-container用于页面,auth-flow-popup用于弹窗,两者功用一个行为类。该模块的具体介绍可参考文章《小程序用户登录方案设计》,本文不做赘述。

3. authLogin 类 - 理解静默登录

3.1 什么是静默登录?

谈静默登录之前,首先要了解,在微信小程序生态中,是如何标识一个用户?

微信官方提供了两种标识手段:

  1. OpenId 是一个用户对于一个小程序/公众号的标识,开发者可以通过这个标识识别出用户。
  2. UnionId 是一个用户对于同主体微信小程序/公众号/APP 的标识,开发者需要在微信开放平台下绑定相同账号的主体。开发者可通过UnionId,实现多个小程序、公众号、甚至 APP 之间的数据互通。

同一个用户的这两个 ID 对于同一个小程序来说是永久不变的,就算用户更换手机、删除小程序,下次用户进入小程序,开发者依旧可以通过后台的记录标识出来

所以,静默登录的目的是为了获取用户唯一标识 OpenID会话密钥 session_keyOpenID 主要用于关联生成自定义登录态、用户id、初始化用户信息数据,session_key 主要用于解密微信授权登录的手机号,以及特定业务场景下的风控方案。

静默登录流程时序图

如图所示,描述静默登录的时序流程图,主要分为以下三步:

  1. 小程序端调用 wx.login() 获取 临时登录凭证code ,并回传到开发者服务器。
  2. 服务器端调用 auth.code2Session 接口,换取 用户唯一标识 OpenID会话密钥 session_key,关联生成自定义登录态auth-token,并为每一位用户分配唯一的用户 id(uid),且初始化用户信息。
  3. 开发者服务器将根据用户标识生成的自定义登录态(auth-token)用户信息返回给小程序端,小程序接收并存储到本地。

如此复杂的机制主要是为了防御各种安全攻击。小程序端拿到的临时登录凭证code的有效期且只有 5 分钟,仅能使用一次。服务端发送code以及小程序的appidappsecret到微信服务器换取用户微信身份。其中appsecret也是鉴别开发者的重要信息,可以从微信管理后台获取。所以即使攻击者拿到code,没有appsecret也无法换取用户信息。

当小程序端发起需要鉴权的业务请求时,可以从本地 storage 获取auth-token,并注入请求头部。后端接收到请求后可以根据auth-token进行鉴权,鉴权通过后返回业务请求结果,从而避免一系列的安全问题。

3.2 云开发改造静默登录流程

前面提到过,通过对前端监控上报数据的分析,发现静默登录的成功率和耗时存在提升的空间。统计了下静默登录请求失败的原因,发现 71.4% 的失败案例都是因为请求超时问题,“罪魁祸首”还是小程序端用临时登录凭证code 换取 auth-token用户信息 这个接口,该接口的调用链路长导致耗时高。当小程序端发送请求到开发者服务器,服务器端调用 auth.code2Session 接口,换取 用户唯一标识 OpenID会话密钥 session_key,进行关联自定义登录态、初始化用户信息等操作后才将auth-token和用户信息数据返回给小程序。

那么如何优化这条链路呢?小程序方提出了云开发解决方案

云开发的云函数的独特优势在于与微信登录鉴权的无缝整合。从小程序端调用云函数时,开发者可以在云函数内使用  wx-server-sdk  提供的  getWXContext  方法获取到每次调用的上下文(appidopenidunionid  等),无需维护复杂的鉴权机制,即可获取天然可信任的用户登录态(openid)。

云开发改造静默登录流程时序图

如图所示,描述云开发改造后的静默登录流程图,主要分为以下三步:

  1. 小程序端调用 silentLogin 云函数服务。
  2. silentLogin 云函数是一段运行在云端的代码,在云函数内使用  wx-server-sdk  提供的  getWXContext  方法获取到appidopenidunionid,将其发送给开发者服务器。
  3. 开发者服务器获取到openid,进行关联自定义登录态、生成业务身份、初始化用户信息等操作后将auth-token和用户信息数据返回给小程序。

这里肯定有人质疑,直接拿openid换取用户信息,不就存在安全漏洞了?解决方案如下:云函数服务与开发者服务器之间的通信,可以使用腾讯云私有网络(VPC),小程序方调用silentLogin 云函数时,微信云函数网关将其转发到开发者自己部署的云函数服务上,与开发者服务器的通信走内网专线既加快了通信速度,又提供了安全保障

理想情况下,云开发改造静默登录流程,可以减小静默登录的请求耗时,提高成功率。目前作者所处业务正处于试验阶段,需要一段时间的灰度观察和数据监控才能验证这个结论。

3.3 session_key 那些事

前面提到过的静默登录流程,服务端通过调用微信接口服务的登录凭证校验接口获取openidsession_keysession_key和我们日常开发中的cookie或者session相似,主要用于对用户数据进行加密签名。

自 2021 年 2 月 23 日起,开发者通过wx.login接口获取的登录凭证可直接换取unionID。也就是说,现在session_key的作用主要用于解密微信授权登录的手机号。

目前获取手机号需要用户主动触发 才能发起获取手机号接口,所以该功能不由 API 来调用,需用  button  组件的点击来触发。需要将  button  组件  open-type  的值设置为  getPhoneNumber,当用户点击按钮,弹出授权手机号弹窗,用户同意授权之后,可以通过  bindgetphonenumber  事件回调获取到微信服务器返回的加密数据,如下表格所示:

属性类型说明
encryptedDatastring包括敏感数据在内的完整用户信息的加密数据
ivstring加密算法的初始向量
cloudIDstring敏感数据对应的云 ID,开通云开发的小程序才会返回,可通过云调用直接获取开放数据

授权手机号登录流程

之前的手机号授权登录流程如上图所示,小程序端将授权手机号回调返回的加密数据encryptedDataiv发送给开发者服务器,由服务器使用session_key(对称解密密钥)进行对称解密,获取手机号来绑定用户id,并更新用户身份(游客->普通用户),最后将更新后的用户信息数据返回给小程序端,小程序端更新本地 storage 存储的用户信息。

如果使用云开发改造静默登录流程的话,开发者服务器是拿不到session_key的,那么如何解密手机号呢?

可以使用云调用直接获取开发数据。

云开发-授权手机号登录流程

云开发改造授权手机号登录流程的操作如上图所示,小程序端将授权手机号回调返回的cloudID和静默登录成功后本地存储的auth-token作为参数调用userLogin云函数,云函数调用getOpenData直接获取手机号,通过内网专线将手机号和auth-token作为参数发起登录请求,开发者服务器进行鉴权,鉴权通过后用手机号来绑定用户 id,并更新用户身份(游客->普通用户),最后将更新后的用户信息数据返回给小程序端,小程序端更新本地 storage 存储的用户信息。

3.4 静默登录的调用时机

3.4.1 authLogin.login - 小程序启动时调用

我们知道,大部分的核心业务请求都需要携带auth-token进行鉴权,静默登录是一个十分前置的过程。据我们统计,目前静默登录全流程的耗时在 800ms 左右,如果每次启动小程序时都重新发起静默登录,那势必阻塞部分核心业务请求。 其实,只要保证登录态仍然有效,那么当用户再次进入小程序时,就无需发起静默登录流程。登录态有效的判断依据有两点,一是本地 storage 中是否存在auth-token(存在证明之前成功发起过静默登录流程),二是调用wx.checkSession判断session_key是否过期(如果服务端存储的session_key过期,那么无法解密开放数据,用户就无法走通注册流程,所以必须重新发起新的静默登录流程更新session_key)。

当用户启动小程序时,主要有以下四种场景:

用户启动小程序状况

根据如图所示场景,我们封装login函数供小程序启动时onLaunch生命周期调用。如下图所示,判断本地是否存在auth-token以及session_key是否过期,如果存在且未过期,则将状态机状态扭转到成功(这里下一小节会讲),返回Promise.resolve()。否则,发起静默登录流程。

authLogin.login接口

但是由于原生的小程序启动流程中, AppPageComponent 的生命周期钩子函数,都不支持异步阻塞。所以很有可能出现小程序页面加载完成后,静默登录过程还没有执行完毕的情况,这会导致后续一些依赖登录态的操作(比如请求发起)出错

3.4.2 authLogin.refreshLogin - 请求发起时调用

前面提到过,部分业务请求需要鉴权,即在请求发起时,请求头部需要携带auth-token发送到开发者服务器,开发者服务器获取请求头部的auth-token进行鉴权,对于鉴权失败的请求,不进行业务逻辑响应,而是返回鉴权失败的状态码。

我们知道,静默登录成功之后,小程序端才能获取auth-token,然后小程序启动时onLaunch生命周期并不支持异步阻塞,也就是说,可能出现静默登录进行中时,请求已经发起。由于请求未携带auth-token,会返回鉴权失败的状态码。

仅在小程序启动时发起静默登录的情况

如上图所示,阐述了三种类型请求发起的请求。请求 A 是在静默登录进行中发起的,开发者服务器返回鉴权失败的状态码,小程序收到结果时,静默登录已经成功,可以再次发起请求 A,消耗时间为2n。请求 B 也是在静默登录进行中发起,区别在于小程序收到请求 B 结果时,静默登录请求还未成功,需要发起静默登录流程,等待请求成功后,才能再次发起请求 B,消耗时间为3n。请求 C 是静默登录成功后发起的业务请求,此时已经携带登录态,可以顺利通过鉴权。

通过上图我们了解到,仅仅在小程序启动时发起静默登录是不合理的,会导致一些前置请求需要消耗2n甚至3n的时间才能返回我们想要的业务数据。这时我们会想,能否在请求发起时进行拦截,等待静默登录成功才正常发起请求。而 Fly.js 正好支持这个功能。Fly.js 是一个基于 promise 的,轻量且强大的Javascript http 网络库,它引入了Http Engine 的概念,就是真正发起 http 请求的引擎,在小程序端,就是wx.request。Fly.js 实现了一个适配器(adapter)对wx.request进行二次封装,拓展了请求/响应拦截器、请求重定向等能力。

在一般的业务场景下,都会基于 Fly.js 做一层业务定制封装,比如在请求拦截器中,鉴权、注入请求头部、注入请求参数等等,在响应拦截器中,上报接口日志、处理状态码、提供重试机制等等。

Fly.js 请求拦截器和响应拦截器都提供了锁定自身的 API,拦截器锁定后,未进入到该拦截器的请求将在拦截器外面排队,暂停网络请求,直到拦截器解锁时,排队的请求才再次进入拦截器继续请求。

使用fly.lock拦截的情况

如图所示,当第一个请求发起时,本地如果没有登录态,则会锁住拦截器,未进入该拦截器的请求,将在拦截器外排队,直到静默登录成功后,拦截器解锁,所有请求在正常发起。平均的等待时间是m/2(m表示静默登录请求的时间)。

在普通的业务场景下,Fly.js 提供的fly.lock()fly.unlock()API 已经能很好的解决请求携带auth-token鉴权的问题,但这种方案的致命缺陷在于对所有的请求“一视同仁”,即所有的请求都会被挂起直到静默登录成功。在真实的业务场景下,这么“一刀切”显然是不合理的。举个例子,对于电商业务,商品详情页是引流的重要路口,当获取商品详情等接口被静默登录流程阻塞挂起,必将引起首屏渲染的时间。

所以,对所有的业务请求,应该进行合理的“分流”。对于无需鉴权的请求,可直接发起。对于需要鉴权的请求,挂起直到静默登录成功。

静默登录请求理想状态

如上图所示,小程序启动时,会发起静默登录请求。对于需要鉴权的业务请求,如请求 A、请求 B 发起时,进入请求拦截器,如果此时本地还未有登录态auth-token,则挂起等待静默登录请求成功;比如请求 C 发起时,此时本地已经存在登录态auth-token,那么将登录态注入请求头部,然后发起请求。对于无需鉴权的请求 D,则正常发起,不会进行阻塞。

静默登录请求拦截流程图

如图所示,是一个业务请求发起的流程图,在请求拦截器和响应拦截器中,分别做如下操作:

  • 请求拦截器

    1. 判断该请求是否需要鉴权:请求发起时,拦截请求,判断请求是否需要添加auth-token,如若不需要,直接发起请求。如若需要,执行第二步。
    2. 判断是否需要发起静默登录:判断  storage  中是否存在auth-token,如若不存在,发起「刷新登录」。
    3. 请求头部添加auth-token:添加auth-token,发起请求。

请求拦截器代码示例

  • 响应拦截器: 解析状态码

    1. 状态码为AUTH_FAIL:服务端返回code为“鉴权失败”,触发这种情景的原因有两个,一是接口需要鉴权,但是发起请求时未携带auth-token,二是auth-token过期。这时将上一次请求携带的auth-token与本地存储的auth-token比较,如果不一致,表示登录态已经刷新过了,那么就直接重新发起请求。如果一致,发起刷新登录,拿到新的auth-token后重新发起请求,这个动作对用户来说是无感知的
    2. 状态码为USER_WX_SESSIONKEY_EXPIRE:服务器返回code为“用户登录态过期”,这是针对用户授权手机号登录失败定制的状态码,如果登录态已过期,表示存储在服务端的session_key也是过期的,那么点击授权手机号获取的加密数据发送到服务端进行对称解密,由于session_key失效,无法解密出真正的手机号。因此需要重新发起静默登录,等待用户重新点击授权按钮获取新的加密数据,然后发起新的解密请求。
    3. 状态码为其它:比如Success或者其他业务请求错误的情况,不进行拦截,返回 response 让业务代码解析。

响应拦截器代码示例

这里需要注意的是,每个业务请求被拦截时,都会触发authLogin.refreshLogin方法来发起静默登录,但是理想状态下,静默登录请求只需要发起一次。这里可以使用 Promise单例模式 或者单任务队列模式进行优化,只发起一次静默登录请求,请求成功返回时所有被挂起的请求能够正常发起

3.4.3 单任务队列模式

单任务队列模式的实现原理,其实类似fly.lock()fly.lock()是将所有的请求拦截在请求拦截器之外,导致“误伤”部分无需鉴权的业务请求。而单队列模式仅对authLogin.refreshLogin()方法加锁,所有需要鉴权的请求都会被挂起直到静默登录发起成功。

单任务队列源码

如上图所示,是SingleQueue的源码,其核心原理就是维护一个队列,同一时间,只允许发起一个异步请求,请求被锁定之后,同样的请求都会被推入队列,等待进行中的请求返回后,消费同一个结果。比如,在请求拦截器中会陆续发起多个authLogin.refreshLogin()请求方法,这些方法都会一一入队。当第一个authLogin.refreshLogin()请求方法发起时,加锁,直到请求返回结果,如果返回成功结果,则队列的所有方法逐一出队,并消费该结果,如果返回失败结果,队列中的所有方法逐一出队,并消费同一个失败结果。消费完所有结果后,解锁。之后入队的请求方法,又可以重复这个逻辑。

单队列方法装饰器

单任务队列针对类的方法,为了方便使用,可将其封装成装饰器,代码如上图所示。那么这个装饰器是针对authLogin.refreshLogin方法进行调用吗?其实不然,authLogin.refreshLogin方法同authLogin.login方法一样,都是对authLogin.silentLogin方法的二次封装。所以,用singleQueue装饰器装饰authLogin.silentLogin更为合理。

authLogin.silentLogin方法源码如下图所示。这里需要关注的有两点:一是状态机的重置和扭转过程,下一小节将会讨论。二是静默登录请求添加了二次重试机制,根据监控日志分析,接近四分之三的静默登录失败原因在于网络波动,重试可以提高成功率。按照我们之前的逻辑,第一次静默登录请求失败,队列中的所有请求消费失败结果。当下一次请求再次进入队列时,才会重新发起静默登录请求。这个重试时间是不可控的。所以我们在authLogin.silentLogin方法中简单添加了二次重试机制(代码仅供参考,可以有更优雅的封装),只有连续两次请求都失败,才表示此次静默登录请求失败。根据日志数据统计,二次重试机制确实提高了静默登录请求的成功率。

silentLogin封装

3.4.4 authLogin.waitAuth - 等待静默登录结果

在具体的实践中,某些业务场景需要等待静默登录请求结果,从而拿到用户信息,比如是否授权登录、用户id等等。这些业务场景包括,埋点上报、日志上报需要获取用户id,某些页面展示需要判断用户是否授权从而渲染不同的页面。

因为有必要封装一个authLogin.waitAuth方法供业务方调用等待静默登录结果。该方法可依赖状态机实现,以下是状态机status的部分源码。借鉴了 node.js 中EventEmitter的原理,使用once为指定事件注册一次单次监听器,使用emit执行对应的事件监听器。

状态机源码实现

前面几小节authLogin.loginauthLogin.silentLogin方法已经介绍的状态机的扭转,即this.status.silentLogin.success();this.status.silentLogin.fail();分别执行成功和失败两种事件监听器。本小节介绍authLogin.waitAuth方法,注册监听器等待结果,源码如下所示。

waitAuth方法实现

3.4.5 大道至简,promise 单例模式实现“三合一”

之前,也有很多人提出质疑,不就一个简简单单的静默登录吗,有必要封装loginrefreshLoginwaitAuth三种方法供不同的业务场景调用吗?在这个问题上,其实众口难调、各执己见。

下面简单介绍下 promise 单例模式,此处参考书籍《小程序开发原理与实战》,可以单独抽象出一个singleInstancePromise函数供多种业务场景复用,如下图所示,维护一个promiseInstance,当这个实例存在时,返回这个实例,否则发起asyncFunc函数调用,并消费调用结果。

promise单例

这个asyncFunc可以参考authLogin.login,判断本地是否存在登录态和session_key是否过期。两者都满足则返回resolve,否则就发起静默登录流程。

authLogin.login接口

可通过export const waitSilentLogin = singleInstancePromise(authLogin.login);导出waitSilentLogin方法供小程序启动时、需要鉴权的请求发起时、需要用户信息的页面渲染时等等各种场景调用。当第一个waitSilentLogin方法发起时,会创建一个promiseInstance实例,后续的方法发起时,都会返回这个实例,promiseInstance实例原理其实类似状态机的机制,从pending扭转到fulfilled或者rejected且不可逆,从而达到所有的调用方消费一个结果的目的。

这种方式唯一的缺点是会频繁调用asyncFunc函数,在本场景下,即authLogin.login函数,仔细分析该函数,发现频繁调用wx.checkSession接口,根据小程序开发者文档,wx.checkSession接口是限频的,当使用频率过高,可能存在风险,这时会重新发起静默登录,存在额外的开支。

但是后续改造云开发方案后,就无需获取session_key,那么wx.checkSession这个接口就被摒弃了,promise 单例模式也能真正派上用场。

4. session 类 - 理解用户身份

相信大家在体验 app 和小程序时,会产生如下质疑:为什么 app 长期不使用,就需要重新登录账号,而在当前市场上绝大部分的小程序中,只要一次授权用户手机号注册为用户,无论间隔多长时间,再次启动小程序,当前注册账号的身份依然存在。这一切,其实是静默登录在发挥作用。

我们知道,静默登录的目的是为了获取微信用户身份,开发者后端将微信用户身份与业务身份进行绑定,关联自定义登录态,生成用户id,同时初始化用户信息。也就是说,静默登录不仅仅用于获取auth-token进行鉴权,也可以返回当前用户信息以此识别用户身份。

用户身份定义

如上图所示,显示用户的三种身份:

  1. 游客: 当新用户第一次启动小程序时,会走静默登录流程,为这个用户生成独一无二的业务身份(uid),用户标签(busiIdentity)为VISIT。其它用户信息均初始化为空。

  2. 普通用户: 当用户授权手机号登录或使用验证码/密码注册账号时,调用注册接口,此时开发者服务端会将手机号与uid进行绑定,为该用户生成默认的用户昵称和默认头像,同时将用户标签(busiIdentity)更新为USER,将用户信息返回供小程序端更新存储。

  3. 完善用户: 此时用户已经注册账号,可以正常的操作各种业务场景(比如领券、下单),但对管理者来说,肯定希望获取更详细的用户画像。这时候就需要有授权用户信息的步骤,供用户完善个人信息。

在我们的业务场景中,肯定希望能够有方法判断当前用户处于哪个授权阶段,那么便可定义如下枚举:

用户登录的三个阶段

4.1 session.getCurrentAuthStep - 判断当前授权阶段

通过上一步定义的AuthStepType枚举,很容易就能封装session.getCurrentAuthStep方法判断当前授权阶段, 这个方法主要用于登录渲染不同的状态,比如当登录阶段为AuthStepType.ONE时,展示授权手机号的登录按钮,当登录阶段为AuthStepType.TWO时,展示授权用户信息按钮,当登录阶段为AuthStepType.THREE时,表示登录成功,直接返回上一级页面或者主页。

getCurrentAuthStep方法代码

当然,可以二次封装session.hasAuth方法判断用户是否注册过手机号,供各种业务场景调用。

4.2 session.mustAuth - 登录拦截跳转

前面提到过,「用户登录」的目的是为了整合各个渠道的交易、促销、收藏等数据,以电商小程序为例,以下场景需要用户登录才能正常使用:

登录拦截跳转

如图所示,当一名游客触发一些特定的操作时,比如加购、领券,会拦截跳转至登录页面(有些小程序是登录弹窗)。我们封装session.mustAuth方法来实现这个功能,代码如下所示:

mustAuth代码

我们使用session.mustAuth.then方法来拦截跳转登录页面,该方法会浸入各种业务逻辑,可以将其封装成装饰器@mustAuth,用来修饰各个业务需求的类的方法,从而实现解耦。装饰器源码如下:

mustAuth装饰器

5. 总结

一千个观众眼中有一千个哈姆雷特。静默登录流程几乎是所有小程序开发都会遇到的业务场景,每个人的实现方法都大同小异。但查阅资料,作者发现很少有人愿意花笔墨去讲清楚这个故事。于是,作者拿起了笔,自以为相较之前的两篇文章,还是有所改进,水平有限,欢迎探讨~

6. 作者相关文章

  1. 《小程序静默登录方案设计》
  2. 《小程序用户登录方案设计》