小程序用户登录架构设计

首发于公众号@程序员思语
作者:程序员思语

1. 背景

首先谈谈在小程序的开发中,如何借助微信的能力标识一个用户?

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

  • OpenId 是一个用户对于一个小程序/公众号的标识,开发者可以通过这个标识识别出用户。

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

同一个用户的这两个 ID 对于同一个小程序来说是永久不变的,就算用户删了小程序,下次用户进入小程序,开发者依旧可以通过后台的记录标识出来。那么如何获取 OpenId 和 UnionId 呢?

早期(2018 年 4 月之前)的小程序设计使用 wx.getUserInfo 接口 (现改用 wx.getUserProfileget),来获取用户信息。设计这个接口的初衷是希望开发者在真正需要用户信息(如头像、昵称)的情况下才去调取这个接口。但很多开发者为了拿到 UnionId,会在小程序启动时直接调用这个接口,导致用户在使用小程序的时候产生困扰,归结起来有几点:

  • 开发者在小程序首页直接调用 wx.getUserInfo 进行授权,弹框获取用户信息,会使得一部分用户点击“拒绝”按钮。
  • 在开发者没有处理用户拒绝弹框的情况下,用户必须授权头像昵称等信息才能继续使用小程序,会导致某些用户放弃使用该小程序(以前有些产品在登录时强制用户登录才能使用也是非常不妥的)。
  • 用户没有很好的方式重新授权,尽管微信官方增加了设置页面,可以让用户选择重新授权,但很多用户并不知道可以这么操作 (多数产品会采用弹窗 or 浮层的形式引导用户打开设置)。

微信官方也意识到了这个问题,针对获取用户信息更新了三个能力:

  • 使用组件来获取用户信息。
    若用户满足一定条件,则可以用 wx.login 获取到的 code 直接换到 unionId。
    wx.getUserInfo 不需要依赖 wx.login 就能调用得到数据。

本文主要讲述的是第二点能力,微信官方鼓励开发者在不骚扰用户的情况下合理获得 unionid,而仅在必要时才向用户弹窗申请使用昵称头像,从而衍生出「静默登录」「用户登录」两种概念。

2. 静默登录

小程序可以通过微信官方提供的登录能力方便地获取微信提供的用户身份标识,快速建立小程序内的用户体系。

很多开发者会把 wx.loginwx.getUserInfo 捆绑调用当成登录使用,其实 wx.login 已经可以完成登录,wx.getUserInfo 只是获取额外的用户信息。

wx.login 获取到 code 后,会发送到开发者后端,开发者后端通过接口去微信后端换取到 openid 和 sessionKey(现在会将 unionid 也一并返回)后,把自定义登录态 3rd_session(本业务命名为 auth-token) 返回给前端,就已经完成登录行为了。wx.login 行为是静默,不必授权的,用户不会察觉。

wx.getUserInfo 只是为了提供更优质的服务而存在,比如获取用户的手机号注册会员,或者展示头像昵称,判断性别,开发者可通过 unionId 和其他公众号上已有的用户画像结合来提供历史数据。因此开发者不必在用户刚刚进入小程序的时候就强制要求授权。

2.1 静默登录流程时序

官方给出了 wx.login 的最佳实践如下:

总结为以下三步:

  • 小程序端调用 wx.login() 获取 临时登录凭证code ,并回传到开发者服务器。
  • 服务器端调用 auth.code2Session 接口,换取 用户唯一标识 OpenID 和 会话密钥 session_key。
  • 开发者服务器可以根据用户标识来生成自定义登录态(例如:auth-token),用于后续业务逻辑中前后端交互时识别用户身份。

静默登录英文简称为 silentLogin,项目代码示例如下:

// 先使用 wx.login 换code,再用code换取后端token,和数据库内用户的其他信息
async login() {
    try {
    // 获取临时登录凭证code
    let wxLogin = await wxp.login()
    // 将code发送给服务端
    let result = await commonApi.login({
      code: wxLogin.code
    }).catch(err=>{

    })
    if(!result) return
    wx.hideLoading()
    // 保存登录信息,如 jwtString
    wx.setStorageSync('token', result.data.jwtString)
    return result.data
  } catch (error) {
    throw error;
  }
}

在token放在header请求里带给后端,在微信小程序生态下不会担心被抓包的。

2.2 开发者后台校验与解密开放数据

静默登录成功后,微信服务器端会下发一个session_key给服务端,而这个会在需要获取微信开放数据的时候会用到。

为了确保开放接口返回用户数据的安全性,微信会对明文数据进行签名。开发者可以根据业务需要对数据包进行签名校验,确保数据的完整性。

  • 小程序通过调用接口(如 wx.getUserInfo)获取数据时,如果用户已经授权,接口会同时返回以下几个字段。如用户未授权,会先弹出用户弹窗,用户点击同意授权,接口会同时返回以下几个字段。相反如果用户拒绝授权,将调用失败。
属性类型说明
userInfoUserInfo用户信息对象,不包含 openid 等敏感信息
rawDatastring不包括敏感信息的原始数据字符串,用于计算签名
signaturestring包括敏感数据在内的完整用户信息的加密数据
encryptedDatastring包括敏感数据在内的完整用户信息的加密数据
ivstring加密算法的初始向量
cloudIDstring敏感数据对应的云 ID,开通云开发的小程序才会返回,可通过云调用直接获取开放数据
  • 开发者将 signature、rawData 发送到开发者服务器进行校验。服务器利用用户对应的 session_key 使用相同的算法计算出签名 signature2 ,比对 signature 与 signature2 即可校验数据的完整性。开发者服务器告诉前端开发者数据可信,即可安全使用用户信息数据。
  • 如果开发者想要获取敏感数据(如 openid,unionID),则将encryptedData和iv发送到开发者服务器,由服务器使用session_key(对称解密密钥)进行对称解密,获取敏感数据进行存储并返回给前端开发者。

注意: 因为需要用户主动触发才能发起获取手机号接口,所以该功能不由 API 来调用(即上述提到的wx.getUserInfo是无法获取手机号的),需用 button 组件的点击来触发。获得encryptedData和iv,同样发送给开发者服务器,由服务器使用session_key(对称解密密钥)进行对称解密,获得对应的手机号。

微信API调整

  • 2021 年 2 月 23 日起,通过wx.login接口获取的登录凭证可直接换取unionID。
  • 2021 年 4 月 13 日后发布新版本的小程序,无法通过wx.getUserInfo接口获取用户个人信息(头像、昵称、性别与地区),将直接获取匿名数据。getUserInfo接口获取加密后的openID与unionID数据的能力不做调整。
    新增getUserProfile接口(基础库 2.10.4 版本开始支持),可获取用户头像、昵称、性别及地区信息,开发者每次通过该接口获取用户个人信息均需用户确认。
  • 即开发者通过组件调用wx.getUserInfo将不再弹出弹窗,直接返回匿名的用户个人信息。如果要获取用户头像、昵称、性别及地区信息,需要改造成wx.getUserProfile接口。

针对一些老用户在停留在低版本基础库,在实际生产中需要判断一下,兼容操作。

// 通过定义一个全局变量 检查当前用户使用的版本是否支持wx.getUserProfile,再去调整不同的授权按钮
canIUseProfile: wx.canIUse('getUserProfile')

<button wx:if="{{canIUseProfile}}" bindtap="getUserInfo">获取用户头像</button>
<button wx:else open-type="getUserInfo" bindgetuserinfo="getUserInfo">获取用户头像</button>

2.3 session_key 的有效期

开发者如果遇到因为 session_key 不正确而校验签名失败或解密失败,请关注下面几个与 session_key 有关的注意事项。

  • wx.login 调用时,用户的 session_key 可能会被更新而致使旧 session_key 失效(刷新机制存在最短周期,如果同一个用户短时间内多次调用 wx.login,并非每次调用都导致 session_key 刷新)。开发者应该在明确需要重新登录时才调用 wx.login,及时通过 auth.code2Session 接口更新服务器存储的 session_key。
  • 微信不会把 session_key 的有效期告知开发者。我们会根据用户使用小程序的行为对 session_key 进行续期。用户越频繁使用小程序,session_key 有效期越长。
  • 开发者在 session_key 失效时,可以通过重新执行登录流程获取有效的 session_key。使用接口wx.checkSession可以校验 session_key 是否有效,从而避免小程序反复执行登录流程。
  • 当开发者在实现自定义登录态时,可以考虑以 session_key 有效期作为自身登录态有效期,也可以实现自定义的时效性策略。

