徹底搞懂小程式登入流程-附小程式和服務端程式碼

奇舞週刊發表於2018-11-01

編者按:本文作者奇舞團高階前端開發工程師馮通

使用者登入是大部分完整 App 必備的流程

一個簡單的使用者系統需要關注至少這些層面

  • 安全性(加密)
  • 持久化登入態(類似cookie)
  • 登入過期處理
  • 確保使用者唯一性, 避免出現多賬號
  • 授權
  • 繫結使用者暱稱頭像等資訊
  • 繫結手機號(實名和密保方式)

很多的業務需求都可以抽象成 Restful 介面配合 CRUD 操作

但登入流程卻是錯綜複雜, 各個平臺有各自的流程, 反倒成了專案中費時間的部分, 比如小程式的登入流程

徹底搞懂小程式登入流程-附小程式和服務端程式碼

對於一個從零開始的專案來說, 搞定登入流程, 就是一個好的開始, 一個好的開始, 就是成功的一半

本文就以微信小程式這個平臺, 講述一個完整的自定義使用者登入流程, 一起來啃這塊難啃的骨頭

名詞解釋

先給登入流程時序圖中出現的名詞簡單做一個解釋

  • code 臨時登入憑證, 有效期五分鐘, 通過 wx.login() 獲取
  • session_key 會話金鑰, 服務端通過 code2Session 獲取
  • openId 使用者在該小程式下的使用者唯一標識, 永遠不變, 服務端通過 code 獲取
  • unionId 使用者在同一個微信開放平臺帳號(公眾號, 小程式, 網站, 移動應用)下的唯一標識, 永遠不變
  • appId 小程式唯一標識
  • appSecret 小程式的 app secret, 可以和 code, appId 一起換取 session_key

其他名詞

  • rawData 不包括敏感資訊的原始資料字串,用於計算簽名
  • encryptedData 包含敏感資訊的使用者資訊, 是加密的
  • signature 用於校驗使用者資訊是否無篡改
  • iv 加密演算法的初始向量

哪些資訊是敏感資訊呢? 手機號, openId, unionId, 可以看出這些值都可以唯一定位一個使用者, 而暱稱, 頭像這些不能定位使用者的都不是敏感資訊

小程式登入相關函式

  • wx.login
  • wx.getUserInfo
  • wx.checkSession

小程式的 promise

我們發現小程式的非同步介面都是 success 和 fail 的回撥, 很容易寫出回撥地獄

因此可以先簡單實現一個 wx 非同步函式轉成 promise 的工具函式

const promisify = original => {
  return function(opt) {
    return new Promise((resolve, reject) => {
      opt = Object.assign({
        success: resolve,
        fail: reject
      }, opt)
      original(opt)
    })
  }
}
複製程式碼

這樣我們就可以這樣呼叫函式了

promisify(wx.getStorage)({key: 'key'}).then(value => {
  // success
}).catch(reason => {
  // fail
})
複製程式碼

服務端實現

本 demo 的服務端實現基於 express.js

注意, 為了 demo 的簡潔性, 服務端使用 js 變數來儲存使用者資料, 也就是說如果重啟服務端, 使用者資料就清空了

如需持久化儲存使用者資料, 可以自行實現資料庫相關邏輯

// 儲存所有使用者資訊
const users = {
  // openId 作為索引
  openId: {
    // 資料結構如下
    openId: '', // 理論上不應該返回給前端
    sessionKey: '',
    nickName: '',
    avatarUrl: '',
    unionId: '',
    phoneNumber: ''
  }
}

app
  .use(bodyParser.json())
  .use(session({
    secret: 'alittlegirl',
    resave: false,
    saveUninitialized: true
  }))
複製程式碼

小程式登入

我們先實現一個基本的 oauth 授權登入

oauth 授權登入主要是 code 換取 openId 和 sessionKey 的過程

前端小程式登入

寫在 app.js 中

