編者按:本文做者奇舞團高級前端開發工程師馮通html
用戶登陸是大部分完整 App 必備的流程前端
一個簡單的用戶系統須要關注至少這些層面ios
不少的業務需求均可以抽象成 Restful 接口配合 CRUD 操做git
但登陸流程倒是錯綜複雜, 各個平臺有各自的流程, 反倒成了項目中費時間的部分, 好比小程序的登陸流程github
對於一個從零開始的項目來講, 搞定登陸流程, 就是一個好的開始, 一個好的開始, 就是成功的一半web
本文就以微信小程序這個平臺, 講述一個完整的自定義用戶登陸流程, 一塊兒來啃這塊難啃的骨頭算法
先給登陸流程時序圖中出現的名詞簡單作一個解釋數據庫
code
臨時登陸憑證, 有效期五分鐘, 經過 wx.login()
獲取session_key
會話密鑰, 服務端經過 code2Session 獲取openId
用戶在該小程序下的用戶惟一標識, 永遠不變, 服務端經過 code 獲取unionId
用戶在同一個微信開放平臺賬號(公衆號, 小程序, 網站, 移動應用)下的惟一標識, 永遠不變appId
小程序惟一標識appSecret
小程序的 app secret, 能夠和 code, appId 一塊兒換取 session_keyrawData
不包括敏感信息的原始數據字符串,用於計算簽名encryptedData
包含敏感信息的用戶信息, 是加密的signature
用於校驗用戶信息是否無篡改iv
加密算法的初始向量哪些信息是敏感信息呢? 手機號, openId, unionId, 能夠看出這些值均可以惟必定位一個用戶, 而暱稱, 頭像這些不能定位用戶的都不是敏感信息express
wx.login
wx.getUserInfo
wx.checkSession
咱們發現小程序的異步接口都是 success 和 fail 的回調, 很容易寫出回調地獄json
所以能夠先簡單實現一個 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/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
理論上小程序也能夠自定義登陸失效時間策略, 但這樣的話咱們須要考慮開發者本身的失效時間和小程序接口服務的失效時間, 還不如保持統一來的簡單
若是在新建小程序項目中選擇 創建普通快速啓動模板
咱們會獲得一個能夠直接運行的模板
點開代碼一看, 大部分代碼都在處理 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公司專業前端團隊「奇舞團」運營的前端技術社區。關注公衆號後,直接發送連接到後臺便可給咱們投稿。