Tutorial: [基础能力] 健壮高效的登录机制

[基础能力] 健壮高效的登录机制

效果

  • 用户体验
    • 流畅
      登录过程不会阻塞操作,登录前后自然衔接,e.g.点击留言-触发登录-自动继续完成留言行为,不需要刷新页面也不需要再次点击留言按钮。
    • 多元
      支持不同小程序不同页面不同场景配置不同的登录界面和登录流程,在合适的时候通过合适的引导提高用户登录意愿。
    • 精细
      支持一进页面就悄悄对用户进行精细区分,对老用户进行个性化定制,同时不需要弹出登录界面打扰新用户;支持精细控制登录时机,可以在新用户进行任何一项要求登录的操作时自动触发登录流程。
  • 业务开发
    • 省心
      只需指明业务功能是否需要登录态即可,各种登录细节不用管,e.g.留言业务,只需指明“留言过程需要登录态”即可,至于:用户点击留言按钮时登录了没、怎么进行登录交互、会不会页面其它地方正登录到一半、会不会后端登录态刚好过期了等各种问题都不需要业务方care。
    • 自由
      不想关注登录时,各种细节完全不用care;想关注时,各种细节又都可以自由定制,e.g.定制登录界面、跳过登录界面、附加登录步骤、监听登录成功等。
  • 健壮性
    • 后端登录态过期自动重新登录,避免前后端登录态时效差异造成偶现功能异常
    • 页面多处同时触发登录自动合并,避免时序混乱互相干扰造成潜在逻辑异常
    • 各机制对调用方透明,不依赖调用方自觉性,避免调用失误造成业务异常
  • 高效性
    • 后端登录态惰性检查,节约每次校验开销
    • 公共步骤免并发,节约并发成本
    • 前后操作无缝衔接,节约用户重复操作开销
  • 复用、扩展、维护
    • 支持多小程序多页面多场景复用
    • 公共流程统一维护,差异特性各自扩展
    • 支持子类继承自定义逻辑扩展

原理

详见ppt:健壮高效的小程序登录方案

