jiji262/wechat-miniprogram-login-boilerplate

一文打通微信小程序登录与授权的任督二脉-理论篇(附代码)

jiji262 opened this issue · 1 comments

image

由于微信的各种限制,小程序的登录和授权一直是小程序开发中比较容易让人困惑的点。再加上微信小程序官方规则的经常变更,也使得之前的一些方式,在新版本的小程序基础库上无法使用。

本文试图通过简单的梳理,给读者推荐一个比较简单可靠的使用小程序登录或者授权的最佳实践,读者可以根据自己特定的使用场景来合理使用。

本文基于小程序基础库版本v2.8.0 (2019-07-30发布),后续如有更新,本文也会持续update。

一些概念

小程序是一个相对封闭的生态系统,因此其开发技术栈中有很多特殊的/特有的名词,有时候容易让人觉得很困惑,比如什么是小程序的”登录“和”授权“,分别在什么场景下需要使用?所以在本文开始,我们首先先对这些概念做一简单梳理,以期有一个较为清晰的理解(暂时理解不了也没关系,等看到后面内容的时候记得回头再来看)。

登录

小程序可以通过微信官方提供的登录能力方便地获取微信提供的用户身份标识,快速建立小程序内的用户体系。在小程序/微信生态体系中,每个用户会有唯一标识的OpenIDUnionID,使用他们可以帮助开发者优化自己的注册和登录逻辑。毕竟,在**,除了身份证和手机号,真的没有什么比微信号更被普遍使用的ID了!

授权

小程序的特殊性/优势在于,其可以直接使用微信APP提供的很多原生APP才可以具有的能力,比如获取用户地理位置,获取用户手机号,操作用户的camera。但是对于小程序来说,必须进行用户授权,才能在后续的开发中使用接口获取用户的这些开放数据。

我们把这些接口按使用范围分成多个scope,用户选择对scope来进行授权,当授权给一个scope之后,其对应的所有接口都可以直接使用。此类接口调用时:

  • 如果用户未接受或拒绝过此权限,会弹窗询问用户,用户点击同意后方可调用接口;
  • 如果用户已授权,可以直接调用接口;
  • 如果用户已拒绝授权,则不会出现弹窗,而是直接进入接口 fail 回调。

这些scope,其中一些是可以由小程序直接弹窗请求用户授权,另外一些是需要用户主动点击按钮请求之后,才可以调起授权弹窗,因此在开发过程中需要特别考虑用户的交互流程以及优化体验。

由小程序直接弹窗请求用户授权的scope

具体的scope信息以及使用 wx.getSetting 获取用户当前的授权状态的方法可以参考这里的官方文档查看.

scope 对应接口 描述
scope.userInfo wx.getUserInfo 用户信息 (1)
scope.userLocation wx.getLocation, wx.chooseLocation 地理位置 (2)
scope.userLocationBackground wx.userLocationBackground 后台定位 (2)
scope.address wx.chooseAddress 通讯地址 (2)
scope.invoiceTitle wx.chooseInvoiceTitle 发票抬头 (2)
scope.invoice wx.chooseInvoice 获取发票 (2)
scope.werun wx.getWeRunData 微信运动步数 (2)
scope.record wx.startRecord 录音功能 (2)
scope.writePhotosAlbum wx.saveImageToPhotosAlbum, wx.saveVideoToPhotosAlbum 保存到相册 (2)
scope.camera camera 组件 摄像头 (2)

其中,(1)标识"自动弹窗授权", (2)表示"需要用户主动点击按钮,才可以弹窗授权".

另外,需要提到一点,需要授权 scope.userLocationscope.userLocationBackground 时必须配置地理位置用途说明。

需要用户主动点击按钮请求之后,才可以调起授权弹窗

比如直接使用 wx.authorize({scope: "scope.userInfo"}),在新版本中不会弹出授权窗口,必须使用

 <button open-type="getUserInfo"/>

的方式,由用户主动点击按钮才可以。
(之前的版本是可以自动弹出授权窗口,2018-04-30开始不再支持。)

获取用户手机号

获取用户手机号是个特殊的接口,这个接口并没有定义在scope内。获取微信用户绑定的手机号,需先调用wx.login接口。

因为需要用户主动触发才能发起获取手机号接口,所以该功能不由 API 来调用,需用 button 组件的点击来触发

使用方法

需要将 button 组件 open-type 的值设置为 getPhoneNumber,当用户点击并同意之后,可以通过 bindgetphonenumber 事件回调获取到微信服务器返回的加密数据, 然后在第三方服务端结合 session_key 以及 app_id 进行解密获取手机号。