login () {
  console.log('登入')
  return util.promisify(wx.login)().then(({code}) => {
    console.log(`code: ${code}`)
    return http.post('/oauth/login', {
      code,
      type: 'wxapp'
    })
  })
}
複製程式碼

服務端實現 oauth 授權

服務端實現上述 /oauth/login 這個介面

app
  .post('/oauth/login', (req, res) => {
    var params = req.body
    var {code, type} = params
    if (type === 'wxapp') {
      // code 換取 openId 和 sessionKey 的主要邏輯
      axios.get('https://api.weixin.qq.com/sns/jscode2session', {
        params: {
          appid: config.appId,
          secret: config.appSecret,
          js_code: code,
          grant_type: 'authorization_code'
        }
      }).then(({data}) => {
        var openId = data.openid
        var user = users[openId]
        if (!user) {
          user = {
            openId,
            sessionKey: data.session_key
          }
          users[openId] = user
          console.log('新使用者', user)
        } else {
          console.log('老使用者', user)
        }
        req.session.openId = user.openId
        req.user = user
      }).then(() => {
        res.send({
          code: 0
        })
      })
    } else {
      throw new Error('未知的授權型別')
    }
  })
複製程式碼

獲取使用者資訊

登入系統中都會有一個重要的功能: 獲取使用者資訊, 我們稱之為 getUserInfo

如果已登入使用者呼叫 getUserInfo 則返回使用者資訊, 比如暱稱, 頭像等, 如果未登入則返回"使用者未登入"

也就是說此介面還有判斷使用者是否登入的功效...

小程式的使用者資訊一般儲存在 app.globalData.userInfo 中(模板如此)

我們在服務端加上前置中介軟體, 通過 session 來獲取對應的使用者資訊, 並放在 req 物件中

app
  .use((req, res, next) => {
    req.user = users[req.session.openId]
    next()
  })
複製程式碼

然後實現 /user/info 介面, 用來返回使用者資訊

app
  .get('/user/info', (req, res) => {
    if (req.user) {
      return res.send({
        code: 0,
        data: req.user
      })
    }
    throw new Error('使用者未登入')
  })
複製程式碼

小程式呼叫使用者資訊介面

getUserInfo () {
  return http.get('/user/info').then(response => {
    let data = response.data
    if (data && typeof data === 'object') {
      // 獲取使用者資訊成功則儲存到全域性
      this.globalData.userInfo = data
      return data
    }
    return Promise.reject(response)
  })
}
複製程式碼

專為小程式發請求設計的庫

小程式程式碼通過 http.get, http.post 這樣的 api 來發請求, 背後使用了一個請求庫

@chunpu/http 是一個專門為小程式設計的 http 請求庫, 可以在小程式上像 axios 一樣發請求, 支援攔截器等強大功能, 甚至比 axios 更順手

初始化方法如下

import http from '@chunpu/http'

http.init({
  baseURL: 'http://localhost:9999', // 定義 baseURL, 用於本地測試
  wx // 標記是微信小程式用
})
複製程式碼

具體使用方法可參照文件 github.com/chunpu/http…

自定義登入態持久化

瀏覽器有 cookie, 然而小程式沒有 cookie, 那怎麼模仿出像網頁這樣的登入態呢?

這裡要用到小程式自己的持久化介面, 也就是 setStorage 和 getStorage

為了方便各端共用介面, 或者直接複用 web 介面, 我們自行實現一個簡單的讀 cookie 和種 cookie 的邏輯

先是要根依據返回的 http response headers 來種上 cookie, 此處我們用到了 @chunpu/http 中的 response 攔截器, 和 axios 用法一樣