基础用法

  1. fancy-mini setup
  2. 编写鉴权逻辑
    编写鉴权模块,根据用户提供的信息,完成校验过程,并返回对应的登录数据。e.g.:
  //FancyWechatAuth.js
  import WechatAuth from "fancy-mini/lib/login/auth/WechatAuth";

  /**
   * 微信登录鉴权模块-自定义实现示例
   */
  class FancyWechatAuth extends WechatAuth {
    /**
     * 微信授权登录
     * 根据用户同意授权后从微信处拿到的信息,完成登录过程
     * @param {WechatAuth~WxLoginRes} wxLoginRes wx.login执行结果
     * @param {Object} authData 登录界面交互结果,格式同wx.getUserInfo返回结果
     * @param loginOptions 登录函数调用参数,参见BaseLogin#login
     * @param configOptions 登录模块配置参数,参见BaseLogin#config
     * @return {BaseAuth~LoginRes}
     */
    async loginByWxAuth({wxLoginRes, authData, loginOptions, configOptions}) {
      //调用后端接口,根据微信授权信息获取登录结果
      let loginRes = await configOptions.requester.request({
        url: 'https://xxx/wxAuthLogin', //todo:换成实际后端接口
        data: {
          code: wxLoginRes.code,
          encryptedData: authData.encryptedData,
          iv: authData.iv,
          //...
        },
        method: "POST",
      });

      //后端:
      //  1. 调用微信api,根据入参中的code,获取session_key,官方文档:https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html
      //  2. 调用微信api,根据入参中的encryptedData、iv和上一步中获取的session_key,获取微信用户标识和用户信息,官方文档:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/signature.html
      //      2.1 用户标识:openId,用户在小程序中的唯一标识,因小程序而异
      //      2.2 用户标识:unionId,用户在同一主体中的唯一标识,同一用户在同一主体下的所有小程序、公众号、网页、APP等应用中unionId一致
      //      2.3 用户信息:昵称、头像、性别、地区等
      //  3. 根据上一步中获取的openId/unionId,查询用户表;若存在相关记录,则返回对应用户信息,登录成功;若不存在,则自动注册,并存储微信用户信息作为用户初始信息,存储微信用户标识作为关联标识备查,之后返回对应用户信息,登录成功。

      //登录失败处理 todo: 根据接口实际格式进行字段调整
      if (loginRes.respCode != 0) {
        return {
          succeeded: false, //是否成功
          errMsg: 'login api failed:' + JSON.stringify(loginRes), //详细错误信息,调试用
          toastMsg: loginRes.respData && loginRes.respData.errMsg //(若有)错误话术,向用户提示用
        };
      }

      //登录成功处理 todo: 根据接口实际格式进行字段调整
      let data = loginRes.respData;
      return {
        succeeded: true, //是否成功
        errMsg: 'ok', //错误信息
        userInfo: { //用户信息 todo: 改为实际所需格式
          nickName: data.nickName, //昵称
          avatarUrl: data.avatarUrl, //头像
          gender: data.gender, //性别
          uid: data.uid, //后端自己维护的用户标识
          sessionKey: data.sessionKey, //后端自己维护的登录凭证
        },
        expireTime: Date.now() + data.validityPeriod, //有效期至,格式:绝对时间戳,-1表示长期有效
        anonymousInfo: { //(若有)匿名信息,登录前分配的临时标识,登录后继续关联
          token: data.token,
        },
      };
    }

    /**
     * 微信静默登录
     * 在用户无感知的情况下悄悄完成登录过程
     * @param {WechatAuth~WxLoginRes} wxLoginRes wx.login执行结果
     * @param loginOptions 登录函数调用参数,参见BaseLogin#login
     * @param configOptions 登录模块配置参数,参见BaseLogin#config
     * @return {BaseAuth~LoginRes}
     */
    async loginByWxSilent({wxLoginRes, loginOptions, configOptions}) {
      let loginRes = await configOptions.requester.request({
        url: 'https://xxx/wxSilentLogin', //todo:换成实际后端接口
        data: {
          code: wxLoginRes.code,
          //...
        },
        method: "POST",
      });

      //后端:
      //  1. 调用微信api,根据入参中的code,获取openId,官方文档:https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html
      //  2. 根据openId,查询用户表;若存在相关记录,则返回对应用户信息,登录成功;若不存在相关记录,则登录失败
      //     效果:老用户可以在无感知的情况下悄悄静默登录;新用户依然是要等到授权登录时拿到用户信息才进行注册和登录,但静默尝试用户无感知,不会产生打扰

      //登录失败处理 todo:根据接口实际格式进行字段调整
      if (loginRes.respCode != 0) {
        return {
          succeeded: false, //是否成功
          errMsg: 'fail', //详细错误信息,调试用
          anonymousInfo: { //(若有)匿名信息,登录前分配的临时标识,登录后继续关联
            token: loginRes.respData.token,
          }
        };
      }

      //登录成功处理 todo:根据接口实际格式进行字段调整
      let data = loginRes.respData;

      return {
        succeeded: true, //是否成功
        errMsg: 'ok', //错误信息
        userInfo: { //用户信息 todo: 改为实际所需格式
          nickName: data.nickName, //昵称
          avatarUrl: data.avatarUrl, //头像
          gender: data.gender, //性别
          uid: data.uid, //后端自己维护的用户标识
          sessionKey: data.sessionKey, //后端自己维护的登录凭证
        },
        expireTime: Date.now() + data.validityPeriod, //有效期至,格式:绝对时间戳,-1表示长期有效
        anonymousInfo: { //(若有)匿名信息,登录前分配的临时标识,登录后继续关联
          token: data.token,
        },
      }
    }
  }

  export default FancyWechatAuth;

  1. 编写登录逻辑
    编写登录模块,添加自定义参数、自定义流程等。e.g.:
  //FancyLogin.js
  import BaseLogin from 'fancy-mini/lib/login/BaseLogin';
  import {peerAssign} from 'fancy-mini/lib/operationKit';

  /**
   * 登录模块-自定义实现示例
   * 通过继承覆盖的形式,可以在登录模块的各个环节中添加各种自定义逻辑
   * e.g.引入cookie逻辑,使得所有接口请求自动携带登录信息
   */
  class FancyLogin extends BaseLogin{
    cookie = null;

    //模块配置
    config(configOptions){
      //参数校验...

      //处理模块自身所需的自定义参数
      this.cookie = configOptions.cookie;

      //处理需要传递给鉴权模块、钩子函数等其它可配模块的自定义参数
      const defaultFancyOpts = {
        source: '', //小程序编号,用于区分不同的小程序,由rd指定,后端可以根据source查询到对应小程序的appId、appSecret等信息
      };

      let fancyOptions = peerAssign({}, defaultFancyOpts, configOptions);

      //注册额外参数
      this._appendConfig('fancyOptions', fancyOptions); //则鉴权模块、钩子函数等可以通过 configOptions.fancyOptions 拿到这些自定义参数

      //处理通用参数
      super.config(configOptions);
    }

    //模块初始化
    _init(){
      super._init();

      //写入cookie
      this.cookie.set('uid', this._loginInfo.userInfo.uid || '');
      this.cookie.set('sessionKey', this._loginInfo.userInfo.sessionKey || '');
    }

    //清除登录态
    clearLogin(...args){
      super.clearLogin(...args);
      cookie.set('uid', '');
      cookie.set('sessionKey', '');
    }

    //保存/更新用户信息
    _saveInfo(loginInfo){
      super._saveInfo(loginInfo);

      // 写入cookie
      cookie.set('uid', loginInfo.userInfo.uid);
      cookie.set('sessionKey', loginInfo.userInfo.sessionKey);
    }
  }

  export default FancyLogin;

  1. 进行整体配置
    小程序引入登录模块,提供相关信息并进行整体配置。e.g.:
  //appPlugin.js
  import FancyLogin from "../demoExtend/login/FancyLogin";
  import FancyWechatAuth from "../demoExtend/login/auth/FancyWechatAuth";
  import LoginPlugin from "fancy-mini/lib/request/plugin/LoginPlugin";
  import Requester from "fancy-mini/lib/request/Requester";
  import Cookie from 'fancy-mini/lib/Cookie';
  import CookiePlugin from 'fancy-mini/lib/request/plugin/CookiePlugin';
  import {authEvents} from 'fancy-mini/lib/globalEvents';

  //实例创建
  const loginCenter = new FancyLogin(); //登录中心
  const requester = new Requester(); //请求管理器
  const cookie = new Cookie(); //cookie管理器

  //登录模块配置
  loginCenter.config({
    requester, //请求管理器,用于发送接口请求
    authEngineMap: { //鉴权器映射表,key为登录方式,value为对应的鉴权器
      'wechat' : new FancyWechatAuth(), //登录方式:微信,鉴权器:微信登录鉴权
    },
    defaultAuthType: 'wechat', //默认登录方式:微信
    async userAuthHandler(){ //默认登录交互
      let userAuthRes = await new Promise(resolve=>{
        authEvents.subscribe({ //监听交互结果
          eventType: 'userAuthFinish',
          handler: resolve, //交互结束时resolve当前promise
          persistType: 'once'
        });
        wx.navigateTo({ //展示登录界面
          url: '/pages/login/index'
        });
      });

      //等待用户交互,直到用户交互结束,登录界面信息收集完毕,该Promise才会被resolve,后续代码才会自动继续执行

      //返回交互结果
      return userAuthRes;
    },

    //自定义参数
    cookie,
    source: 1,
  });

  //请求管理器配置
  requester.config({
    plugins: [
      //登录插件,在请求前后自动加入登录态相关逻辑
      new LoginPlugin({
        loginCenter,
        apiAuthFailChecker(resData, reqOptions){ //根据接口返回内容,判断后端登录态是否已失效
          return (
            (resData.respMsg && resData.respMsg.includes('请登录')) || //后端登录态失效通用判断条件
            (reqOptions.url.includes('/bizA/') && resData.respCode===-1) || //业务线A后端接口登录态失效
            (reqOptions.url.includes('/bizB/') && resData.respCode===-2) //业务线B后端接口登录态失效
          );
        }
      }),
      //cookie插件,在请求前后自动加入cookie相关逻辑
      new CookiePlugin({
        cookie,
      }),
    ]
  });

  export {
    requester,
    loginCenter,
    cookie,
  }

  1. 编写登录界面
    小程序提供一个默认的登录界面,负责完成登录交互收集所需信息。e.g.:(示例采用wepy1.x框架语法,其它框架类似)
  <template>
    <view>
      <button open-type="getUserInfo" @getuserinfo="onGetUserInfo">立即登录</button>
    </view>
  </template>

  <script>
    //pages/login/index.wpy
    import wepy from 'wepy';
    import {authEvents} from 'fancy-mini/lib/globalEvents';

    export default class extends wepy.page {
      config = {
        navigationBarTitleText: '登录',
      }

      data = {
        isActiveUnload: false, //是否代码主动触发的页面卸载:true-代码主动退出 | false-被动退出(e.g.用户点击了系统返回按钮)
      }

      methods = {
        onGetUserInfo(ev){
          //授权失败,留在当前界面,等待用户再次交互
          if (!ev.detail.errMsg.includes('ok'))
            return;

          //授权成功

          //通知登录模块交互结果
          authEvents.notify({
            eventType: 'userAuthFinish',
            data: {
              succeeded: true, //是否成功收集到了所需信息
              errMsg: 'ok', //(失败时)错误信息
              authType: 'wechat', //(成功时)用户选择的登录方式
              authData: { //(成功时)该登录方式所需的鉴权信息
                ...ev.detail
              }
            }
          });

          //自动返回
          this.isActiveUnload = true;
          wx.navigateBack();
        }
      }

      onUnload() {
        //被动退出页面时,通知登录模块交互结果
        if (!this.isActiveUnload) {
          authEvents.notify({
            eventType: 'userAuthFinish',
            data: {
              succeeded: false, //是否成功收集到了所需信息
              errMsg: 'cancel', //(失败时)错误信息
              authType: '', //(成功时)用户选择的登录方式
              authData: { //(成功时)该登录方式所需的鉴权信息
              }
            }
          });
        }
      }
    }
  </script>
  1. 手动触发登录
    页面/组件中调用登录相关api,手动触发登录相关功能。e.g.:
  //页面/组件
  import {loginCenter} from '../../lib/appPlugin';

  async onLoad(){
    let loginRes = await loginCenter.login(); //登录
    console.log('login finished, res:', loginRes);
  }
  1. 自动触发登录
    页面/组件中调用接口相关api,自动触发登录功能。e.g.:
  //页面/组件
  import {requester} from '../../lib/appPlugin';

  //留言
  async onComment(){
    let commentRes = await requester.requestWithLogin({ //调用留言接口时使用requestWithLogin表明“该接口需要登录态”
      url: 'https://xxx/comment',
      data: {
        //...
      }
    });
    
    //则模块会在请求前后自动加入登录态相关逻辑:
    //  1.请求发出前,若未登录,则先触发登录,成功后再发送接口请求,失败则取消接口调用
    //  2.请求返回后,若判断后端登录态已失效,则自动重新登录重新发送接口请求,并以重新请求的结果作为本次调用结果返回
    
    //业务方无需关注是否已登录、如何登录、登录并发、登录态过期等各种问题,直接正常进行业务逻辑后续处理即可
    console.log('comment finished, res:', commentRes); //处理留言接口返回的内容
  }

