如何實現(xiàn)小程序靜默登錄?一個很詳細的設計方案,值得收藏!

作者:蔡小真
https://juejin.cn/post/6933082931653148680
1. 背景
首先談談在小程序的開發(fā)中,如何借助微信的能力標識一個用戶?
微信官方提供了兩種標識:
OpenId是一個用戶對于一個小程序/公眾號的標識,開發(fā)者可以通過這個標識識別出用戶。UnionId是一個用戶對于同主體微信小程序/公眾號/APP 的標識,開發(fā)者需要在微信開放平臺下綁定相同賬號的主體。開發(fā)者可通過UnionId,實現(xiàn)多個小程序、公眾號、甚至 APP 之間的數(shù)據(jù)互通。
同一個用戶的這兩個 ID 對于同一個小程序來說是永久不變的,就算用戶刪了小程序,下次用戶進入小程序,開發(fā)者依舊可以通過后臺的記錄標識出來。那么如何獲取OpenId和UnionId呢?
早期(2018 年 4 月之前)的小程序設計使用 wx.getUserInfo 接口,來獲取用戶信息。設計這個接口的初衷是希望開發(fā)者在真正需要用戶信息(如頭像、昵稱、手機號等)的情況下才去調(diào)取這個接口。但很多開發(fā)者為了拿到UnionId,會在小程序啟動時直接調(diào)用這個接口,導致用戶在使用小程序的時候產(chǎn)生困擾,歸結起來有幾點:
開發(fā)者在小程序首頁直接調(diào)用 wx.getUserInfo進行授權,彈框獲取用戶信息,會使得一部分用戶點擊“拒絕”按鈕。在開發(fā)者沒有處理用戶拒絕彈框的情況下,用戶必須授權頭像昵稱等信息才能繼續(xù)使用小程序,會導致某些用戶放棄使用該小程序。 用戶沒有很好的方式重新授權,盡管微信官方增加了設置頁面,可以讓用戶選擇重新授權,但很多用戶并不知道可以這么操作。
微信官方也意識到了這個問題,針對獲取用戶信息更新了三個能力:
使用組件來獲取用戶信息。 若用戶滿足一定條件,則可以用 wx.login獲取到的 code 直接換到unionId。wx.getUserInfo不需要依賴wx.login就能調(diào)用得到數(shù)據(jù)。
本文主要講述的是第二點能力,微信官方鼓勵開發(fā)者在不騷擾用戶的情況下合理獲得unionid,而僅在必要時才向用戶彈窗申請使用昵稱頭像,從而衍生出「靜默登錄」和「用戶登錄」兩種概念。
2. 什么是靜默登錄?
小程序可以通過微信官方提供的登錄能力方便地獲取微信提供的用戶身份標識,快速建立小程序內(nèi)的用戶體系。
很多開發(fā)者會把 wx.login 和 wx.getUserInfo 捆綁調(diào)用當成登錄使用,其實 wx.login 已經(jīng)可以完成登錄,wx.getUserInfo 只是獲取額外的用戶信息。
在 wx.login 獲取到 code 后,會發(fā)送到開發(fā)者后端,開發(fā)者后端通過接口去微信后端換取到 openid 和 sessionKey(現(xiàn)在會將 unionid 也一并返回)后,把自定義登錄態(tài) 3rd_session(本業(yè)務命名為auth-token) 返回給前端,就已經(jīng)完成登錄行為了。wx.login 行為是靜默,不必授權的,用戶不會察覺。
wx.getUserInfo 只是為了提供更優(yōu)質的服務而存在,比如獲取用戶的手機號注冊會員,或者展示頭像昵稱,判斷性別,開發(fā)者可通過 unionId 和其他公眾號上已有的用戶畫像結合來提供歷史數(shù)據(jù)。因此開發(fā)者不必在用戶剛剛進入小程序的時候就強制要求授權。
2.1 靜默登錄流程時序
官方給出了 wx.login 的最佳實踐如下:

靜默登錄英文簡稱為silentLogin,代碼如下所示:
private async silentLogin(): Promise<void> {
try {
this.status.silentLogin.ing();
// 獲取臨時登錄憑證code
const code = await getWxLoginCode();
// 將code發(fā)送給服務端
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;
}
}
復制代碼
總結為以下三步:
小程序端調(diào)用 wx.login()獲取 臨時登錄憑證code,并回傳到開發(fā)者服務器。服務器端調(diào)用 auth.code2Session接口,換取 用戶唯一標識OpenID和 會話密鑰session_key。開發(fā)者服務器可以根據(jù)用戶標識來生成自定義登錄態(tài)(例如: auth-token),用于后續(xù)業(yè)務邏輯中前后端交互時識別用戶身份。
2.2 開發(fā)者后臺校驗與解密開放數(shù)據(jù)
靜默登錄成功后,微信服務器端會下發(fā)一個session_key給服務端,而這個會在需要獲取微信開放數(shù)據(jù)的時候會用到。

為了確保開放接口返回用戶數(shù)據(jù)的安全性,微信會對明文數(shù)據(jù)進行簽名。開發(fā)者可以根據(jù)業(yè)務需要對數(shù)據(jù)包進行簽名校驗,確保數(shù)據(jù)的完整性。
小程序通過調(diào)用接口(如 wx.getUserInfo)獲取數(shù)據(jù)時,如果用戶已經(jīng)授權,接口會同時返回以下幾個字段。如用戶未授權,會先彈出用戶彈窗,用戶點擊同意授權,接口會同時返回以下幾個字段。相反如果用戶拒絕授權,將調(diào)用失敗。
| 屬性 | 類型 | 說明 |
|---|---|---|
userInfo | UserInfo | 用戶信息對象,不包含 openid 等敏感信息 |
rawData | string | 不包括敏感信息的原始數(shù)據(jù)字符串,用于計算簽名 |
signature | string | 使用 sha1( rawData + sessionkey ) 得到字符串,用于校驗用戶信息 |
encryptedData | string | 包括敏感數(shù)據(jù)在內(nèi)的完整用戶信息的加密數(shù)據(jù) |
iv | string | 加密算法的初始向量 |
cloudID | string | 敏感數(shù)據(jù)對應的云 ID,開通云開發(fā)的小程序才會返回,可通過云調(diào)用直接獲取開放數(shù)據(jù) |
開發(fā)者將 signature、rawData發(fā)送到開發(fā)者服務器進行校驗。服務器利用用戶對應的session_key使用相同的算法計算出簽名signature2,比對signature與signature2即可校驗數(shù)據(jù)的完整性。開發(fā)者服務器告訴前端開發(fā)者數(shù)據(jù)可信,即可安全使用用戶信息數(shù)據(jù)。如果開發(fā)者想要獲取敏感數(shù)據(jù)(如 openid,unionID),則將 encryptedData和iv發(fā)送到開發(fā)者服務器,由服務器使用session_key(對稱解密密鑰)進行對稱解密,獲取敏感數(shù)據(jù)進行存儲并返回給前端開發(fā)者。
注意: 因為需要用戶主動觸發(fā)才能發(fā)起獲取手機號接口,所以該功能不由 API 來調(diào)用(即上述提到的wx.getUserInfo是無法獲取手機號的),需用 button 組件的點擊來觸發(fā)。獲得encryptedData和iv,同樣發(fā)送給開發(fā)者服務器,由服務器使用session_key(對稱解密密鑰)進行對稱解密,獲得對應的手機號。
需要關注的是,2021 年 2 月 23 日,微信團隊發(fā)布了《小程序登錄、用戶信息相關接口調(diào)整說明》,進行了如下調(diào)整:
2021 年 2 月 23 日起,通過 wx.login接口獲取的登錄憑證可直接換取unionID。2021 年 4 月 13 日后發(fā)布新版本的小程序,無法通過 wx.getUserInfo接口獲取用戶個人信息(頭像、昵稱、性別與地區(qū)),將直接獲取匿名數(shù)據(jù)。getUserInfo接口獲取加密后的openID與unionID數(shù)據(jù)的能力不做調(diào)整。新增 getUserProfile接口(基礎庫 2.10.4 版本開始支持),可獲取用戶頭像、昵稱、性別及地區(qū)信息,開發(fā)者每次通過該接口獲取用戶個人信息均需用戶確認。
即開發(fā)者通過組件調(diào)用wx.getUserInfo將不再彈出彈窗,直接返回匿名的用戶個人信息。如果要獲取用戶頭像、昵稱、性別及地區(qū)信息,需要改造成wx.getUserProfile接口。
2.3 session_key 的有效期
開發(fā)者如果遇到因為 session_key 不正確而校驗簽名失敗或解密失敗,請關注下面幾個與 session_key 有關的注意事項。
wx.login調(diào)用時,用戶的session_key可能會被更新而致使舊session_key失效(刷新機制存在最短周期,如果同一個用戶短時間內(nèi)多次調(diào)用wx.login,并非每次調(diào)用都導致session_key刷新)。開發(fā)者應該在明確需要重新登錄時才調(diào)用wx.login,及時通過auth.code2Session接口更新服務器存儲的session_key。微信不會把 session_key的有效期告知開發(fā)者。我們會根據(jù)用戶使用小程序的行為對session_key進行續(xù)期。用戶越頻繁使用小程序,session_key有效期越長。開發(fā)者在 session_key失效時,可以通過重新執(zhí)行登錄流程獲取有效的session_key。使用接口wx.checkSession可以校驗session_key是否有效,從而避免小程序反復執(zhí)行登錄流程。當開發(fā)者在實現(xiàn)自定義登錄態(tài)時,可以考慮以 session_key有效期作為自身登錄態(tài)有效期,也可以實現(xiàn)自定義的時效性策略。
3 「登錄」架構