<button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber"></button>

注意

1)目前该接口针对非个人开发者,且完成了认证的小程序开放(不包含海外主体)。需谨慎使用,若用户举报较多或被发现在不必要场景下使用,微信有权永久回收该小程序的该接口权限。

2)在回调中调用 wx.login 登录,可能会刷新登录态。此时服务器使用 code 换取的 sessionKey 不是加密时使用的 sessionKey,导致解密失败。建议开发者提前进行 login;或者在回调中先使用 checkSession 进行登录态检查,避免 login 刷新登录态。

UnionID

UnionID 是一个用户对于同主体微信小程序/公众号/APP的标识,开发者需要在微信开放平台下绑定相同账号的主体。开发者可通过UnionID,实现多个小程序、公众号、甚至APP 之间的数据互通。

如果开发者拥有多个移动应用、网站应用、和公众帐号(包括小程序),可通过 UnionID 来区分用户的唯一性,因为只要是同一个微信开放平台帐号下的移动应用、网站应用和公众帐号(包括小程序),用户的 UnionID 是唯一的。换句话说,同一用户,对同一个微信开放平台下的不同应用,UnionID是相同的。

UnionID获取途径

绑定了开发者帐号的小程序,可以通过以下途径获取 UnionID:

  • 调用接口 wx.getUserInfo,从解密数据中获取 UnionID。注意本接口需要用户授权,请开发者妥善处理用户拒绝授权后的情况。

  • 如果开发者帐号下存在同主体的公众号,并且该用户已经关注了该公众号。开发者可以直接通过 wx.login + code2Session 获取到该用户 UnionID,无须用户再次授权。

  • 如果开发者帐号下存在同主体的公众号或移动应用,并且该用户已经授权登录过该公众号或移动应用。开发者也可以直接通过 wx.login + code2Session 获取到该用户 UnionID ,无须用户再次授权。

  • 用户在小程序中支付完成后,开发者可以直接通过getPaidUnionID接口获取该用户的 UnionID,无需用户授权。注意:本接口仅在用户支付完成后的5分钟内有效,请开发者妥善处理。

  • 小程序端调用云函数时,如果开发者帐号下存在同主体的公众号,并且该用户已经关注了该公众号,可在云函数中通过 cloud.getWXContext 获取 UnionID

  • 小程序端调用云函数时,如果开发者帐号下存在同主体的公众号或移动应用,并且该用户已经授权登录过该公众号或移动应用,也可在云函数中通过 cloud.getWXContext 获取 UnionID

OpenID

OpenId 是一个用户对于一个小程序/公众号的标识,开发者可以通过这个标识识别出用户。

同一个用户的UnionIDOpenID,对于同一个小程序来说是永久不变的,就算用户删了小程序,下次用户进入小程序,开发者依旧可以通过后台的记录标识出来。

登录及授权相关接口和使用方式

在小程序开发过程中,针对用户的登录以及授权获取信息的功能,需要用到的主要的API有wx.login()wx.getUserInfo()以及wx.authorize()等。

wx.login(Object object)

wx.login()接口调用后仅仅可以获取用户的登录凭证(也就是所谓的code)。开发者需要在后台通过code凭证进而换取用户登录态信息,包括用户的唯一标识(OpenID)及本次登录的会话密钥(session_key)等。这个需要在后台完成,因为涉及到较为复杂的解密流程

wx.checkSession(Object object)

这个接口用于检查登录态是否过期。

通过 wx.login 接口获得的用户登录态拥有一定的时效性。用户越久未使用小程序,用户登录态越有可能失效。反之如果用户一直在使用小程序,则用户登录态一直保持有效。具体时效逻辑由微信维护,对开发者透明。开发者只需要调用 wx.checkSession 接口检测当前用户登录态是否有效。

登录态过期后开发者可以再调用 wx.login 获取新的用户登录态。调用成功说明当前 session_key 未过期,调用失败说明 session_key 已过期。

需要注意的是,很多开发者反馈,wx.checkSession接口会比较费时(>300ms),所以在使用时需要考虑性能问题。

wx.getUserInfo(Object object)

很多开发者会把 logingetUserInfo 捆绑调用当成登录使用,其实 login 已经可以完成登录,getUserInfo 只是获取额外的用户信息。在 login 获取到 code 后,会发送到开发者后端,开发者后端通过接口去微信后端换取到 OpenIDsessionKey(现在会将 UnionID 也一并返回)后,把自定义登录态 session返回给前端,就已经完成登录行为了。