注册到this上使用

可以将相关方法注册到组件/页面的this对象上,便于组件/页面直接使用。

  1. 将相关功能注册到this上
  //appPlugin.js
  import {registerToThis} from 'fancy-mini/lib/wepyKit';
  
  //接上例,登录相关模块引入&配置...
  
  //将登录模块相关功能注册到this上,方便组件/页面直接使用
  const propMapThis2Login = { //命名映射,key为this属性名,value为loginCenter属性名, '*this'表示loginCenter自身
    '$loginCenter': '*this', // this.$loginCenter 对应 loginCenter
    '$login': 'login', // this.$login() 对应 loginCenter.login()
    '$logout': 'logout',
    '$reLogin': 'reLogin',
    '$checkLogin': 'checkLogin',
  };

  for (let [thisProp, loginProp] of Object.entries(propMapThis2Login)) {
    let loginTarget = loginProp === '*this' ? loginCenter : loginCenter.makeAssignableMethod(loginProp);
    registerToThis(thisProp, loginTarget);
  }

  //将请求模块相关功能注册到this上,方便组件/页面直接使用
  const propMapThis2Requester = { //命名映射,key为this属性名,value为requester属性名, '*this'表示requester自身
    '$requester': '*this', // this.$requester 对应 requester
    '$http': 'request', // this.$http() 对应 requester.request()
    '$httpWithLogin':'requestWithLogin', //this.$httpWithLogin() 对应 requester.requestWithLogin()
  };

  for (let [thisProp, requesterProp] of Object.entries(propMapThis2Requester)) {
    let requesterTarget = requesterProp === '*this' ? requester : requester.makeAssignableMethod(requesterProp);
    registerToThis(thisProp, requesterTarget);
  }
  
  //...
  1. 使注册生效
