NodeJS 微信公共号开发 - 实现微信网页授权获取用户信息

3,393 阅读7分钟
原文链接: blog.dongsj.cn

背景

使用 NodeJS 进行微信公共号开发,我们经常需要获取当前微信用户的用户信息,本文就使用 Express + mongoose 结合 Token 实现微信网页授权获取用户信息。

微信网页授权流程

微信认证流程

上图为结合 JWT 进行微信授权的过程,大致流程如下:

微信公共号 SPA 每次发起 AJAX 请求都会在 header 内附加本地缓存的用户鉴权信息(通过用户信息结合时间戳通过 JWT 生成的 Token),服务器会在所需验证的接口调用前使用中间件对 Token 进行验证,如果合法则继续执行该接口的逻辑,否则则返回 401 错误,同时生成微信网页授权的 authorize URL 供 SPA 进行跳转。

微信公共号 SPA 在接口返回 401 错误时会跳转到错误内返回的 URL(即微信网页授权的 authorize URL),用户授权成功后微信会附带微信网页授权的 Code 将页面重定向到 authorize URL 内的 redirect_uri ,而该重定向的 URL 其实为后端接口,后端在通过 Code 获取微信用户信息完成新增/查询用户的行为后,再根据用户信息和时间戳通过 JWT 生成 Token,结合 redirect_uri 内记录的 spa 发起 401 错误请求的页面附带 Token 进行重定向。

微信公共号 SPA 通过 url 内的 query 参数缓存 Token 信息,并在每次发起 AJAX 请求内在 header 内附带 Token。

其实上述过程的很多行为都是贯穿所有的接口内的,下面就对具体实现进行详细描述。

基于 JWT 的用户身份验证

本文使用 JWT(JSON WEB TOKEN) 生成用户 Token, 使用基于 Token 的身份鉴权,大概要点如下:

  • SPA 在发送 AJAX 请求时在 header 内附带 Token
  • SPA 通过登录或某种全局字段获取并更新 Token
  • 服务器在需要鉴权的接口前使用中间件对 Token 鉴权
  • 服务器实现 Token 的生成和返回

服务器实现用户的 mongoose 模型定义

本文服务器使用 Express + mongoose 实现,首先我们需要在服务器实现用户的 mongoose 模型定义,代码如下:

let mongoose = require('mongoose');
const jwt = require('jsonwebtoken');
// id 为自增主键,token 存储鉴权信息,state 为用户状态预留,其他为微信会返回的各用户信息字段
let UserSchema = new mongoose.Schema({
    id: {
        type: Number,
        unique: true,
        index: true
    },
    token: {
        type: String,
        index: true
    },
    openid: {
        type: String,
        unique: true
    },
    phone: {
        type: String,
        trim: true
    },
    nickname: {type: String},
    sex: {type: Number},
    language: {type: String},
    city: {type: String},
    province: {type: String},
    country: {type: String},
    headimgurl: {type: String},
    privilege: {type: Array},
    unionid: {type: String},
    state:{
        type:Number,
        default:1
    }
});

// 处理用户 id 自增
UserSchema.pre('save', function (next) {
    let doc = this;
    if (doc.isNew) {
        User.findOne({}, 'id', {sort: {id: -1}}).then(d => {
            doc.id = (d ? d.id : 0) + 1;
            next();
        });
    } else {
        next();
    }
});
// 重写 toJSON 在返回用户信息前隐藏敏感字段
UserSchema.methods.toJSON = function () {
    const User = this;
    const UserObject = User.toObject();
    delete UserObject._id;
    delete UserObject.__v;
    delete UserObject.openid;
    delete UserObject.unionid;
    return UserObject;
};

const User = mongoose.model('User', UserSchema);
module.exports = {User};

SPA 在发送 AJAX 请求时在 header 内附带 Token

服务器需要根据 Token 在部分接口内对用户进行鉴权,SPA 需要在发送 AJAX 请求的时候进行封装,在 header 内添加 Token,以下代码为 angular4 项目在 header 内的 Authenticate 字段添加 localStorage.token 的服务:

import {Injectable} from '@angular/core';
import {Headers, RequestOptions} from '@angular/http';
@Injectable()
export class CustomRequestOptions extends RequestOptions {
  constructor () {
    const headers = new Headers();
    headers.append('Accept', 'application/json');
    headers.append('Access-Control-Allow-Origin', '*'); // 处理跨域
    headers.append('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE,PATCH, OPTIONS');
    headers.append('Access-Control-Allow-Headers', 'X-Requested-With');
    headers.append('Content-Type', 'application/json');
    headers.append('crossDomain', 'true');
    headers.append('Authenticate', localStorage.token || 'empty'); // 附带 Token
    super({headers: headers});
  }
}

服务器的鉴权中间件实现

首先在是 User 模型内添加 authenticate 静态方法,通过 Token 查询用户信息支持鉴权:

UserSchema.statics.authenticate = async function (token) {
    const User = this;
    return await User.findOne({'token': token});
};

然后在需要鉴权的接口前添加中间件,根据请求 header 内的 Token 信息调用 User.authenticate 静态方法实现鉴权,成功则在 req 内添加用户信息,否则返回 401 错误:

const app = require('express');
const router = app.Router();

const {User} = require('../../models/user');

// token 验证
router.all('/**', async function (req, res, next) {
    try {
        let user = await User.authenticate(req.header('Authenticate') || 'empty');
        if (!user) {
            res.status(401);
            res.end();
        } else {
            req.user = user;
            req.body.userId = user.id;
            next();
        }
    } catch (e) {
        next(e)
    }
});

// 此后代码才需鉴权,并可在 req.user 内拿到 Token 对应的用户信息
router.use('/user', require('./user/route'));
router.use('/child', require('./child/route'));
router.use('/resources', require('./resources/route'));
router.use('/appointment', require('./appointment/route'));

module.exports = router;

微信网页授权的实现

微信网页授权 首先需要在 微信后台 => 公共号设置 => 功能设置 配置网页授权后重定向的页面域名,并将指定 .txt 文件允许在域名下的根目录进行访问(Express 的情况下直接丢到静态资源目录下即可)
配置网页授权后重定向的页面域名
配置网页授权后重定向的页面域名
服务器则需要实现以下功能:

鉴权失败时引导用户进入授权页面

当服务器鉴权失败时,除返回 401 错误外,我们还应生成用户授权页面的 authorize URL 供 SPA 跳转。关键两个字段如下:
appid:公共号ID,在微信后台可进行查询
redirect_uri:用户授权完成后跳转到的 URL,此处我们填写一后端接口,并获取 401 错误时 SPA 所在的 URL 作为参数传递为 redirect 作准备。
我们将鉴权中间件修改如下:

router.all('/**', async function (req, res, next) {
    try {
        let user = await User.authenticate(req.header('Authenticate') || 'empty');
        if (!user) {
            let oauthUrl= `http://${req.header('host')}/api/v1/weixin/oauth?url=${req.header('Referer')}`;
            res.status(401).send(`https://open.weixin.qq.com/connect/oauth2/authorize?appid=${process.env.WX_ID}&redirect_uri=${encodeURIComponent(oauthUrl)}&response_type=code&scope=snsapi_userinfo#wechat_redirect`);
            res.end();
        } else {
            req.user = user;
            req.body.userId = user.id;
            next();
        }
    } catch (e) {
        next(e)
    }
});

用户授权完成后根据 Code 拉取用户信息查询/新建用户后使用 JWT 生成 Token 并重定向到鉴权失败时记录的 SPA 所在 URL

首先我们为 User 模型添加 generateAuthToken 方法,实现通过 JWT 生成 Token:

UserSchema.methods.generateAuthToken = async function () {
    const doc = this;
    doc.token = jwt.sign({
        id: doc.id,
        name: doc.nickname,
        headimgurl: doc.headimgurl,
        sex: doc.sex,
        timestamp: +new Date()
    }, process.env.JWT_SECRET).toString();
    return await doc.save();
};

然后实现之前网页授权成功后跳转到的 URL。该页面为后端接口,在微信网页授权后微信直接通过 get 的方式进行访问,所以我们在该接口内除了可以通过 query 参数获取到用户 Code 外,还可通过之前记录的 SPA 鉴权 401 所在 URl 通过 Redirect 的方式附带 Token 返回到 SPA 之前的鉴权失败页面。具体接口实现如下:

const express = require('express');
const router = express.Router();
const axios = require('axios');

const {User} = require('../../../models/user');

