小程序靜默登錄方案設計

作者:蔡小真

https://juejin.cn/post/6933082931653148680

  1. 背景 =====

首先談談在小程序的開發中,如何藉助微信的能力標識一個用戶?

微信官方提供了兩種標識:

  1. OpenId 是一個用戶對於一個小程序/公衆號的標識,開發者可以通過這個標識識別出用戶。

  2. UnionId 是一個用戶對於同主體微信小程序/公衆號/APP 的標識,開發者需要在微信開放平臺下綁定相同賬號的主體。開發者可通過UnionId,實現多個小程序、公衆號、甚至 APP 之間的數據互通。

同一個用戶的這兩個 ID 對於同一個小程序來說是永久不變的,就算用戶刪了小程序,下次用戶進入小程序,開發者依舊可以通過後臺的記錄標識出來。那麼如何獲取OpenIdUnionId呢?

早期 (2018 年 4 月之前) 的小程序設計使用 wx.getUserInfo 接口,來獲取用戶信息。設計這個接口的初衷是希望開發者在真正需要用戶信息(如頭像、暱稱、手機號等)的情況下才去調取這個接口。但很多開發者爲了拿到UnionId,會在小程序啓動時直接調用這個接口,導致用戶在使用小程序的時候產生困擾,歸結起來有幾點:

  1. 開發者在小程序首頁直接調用 wx.getUserInfo 進行授權,彈框獲取用戶信息,會使得一部分用戶點擊 “拒絕” 按鈕。

  2. 在開發者沒有處理用戶拒絕彈框的情況下,用戶必須授權頭像暱稱等信息才能繼續使用小程序,會導致某些用戶放棄使用該小程序。

  3. 用戶沒有很好的方式重新授權,儘管微信官方增加了設置頁面,可以讓用戶選擇重新授權,但很多用戶並不知道可以這麼操作。

微信官方也意識到了這個問題,針對獲取用戶信息更新了三個能力:

  1. 使用組件來獲取用戶信息。

  2. 若用戶滿足一定條件,則可以用wx.login 獲取到的 code 直接換到unionId

  3. wx.getUserInfo 不需要依賴 wx.login 就能調用得到數據。

本文主要講述的是第二點能力,微信官方鼓勵開發者在不騷擾用戶的情況下合理獲得unionid,而僅在必要時才向用戶彈窗申請使用暱稱頭像,從而衍生出「靜默登錄」和「用戶登錄」兩種概念。

  1. 什麼是靜默登錄? ===========

小程序可以通過微信官方提供的登錄能力方便地獲取微信提供的用戶身份標識,快速建立小程序內的用戶體系。

很多開發者會把 wx.login 和 wx.getUserInfo 捆綁調用當成登錄使用,其實 wx.login 已經可以完成登錄,wx.getUserInfo 只是獲取額外的用戶信息。

在 wx.login 獲取到 code 後,會發送到開發者後端,開發者後端通過接口去微信後端換取到 openid 和 sessionKey(現在會將 unionid 也一併返回)後,把自定義登錄態 3rd_session(本業務命名爲auth-token) 返回給前端,就已經完成登錄行爲了。wx.login 行爲是靜默,不必授權的,用戶不會察覺

wx.getUserInfo 只是爲了提供更優質的服務而存在,比如獲取用戶的手機號註冊會員,或者展示頭像暱稱,判斷性別,開發者可通過 unionId 和其他公衆號上已有的用戶畫像結合來提供歷史數據。因此開發者不必在用戶剛剛進入小程序的時候就強制要求授權

2.1 靜默登錄流程時序

官方給出了 wx.login 的最佳實踐如下:

靜默登錄英文簡稱爲silentLogin,代碼如下所示:

  private async silentLogin(): Promise<void> {
    try {
      this.status.silentLogin.ing();

      // 獲取臨時登錄憑證code
      const code = await getWxLoginCode();
      // 將code發送給服務端
      const res = await API.login(code);
      // 保存登錄信息,如auth-token
      storage.setSync(constant.STORAGE_SESSION_KEY, res.data);

      this.status.silentLogin.success();
    } catch (error) {
      logger.error('靜默登錄失敗', error);
      this.status.silentLogin.fail(error);
      throw error;
    }
  }