「登錄」方案架構如上圖所示,將所有登錄相關功能抽象到 「service 層」(本項目將其命名為session),供 「業(yè)務層」 調(diào)用。本文主要講述灰色內(nèi)容,其它模塊將在下一篇文章《小程序用戶登錄設計》中闡述。
3.1 libs - 提供登錄相關的類方法供「業(yè)務層」調(diào)用
封裝 session類,提供類方法供「業(yè)務層」調(diào)用。主要有以下幾種方法:
| 方法名 | 功能 | 使用場景 |
|---|---|---|
silentLogin | 發(fā)起靜默登錄 | - |
login | 登錄,silentLogin 方法的一層封裝 | 用于小程序啟動時發(fā)起靜默登錄 |
refreshLogin | 刷新登錄態(tài),silentLogin 方法的一層封裝 | 用于登錄態(tài)過期時發(fā)起靜默登錄 |
ensureSessionKey | 驗證 sessionKey 是否過期,過期則刷新登錄態(tài) | 綁定微信授權手機號時驗證是否過期,過期則得重新彈窗授權 |
裝飾器:
fuse-line:熔斷機制,如果短時間內(nèi)多次調(diào)用,則停止響應一段時間,類似于 TCP 慢啟動。用于解決refreshLogin、login等方法的并發(fā)處理問題。single-queue:單隊列模式,同一時間,只允許一個正在過程中的網(wǎng)絡請求。請求被鎖定之后,同樣的請求都會被推入隊列,等待進行中的請求返回后,消費同一個結果。用于解決refreshLogin、login等方法的并發(fā)處理問題。
4. 靜默登錄的調(diào)用時機
4.1 小程序啟動時調(diào)用
由于大部分情況都需要依賴登錄態(tài),在小程序啟動的時候(app.onLaunch())調(diào)用靜默登錄是最常見的手段。這里我們封裝一個login函數(shù)如下所示,首先調(diào)用wx.checkSession判斷session_key是否過期,如果session_key未過期且本地存在auth_token自定義登錄態(tài),表示當前的靜默登錄態(tài)仍然有效,無需進行其它操作。否則,表示靜默登錄態(tài)失效或者新用戶從未發(fā)起過靜默登錄,那么發(fā)起靜默登錄流程。
public async login(): Promise<void> {
// 調(diào)用wx.checkSession判斷session_key是否過期
const hasSession = await checkSession();
// 本地已有可用登錄態(tài)且session_key未過期,resolve。
if (this.getAuthToken() && hasSession) return Promise.resolve();
// 否則,發(fā)起靜默登錄
await this.silentLogin();
}
復制代碼
但是由于原生的小程序啟動流程中, App,Page,Component 的生命周期鉤子函數(shù),都不支持異步阻塞。所以很有可能出現(xiàn)小程序頁面加載完成后,靜默登錄過程還沒有執(zhí)行完畢的情況,這會導致后續(xù)一些依賴登錄態(tài)的操作(比如請求發(fā)起)出錯。
4.2 接口請求發(fā)起時調(diào)用
保險起見,如果某些接口需要攜帶自定義登錄態(tài)進行鑒權,則需要在請求發(fā)起時進行攔截,校驗登錄態(tài),并刷新登錄。刷新登錄代碼如下所示:
public async refreshLogin(): Promise<void> {
try {
// 清除 Session
this.clearSession();
// 發(fā)起靜默登錄
await this.silentLogin();
} catch (error) {
throw error;
}
}
復制代碼
整個流程如下圖所示:

攔截 request: 判斷是否需要鑒權:請求發(fā)起時,攔截請求,判斷請求是否需要添加 auth-token,如若不需要,直接發(fā)起請求。如若需要,執(zhí)行第二步。判斷是否需要發(fā)起靜默登錄:判斷 storage中是否存在auth-token,如若不存在,發(fā)起「刷新登錄」。請求頭部添加 auth-token:添加auth-token,發(fā)起請求。與服務端通信:發(fā)起請求,服務端處理請求返回結果。 攔截 response: 解析狀態(tài)碼 狀態(tài)碼為 AUTH_FAIL:服務端返回code為“鑒權失敗”,觸發(fā)這種情景的原因有兩個,一是接口需要鑒權,但是發(fā)起請求時未攜帶auth-token,二是auth-token過期。這時將上一次請求攜帶的auth-token與本地存儲的auth-token比較,如果不一致,表示登錄態(tài)已經(jīng)刷新過了,那么就直接重新發(fā)起請求。如果一致,發(fā)起刷新登錄,拿到新的auth-token后重新發(fā)起請求,這個動作對用戶來說是無感知的。狀態(tài)碼為 USER_WX_SESSIONKEY_EXPIRE:服務器返回code為“用戶登錄態(tài)過期”,這是針對用戶授權手機號登錄失敗定制的狀態(tài)碼,如果登錄態(tài)已過期,表示存儲在服務端的session_key也是過期的,那么點擊授權手機號獲取的加密數(shù)據(jù)發(fā)送到服務端進行對稱解密,由于session_key失效,無法解密出真正的手機號。因此需要重新發(fā)起靜默登錄,等待用戶重新點擊授權按鈕獲取新的加密數(shù)據(jù),然后發(fā)起新的解密請求狀態(tài)碼為其它:比如 Success或者其他業(yè)務請求錯誤的情況,不進行攔截,返回 response 讓業(yè)務代碼解析。
4.3 wx.checkSession 罷工之謎
基于上述接口請求發(fā)起時調(diào)用的流程,很多人會有疑問,既然服務端會返回auth-token過期的狀態(tài)碼,為啥不在請求發(fā)送前進行攔截,使用wx.checkSession接口校驗登錄態(tài)是否過期(如下圖所示,增加紅框內(nèi)的步驟)?

這是因為,我們通過實驗發(fā)現(xiàn),在 session_key 已過期的情況下,wx.checkSession 有一定的幾率返回true。即增加wx.checkSession步驟并不能百分百保證登錄態(tài)不會過期,后續(xù)仍然需要對不同的狀態(tài)碼進行處理。
社區(qū)也有相關的反饋未得到解決:
小程序解密手機號,隔一小段時間后,checksession:ok,但是解密失敗 wx.checkSession 有效,但是解密數(shù)據(jù)失敗 checkSession 判斷 session_key 未失效,但是解密手機號失敗
所以結論是:wx.checkSession可靠性是不達 100% 的。
基于以上,我們需要對 session_key 的過期做一些容錯處理:
發(fā)起需要使用 session_key的請求前,做一次wx.checkSession操作,如果失敗了刷新登錄態(tài)。后端使用 session_key解密開放數(shù)據(jù)失敗之后,返回特定錯誤碼(如:USER_WX_SESSIONKEY_EXPIRE),前端刷新登錄態(tài)。
4.4 并發(fā)處理
我們知道,當啟動小程序時,各種監(jiān)控、埋點數(shù)據(jù)上報都需要獲取用戶的個人信息,這些信息都得「靜默登錄」后才能獲取,因此會同時發(fā)起多個login請求。另一種情況下,假設一個新用戶進入一個業(yè)務復雜的頁面,同時發(fā)起五個不同的業(yè)務請求,恰巧這五個請求都需要鑒權,那么五個請求都會被攔截并發(fā)起refreshLogin請求。顯然,這樣的并發(fā)是不合理的。
基于此,我們設計了如下方案:
單隊列模式:
請求鎖:同一時間,只允許一個正在過程中的網(wǎng)絡請求。
等待隊列:請求被鎖定之后,同樣的請求都會被推入隊列,等待進行中的請求返回后,消費同一個結果。
熔斷機制:如果短時間內(nèi)多次調(diào)用,則停止響應一段時間,類似于 TCP 慢啟動。