http.interceptors.response.use(response => {
  // 種 cookie
  var {headers} = response
  var cookies = headers['set-cookie'] || ''
  cookies = cookies.split(/, */).reduce((prev, item) => {
    item = item.split(/; */)[0]
    var obj = http.qs.parse(item)
    return Object.assign(prev, obj)
  }, {})
  if (cookies) {
    return util.promisify(wx.getStorage)({
      key: 'cookie'
    }).catch(() => {}).then(res => {
      res = res || {}
      var allCookies = res.data || {}
      Object.assign(allCookies, cookies)
      return util.promisify(wx.setStorage)({
        key: 'cookie',
        data: allCookies
      })
    }).then(() => {
      return response
    })
  }
  return response
})
複製程式碼

當然我們還需要在發請求的時候帶上所有 cookie, 此處用的是 request 攔截器

http.interceptors.request.use(config => {
  // 給請求帶上 cookie
  return util.promisify(wx.getStorage)({
    key: 'cookie'
  }).catch(() => {}).then(res => {
    if (res && res.data) {
      Object.assign(config.headers, {
        Cookie: http.qs.stringify(res.data, ';', '=')
      })
    }
    return config
  })
})
複製程式碼

登入態的有效期

我們知道, 瀏覽器裡面的登入態 cookie 是有失效時間的, 比如一天, 七天, 或者一個月

也許有朋友會提出疑問, 直接用 storage 的話, 小程式的登入態有效期怎麼辦?

問到點上了! 小程式已經幫我們實現好了 session 有效期的判斷 wx.checkSession

它比 cookie 更智慧, 官方文件描述如下

通過 wx.login 介面獲得的使用者登入態擁有一定的時效性。使用者越久未使用小程式,使用者登入態越有可能失效。反之如果使用者一直在使用小程式,則使用者登入態一直保持有效

也就是說小程式還會幫我們自動 renew 我們們的登入態, 簡直是人工智慧 cookie, 點個贊?

那具體在前端怎麼操作呢? 程式碼寫在 app.js 中

onLaunch: function () {
  util.promisify(wx.checkSession)().then(() => {
    console.log('session 生效')
    return this.getUserInfo()
  }).then(userInfo => {
    console.log('登入成功', userInfo)
  }).catch(err => {
    console.log('自動登入失敗, 重新登入', err)
    return this.login()
  }).catch(err => {
    console.log('手動登入失敗', err)
  })
}
複製程式碼

要注意, 這裡的 session 不僅是前端的登入態, 也是後端 session_key 的有效期, 前端登入態失效了, 那後端也失效了需要更新 session_key

理論上小程式也可以自定義登入失效時間策略, 但這樣的話我們需要考慮開發者自己的失效時間和小程式介面服務的失效時間, 還不如保持統一來的簡單

確保每個 Page 都能獲取到 userInfo

如果在新建小程式專案中選擇 建立普通快速啟動模板

我們會得到一個可以直接執行的模板

點開程式碼一看, 大部分程式碼都在處理 userInfo....

徹底搞懂小程式登入流程-附小程式和服務端程式碼

註釋裡寫著

由於 getUserInfo 是網路請求,可能會在 Page.onLoad 之後才返回

所以此處加入 callback 以防止這種情況

但這樣的模板並不科學, 這樣僅僅是考慮了首頁需要使用者資訊的情況, 如果掃碼進入的頁面也需要使用者資訊呢? 還有直接進入跳轉的未支付頁活動頁等...

如果每個頁面都這樣判斷一遍是否載入完使用者資訊, 程式碼顯得過於冗餘

此時我們想到了 jQuery 的 ready 函式 $(function), 只要 document ready 了, 就可以直接執行函式裡面的程式碼, 如果 document 還沒 ready, 就等到 ready 後執行程式碼

就這個思路了! 我們把小程式的 App 當成網頁的 document

我們的目標是可以這樣在 Page 中不會出錯的獲取 userInfo

Page({
  data: {
    userInfo: null
  },
  onLoad: function () {
    app.ready(() => {
      this.setData({
        userInfo: app.globalData.userInfo
      })
    })
  }
})
複製程式碼

此處我們使用 min-ready 來實現此功能

程式碼實現依然寫在 app.js 中