總結爲以下三步:

  1. 小程序端調用 wx.login() 獲取 臨時登錄憑證code ,並回傳到開發者服務器。

  2. 服務器端調用 auth.code2Session 接口,換取 用戶唯一標識 OpenID 和 會話密鑰 session_key

  3. 開發者服務器可以根據用戶標識來生成自定義登錄態 (例如:auth-token),用於後續業務邏輯中前後端交互時識別用戶身份

2.2 開發者後臺校驗與解密開放數據

靜默登錄成功後,微信服務器端會下發一個session_key給服務端,而這個會在需要獲取微信開放數據的時候會用到。

爲了確保開放接口返回用戶數據的安全性,微信會對明文數據進行簽名。開發者可以根據業務需要對數據包進行簽名校驗,確保數據的完整性。

  1. 小程序通過調用接口(如 wx.getUserInfo)獲取數據時,如果用戶已經授權,接口會同時返回以下幾個字段。如用戶未授權,會先彈出用戶彈窗,用戶點擊同意授權,接口會同時返回以下幾個字段。相反如果用戶拒絕授權,將調用失敗。

JcJ2cl

  1. 開發者將 signaturerawData 發送到開發者服務器進行校驗。服務器利用用戶對應的 session_key 使用相同的算法計算出簽名 signature2 ,比對 signature 與 signature2 即可校驗數據的完整性。開發者服務器告訴前端開發者數據可信,即可安全使用用戶信息數據。

  2. 如果開發者想要獲取敏感數據(如 openid,unionID),則將encryptedDataiv發送到開發者服務器,由服務器使用session_key(對稱解密密鑰)進行對稱解密,獲取敏感數據進行存儲並返回給前端開發者。

注意: 因爲需要用戶主動觸發才能發起獲取手機號接口,所以該功能不由 API 來調用 (即上述提到的wx.getUserInfo是無法獲取手機號的),需用 button 組件的點擊來觸發。獲得encryptedDataiv,同樣發送給開發者服務器,由服務器使用session_key(對稱解密密鑰)進行對稱解密,獲得對應的手機號。

需要關注的是,2021 年 2 月 23 日,微信團隊發佈了《小程序登錄、用戶信息相關接口調整說明》,進行了如下調整:

  1. 2021 年 2 月 23 日起,通過wx.login接口獲取的登錄憑證可直接換取unionID

  2. 2021 年 4 月 13 日後發佈新版本的小程序,無法通過wx.getUserInfo接口獲取用戶個人信息(頭像、暱稱、性別與地區),將直接獲取匿名數據。getUserInfo接口獲取加密後的openIDunionID數據的能力不做調整。

  3. 新增getUserProfile接口(基礎庫 2.10.4 版本開始支持),可獲取用戶頭像、暱稱、性別及地區信息,開發者每次通過該接口獲取用戶個人信息均需用戶確認。

即開發者通過組件調用wx.getUserInfo將不再彈出彈窗,直接返回匿名的用戶個人信息。如果要獲取用戶頭像、暱稱、性別及地區信息,需要改造wx.getUserProfile接口。

2.3 session_key 的有效期

開發者如果遇到因爲 session_key 不正確而校驗簽名失敗或解密失敗,請關注下面幾個與 session_key 有關的注意事項。

  1. wx.login 調用時,用戶的 session_key 可能會被更新而致使舊 session_key 失效(刷新機制存在最短週期,如果同一個用戶短時間內多次調用 wx.login,並非每次調用都導致 session_key 刷新)。開發者應該在明確需要重新登錄時才調用 wx.login,及時通過 auth.code2Session 接口更新服務器存儲的 session_key

  2. 微信不會把 session_key 的有效期告知開發者。我們會根據用戶使用小程序的行爲對 session_key 進行續期。用戶越頻繁使用小程序,session_key 有效期越長。

  3. 開發者在 session_key 失效時,可以通過重新執行登錄流程獲取有效的 session_key。使用接口wx.checkSession可以校驗 session_key 是否有效,從而避免小程序反覆執行登錄流程。

  4. 當開發者在實現自定義登錄態時,可以考慮以 session_key 有效期作爲自身登錄態有效期,也可以實現自定義的時效性策略。