实际生产中,当执行登录返回 session_key 失效时,一般可以将当前的请求存下来,重新刷新获取 session_key 并刷新当前token,走一遍登录后继续执行刚刚的HTTP请求队列。示例代码如下:

/** 刷新Token, 默认只刷新一次 */
function refreshToken(params: AxiosRequestConfig) {
  return promisify(wx.login)()
    .then((res: WechatMiniprogram.LoginSuccessCallbackResult) => promisify(wx.request)({
      url: config.loginUrl + res.code
    }))
    .then((res: any) => {
      wx.setStorageSync('token', res.data.jwtString)
      return refreshRequest(params)
    }, (err: any) => {
      return Promise.reject(err)
    })
}

/** 重新发起请求 */
function refreshRequest(config: AxiosRequestConfig) {
  return promisify(wx.request)({
    url: config.url,
    header: Object.assign({}, config.headers, {
      'Authorization': wx.getStorageSync('token')
    }),
    data: config.data,
    method: config.method,
    timeout: config.timeout
  }).then((res: any) => {
    const response: AxiosResponse = {
      data: res.data,
      status: res.statusCode,
      statusText: res.errMsg,
      headers: res.header,
      config: config,
      cookies: res.cookies
    }
    return response
  }, (err: any) => {
    return Promise.reject(err)
  })
}

3 「登录」架构

「登录」方案架构如上图所示,将所有登录相关功能抽象到 「service 层」(本项目将其命名为session),供 「业务层」 调用。

3.1 libs - 提供登录相关的类方法供「业务层」调用

封装session类,提供类方法供「业务层」调用。主要有以下几种方法:

方法名功能使用场景
silentLogin发起静默登录-
login登录,silentLogin 方法的一层封装用于小程序启动时发起静默登录
refreshLogin刷新登录态,silentLogin 方法的一层封装用于登录态过期时发起静默登录
ensureSessionKey验证 sessionKey 是否过期,过期则刷新登录态绑定微信授权手机号时验证是否过期,过期则得重新弹窗授权

装饰器:

  • fuse-line: 熔断机制,如果短时间内多次调用,则停止响应一段时间,类似于 TCP 慢启动。用于解决refreshLogin、login等方法的并发处理问题。
  • single-queue: 单队列模式,同一时间,只允许一个正在过程中的网络请求。请求被锁定之后,同样的请求都会被推入队列,等待进行中的请求返回后,消费同一个结果。用于解决refreshLogin、login等方法的并发处理问题。

4. 静默登录的调用时机

4.1 小程序启动时调用

由于大部分情况都需要依赖登录态,在小程序启动的时候(app.onLaunch())调用静默登录是最常见的手段。这里我们封装一个login函数如下所示,首先调用wx.checkSession判断session_key是否过期,如果session_key未过期且本地存在auth_token自定义登录态,表示当前的静默登录态仍然有效,无需进行其它操作。否则,表示静默登录态失效或者新用户从未发起过静默登录,那么发起静默登录流程。

public async login(): Promise<void> {
    // 调用wx.checkSession判断session_key是否过期
    const hasSession = await checkSession();

    // 本地已有可用登录态且session_key未过期,resolve。
    if (this.getAuthToken() && hasSession) return Promise.resolve();

    // 否则,发起静默登录
    await this.silentLogin();
}

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

4.2 接口请求发起时调用

保险起见,如果某些接口需要携带自定义登录态进行鉴权,则需要在请求发起时进行拦截,校验登录态,并刷新登录。刷新登录代码如下所示:

整个流程如下图所示:

  • 拦截 request:

    • 判断是否需要鉴权:请求发起时,拦截请求,判断请求是否需要添加auth-token,如若不需要,直接发起请求。如若需要,执行第二步。
    • 判断是否需要发起静默登录:判断 storage 中是否存在auth-token,如若不存在,发起「刷新登录」。
    • 请求头部添加auth-token:添加auth-token,发起请求。
  • 与服务端通信:发起请求,服务端处理请求返回结果。

  • 拦截 response: 解析状态码

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

4.3 wx.checkSession 罢工之谜

基于上述接口请求发起时调用的流程,很多人会有疑问,既然服务端会返回auth-token过期的状态码,为啥不在请求发送前进行拦截,使用wx.checkSession接口校验登录态是否过期(如下图所示,增加红框内的步骤)?

这是因为,我们通过实验发现,在 session_key 已过期的情况下,wx.checkSession 有一定的几率返回true。即增加wx.checkSession步骤并不能百分百保证登录态不会过期,后续仍然需要对不同的状态码进行处理。
社区也有相关的反馈未得到解决:

所以结论是:wx.checkSession可靠性是不达 100% 的。
基于以上,我们需要对 session_key 的过期做一些容错处理:

发起需要使用 session_key 的请求前,做一次 wx.checkSession 操作,如果失败了刷新登录态。
后端使用session_key解密开放数据失败之后,返回特定错误码(如:USER_WX_SESSIONKEY_EXPIRE),前端刷新登录态。

const common: Api.method.common = {
 async checkSession() {
    return new Promise((resole) => {
      wx.checkSession({
        success() {
           resole()
        },
        fail: ()=> {
            this.login().then(res=>{
              resole()
            })
        }
      })
    })
  },
  async login() {
    wx.showLoading({
      title:'加载中'
    })
    let wxLogin = await wxp.login()
    let result = await commonApi.login({
      code: wxLogin.code
    }).catch(err=>{
    })
    if(!result) return
    wx.hideLoading()
    wx.setStorageSync('token', result.data.jwtString)
    return result.data
  },
}

4.4 并发处理

我们知道,当启动小程序时,各种监控、埋点数据上报都需要获取用户的个人信息,这些信息都得「静默登录」后才能获取,因此会同时发起多个login请求。另一种情况下,假设一个新用户进入一个业务复杂的页面,同时发起五个不同的业务请求,恰巧这五个请求都需要鉴权,那么五个请求都会被拦截并发起refreshLogin请求。显然,这样的并发是不合理的。
基于此,我们设计了如下方案:

  • 单队列模式:
  • 请求锁:同一时间,只允许一个正在过程中的网络请求。
  • 等待队列:请求被锁定之后,同样的请求都会被推入队列,等待进行中的请求返回后,消费同一个结果。

如上图所示,首先refreshLogin请求入队,队列中只有一个请求,发送该请求,同时保险丝计入次数 1,服务端返回请求结果,消费结果。接着又发起一个refreshLogin请求,队列中只有一个请求,发送该请求,同时保险丝计入次数 2。然后又连续发起三个请求,由于上一个请求还没有执行完成,将这三个请求入队,等待上一个请求结果返回,队列中的四个请求消费同一个结果。由于触发自动冷却阈值,保险丝重置。
以上两种方案通过装饰器模式引入,代码如下所示,refreshLogin函数其实是slientLogin函数的一层封装,用于接口发起时调用。而前面提到的login函数也是slientLogin函数的一层封装,用户小程序启动时调用。

@singleQueue({ name: 'refreshLogin' })
@fuseLine({ name: 'refreshLogin' })
public async refreshLogin(): Promise<void> {
  try {
    // 清除 Session
    this.clearSession();
    await this.silentLogin();
  } catch (error) {
    throw error;
  }
}

到此,很多读者可能对熔断机制还不甚理解,熔断的目的是为一个函数提供保险丝保障,短时间内多次调用,会熔断一段时间,这段时间内拒绝所有请求。如果在自动冷却阈值内,没有请求通过,则重置保险丝。代码如下所示:

export default function fuseLine({
  // 一次熔断前重试次数
  tryTimes = 3,

  // 重试间隔,单位 ms
  restoreTime = 5000,

  // 自动冷却阈值,单位 ms
  coolDownThreshold = 1000,

  // 名称
  name = 'unnamed',
}: {
  tryTimes?: number;
  restoreTime?: number;
  name?: string;
  coolDownThreshold?: number;
} = {}) {
  // 请求锁
  let fuseLocked = false;

  // 当前重试次数
  let fuseTryTimes = tryTimes;

  // 自动冷却
  let coolDownTimer;

  // 重置保险丝
  const reset = () => {
    fuseLocked = false;
    fuseTryTimes = tryTimes;
    logger.info(`${name}-保险丝重置`);
  };

  const request = async () => {
    if (fuseLocked) throw new Error(`${name}-保险丝已熔断,请稍后重试`);

    // 已达最大重试次数
    if (fuseTryTimes <= 0) {
      fuseLocked = true;

      // 重置保险丝
      setTimeout(() => reset(), restoreTime);

      throw new Error(`${name}-保险丝熔断!!`);
    }

    // 自动冷却系统
    if (coolDownTimer) clearTimeout(coolDownTimer);
    coolDownTimer = setTimeout(() => reset(), coolDownThreshold);

    // 允许当前请求通过保险丝,记录 +1
    fuseTryTimes = fuseTryTimes - 1;
    logger.info(`${name}-通过保险丝(${tryTimes - fuseTryTimes}/${tryTimes})`);
    return Promise.resolve();
  };

  return function(
    _target: Record<string, any>,
    _propertyName: string,
    descriptor: TypedPropertyDescriptor<(...args: any[]) => any>,
  ) {
    const method = descriptor.value;
    descriptor.value = async function(...args: any[]) {
      await request();
      if (method) return method.apply(this, args);
    };
  };
}

5. 用户登录流程

小程序可以通过微信官方提供的登录能力方便地获取微信提供的用户身份标识,快速建立小程序内的用户体系。
即「静默登录」,通过调用 wx.login 获取到 code ,将其发送到开发者后端,开发者后端通过接口去微信后端换取到 openid 和 sessionKey(现在会将 unionid 也一并返回)后,然后把自定义登录态 3rd_session(本业务命名为auth-token) 返回给前端,就已经完成登录行为了。
理论上,开发者后端可以通过 openid识别用户,也能通过unionid关联同主体的多个小程序、公众号、app,实现数据互通,从而为每一个用户创建独一无二的uid(本业务自定义的用户 id),在「微信生态」中建立成熟用户体系。
然而,对于复杂的电商跨端应用,比如pc、h5、小程序,不同渠道注册的uid是不同的,用户登录后难以对各个渠道的交易、促销、收藏等数据进行整合。因此,要实现跨端的用户体系数据互通,就需要提供一个唯一的用户标识——手机号。这便是本文重点讲述的「用户登录」,即「游客态」转变成「会员态」的过程。

当新用户第一次进入小程序时,便会触发「静默登录」,这个过程对用户是无感知的。但此时开发者服务端已经为该用户定义了uid,并下发auth-token给小程序端,对于一些需要鉴权的请求,服务端可以根据请求携带的auth-token精确识别是哪个用户发起的行为。
然而,类似加购、下单、领券等用户行为,涉及到跨端数据的整合,在执行用户操作之前,会判断用户是否登录,如若用户未登录,则跳转登录页面。

比如在「用户中心」页面点击「我的订单」,由于此时用户未登录,跳转到登录页面,可以选择以下两种登录方式:

选择 「微信授权登录」,弹出授权手机号信息弹窗,点击「允许」,此时用户登录成功。
选择 「手机快捷登录」,输入手机号,使用 「验证码」 或者 「密码」 进行登录,登录成功跳转回到「用户中心」页面。

上述步骤已经完成了「用户登录」,用户可以正常的执行加购、领券、下单等操作。 为了提升用户体验,需要对 「会员信息」 进行维护 ,比如昵称、头像、性别、生日等信息,最简单的方法是 获取「微信授权用户信息」。触发时机分为以下两种:

用户第一次选择 「微信授权登录」 成功后跳转授权用户信息页面,点击 「授权用户信息」,弹出授权用户信息弹窗。点击「允许」,跳转回「用户中心」页面。
在「用户中心」页面点击头像昵称区域,弹出授权用户信息弹窗,点击「允许」,更新「会员信息」并跳转用户信息编辑页面。

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

5.1.1 libs - 提供登录相关的类方法供「业务层」调用

封装session类,提供类方法供「业务层」调用。主要有以下几种方法:

当然,session类中还封装了一些方法用于与storage交互,比如获取storage中的auth-token用于各种鉴权请求携带等等。session类也提供的一些拓展方法,比如注销账号、解绑手机号等等用于后续需求迭代。

装饰器:

  • must-auth: mustAuth类方法的装饰器,便于业务层各种场景触发登录。
  • fuse-line: 熔断机制,如果短时间内多次调用,则停止响应一段时间,类似于 TCP 慢启动。用于解决refreshLogin、login等方法的并发处理问题。
  • single-queue: 单队列模式,同一时间,只允许一个正在过程中的网络请求。请求被锁定之后,同样的请求都会被推入队列,等待进行中的请求返回后,消费同一个结果。用于解决refreshLoginlogin等方法的并发处理问题。

5.1.2 ui - 提供通用组件供业务层调用

  • 基础组件: user-container和phone-container分别是获取「微信授权用户信息」和获取「微信授权手机号」的纯 UI 单元组件,给通用组件使用。
  • behavior 类:拿到授权数据后需要发送给服务端进行存储,也需要执行一些跳转逻辑判断,这些都抽象成行为类封装在auth-flow中,供通用组件使用。
  • 通用组件: 共用一个行为类,区别在于auth-flow-container用于页面,auth-flow-popup用于弹窗。如下所示,小程序只有微信授权功能,则可以通过弹窗完成授权。如小程序同时提供手机号验证码和密码登录等功能,则需跳转特定登录页面。

5.2 libs

5.2.1 用户身份定义

综上所示,用户登录的阶段可以分为以下三步:

// 用户登录的阶段
export enum AuthStepType {
  // 阶段一:游客态:静默登录成功,未绑定手机号,无用户信息
  ONE = 1,
  // 阶段二:会员态:用户登录成功,已绑定手机号,无用户信息
  TWO = 2,
  // 阶段三:会员信息态:用户登录成功,已绑定手机号,有用户信息
  THREE = 3,
}

那么如何判断用户此时处于哪个步骤,基于「静默登录」的启发,原本「静默登录」成功开发者后端会将自定义登录态 auth-token返回给前端,此处请求可以携带返回「用户信息」,同auth-token一起命名为session存储在本地storage。当「用户登录」或者「更新用户信息」时,会同步更新storage中key为session的数据,从而通过这些用户数据判断当前用户处于哪一个登录阶段。
以下表格列出了session存储的部分重要的属性以及在三个阶段属性对应的值。

注意: 会员态和会员信息态的busiIdentity值均为MEMBER,区分会员态和会员信息态可以通过用户昵称和头像等字段,比如用户登录成功会为用户生成以’u_'开头的默认昵称和默认为空的用户头像链接。

判断用户此时处于哪个步骤的代码如下:

// 获取当前授权阶段
public getCurrentAuthStep(): AuthStepType {
  // 切换账号登录的时候,始终返回AuthStepType.ONE
  const loginMode = this.getLoginMode();
  if (loginMode === LoginMode.SWITCH_ACCOUNT) return AuthStepType.ONE;

  // 用户身份定义非会员返回AuthStepType.ONE
  const userInfo = this.getUser();
  if (userInfo?.busiIdentity !== 'MEMBER') return AuthStepType.ONE;

  // 初次登录,未授权用户信息,返回AuthStepType.TWO
  if (userInfo.nickName.substring(0, 2) === 'u_' && !userInfo.headUrl)
    return AuthStepType.TWO;

  // 都有,返回AuthStepType.THREE
  return AuthStepType.THREE;
}

5.2.2 用户登录触发场景

前面提到过,「用户登录」的 目的是为了整合各个渠道的交易、促销、收藏等数据,针对电商小程序,目前总结的需要用户登录的场景如下所示:

即当用户登录小程序时,可以正常浏览浏览商品,只有触发某些特定行为,比如领券、加购、收藏、下单等,才会判断用户是否处于登录状态,如未登录,跳转登录页面。

如下所示,封装mustAuth方法进行拦截,未登录则跳转登录页面:

export default class Session {
  ...
  public mustAuth({
    mustAuthStep = AuthStepType.TWO, // 传人参数,需要授权的LEVEL
  } = {}): Promise<void> {
    // 当前阶段处于会员态(2)或者会员信息态(3),执行resolve操作
    if (this.getCurrentAuthStep() >= mustAuthStep) return Promise.resolve();
    // 当前阶段处于游客态(1),跳转登录页
    Navigator.gotoPage('/login/home');
    // 执行reject操作
    return Promise.reject();
  }
}