如上圖所示,首先refreshLogin請求入隊,隊列中只有一個請求,發(fā)送該請求,同時保險絲計入次數(shù) 1,服務端返回請求結果,消費結果。接著又發(fā)起一個refreshLogin請求,隊列中只有一個請求,發(fā)送該請求,同時保險絲計入次數(shù) 2。然后又連續(xù)發(fā)起三個請求,由于上一個請求還沒有執(zhí)行完成,將這三個請求入隊,等待上一個請求結果返回,隊列中的四個請求消費同一個結果。由于觸發(fā)自動冷卻閾值,保險絲重置。
以上兩種方案通過裝飾器模式引入,代碼如下所示,refreshLogin函數(shù)其實是slientLogin函數(shù)的一層封裝,用于接口發(fā)起時調(diào)用。而前面提到的login函數(shù)也是slientLogin函數(shù)的一層封裝,用戶小程序啟動時調(diào)用。
@singleQueue({ name: 'refreshLogin' })
@fuseLine({ name: 'refreshLogin' })
public async refreshLogin(): Promise<void> {
try {
// 清除 Session
this.clearSession();
await this.silentLogin();
} catch (error) {
throw error;
}
}
復制代碼
到此,很多讀者可能對熔斷機制還不甚理解,熔斷的目的是為一個函數(shù)提供保險絲保障,短時間內(nèi)多次調(diào)用,會熔斷一段時間,這段時間內(nèi)拒絕所有請求。如果在自動冷卻閾值內(nèi),沒有請求通過,則重置保險絲。代碼如下所示:
export default function fuseLine({
// 一次熔斷前重試次數(shù)
tryTimes = 3,
// 重試間隔,單位 ms
restoreTime = 5000,
// 自動冷卻閾值,單位 ms
coolDownThreshold = 1000,
// 名稱
name = 'unnamed',
}: {
tryTimes?: number;
restoreTime?: number;
name?: string;
coolDownThreshold?: number;
} = {}) {
// 請求鎖
let fuseLocked = false;
// 當前重試次數(shù)
let fuseTryTimes = tryTimes;
// 自動冷卻
let coolDownTimer;
// 重置保險絲
const reset = () => {
fuseLocked = false;
fuseTryTimes = tryTimes;
logger.info(`${name}-保險絲重置`);
};
const request = async () => {
if (fuseLocked) throw new Error(`${name}-保險絲已熔斷,請稍后重試`);
// 已達最大重試次數(shù)
if (fuseTryTimes <= 0) {
fuseLocked = true;
// 重置保險絲
setTimeout(() => reset(), restoreTime);
throw new Error(`${name}-保險絲熔斷!!`);
}
// 自動冷卻系統(tǒng)
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. 最后
讀到這里,相信你已經(jīng)了解「靜默登錄」和「用戶登錄」的區(qū)別?!胳o默登錄」是獲取微信登錄態(tài)的過程,通過獲取微信提供的用戶身份標識,快速建立小程序內(nèi)的用戶體系。「用戶登錄」是用戶授權個人開放數(shù)據(jù)成為會員的過程,是指從游客態(tài)轉換成會員態(tài)的,擁有購買等操作權限。
兩者并不是一個概念,「用戶登錄」會在下一篇文章《小程序用戶登錄架構設計》中進行闡述。
