微信小程序登录鉴权与获取用户信息

7,817 阅读6分钟

前言

在小程序中,与云开发相比,传统的前后端开发在登录鉴权的实现方面相对来说更加复杂,不仅需要前端和后端的交互,后端还需要与微信接口服务进行交互,以完成整个鉴权流程:

img

整个流程简单来说分为以下7步:

  1. 前端调用wx.login()获取临时登录凭证code,并回传到开发者服务器。
  2. 服务器调用auth.code2Session换取用户唯一标识OpenID和会话密钥session_key
  3. 服务器端根据OpenIDsession_key生成自定义登录态(可以理解为是token),将token响应给前端。
  4. 前端将token存入Storage中。
  5. 当前端之后向后端发起请求时,就会带上token
  6. 后台通过token(或者其他类型密钥),解密获取OpenID,判断是哪个用户的行为,做出响应的逻辑处理(比如操作数据库等)。
  7. 后台响应数据给前端。

这个是小程序登录的流程,但是小程序登录和小程序获取用户信息并不是一回事。小程序登录的APIwx.login,可以获取用户的openIDopenID是用户的唯一标识,是比较隐私的数据,一般不会返回给前端。小程序获取用户信息的APIwx.getUserInfo,它可以获取用户的一些基本信息,比如nickNameavatarUrl等。两者不要弄混。

其实微信登录一开始并不是这样的,以往微信小程序在用户没有任何操作的情况下就会直接弹出授权的登录方式,如果用户点击拒绝授权,则无法使用小程序。按照微信官方对这个功能更新的解释是:

因此,微信对开发的建议是:

  1. 当用户打开小程序时访问第一个页面时,先通过 wx.login,获取用户 openID 。这时无需弹框授权,开发者拿到 openID 可以建立自身的帐号 ID
  2. 在第一步中,拿到 openID 后,判断是新用户还是老用户。如果是老用户,可以直接登录;如果是新用户,可先在小程序首页展示你的信息服务,让用户对这个小程序有大概的了解,再引导用户进行下一步的操作。
  3. 当需要获取用户头像昵称的时候,对用户展示一个登录页面,这个页面只有一个最重要的操作,引导用户进行登录。

小程序登录

在上一节中有提到,小程序登录可以分为7个步骤,下面就详细讲一下7个步骤具体是如何实现。

step1: 前端调用wx.login()获取临时登录凭证code

在项目中,我使用了Taro框架,所以调用的API对应为Taro.login()

Taro.login({})
      .then((res) => {
        if (res.code) {
          // 将code发送到后台,以获取token
          getToken(res.code)
            .then((res: any) => {
              const { token, userExist } = res;

              // 将token存储到Storage中
              Taro.setStorageSync('token', token);

              // 如果是老用户,获取用户信息
              if(userExist) {
                const { userInfo } = res;
                Taro.setStorageSync('userInfo', userInfo);
              }
            })
            .catch((err) => {
              console.error(err);
            });
        } else {
          console.log('登录失败! ' + res.errMsg);
        }
      })
      .catch((err) => {
        console.error(err);
      });

step2: 服务端调用auth.code2Session换取openid和session_key

服务端调用外部接口需要使用egg.js中的一个apithis.ctx.curl,因为是异步请求,所以需要加上await

// app/controller/home.js
// login接口
async login() {
    const { ctx } = this;
    const { code } = ctx.request.body;
    // 服务器根据客户端传来的code向微信接口服务获取session_key和openid
    const res = await ctx.curl(
        `https://api.weixin.qq.com/sns/jscode2session?		
    appid=wx6936c18b38186cf3&secret=d11f77fb7d5a959b6ba46c30dbd4da95&js_code=${code}&grant_type=authorization_code`,
        {
        	dataType: 'json',
        }
    );
    const { openid } = res.data;  // 获取到openid
}

step3: 根据openid生成自定义登录态token,响应给前端

因为openid是用户的唯一标识,根据它生成token,响应给前端后,前端每次发请求带上token,后台解密请求中的token,获取到openid,便能识别这是哪个用户的请求行为。

这里我们使用jwt来生成自定义登录态token,使用jwt-simple库来生成jwt

const jwt = require('jwt-simple');
const SECRET = 'zhuoran'; // 自定义

async login() {
    ...
    const { openid } = res.data;  // 获取到openid
    
    // 根据用户的openid生成token
    const token = jwt.encode(openid, SECRET);
    
    // 将token返回
    ctx.body = {
      token: token,
      ...
    };
    ...
}

step4: 前端将token存入storage

// 将token存储到Storage中
Taro.setStorageSync('token', token);

并在每次请求时带上token,将token放在请求头的Authorization字段里面:

const option = {
    url: BASE_URL + url,
    data: data,
    method: method,
    header: {
        'content-type': contentType,
        Authorization: Taro.getStorageSync('token'),
    },
};
Taro.request(option);

这样前端之后向后端发起请求,都会带上token。

step5: 后台解密token获取openid

在后台解密token获取openid之后,便能知道这是哪个用户的请求,执行响应的操作:

async request() {
    const { ctx } = this;

    // 从请求头的authorization字段获取token
    const token = ctx.get('authorization');

    // 对token进行解密获取其中的openid
    const openid = jwt.decode(token, SECRET);

    // 根据openid查找用户信息
    const res = await ctx.model.User.findAll({
      where: {
        openid: openid,
      },
    });

    // 之后注意要将openid属性去掉,私密属性不传回给客户端
    ctx.body = res;
}

额外需要注意的点