上述代码是跳转页面拦截,对于弹窗而言,需要把弹窗注入base-page(每个页面都需要引入的通用组件,封装每个页面都需要使用的通用方法,比如错误处理等)中,通过 id 查找到弹窗组件,并进行调用。

export default class Session {
  ...
   public mustAuth({
    mustAuthStep = AuthStepType.TWO, // 需要授权的LEVEL
    popupCompName = 'auth-flow-popup',
  } = {}): Promise<void> {
    // 当前阶段处于会员态(2)或者会员信息态(3),执行resolve操作
    if (this.getCurrentAuthStep() >= mustAuthStep) return Promise.resolve();
    // 获取弹窗组件
    const pages = getCurrentPages();
    const curPage = pages[pages.length - 1];
    const context = curPage.$$basePage || curPage;
    const popupComp = context.selectComponent(`#${popupCompName}`);
    // 容错处理
    if (!popupComp) {
      return Promise.reject(
        new Error(
          "当前页面未找到 #auth-popup 组件,请参考 'doc/登录组件的使用方式.md'",
        ),
      );
    }
    // 调用弹窗组件方法
    popupComp.setMustAuthStep(mustAuthStep);
    popupComp.nextStep();
    // 等待授权成功回调
    return this.waitAuth();
  }
}

各个业务使用时可以通过session.mustAuth().then(() => {…});进行调用,为了提高使用体验,也可以使用装饰器@mustAuth()来修饰各个业务需求 类的方法,装饰器源码如下:

/**
 * 登录检查装饰器,使用该装饰器的方法,会先执行授权检查,如果未授权,将跳转登录页面
 */
export default function mustAuth(option = {}) {
  return function(
    _target: Record<string, any>,
    _propertyName: string,
    descriptor: TypedPropertyDescriptor<(...args: any[]) => any>,
  ) {
    const method = descriptor.value;
    descriptor.value = function(...args: any[]) {
      if (!session) return;
      // 登录拦截
      return session.mustAuth(option).then(() => {
        if (method) return method.apply(this, args);
      });
    };
  };
}

6. 基础组件的封装

2012 年 4 月 13 日之前,使用wx.getUserInfo弹出授权弹窗时,如果用户点击允许授权,那么会记录用户的行为,下次再点击时,不会弹窗而是直接将授权结果返回。4 月 13 日之后后,使用wx.getUserProfile,开发者每次通过该接口获取用户个人信息均需用户确认,因此需要妥善保管用户授权的头像昵称,避免重复弹窗。

// index.wxml
 <button class="reset-button" open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber" hover-class="none" disabled="{{disabled}}"><slot></slot></button>

// index.ts
export default class PhoneContainer extends BaseComponent {
  getPhoneNumber(
    e: WechatMiniprogram.Event<WechatMiniprogram.GetPhoneNumberCallbackResult>,
  ) {
    this.triggerEvent('getphonenumber', { ...e.detail,  authType: AuthType.PHONE,});
  }
}

在「微信授权登录」过程中,小程序拿到加密的encryptedData和iv数据,将其和携带的auth-token一起发送给开发者服务器,服务端通过auth-token鉴权识别这个用户,并使用静默登录成功获取的session_key(对称解密密钥)对encryptedData和iv数据进行对称解密,获取该用户的手机号,将手机号与uid绑定,此时该用户成功注册会员,并将会员信息返回给小程序端。
小程序端更新本地storage存储的session数据,此时busiIdentity的值已经从VISIT更新为MEMBER,用户身份转变为会员态,登录成功。
在「授权用户信息」的过程中,小程序调用wx.getUserProfile方法拿到用户数据,并将这些数据与携带的auth-token一起发送给开发者服务器,服务端通过auth-token鉴权识别这个用户,更新该用户的信息并将新的会员数据返回给小程序端。
小程序端更新本地storage存储的session数据,此时用户昵称和头像均已更新,用户身份转变为会员信息态,授权成功。
眼尖的读者一定观察到了,时序图中还对微信头像做了转存。这是因为用户在微信端修改微信头像后,之前「授权用户信息」获取的微信头像链接就会失效,因此开发者应该在自己获取用户信息后,将头像保存下来,避免微信头像 URL 失效后的异常情况。

7. 总结

我们将用户登录能力从业务层中抽象出来,统一封装在service层,便于复用。本文主要讲述的是service层的架构,对于业务层的逻辑实现并没有多加累赘。下列表格以小程序端为例,简述了「静默登录」和「用户登录」整套方案的前后端逻辑实现。# 小程序静默登录方案设计

1. 背景

首先谈谈在小程序的开发中,如何借助微信的能力标识一个用户?

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

  • OpenId 是一个用户对于一个小程序/公众号的标识,开发者可以通过这个标识识别出用户。

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

同一个用户的这两个 ID 对于同一个小程序来说是永久不变的,就算用户删了小程序,下次用户进入小程序,开发者依旧可以通过后台的记录标识出来。那么如何获取 OpenId 和 UnionId 呢?

早期(2018 年 4 月之前)的小程序设计使用 wx.getUserInfo 接口 (现改用 wx.getUserProfileget),来获取用户信息。设计这个接口的初衷是希望开发者在真正需要用户信息(如头像、昵称)的情况下才去调取这个接口。但很多开发者为了拿到 UnionId,会在小程序启动时直接调用这个接口,导致用户在使用小程序的时候产生困扰,归结起来有几点:

  • 开发者在小程序首页直接调用 wx.getUserInfo 进行授权,弹框获取用户信息,会使得一部分用户点击“拒绝”按钮。
  • 在开发者没有处理用户拒绝弹框的情况下,用户必须授权头像昵称等信息才能继续使用小程序,会导致某些用户放弃使用该小程序(以前有些产品在登录时强制用户登录才能使用也是非常不妥的)。
  • 用户没有很好的方式重新授权,尽管微信官方增加了设置页面,可以让用户选择重新授权,但很多用户并不知道可以这么操作 (多数产品会采用弹窗 or 浮层的形式引导用户打开设置)。

微信官方也意识到了这个问题,针对获取用户信息更新了三个能力:

  • 使用组件来获取用户信息。
    若用户满足一定条件,则可以用 wx.login 获取到的 code 直接换到 unionId。
    wx.getUserInfo 不需要依赖 wx.login 就能调用得到数据。

本文主要讲述的是第二点能力,微信官方鼓励开发者在不骚扰用户的情况下合理获得 unionid,而仅在必要时才向用户弹窗申请使用昵称头像,从而衍生出「静默登录」「用户登录」两种概念。

2. 静默登录

小程序可以通过微信官方提供的登录能力方便地获取微信提供的用户身份标识,快速建立小程序内的用户体系。

很多开发者会把 wx.loginwx.getUserInfo 捆绑调用当成登录使用,其实 wx.login 已经可以完成登录,wx.getUserInfo 只是获取额外的用户信息。

wx.login 获取到 code 后,会发送到开发者后端,开发者后端通过接口去微信后端换取到 openid 和 sessionKey(现在会将 unionid 也一并返回)后,把自定义登录态 3rd_session(本业务命名为 auth-token) 返回给前端,就已经完成登录行为了。wx.login 行为是静默,不必授权的,用户不会察觉。

wx.getUserInfo 只是为了提供更优质的服务而存在,比如获取用户的手机号注册会员,或者展示头像昵称,判断性别,开发者可通过 unionId 和其他公众号上已有的用户画像结合来提供历史数据。因此开发者不必在用户刚刚进入小程序的时候就强制要求授权。

2.1 静默登录流程时序

官方给出了 wx.login 的最佳实践如下:

总结为以下三步:

  • 小程序端调用 wx.login() 获取 临时登录凭证code ,并回传到开发者服务器。
  • 服务器端调用 auth.code2Session 接口,换取 用户唯一标识 OpenID 和 会话密钥 session_key。
  • 开发者服务器可以根据用户标识来生成自定义登录态(例如:auth-token),用于后续业务逻辑中前后端交互时识别用户身份。

静默登录英文简称为 silentLogin,项目代码示例如下:

// 先使用 wx.login 换code,再用code换取后端token,和数据库内用户的其他信息
async login() {
    try {
    // 获取临时登录凭证code
    let wxLogin = await wxp.login()
    // 将code发送给服务端
    let result = await commonApi.login({
      code: wxLogin.code
    }).catch(err=>{

    })
    if(!result) return
    wx.hideLoading()
    // 保存登录信息,如 jwtString
    wx.setStorageSync('token', result.data.jwtString)
    return result.data
  } catch (error) {
    throw error;
  }
}

在token放在header请求里带给后端,在微信小程序生态下不会担心被抓包的。

2.2 开发者后台校验与解密开放数据

静默登录成功后,微信服务器端会下发一个session_key给服务端,而这个会在需要获取微信开放数据的时候会用到。

为了确保开放接口返回用户数据的安全性,微信会对明文数据进行签名。开发者可以根据业务需要对数据包进行签名校验,确保数据的完整性。

  • 小程序通过调用接口(如 wx.getUserInfo)获取数据时,如果用户已经授权,接口会同时返回以下几个字段。如用户未授权,会先弹出用户弹窗,用户点击同意授权,接口会同时返回以下几个字段。相反如果用户拒绝授权,将调用失败。
属性类型说明
userInfoUserInfo用户信息对象,不包含 openid 等敏感信息
rawDatastring不包括敏感信息的原始数据字符串,用于计算签名
signaturestring包括敏感数据在内的完整用户信息的加密数据
encryptedDatastring包括敏感数据在内的完整用户信息的加密数据
ivstring加密算法的初始向量
cloudIDstring敏感数据对应的云 ID,开通云开发的小程序才会返回,可通过云调用直接获取开放数据
  • 开发者将 signature、rawData 发送到开发者服务器进行校验。服务器利用用户对应的 session_key 使用相同的算法计算出签名 signature2 ,比对 signature 与 signature2 即可校验数据的完整性。开发者服务器告诉前端开发者数据可信,即可安全使用用户信息数据。
  • 如果开发者想要获取敏感数据(如 openid,unionID),则将encryptedData和iv发送到开发者服务器,由服务器使用session_key(对称解密密钥)进行对称解密,获取敏感数据进行存储并返回给前端开发者。

注意: 因为需要用户主动触发才能发起获取手机号接口,所以该功能不由 API 来调用(即上述提到的wx.getUserInfo是无法获取手机号的),需用 button 组件的点击来触发。获得encryptedData和iv,同样发送给开发者服务器,由服务器使用session_key(对称解密密钥)进行对称解密,获得对应的手机号。

微信API调整

  • 2021 年 2 月 23 日起,通过wx.login接口获取的登录凭证可直接换取unionID。
  • 2021 年 4 月 13 日后发布新版本的小程序,无法通过wx.getUserInfo接口获取用户个人信息(头像、昵称、性别与地区),将直接获取匿名数据。getUserInfo接口获取加密后的openID与unionID数据的能力不做调整。
    新增getUserProfile接口(基础库 2.10.4 版本开始支持),可获取用户头像、昵称、性别及地区信息,开发者每次通过该接口获取用户个人信息均需用户确认。
  • 即开发者通过组件调用wx.getUserInfo将不再弹出弹窗,直接返回匿名的用户个人信息。如果要获取用户头像、昵称、性别及地区信息,需要改造成wx.getUserProfile接口。

针对一些老用户在停留在低版本基础库,在实际生产中需要判断一下,兼容操作。

// 通过定义一个全局变量 检查当前用户使用的版本是否支持wx.getUserProfile,再去调整不同的授权按钮
canIUseProfile: wx.canIUse('getUserProfile')

<button wx:if="{{canIUseProfile}}" bindtap="getUserInfo">获取用户头像</button>
<button wx:else open-type="getUserInfo" bindgetuserinfo="getUserInfo">获取用户头像</button>

2.3 session_key 的有效期

开发者如果遇到因为 session_key 不正确而校验签名失败或解密失败,请关注下面几个与 session_key 有关的注意事项。

  • wx.login 调用时,用户的 session_key 可能会被更新而致使旧 session_key 失效(刷新机制存在最短周期,如果同一个用户短时间内多次调用 wx.login,并非每次调用都导致 session_key 刷新)。开发者应该在明确需要重新登录时才调用 wx.login,及时通过 auth.code2Session 接口更新服务器存储的 session_key。
  • 微信不会把 session_key 的有效期告知开发者。我们会根据用户使用小程序的行为对 session_key 进行续期。用户越频繁使用小程序,session_key 有效期越长。
  • 开发者在 session_key 失效时,可以通过重新执行登录流程获取有效的 session_key。使用接口wx.checkSession可以校验 session_key 是否有效,从而避免小程序反复执行登录流程。
  • 当开发者在实现自定义登录态时,可以考虑以 session_key 有效期作为自身登录态有效期,也可以实现自定义的时效性策略。

实际生产中,当执行登录返回 session_key 失效时,一般可以将当前的请求存下来,重新刷新获取 session_key 并刷新当前token,走一遍登录后继续执行刚刚的HTTP请求队列。示例代码如下:

/** 刷新Token, 默认只刷新一次 */
function refreshToken(params: AxiosRequestConfig) {
  return promisify(wx.login)()
    .then((res: WechatMiniprogram.LoginSuccessCallbackResult) => promisify(wx.request)({
      url: config.loginUrl + res.code
    }))
    .then((res: any) => {
      wx.setStorageSync('token', res.data.jwtString)
      return refreshRequest(params)
    }, (err: any) => {
      return Promise.reject(err)
    })
}

/** 重新发起请求 */
function refreshRequest(config: AxiosRequestConfig) {
  return promisify(wx.request)({
    url: config.url,
    header: Object.assign({}, config.headers, {
      'Authorization': wx.getStorageSync('token')
    }),
    data: config.data,
    method: config.method,
    timeout: config.timeout
  }).then((res: any) => {
    const response: AxiosResponse = {
      data: res.data,
      status: res.statusCode,
      statusText: res.errMsg,
      headers: res.header,
      config: config,
      cookies: res.cookies
    }
    return response
  }, (err: any) => {
    return Promise.reject(err)
  })
}

3 「登录」架构

「登录」方案架构如上图所示,将所有登录相关功能抽象到 「service 层」(本项目将其命名为session),供 「业务层」 调用。

3.1 libs - 提供登录相关的类方法供「业务层」调用

封装session类,提供类方法供「业务层」调用。主要有以下几种方法:

方法名功能使用场景
silentLogin发起静默登录-
login登录,silentLogin 方法的一层封装用于小程序启动时发起静默登录
refreshLogin刷新登录态,silentLogin 方法的一层封装用于登录态过期时发起静默登录
ensureSessionKey验证 sessionKey 是否过期,过期则刷新登录态绑定微信授权手机号时验证是否过期,过期则得重新弹窗授权

装饰器:

  • fuse-line: 熔断机制,如果短时间内多次调用,则停止响应一段时间,类似于 TCP 慢启动。用于解决refreshLogin、login等方法的并发处理问题。
  • single-queue: 单队列模式,同一时间,只允许一个正在过程中的网络请求。请求被锁定之后,同样的请求都会被推入队列,等待进行中的请求返回后,消费同一个结果。用于解决refreshLogin、login等方法的并发处理问题。

4. 静默登录的调用时机

4.1 小程序启动时调用

由于大部分情况都需要依赖登录态,在小程序启动的时候(app.onLaunch())调用静默登录是最常见的手段。这里我们封装一个login函数如下所示,首先调用wx.checkSession判断session_key是否过期,如果session_key未过期且本地存在auth_token自定义登录态,表示当前的静默登录态仍然有效,无需进行其它操作。否则,表示静默登录态失效或者新用户从未发起过静默登录,那么发起静默登录流程。

public async login(): Promise<void> {
    // 调用wx.checkSession判断session_key是否过期
    const hasSession = await checkSession();

    // 本地已有可用登录态且session_key未过期,resolve。
    if (this.getAuthToken() && hasSession) return Promise.resolve();

    // 否则,发起静默登录
    await this.silentLogin();
}

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

4.2 接口请求发起时调用

保险起见,如果某些接口需要携带自定义登录态进行鉴权,则需要在请求发起时进行拦截,校验登录态,并刷新登录。刷新登录代码如下所示:

整个流程如下图所示:

  • 拦截 request:

    • 判断是否需要鉴权:请求发起时,拦截请求,判断请求是否需要添加auth-token,如若不需要,直接发起请求。如若需要,执行第二步。
    • 判断是否需要发起静默登录:判断 storage 中是否存在auth-token,如若不存在,发起「刷新登录」。
    • 请求头部添加auth-token:添加auth-token,发起请求。
  • 与服务端通信:发起请求,服务端处理请求返回结果。

  • 拦截 response: 解析状态码

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

4.3 wx.checkSession 罢工之谜

基于上述接口请求发起时调用的流程,很多人会有疑问,既然服务端会返回auth-token过期的状态码,为啥不在请求发送前进行拦截,使用wx.checkSession接口校验登录态是否过期(如下图所示,增加红框内的步骤)?

这是因为,我们通过实验发现,在 session_key 已过期的情况下,wx.checkSession 有一定的几率返回true。即增加wx.checkSession步骤并不能百分百保证登录态不会过期,后续仍然需要对不同的状态码进行处理。
社区也有相关的反馈未得到解决:

所以结论是:wx.checkSession可靠性是不达 100% 的。
基于以上,我们需要对 session_key 的过期做一些容错处理:

发起需要使用 session_key 的请求前,做一次 wx.checkSession 操作,如果失败了刷新登录态。
后端使用session_key解密开放数据失败之后,返回特定错误码(如:USER_WX_SESSIONKEY_EXPIRE),前端刷新登录态。

const common: Api.method.common = {
 async checkSession() {
    return new Promise((resole) => {
      wx.checkSession({
        success() {
           resole()
        },
        fail: ()=> {
            this.login().then(res=>{
              resole()
            })
        }
      })
    })
  },
  async login() {
    wx.showLoading({
      title:'加载中'
    })
    let wxLogin = await wxp.login()
    let result = await commonApi.login({
      code: wxLogin.code
    }).catch(err=>{
    })
    if(!result) return
    wx.hideLoading()
    wx.setStorageSync('token', result.data.jwtString)
    return result.data
  },
}

4.4 并发处理

我们知道,当启动小程序时,各种监控、埋点数据上报都需要获取用户的个人信息,这些信息都得「静默登录」后才能获取,因此会同时发起多个login请求。另一种情况下,假设一个新用户进入一个业务复杂的页面,同时发起五个不同的业务请求,恰巧这五个请求都需要鉴权,那么五个请求都会被拦截并发起refreshLogin请求。显然,这样的并发是不合理的。
基于此,我们设计了如下方案:

  • 单队列模式:
  • 请求锁:同一时间,只允许一个正在过程中的网络请求。
  • 等待队列:请求被锁定之后,同样的请求都会被推入队列,等待进行中的请求返回后,消费同一个结果。

如上图所示,首先refreshLogin请求入队,队列中只有一个请求,发送该请求,同时保险丝计入次数 1,服务端返回请求结果,消费结果。接着又发起一个refreshLogin请求,队列中只有一个请求,发送该请求,同时保险丝计入次数 2。然后又连续发起三个请求,由于上一个请求还没有执行完成,将这三个请求入队,等待上一个请求结果返回,队列中的四个请求消费同一个结果。由于触发自动冷却阈值,保险丝重置。
以上两种方案通过装饰器模式引入,代码如下所示,refreshLogin函数其实是slientLogin函数的一层封装,用于接口发起时调用。而前面提到的login函数也是slientLogin函数的一层封装,用户小程序启动时调用。

@singleQueue({ name: 'refreshLogin' })
@fuseLine({ name: 'refreshLogin' })
public async refreshLogin(): Promise<void> {
  try {
    // 清除 Session
    this.clearSession();
    await this.silentLogin();
  } catch (error) {
    throw error;
  }
}

到此,很多读者可能对熔断机制还不甚理解,熔断的目的是为一个函数提供保险丝保障,短时间内多次调用,会熔断一段时间,这段时间内拒绝所有请求。如果在自动冷却阈值内,没有请求通过,则重置保险丝。代码如下所示:

export default function fuseLine({
  // 一次熔断前重试次数
  tryTimes = 3,

  // 重试间隔,单位 ms
  restoreTime = 5000,

  // 自动冷却阈值,单位 ms
  coolDownThreshold = 1000,

  // 名称
  name = 'unnamed',
}: {
  tryTimes?: number;
  restoreTime?: number;
  name?: string;
  coolDownThreshold?: number;
} = {}) {
  // 请求锁
  let fuseLocked = false;

  // 当前重试次数
  let fuseTryTimes = tryTimes;

  // 自动冷却
  let coolDownTimer;

  // 重置保险丝
  const reset = () => {
    fuseLocked = false;
    fuseTryTimes = tryTimes;
    logger.info(`${name}-保险丝重置`);
  };

  const request = async () => {
    if (fuseLocked) throw new Error(`${name}-保险丝已熔断,请稍后重试`);

    // 已达最大重试次数
    if (fuseTryTimes <= 0) {
      fuseLocked = true;

      // 重置保险丝
      setTimeout(() => reset(), restoreTime);

      throw new Error(`${name}-保险丝熔断!!`);
    }

    // 自动冷却系统
    if (coolDownTimer) clearTimeout(coolDownTimer);
    coolDownTimer = setTimeout(() => reset(), coolDownThreshold);

    // 允许当前请求通过保险丝,记录 +1
    fuseTryTimes = fuseTryTimes - 1;
    logger.info(`${name}-通过保险丝(${tryTimes - fuseTryTimes}/${tryTimes})`);
    return Promise.resolve();
  };

  return function(
    _target: Record<string, any>,
    _propertyName: string,
    descriptor: TypedPropertyDescriptor<(...args: any[]) => any>,
  ) {
    const method = descriptor.value;
    descriptor.value = async function(...args: any[]) {
      await request();
      if (method) return method.apply(this, args);
    };
  };
}

5. 用户登录流程

小程序可以通过微信官方提供的登录能力方便地获取微信提供的用户身份标识,快速建立小程序内的用户体系。
即「静默登录」,通过调用 wx.login 获取到 code ,将其发送到开发者后端,开发者后端通过接口去微信后端换取到 openid 和 sessionKey(现在会将 unionid 也一并返回)后,然后把自定义登录态 3rd_session(本业务命名为auth-token) 返回给前端,就已经完成登录行为了。
理论上,开发者后端可以通过 openid识别用户,也能通过unionid关联同主体的多个小程序、公众号、app,实现数据互通,从而为每一个用户创建独一无二的uid(本业务自定义的用户 id),在「微信生态」中建立成熟用户体系。
然而,对于复杂的电商跨端应用,比如pc、h5、小程序,不同渠道注册的uid是不同的,用户登录后难以对各个渠道的交易、促销、收藏等数据进行整合。因此,要实现跨端的用户体系数据互通,就需要提供一个唯一的用户标识——手机号。这便是本文重点讲述的「用户登录」,即「游客态」转变成「会员态」的过程。

当新用户第一次进入小程序时,便会触发「静默登录」,这个过程对用户是无感知的。但此时开发者服务端已经为该用户定义了uid,并下发auth-token给小程序端,对于一些需要鉴权的请求,服务端可以根据请求携带的auth-token精确识别是哪个用户发起的行为。
然而,类似加购、下单、领券等用户行为,涉及到跨端数据的整合,在执行用户操作之前,会判断用户是否登录,如若用户未登录,则跳转登录页面。

比如在「用户中心」页面点击「我的订单」,由于此时用户未登录,跳转到登录页面,可以选择以下两种登录方式:

选择 「微信授权登录」,弹出授权手机号信息弹窗,点击「允许」,此时用户登录成功。
选择 「手机快捷登录」,输入手机号,使用 「验证码」 或者 「密码」 进行登录,登录成功跳转回到「用户中心」页面。

上述步骤已经完成了「用户登录」,用户可以正常的执行加购、领券、下单等操作。 为了提升用户体验,需要对 「会员信息」 进行维护 ,比如昵称、头像、性别、生日等信息,最简单的方法是 获取「微信授权用户信息」。触发时机分为以下两种:

用户第一次选择 「微信授权登录」 成功后跳转授权用户信息页面,点击 「授权用户信息」,弹出授权用户信息弹窗。点击「允许」,跳转回「用户中心」页面。
在「用户中心」页面点击头像昵称区域,弹出授权用户信息弹窗,点击「允许」,更新「会员信息」并跳转用户信息编辑页面。

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

5.1.1 libs - 提供登录相关的类方法供「业务层」调用

封装session类,提供类方法供「业务层」调用。主要有以下几种方法:

当然,session类中还封装了一些方法用于与storage交互,比如获取storage中的auth-token用于各种鉴权请求携带等等。session类也提供的一些拓展方法,比如注销账号、解绑手机号等等用于后续需求迭代。

装饰器:

  • must-auth: mustAuth类方法的装饰器,便于业务层各种场景触发登录。
  • fuse-line: 熔断机制,如果短时间内多次调用,则停止响应一段时间,类似于 TCP 慢启动。用于解决refreshLogin、login等方法的并发处理问题。
  • single-queue: 单队列模式,同一时间,只允许一个正在过程中的网络请求。请求被锁定之后,同样的请求都会被推入队列,等待进行中的请求返回后,消费同一个结果。用于解决refreshLoginlogin等方法的并发处理问题。

5.1.2 ui - 提供通用组件供业务层调用

  • 基础组件: user-container和phone-container分别是获取「微信授权用户信息」和获取「微信授权手机号」的纯 UI 单元组件,给通用组件使用。
  • behavior 类:拿到授权数据后需要发送给服务端进行存储,也需要执行一些跳转逻辑判断,这些都抽象成行为类封装在auth-flow中,供通用组件使用。
  • 通用组件: 共用一个行为类,区别在于auth-flow-container用于页面,auth-flow-popup用于弹窗。如下所示,小程序只有微信授权功能,则可以通过弹窗完成授权。如小程序同时提供手机号验证码和密码登录等功能,则需跳转特定登录页面。

5.2 libs

5.2.1 用户身份定义

综上所示,用户登录的阶段可以分为以下三步:

// 用户登录的阶段
export enum AuthStepType {
  // 阶段一:游客态:静默登录成功,未绑定手机号,无用户信息
  ONE = 1,
  // 阶段二:会员态:用户登录成功,已绑定手机号,无用户信息
  TWO = 2,
  // 阶段三:会员信息态:用户登录成功,已绑定手机号,有用户信息
  THREE = 3,
}

那么如何判断用户此时处于哪个步骤,基于「静默登录」的启发,原本「静默登录」成功开发者后端会将自定义登录态 auth-token返回给前端,此处请求可以携带返回「用户信息」,同auth-token一起命名为session存储在本地storage。当「用户登录」或者「更新用户信息」时,会同步更新storage中key为session的数据,从而通过这些用户数据判断当前用户处于哪一个登录阶段。
以下表格列出了session存储的部分重要的属性以及在三个阶段属性对应的值。

注意: 会员态和会员信息态的busiIdentity值均为MEMBER,区分会员态和会员信息态可以通过用户昵称和头像等字段,比如用户登录成功会为用户生成以’u_'开头的默认昵称和默认为空的用户头像链接。

判断用户此时处于哪个步骤的代码如下:

// 获取当前授权阶段
public getCurrentAuthStep(): AuthStepType {
  // 切换账号登录的时候,始终返回AuthStepType.ONE
  const loginMode = this.getLoginMode();
  if (loginMode === LoginMode.SWITCH_ACCOUNT) return AuthStepType.ONE;

  // 用户身份定义非会员返回AuthStepType.ONE
  const userInfo = this.getUser();
  if (userInfo?.busiIdentity !== 'MEMBER') return AuthStepType.ONE;

  // 初次登录,未授权用户信息,返回AuthStepType.TWO
  if (userInfo.nickName.substring(0, 2) === 'u_' && !userInfo.headUrl)
    return AuthStepType.TWO;

  // 都有,返回AuthStepType.THREE
  return AuthStepType.THREE;
}

5.2.2 用户登录触发场景

前面提到过,「用户登录」的 目的是为了整合各个渠道的交易、促销、收藏等数据,针对电商小程序,目前总结的需要用户登录的场景如下所示:

即当用户登录小程序时,可以正常浏览浏览商品,只有触发某些特定行为,比如领券、加购、收藏、下单等,才会判断用户是否处于登录状态,如未登录,跳转登录页面。

如下所示,封装mustAuth方法进行拦截,未登录则跳转登录页面:

export default class Session {
  ...
  public mustAuth({
    mustAuthStep = AuthStepType.TWO, // 传人参数,需要授权的LEVEL
  } = {}): Promise<void> {
    // 当前阶段处于会员态(2)或者会员信息态(3),执行resolve操作
    if (this.getCurrentAuthStep() >= mustAuthStep) return Promise.resolve();
    // 当前阶段处于游客态(1),跳转登录页
    Navigator.gotoPage('/login/home');
    // 执行reject操作
    return Promise.reject();
  }
}

上述代码是跳转页面拦截,对于弹窗而言,需要把弹窗注入base-page(每个页面都需要引入的通用组件,封装每个页面都需要使用的通用方法,比如错误处理等)中,通过 id 查找到弹窗组件,并进行调用。

export default class Session {
  ...
   public mustAuth({
    mustAuthStep = AuthStepType.TWO, // 需要授权的LEVEL
    popupCompName = 'auth-flow-popup',
  } = {}): Promise<void> {
    // 当前阶段处于会员态(2)或者会员信息态(3),执行resolve操作
    if (this.getCurrentAuthStep() >= mustAuthStep) return Promise.resolve();
    // 获取弹窗组件
    const pages = getCurrentPages();
    const curPage = pages[pages.length - 1];
    const context = curPage.$$basePage || curPage;
    const popupComp = context.selectComponent(`#${popupCompName}`);
    // 容错处理
    if (!popupComp) {
      return Promise.reject(
        new Error(
          "当前页面未找到 #auth-popup 组件,请参考 'doc/登录组件的使用方式.md'",
        ),
      );
    }
    // 调用弹窗组件方法
    popupComp.setMustAuthStep(mustAuthStep);
    popupComp.nextStep();
    // 等待授权成功回调
    return this.waitAuth();
  }
}

各个业务使用时可以通过session.mustAuth().then(() => {…});进行调用,为了提高使用体验,也可以使用装饰器@mustAuth()来修饰各个业务需求 类的方法,装饰器源码如下:

/**
 * 登录检查装饰器,使用该装饰器的方法,会先执行授权检查,如果未授权,将跳转登录页面
 */
export default function mustAuth(option = {}) {
  return function(
    _target: Record<string, any>,
    _propertyName: string,
    descriptor: TypedPropertyDescriptor<(...args: any[]) => any>,
  ) {
    const method = descriptor.value;
    descriptor.value = function(...args: any[]) {
      if (!session) return;
      // 登录拦截
      return session.mustAuth(option).then(() => {
        if (method) return method.apply(this, args);
      });
    };
  };
}

6. 基础组件的封装

2012 年 4 月 13 日之前,使用wx.getUserInfo弹出授权弹窗时,如果用户点击允许授权,那么会记录用户的行为,下次再点击时,不会弹窗而是直接将授权结果返回。4 月 13 日之后后,使用wx.getUserProfile,开发者每次通过该接口获取用户个人信息均需用户确认,因此需要妥善保管用户授权的头像昵称,避免重复弹窗。

// index.wxml
 <button class="reset-button" open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber" hover-class="none" disabled="{{disabled}}"><slot></slot></button>

// index.ts
export default class PhoneContainer extends BaseComponent {
  getPhoneNumber(
    e: WechatMiniprogram.Event<WechatMiniprogram.GetPhoneNumberCallbackResult>,
  ) {
    this.triggerEvent('getphonenumber', { ...e.detail,  authType: AuthType.PHONE,});
  }
}

