完全搞懂小程序登陆流程-附小程序和服务端代码

编者按:本文做者奇舞团高级前端开发工程师冯通html

用户登陆是大部分完整 App 必备的流程前端

一个简单的用户系统须要关注至少这些层面ios

  • 安全性(加密)
  • 持久化登陆态(相似cookie)
  • 登陆过时处理
  • 确保用户惟一性, 避免出现多帐号
  • 受权
  • 绑定用户昵称头像等信息
  • 绑定手机号(实名和密保方式)

不少的业务需求均可以抽象成 Restful 接口配合 CRUD 操做git

但登陆流程倒是错综复杂, 各个平台有各自的流程, 反倒成了项目中费时间的部分, 好比小程序的登陆流程github

对于一个从零开始的项目来讲, 搞定登陆流程, 就是一个好的开始, 一个好的开始, 就是成功的一半web

本文就以微信小程序这个平台, 讲述一个完整的自定义用户登陆流程, 一块儿来啃这块难啃的骨头算法

名词解释

先给登陆流程时序图中出现的名词简单作一个解释数据库

  • code 临时登陆凭证, 有效期五分钟, 经过 wx.login() 获取
  • session_key 会话密钥, 服务端经过 code2Session 获取
  • openId 用户在该小程序下的用户惟一标识, 永远不变, 服务端经过 code 获取
  • unionId 用户在同一个微信开放平台账号(公众号, 小程序, 网站, 移动应用)下的惟一标识, 永远不变
  • appId 小程序惟一标识
  • appSecret 小程序的 app secret, 能够和 code, appId 一块儿换取 session_key

其余名词

  • rawData 不包括敏感信息的原始数据字符串,用于计算签名
  • encryptedData 包含敏感信息的用户信息, 是加密的
  • signature 用于校验用户信息是否无篡改
  • iv 加密算法的初始向量

哪些信息是敏感信息呢? 手机号, openId, unionId, 能够看出这些值均可以惟必定位一个用户, 而昵称, 头像这些不能定位用户的都不是敏感信息express

小程序登陆相关函数

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

小程序的 promise

咱们发现小程序的异步接口都是 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 受权

服务端实现上述 /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公司专业前端团队「奇舞团」运营的前端技术社区。关注公众号后,直接发送连接到后台便可给咱们投稿。

相关文章
相关标签/搜索