//项目入口文件(app.js/app.wpy/app.vue/main.js/……,因框架而异)
import './lib/appPlugin';  //负责各种小程序级公共模块的引入和配置
  1. 页面/组件直接使用相关功能
  import wepy from 'wepy';

  export default class extends wepy.page {
    async onLoad(){
      let loginRes = await this.$login(); //可以直接通过this调用相关方法,而不用每次引入相关模块
      console.log('login finished, res:', loginRes);
      
      let commentRes = await this.$httpWithLogin({ //可以直接通过this调用相关方法,而不用每次引入相关模块
        url: 'https://xxx/comment',
        data: {
          //...
        }
      });
      console.log('comment finished, res:', commentRes); 
    }
  }

模块级功能定制

对登录模块有特殊需求时,支持进行各种自定义逻辑扩展。
如上文中编写登录逻辑小节所示,可以通过继承&重写的方式编写自己的登录模块,从而实现各种模块级功能定制。
可重写节点详见:API文档-登录模块源码-登录模块

小程序级功能定制

同时维护多个小程序时,支持所有小程序共用一个登录模块的同时,各自指定不同的功能逻辑。
如上文中进行整体配置小节所示,小程序可以通过传入各种配置项改写登录模块的默认行为,从而实现各种小程序级功能定制。
可配项详见:BaseLogin#config