getUserInfo 只是为了提供更优质的服务而存在,比如展示头像昵称,判断性别,开发者可通过 UnionID 和其他公众号上已有的用户画像结合来提供历史数据。因此开发者不必在用户刚刚进入小程序的时候就强制要求授权。

需要特别强调的是, login 行为是静默,不必授权的,用户不会察觉。如前所述,直接调用wx.getUserInfo在新版本微信已经没有办法直接调起弹出框,必须使用组件来获取用户信息(使用方式类似 <button open-type="getUserInfo"/>)。

几个注意事项

1)很多使用场景下,开发者申请 userinfo 授权主要为了获取 UnionID,这种情况下,最好是能够在不*扰用户的情况下合理获得UnionID,而仅在必要时才向用户弹窗申请使用昵称头像。为此,凡使用“获取用户信息组件”获取用户昵称头像的小程序,在满足以下全部条件时,将可以静默获得 UnionID

  • 在微信开放平台下存在同主体的App、公众号、小程序。

  • 用户关注了某个相同主体公众号,或曾经在某个相同主体App、公众号上进行过微信登录授权。

这样可让其他同主体的App、公众号、小程序的开发者快速获得已有用户的数据。

获取UnionID的其他方式,可以参考上文对UnionID的介绍部分。

2)某些工具类的轻量小程序不需要登录行为,但是也想获取用户信息,那么就可以在 wx.getUserInfo 的时候加一个参数 withCredentials: false 直接获取到用户信息,可以少一次网络请求。

这样可以在不给用户弹窗授权的情况下直接展示用户的信息。

wx.getSetting(Object object)

开发者可以使用 wx.getSetting 获取用户已经授权的情况,可以以此为依据来判断是否需要请求授权。
scope.userInfo为例:

  • 如果用户已经授权,直接调用 API wx.getUserInfo 获取用户最新的信息;
  • 用户未授权,在界面中显示一个按钮提示用户登入,当用户点击并授权后就获取到用户的最新信息。

示例代码如下:

    wx.getSetting({
      success (res){
        if (res.authSetting['scope.userInfo']) {
          // 已经授权,可以直接调用 getUserInfo 获取头像昵称
          wx.getUserInfo({
            success: function(res) {
              console.log(res.userInfo)
            }
          })
        }
      }
    })

登录流程

小程序本身和微信的账号体系密切关联,因此很容易让人觉得使用小程序登录就需要用户的微信个人信息(比如头像昵称等)。但理论上来说,这些并不是必须的。纯粹的去看小程序提供的登录功能,其实可以理解为,它提供了一种方便快捷的创建一套用户体系的方式。

从官方提供的登录流程示意图,来具体看看是怎么来完成这个过程的。

image

可以直观的看到,我们需要设计的服务器,除了微信的接口服务之外,还需要有开发者服务器,因为有些操作需要在后台进行,比如对敏感数据的解密等等。当然,我们可以使用微信官方提供的云开发来简化这个过程。

小程序登录的具体步骤,按照官方流程图解释如下:

1. 调用 wx.login() 获取临时登录凭证code

wx.login()这个API的作用就是为当前用户生成一个临时的登录凭证,这个临时登录凭证的有效期只有五分钟。我们拿到这个登录凭证后就可以进行下一步操作:获取OpenIDsession_key.

Question: 为什么不直接返回OpenID,而只是返回code

说到登录,我们可能很正常地想到一个做法:通过wx.login直接拿到微信用户的id编号,再把这个id传到自己的后台,从而知道是哪个微信用户在使用我的服务。而我们上述微信登录的流程中并不是通过wx.login直接获取微信用户的id,那直接获取微信用户id的做法有什么问题呢? 假设现在我们有个接口,通过wx.request请求https://test.com/getUserInfo?id=1拉取到微信用户id为1在我们业务侧的个人信息,那么黑客就可以通过遍历所有的id,把整个业务侧的个人信息数据全部拉走,如果我们还有其他接口也是依赖这样的方式去实现的话,那黑客就可以伪装成任意身份来操作任意账户下的数据,想想这给业务带来多大的安全风险。

为了避免这样的风险,wx.login是生成一个带有时效性的凭证,就像是一个会过期的临时身份证一样,在wx.login调用时,会先在微信后台生成一张临时的身份证,其有效时间仅为5分钟。然后把这个临时身份证返回给小程序方,这个临时的身份证我们把它称为微信登录凭证code。如果5分钟内小程序的后台不拿着这个临时身份证来微信后台服务器换取微信用户id的话,那么这个身份证就会被作废,需要再调用wx.login重新生成登录凭证。