import Ready from 'min-ready'

const ready = Ready()

App({
  getUserInfo () {
    // 獲取使用者資訊作為全域性方法
    return http.get('/user/info').then(response => {
      let data = response.data
      if (data && typeof data === 'object') {
        this.globalData.userInfo = data
        // 獲取 userInfo 成功的時機就是 app ready 的時機
        ready.open()
        return data
      }
      return Promise.reject(response)
    })
  },
  ready (func) {
    // 把函式放入佇列中
    ready.queue(func)
  }
})
複製程式碼

繫結使用者資訊和手機號

僅僅獲取使用者的 openId 是遠遠不夠的, openId 只能標記使用者, 連使用者的暱稱和頭像都拿不到

如何獲取這些使用者資訊然後存到後端資料庫中呢?

我們在服務端實現這兩個介面, 繫結使用者資訊, 繫結使用者手機號

app
  .post('/user/bindinfo', (req, res) => {
    var user = req.user
    if (user) {
      var {encryptedData, iv} = req.body
      var pc = new WXBizDataCrypt(config.appId, user.sessionKey)
      var data = pc.decryptData(encryptedData, iv)
      Object.assign(user, data)
      return res.send({
        code: 0
      })
    }
    throw new Error('使用者未登入')
  })

  .post('/user/bindphone', (req, res) => {
    var user = req.user
    if (user) {
      var {encryptedData, iv} = req.body
      var pc = new WXBizDataCrypt(config.appId, user.sessionKey)
      var data = pc.decryptData(encryptedData, iv)
      Object.assign(user, data)
      return res.send({
        code: 0
      })
    }
    throw new Error('使用者未登入')
  })
複製程式碼

小程式個人中心 wxml 實現如下

<view wx:if="userInfo" class="userinfo">
  <button
    wx:if="{{!userInfo.nickName}}"
    type="primary"
    open-type="getUserInfo"
    bindgetuserinfo="bindUserInfo"> 獲取頭像暱稱 </button>
  <block wx:else>
    <image class="userinfo-avatar" src="{{userInfo.avatarUrl}}" mode="cover"></image>
    <text class="userinfo-nickname">{{userInfo.nickName}}</text>
  </block>

  <button
    wx:if="{{!userInfo.phoneNumber}}"
    type="primary"
    style="margin-top: 20px;"
    open-type="getPhoneNumber"
    bindgetphonenumber="bindPhoneNumber"> 繫結手機號 </button>
  <text wx:else>{{userInfo.phoneNumber}}</text>
</view>
複製程式碼

小程式中的 bindUserInfo 和 bindPhoneNumber 函式, 根據微信最新的策略, 這倆操作都需要使用者點選按鈕統一授權才能觸發

bindUserInfo (e) {
  var detail = e.detail
  if (detail.iv) {
    http.post('/user/bindinfo', {
      encryptedData: detail.encryptedData,
      iv: detail.iv,
      signature: detail.signature
    }).then(() => {
      return app.getUserInfo().then(userInfo => {
        this.setData({
          userInfo: userInfo
        })
      })
    })
  }
},
bindPhoneNumber (e) {
  var detail = e.detail
  if (detail.iv) {
    http.post('/user/bindphone', {
      encryptedData: detail.encryptedData,
      iv: detail.iv
    }).then(() => {
      return app.getUserInfo().then(userInfo => {
        this.setData({
          userInfo: userInfo
        })
      })
    })
  }
}
複製程式碼

程式碼

本文所提到的程式碼都可以在我的 github 上找到

小程式程式碼在 wxapp-login-demo

服務端 Node.js 程式碼在 wxapp-login-server

關於奇舞週刊

《奇舞週刊》是360公司專業前端團隊「奇舞團」運營的前端技術社群。關注公眾號後,直接傳送連結到後臺即可給我們投稿。

徹底搞懂小程式登入流程-附小程式和服務端程式碼

相關文章