保存用户登录态,一直以来都有两种解决方案:前端保存和后端保存。

  • 后端保存:在后端设定并存储当前token的过期时间,定期通知小程序前端重新登录
  • 前端保存:因为session_key存在时效性(因为通过session_key我们可以查看敏感信息,所以必定会有一定的时效性),而小程序前端可以通过wx.checkSession()来检查session_key是否过期。我们可以自定义登录态,并考虑以session_key有效期作为自身登录态有效期(也就是以session_key的到期时间作为自定义登录态的到期时间,两者实际上并没有实质联系)。这个也是小程序文档中推荐的方法。

因此,在项目中token是会过期的,一段时间没有使用就会导致它过期。那怎么去检验token是否过期,然后去更新它呢?

这里就需要用到前端拦截器和后端中间件。前端拦截器用来判断token是否过期,后端中间件用于判断请求是否携带token以及token是否有效。

前端拦截器

这里会使用到Taro拦截器APITaro.addInterceptor(callback)。拦截器允许我们在请求发出前或发出后做一些额外操作。

例如:

const interceptor = function (chain) {
  // 拦截请求发出前做一些额外操作
  const requestParams = chain.requestParams
  const { method, data, url } = requestParams
  
  console.log(`http ${method || 'GET'} --> ${url} data: `, data)

  return chain.proceed(requestParams)
    .then(res => {
      // 拦截请求发出后做一些额外操作
      console.log(`http <-- ${url} result:`, res)
      return res
    })
  }
Taro.addInterceptor(interceptor)

所以,我们可以将checkSession步骤写在前端拦截器里,在请求每次发出之前判断session_key是否有过期,如果过期了,则重新调用login方法,更新token,如果没有过期,则正常发起请求:

// login方法
const login = () => {
  Taro.login({})
    .then((res) => {
      if (res.code) {
        console.log('code为' + res.code);

        // 将code和userInfo发送到后台,以获取token
        getToken(res.code)
          .then((token) => {
            console.log('获取token');
            console.log(token);

            // 将token存储到Storage中
            Taro.setStorageSync('token', token);
          })
          .catch((err) => {
            console.error(err);
          });
      } else {
        console.log('登录失败! ' + res.errMsg);
      }
    })
    .catch((err) => {
      console.error(err);
    });
};

// 自定义拦截器
const customInterceptor = (chain) => {
  const requestParams = chain.requestParams;

  // 获取token
  const loginFlag = Taro.getStorageSync('token');

  // 检查session是否过期
  Taro.checkSession({})
    .then((res) => {
      console.log(res);
      console.log('session没过期,不用重新登录');
      console.log('token为' + loginFlag);
    })
    .catch((err) => {
      console.log(err);
      console.log('session已过期,要重新登录');

      // 重新登录
      login();
    });

  return chain.proceed(requestParams).then((res) => {
    // 只要请求成功,不管返回什么状态码,都走这个回调
    switch (res.statusCode) {
      case HTTP_STATUS.NOT_FOUND:
        return Promise.reject('请求资源不存在');
      case HTTP_STATUS.BAD_GATEWAY:
        return Promise.reject('服务端出现了问题');
      case HTTP_STATUS.FORBIDDEN: {
        Taro.setStorageSync('Authorization', '');
        // pageToLogin();
        // TODO 根据自身业务修改
        return Promise.reject('没有权限访问');
      }
      case HTTP_STATUS.AUTHENTICATE: {
        Taro.setStorageSync('Authorization', '');
        // pageToLogin();
        return Promise.reject('需要鉴权');
      }
      case HTTP_STATUS.SUCCESS:
        return res.data;
    }
  });
};

// Taro 提供了两个内置拦截器
// logInterceptor - 用于打印请求的相关信息
// timeoutInterceptor - 在请求超时时抛出错误。
const interceptors = [
  customInterceptor,
  Taro.interceptors.logInterceptor,
];

export default interceptors;

后端中间件

正如之前所述,后端中间件的作用是用户判断请求是否携带token以及token是否有效的。我们在egg Node.js后端项目中定义校验token的中间件:

// app/middleware/auth.js
let jwt = require('jwt-simple');
const SECRET = 'zhuoran';

module.exports = (options) => {
  return async function auth(ctx, next) {
    const token = ctx.get('authorization');

    if (token) {
      console.log('请求带有token');

      try {
        const openid = jwt.decode(token, SECRET);
        await next();
      } catch (err) {
        ctx.body = {
          code: 401,
          msg: 'token有误',
        };
      }
    } else {
      console.log('请求没有带token');
      ctx.body = {
        code: 401,
        msg: '您没有登录',
      };
    }
  };
};

因为小程序中,有些功能不强制要求用户登录之后才能使用,所以有些请求操作不需要后台校验是否有token,那么这个auth.js中间件就不能够全局配置,而是放在需要校验token的路由下:

module.exports = (app) => {
  const { router, controller, middleware } = app;
  const auth = middleware.auth();

  router.get('/', controller.home.index);
  router.get('/request', auth, controller.home.request); // 将middleware放在中间
  router.post('/login', controller.home.login);
  router.post('/userInfo', controller.home.userInfo);
};

登录鉴权及获得用户信息流程梳理

登录:

获取用户信息:

总结

相对于云开发来说,传统的前后端开发在登录鉴权方面复杂许多,涉及多端交互,以上总结也有许多要改进的地方。总而言之,将逻辑理顺之后,写代码就比较简单了。

在登录鉴权过程中比较重要的点:

  1. 知道什么是用户的唯一标识,并根据它生成token
  2. 对token进行校验,判断其是否过期,是否准确
  3. 通过画流程图等方式理清各个过程的逻辑

参考

使用Nodejs实现jwt原理

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

小程序登录那些事

小程序:授权、登录、session_key、unionId

微信小程序wx.getUserInfo授权获取用户信息(头像、昵称)

前后端分离项目,token过期,重新登录和刷新token的问题

egg中间件匹配路由

Taro微信小程序登录

Button组件onGetUserInfo未执行