由于这个临时身份证5分钟后会过期,如果黑客要冒充一个用户的话,那他就必须在5分钟内穷举所有的身份证id,然后去开发者服务器换取真实的用户身份。显然,黑客要付出非常大的成本才能获取到一个用户信息,同时,开发者服务器也可以通过一些技术手段检测到5分钟内频繁从某个ip发送过来的登录请求,从而拒绝掉这些请求。

wx.login({
    success: function(loginRes) {
        if (loginRes.code) {
            // example: 081LXytJ1Xq1Y40sg3uJ1FWntJ1LXyth
        }
    }
});

2. 回传临时登录凭证code到开发者服务器

wx.loginsuccess回调中拿到微信登录凭证,紧接着会通过wx.request把code传到开发者服务器,为了后续可以换取微信用户身份id。如果当前微信用户还没有绑定当前小程序业务的用户身份,那在这次请求应该顺便把用户输入的帐号密码一起传到后台,然后开发者服务器就可以校验账号密码之后再和微信用户id进行绑定.

3. 调用 auth.code2Session 接口,换取 用户唯一标识OpenID和 会话密钥 session_key

到了第3步,开发者的后台就拿到了前边wx.login()所生成的微信登录凭证code,此时就可以拿这个code到微信服务器换取微信用户身份。

微信服务器为了确保拿code过来换取身份信息的人就是刚刚对应的小程序开发者,到微信服务器的请求要同时带上AppIdAppSecret,这两个信息在小程序管理平台的开发设置界面可以看到,由此可以看出,AppIdAppSecret是微信鉴别开发者身份的重要信息,AppId是公开信息,泄露AppId不会带来安全风险,但是AppSecret是开发者的隐私数据不应该泄露,如果发现泄露需要到小程序管理平台进行重置AppSecret,而code在成功换取一次信息之后也会立即失效,即便凭证code生成时间还没过期。

开发者服务器和微信服务器通信也是通过HTTPS协议,微信服务器提供的接口地址是:

https://api.weixin.qq.com/sns/jscode2session?appid=<AppId>&secret=<AppSecret>&js_code=<code>&grant_type=authorization_code

URL的query部分的参数中 <AppId>, <AppSecret>, <code> 就是前文所提到的三个信息,请求参数合法的话,接口会返回以下字段。

  • OpenID | 微信用户的唯一标识
  • session_key | 会话密钥
  • UnionID | 用户在微信开放平台的唯一标识符。

如上介绍,OpenID用来区分不同的微信用户,session_key则是微信服务器给开发者服务器颁发的身份凭证,开发者可以用session_key请求微信服务器其他接口来获取一些其他信息。由此可以看到,session_key不应该泄露或者下发到小程序前端。

UnionID这个字段比较特殊,本字段在满足一定条件的情况下才返回。至于具体条件,可以参考本文前面wx.getUserInfo(Object object)部分的介绍。

除了需要在服务端进行session_key的获取,我们还需要注意两点:

  • session_key和微信派发的code是一一对应的,同一code只能换取一次session_key。每次调用wx.login(),都会下发一个新的code和对应的session_key,为了保证用户体验和登录态的有效性,开发者需要清楚用户需要重新登录时才去调用wx.login()

  • session_key是有时效性的,即便是不调用wx.loginsession_key也会过期,过期时间跟用户使用小程序的频率成正相关,但具体的时间长短开发者和用户都是获取不到的

function getSessionKey (code, appid, appSecret) {
    var opt = {
        method: 'GET',
        url: 'https://api.weixin.qq.com/sns/jscode2session',
        params: {
            appid: appid,
            secret: appSecret,
            js_code: code,
            grant_type: 'authorization_code'
        }
    };
    return http(opt).then(function (response) {
        var data = response.data;
        if (!data.OpenID || !data.session_key || data.errcode) {
            return {
                result: -2,
                errmsg: data.errmsg || '返回数据字段不完整'
            }
        } else {
            return data
        }
    });
}

Question: 为什么要设计session_key,而不是用户每次打开小程序都重新login?

如果我们每次都通过小程序前端wx.login()生成微信登录凭证code去微信服务器请求信息,步骤太多造成整体耗时比较严重,因此对于一个比较可信的服务端,给开发者服务器颁发一个时效性更长的会话密钥就显得很有必要了。

因此可以将登录态存入storage中,用户再次登录就可以拿storage 里的登录态做正常的业务请求,只有当登录态过期了之后才需要重新login 。这样子做一则可以减少用户等待时间,二则可以减少网络带宽。

目前微信的session_key 有效期是三天,所以建议开发者设置的登录态有效期要小于这个值。