3 「登錄」架構

用戶登錄架構

「登錄」方案架構如上圖所示,將所有登錄相關功能抽象到 「service 層」(本項目將其命名爲session),供 「業務層」 調用。本文主要講述灰色內容,其它模塊將在下一篇文章《小程序用戶登錄設計》中闡述。

3.1 libs - 提供登錄相關的類方法供「業務層」調用

  1. 封裝session類,提供類方法供「業務層」調用。主要有以下幾種方法:

2mWJNL

  1. 裝飾器:
  1. 靜默登錄的調用時機 ============

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 接口請求發起時調用

保險起見,如果某些接口需要攜帶自定義登錄態進行鑑權,則需要在請求發起時進行攔截,校驗登錄態,並刷新登錄。刷新登錄代碼如下所示:

  public async refreshLogin(): Promise<void> {
    try {
      // 清除 Session
      this.clearSession();
      // 發起靜默登錄
      await this.silentLogin();
    } catch (error) {
      throw error;
    }
  }

整個流程如下圖所示:

  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 讓業務代碼解析。

4.3 wx.checkSession 罷工之謎

基於上述接口請求發起時調用的流程,很多人會有疑問,既然服務端會返回auth-token過期的狀態碼,爲啥不在請求發送前進行攔截,使用wx.checkSession接口校驗登錄態是否過期(如下圖所示,增加紅框內的步驟)?

這是因爲,我們通過實驗發現,在 session_key 已過期的情況下,wx.checkSession 有一定的幾率返回true。即增加wx.checkSession步驟並不能百分百保證登錄態不會過期,後續仍然需要對不同的狀態碼進行處理。

社區也有相關的反饋未得到解決:

所以結論是:wx.checkSession可靠性是不達 100% 的。

基於以上,我們需要對 session_key 的過期做一些容錯處理:

  1. 發起需要使用 session_key 的請求前,做一次 wx.checkSession 操作,如果失敗了刷新登錄態。

  2. 後端使用session_key解密開放數據失敗之後,返回特定錯誤碼(如:USER_WX_SESSIONKEY_EXPIRE),前端刷新登錄態。

4.4 併發處理

我們知道,當啓動小程序時,各種監控、埋點數據上報都需要獲取用戶的個人信息,這些信息都得「靜默登錄」後才能獲取,因此會同時發起多個login請求。另一種情況下,假設一個新用戶進入一個業務複雜的頁面,同時發起五個不同的業務請求,恰巧這五個請求都需要鑑權,那麼五個請求都會被攔截併發起refreshLogin請求。顯然,這樣的併發是不合理的。

基於此,我們設計瞭如下方案:

  1. 請求鎖:同一時間,只允許一個正在過程中的網絡請求。

  2. 等待隊列:請求被鎖定之後,同樣的請求都會被推入隊列,等待進行中的請求返回後,消費同一個結果。

如上圖所示,首先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);
    };
  };
}
  1. 最後 =====

讀到這裏,相信你已經瞭解「靜默登錄」和「用戶登錄」的區別。「靜默登錄」是獲取微信登錄態的過程,通過獲取微信提供的用戶身份標識,快速建立小程序內的用戶體系。「用戶登錄」是用戶授權個人開放數據成爲會員的過程,是指從遊客態轉換成會員態的,擁有購買等操作權限。

兩者並不是一個概念,「用戶登錄」會在下一篇文章《小程序用戶登錄架構設計》中進行闡述。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-w9xbArYSLLUKgNkfCATeg