在「微信授权登录」过程中,小程序拿到加密的encryptedData和iv数据,将其和携带的auth-token一起发送给开发者服务器,服务端通过auth-token鉴权识别这个用户,并使用静默登录成功获取的session_key(对称解密密钥)对encryptedData和iv数据进行对称解密,获取该用户的手机号,将手机号与uid绑定,此时该用户成功注册会员,并将会员信息返回给小程序端。
小程序端更新本地storage存储的session数据,此时busiIdentity的值已经从VISIT更新为MEMBER,用户身份转变为会员态,登录成功。
在「授权用户信息」的过程中,小程序调用wx.getUserProfile方法拿到用户数据,并将这些数据与携带的auth-token一起发送给开发者服务器,服务端通过auth-token鉴权识别这个用户,更新该用户的信息并将新的会员数据返回给小程序端。
小程序端更新本地storage存储的session数据,此时用户昵称和头像均已更新,用户身份转变为会员信息态,授权成功。
眼尖的读者一定观察到了,时序图中还对微信头像做了转存。这是因为用户在微信端修改微信头像后,之前「授权用户信息」获取的微信头像链接就会失效,因此开发者应该在自己获取用户信息后,将头像保存下来,避免微信头像 URL 失效后的异常情况。

7. 总结

我们将用户登录能力从业务层中抽象出来,统一封装在service层,便于复用。本文主要讲述的是service层的架构,对于业务层的逻辑实现并没有多加累赘。下列表格以小程序端为例,简述了「静默登录」和「用户登录」整套方案的前后端逻辑实现。

热门文章

暂无图片
编程学习 ·

Java输出数组的内容

Java输出数组的内容_一万个小时-CSDN博客_java打印数组内容1. 输出内容最常见的方式// List<String>类型的列表List<String> list new ArrayList<String>();list.add("First");list.add("Second");list.add("Third");list.ad…
暂无图片
编程学习 ·

母螳螂的“魅惑之术”

在它们对大蝗虫发起进攻的时候&#xff0c;我认认真真地观察了一次&#xff0c;因为它们突然像触电一样浑身痉挛起来&#xff0c;警觉地面对限前这个大家伙&#xff0c;然后放下自己优雅的身段和祈祷的双手&#xff0c;摆出了一个可怕的姿势。我被眼前的一幕吓到了&#xff0c;…
暂无图片
编程学习 ·

疯狂填词 mad_libs 第9章9.9.2

#win7 python3.7.0 import os,reos.chdir(d:\documents\program_language) file1open(.\疯狂填词_d9z9d2_r.txt) file2open(.\疯狂填词_d9z9d2_w.txt,w) words[ADJECTIVE,NOUN,VERB,NOUN] str1file1.read()#方法1 for word in words :word_replaceinput(fEnter a {word} :)str1…
暂无图片
编程学习 ·

HBASE 高可用

为了保证HBASE是高可用的,所依赖的HDFS和zookeeper也要是高可用的. 通过参数hbase.rootdir指定了连接到Hadoop的地址,mycluster表示为Hadoop的集群. HBASE本身的高可用很简单,只要在一个健康的集群其他节点通过命令 hbase-daemon.sh start master启动一个Hmaster进程,这个Hmast…
暂无图片
编程学习 ·

js事件操作语法

一、事件的绑定语法 语法形式1 事件监听 标签对象.addEventListener(click,function(){}); 语法形式2 on语法绑定 标签对象.onclick function(){} on语法是通过 等于赋值绑定的事件处理函数 , 等于赋值本质上执行的是覆盖赋值,后赋值的数据会覆盖之前存储的数据,也就是on…
暂无图片
编程学习 ·

Photoshop插件--晕影动态--选区--脚本开发--PS插件

文章目录1.插件界面2.关键代码2.1 选区2.2 动态晕影3.作者寄语PS是一款栅格图像编辑软件&#xff0c;具有许多强大的功能&#xff0c;本文演示如何通过脚本实现晕影动态和选区相关功能&#xff0c;展示从互联网收集而来的一个小插件&#xff0c;供大家学习交流&#xff0c;请勿…
暂无图片
编程学习 ·

vs LNK1104 无法打开文件“xxx.obj”

写在前面&#xff1a; 向大家推荐两本新书&#xff0c;《深度学习计算机视觉实战》和《学习OpenCV4&#xff1a;基于Python的算法实战》。 《深度学习计算机视觉实战》讲了计算机视觉理论基础&#xff0c;讲了案例项目&#xff0c;讲了模型部署&#xff0c;这些项目学会之后可以…
暂无图片
编程学习 ·

工业元宇宙的定义与实施路线图

工业元宇宙的定义与实施路线图 李正海 1 工业元宇宙 给大家做一个关于工业元宇宙的定义。对于工业&#xff0c;从设计的角度来讲&#xff0c;现在的设计人员已经做到了普遍的三维设计&#xff0c;但是进入元宇宙时代&#xff0c;就不仅仅只是三维设计了&#xff0c;我们的目…
暂无图片
编程学习 ·

【leectode 2022.1.15】完成一半题目

有 N 位扣友参加了微软与力扣举办了「以扣会友」线下活动。主办方提供了 2*N 道题目&#xff0c;整型数组 questions 中每个数字对应了每道题目所涉及的知识点类型。 若每位扣友选择不同的一题&#xff0c;请返回被选的 N 道题目至少包含多少种知识点类型。 示例 1&#xff1a…
暂无图片
编程学习 ·

js 面试题总结

一、js原型与原型链 1. prototype 每个函数都有一个prototype属性&#xff0c;被称为显示原型 2._ _proto_ _ 每个实例对象都会有_ _proto_ _属性,其被称为隐式原型 每一个实例对象的隐式原型_ _proto_ _属性指向自身构造函数的显式原型prototype 3. constructor 每个prot…
暂无图片
编程学习 ·

java练习代码

打印自定义行数的空心菱形练习代码如下 import java.util.Scanner; public class daYinLengXing{public static void main(String[] args) {System.out.println("请输入行数");Scanner myScanner new Scanner(System.in);int g myScanner.nextInt();int num g%2;//…
暂无图片
编程学习 ·

RocketMQ-什么是死信队列?怎么解决

目录 什么是死信队列 死信队列的特征 死信消息的处理 什么是死信队列 当一条消息初次消费失败&#xff0c;消息队列会自动进行消费重试&#xff1b;达到最大重试次数后&#xff0c;若消费依然失败&#xff0c;则表明消费者在正常情况下无法正确地消费该消息&#xff0c;此时…
暂无图片
编程学习 ·

项目 cg day04

第4章 lua、Canal实现广告缓存 学习目标 Lua介绍 Lua语法 输出、变量定义、数据类型、流程控制(if..)、循环操作、函数、表(数组)、模块OpenResty介绍(理解配置) 封装了Nginx&#xff0c;并且提供了Lua扩展&#xff0c;大大提升了Nginx对并发处理的能&#xff0c;10K-1000K Lu…
暂无图片
编程学习 ·

输出三角形

#include <stdio.h> int main() { int i,j; for(i0;i<5;i) { for(j0;j<i;j) { printf("*"); } printf("\n"); } }
暂无图片
编程学习 ·

stm32的BOOTLOADER学习1

序言 最近计划学习stm32的BOOTLOADER学习,把学习过程记录下来 因为现在网上STM32C8T6还是比较贵的,根据我的需求flash空间小一些也可以,所以我决定使用stm32c6t6.这个芯片的空间是32kb的。 #熟悉芯片内部的空间地址 1、flash ROM&#xff1a; 大小32KB&#xff0c;范围&#xf…
暂无图片
编程学习 ·

通过awk和shell来限制IP多次访问之学不会你打死我

学不会你打死我 今天我们用shell脚本&#xff0c;awk工具来分析日志来判断是否存在扫描器来进行破解网站密码——限制访问次数过多的IP地址&#xff0c;通过Iptables来进行限制。代码在末尾 首先我们要先查看日志的格式&#xff0c;分析出我们需要筛选的内容&#xff0c;日志…
暂无图片
编程学习 ·

Python - 如何像程序员一样思考

在为计算机编写程序之前&#xff0c;您必须学会如何像程序员一样思考。学习像程序员一样思考对任何学生都很有价值。以下步骤可帮助任何人学习编码并了解计算机科学的价值——即使他们不打算成为计算机科学家。 顾名思义&#xff0c;Python经常被想要学习编程的人用作第一语言…
暂无图片
编程学习 ·

蓝桥杯python-数字三角形

问题描述 虽然我前后用了三种做法&#xff0c;但是我发现只有“优化思路_1”可以通过蓝桥杯官网中的测评&#xff0c;但是如果用c/c的话&#xff0c;每个都通得过&#xff0c;足以可见python的效率之低&#xff08;但耐不住人家好用啊&#xff08;哭笑&#xff09;&#xff09…