4. 生成3rd_session

前面说过通过session_key来“间接”地维护登录态,所谓间接,也就是我们需要自己维护用户的登录态信息,这里也是考虑到安全性因素,如果直接使用微信服务端派发的session_key来作为业务方的登录态使用,会被“有心之人”用来获取用户的敏感信息,比如wx.getUserInfo()这个接口,就需要session_key来配合解密微信用户的敏感信息。

那么我们如果生成自己的登录态标识呢,这里可以使用几种常见的不可逆的哈希算法,比如md5、sha1等,将生成后的登录态标识(这里我们统称为'skey')返回给前端,并在前端维护这份登录态标识(一般是存入storage)。而在服务端呢,我们会把生成的skey存在用户对应的数据表中,前端通过传递skey来存取用户的信息。

可以看到这里我们使用了sha1算法来生成了一个skey:

const crypto = require('crypto');

return getSessionKey(code, appid, secret)
    .then(resData => {
        // 选择加密算法生成自己的登录态标识
        const { session_key } = resData;
        const skey = encryptSha1(session_key);
    });
    
function encryptSha1(data) {
    return crypto.createHash('sha1').update(data, 'utf8').digest('hex')
}

Question: 既然用户的OpenID 是永远不变的,那么开发者可以使用OpenID 作为用户的登录态么?

不行,这是非常危险的行为。因为 OpenID 是不变的,如果有坏人拿着别人的 OpenID 来进行请求,那么就会出现冒充的情况。所以我们建议开发者可以自己在后台生成一个拥有有效期的 第三方session 来做登录态,用户每隔一段时间都需要进行更新以保障数据的安全性。

5. checkSession

前面我们将skey存入前端的storage里,每次进行用户数据请求时会带上skey,那么如果此时session_key过期呢?所以我们需要调用到wx.checkSession()这个API来校验当前session_key是否已经过期,这个API并不需要传入任何有关session_key的信息参数,而是微信小程序自己去调自己的服务来查询用户最近一次生成的session_key是否过期。如果当前session_key过期,就让用户来重新登录,更新session_key,并将最新的skey存入用户数据表中。

checkSession这个步骤,我们一般是放在小程序启动时就校验登录态的逻辑处,这里贴个校验登录态的流程图:

image

let loginFlag = wx.getStorageSync('skey');
if (loginFlag) {
    // 检查 session_key 是否过期
    wx.checkSession({
        // session_key 有效(未过期)
        success: function() {
            // 业务逻辑处理
        },
    
        // session_key 过期
        fail: function() {
            // session_key过期,重新登录
            doLogin();
        }
    });
) else {
    // 无skey,作为首次登录
    doLogin();
}

云开发

云开发是由微信官方提供的服务,有了云开发,开发者不需要拥有自己的开发服务器就可以进行小程序的开发。关于云开发的具体介绍,这里不再赘述,请参考官方文档。这里仅说明和登录相关部分的使用。

云开发的云函数的独特优势在于与微信登录鉴权的无缝整合。当小程序端调用云函数时,云函数的传入参数中会被注入小程序端用户的 openid,开发者无需校验 openid 的正确性,因为微信已经完成了这部分鉴权,开发者可以直接使用该 openid。与 openid 一起同时注入云函数的还有小程序的 appid

从小程序端调用云函数时,开发者可以在云函数内使用 wx-server-sdk 提供的 getWXContext 方法获取到每次调用的上下文(appidopenid 等),无需维护复杂的鉴权机制,即可获取天然可信任的用户登录态(openid)。可以写这么一个云函数进行测试:

// index.js
const cloud = require('wx-server-sdk')
exports.main = (event, context) => {
  // 这里获取到的 openId、 appId 和 unionId 是可信的,注意 unionId 仅在满足 unionId 获取条件时返回
  let { OPENID, APPID, UNIONID } = cloud.getWXContext()

  return {
    OPENID,
    APPID,
    UNIONID,
  }
}

假设云函数命名为 test,上传并部署该云函数后,可在小程序中测试调用:

wx.cloud.callFunction({
  name: 'test',
  complete: res => {
    console.log('callFunction test result: ', res)
  }
})

会在调试器看到输出的 res 为如下结构的对象:

{
  "APPID": "xxx",
  "OPENID": "yyy",
  "UNIONID": "zzz", // 仅在满足 unionId 获取条件时返回
}

Reference

小程序登录

微信登录

TencentCloudBase/mp-book

获取用户信息

微信登录能力优化官

做好这个登录优化,你的小程序将会赢得更多用户的青睐!

手把手教会你小程序登录鉴权