router.get('/', async function (req, res, next) {
    try {
        // 通过微信的 App ID、Secret 和用户的 Code 获取用户的 access token
        let userAccessToken = (await axios.get(`https://api.weixin.qq.com/sns/oauth2/access_token?appid=${process.env.WX_ID}&secret=${process.env.WX_SECRET}&code=${req.query.code}&grant_type=authorization_code`)).data;
        if (userAccessToken.errcode) {
            userAccessToken.param = 'userAccessToken';
            next(new Error(JSON.stringify(userAccessToken)));
            return;
        }
        // 通过用户的 access token 从微信拉取微信的用户信息
        let userInfo = (await axios.get(`https://api.weixin.qq.com/sns/userinfo?access_token=${userAccessToken.access_token}&openid=${userAccessToken.openid}&lang=zh_CN`)).data;
        if (userInfo.errcode) {
            userAccessToken.param = 'userInfo';
            next(new Error(JSON.stringify(userInfo)));
            return;
        }
        // 通过用户的 openid 查询,如果不存在该用户则添加
        let user = await User.findOne({
            openid: userInfo.openid
        });
        if (!user) {
            user = new User(userInfo);
            await user.save();
            console.log('create new wx user finished');
        }
        // 生成用户 token
        user = await user.generateAuthToken();

        // 处理重定向 url,在其中添加 query 参数 token
        let url=req.query.url.split('?');
        let q=[];
        if(url.length!==1){
            q=url[1].split('&');
        }
        q.push(`token=${user.token}`);
        // 因微信通过 get 的方式在浏览器内访问该接口,我们可以通过 res.redirect 进行重定向
        res.redirect(`${url[0]}?${q.join('&')}`);
        res.end();
    } catch (e) {
        next(e);
    }
});

module.exports = router;

SPA 添加 query 内 token 参数的缓存

我们已经实现后端通过微信网页授权的 Code 生成/查询用户并返回 Token 的全部逻辑,下面需要对所有可以被重定向的页面添加对 url 内 query 参数 token 的全局处理,进行缓存后删除 url 内 query 的 token 字段防止分享时附带 token 信息。在 SPA 应用的入口 html 文件的 header 最上方添加以下代码:

function getQueryString(name) {
   var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)", "i");
   var r = window.location.search.substr(1).match(reg);
   if (r != null) return unescape(r[2]);
   return null;
 }
 function delParam(url,paramKey){
   var urlParam = url.substr(url.indexOf("?")+1);
   var beforeUrl = url.substr(0,url.indexOf("?"));
   var nextUrl = "";

   var arr = [];
   if(urlParam!==""){
     var urlParamArr = urlParam.split("&");

     for(var i=0;i<urlParamArr.length;i++){
       var paramArr = urlParamArr[i].split("=");
       if(paramArr[0]!==paramKey){
         arr.push(urlParamArr[i]);
       }
     }
   }

   if(arr.length>0){
     nextUrl = "?"+arr.join("&");
   }
   url = beforeUrl+nextUrl;
   return url;
 }
 if(getQueryString("token")){
   localStorage.token = getQueryString("token");
   location.href = delParam(window.location.href,"token");
 }

使用微信开发者工具查看效果

至此微信网页授权获取用户信息的全部过程已经实现,从微信开发者工具查看效果如下:
微信网页授权效果
查看 Network 梳理一下访问了那些页面和接口:

  • baohejm-dev.youhujia.com/
    SPA 页面

  • baohejm-api-dev.youhujia.com/api/v1/weixin/resources/department
    SPA 页面访问的接口,返回 401 和微信网页授权的 authorize URL

  • open.weixin.qq.com/connect/oauth2/authorize?appid={appid}&redirect_uri=http%3A%2F%2Fbaohejm-api-dev.youhujia.com%2Fapi%2Fv1%2Fweixin%2Foauth%3Furl%3Dhttp%3A%2F%2Fbaohejm-dev.youhujia.com%2F&response_type=code&scope=snsapi_userinfo
    微信授权页面,注意 query 参数内的 redirect_uri ,其实值为 baohejm-api-dev.youhujia.com/api/v1/weix…

  • baohejm-api-dev.youhujia.com/api/v1/weixin/oauth?url=baohejm-dev.youhujia.com/&code={code…
    后台根据 Code 进行用户添加/查询并生成 Token 并重定向到 query 参数 url(根据 401 时记录的 SPA 的所在页面)

  • baohejm-dev.youhujia.com/?token={token}
    重新附带 token 访问 SPA 页面