页面级功能定制

同一小程序中,支持不同页面指定不同的登录逻辑。

  1. 小程序中配置页面级参数获取方式
  //appPlugin.js
  import {getCurWepyPage} from 'fancy-mini/lib/wepyKit';
  
  //...
  
  //登录模块配置
  loginCenter.config({
    //...
    pageConfigHandler(){ //获取页面级登录配置
      let curPage = getCurWepyPage() || {}; //获取当前页面实例
      return curPage.$loginOpts; //返回当前页面的自定义配置参数
    },
  });
  
  //...
  1. 页面中进行功能定制
  import wepy from 'wepy';

  export default class extends wepy.page {
    //对登录行为进行页面级定制
    $loginOpts = {
      userAuthHandler: async()=>{ //默认登录界面
        //展示更符合本页面风格和本页面价值点的登录弹窗,而不使用小程序默认登录交互,提高用户登录意愿
        //...
      },
      onNewlyLogin: ()=>{ //钩子函数,未登录=>已登录 时触发
        //当页面中任何位置成功发生登录行为时
        //更新页面中所有的用户相关数据...
      }
    }
  }

可配项详见:BaseLogin#_mergeConfigOptions

调用级功能定制

同一页面中,支持不同函数指定不同的登录逻辑。

  let loginRes = await this.$login({
    userAuthHandler: async()=>{ //指定登录界面
      //展示更符合本次操作价值点的登录弹窗,而不使用小程序级/页面级默认登录交互,提高用户登录意愿
      //...
    },
  });
  
  let commentRes = await this.$httpWithLogin({
    url: 'https://xxx/comment',
    data: {
      //...
    },
    loginOpts: { 
      userAuthHandler: async()=>{ //指定登录界面
        //展示更符合本次操作价值点的登录弹窗,而不使用小程序级/页面级默认登录交互,提高用户登录意愿
        //...
      }
    }
  });

可配项详见:BaseLogin#loginLoginPlugin#requestWithLogin

实现其它登录方式

除了微信登录,也支持实现其它登录方式,如:账号密码登录、手机号验证码登录等。

  1. 编写登录方式对应的鉴权模块
    继承BaseAuth类,编写对应的鉴权逻辑。
  2. 小程序中配置对应鉴权映射
    在appPlugin.js中loginCenter.config时,authEngineMap选项中添加相应的键值对。
  3. 登录界面中收集相应信息
    登录界面中返回相应的authType和authData。

登录界面可以同时展示多种登录方式,然后根据用户交互返回用户实际所选登录方式对应的authType和authData。

api查询