一、引言

全网最全的前后端分离微信网页授权解决方案。如果有更好的优化方案,欢迎多多交流

二、网页授权的步骤

  • 1 第一步:用户同意授权,获取code

  • 2 第二步:通过code换取网页授权access_token

  • 3 第三步:刷新access_token(如果需要)

  • 4 第四步:拉取用户信息(需scope为 snsapi_userinfo)

  • 5 附:检验授权凭证(access_token)是否有效

注意:这里的access_token属于网页授权access_token,而非普通授权的access_token,官方给出的解释如下:

关于网页授权access_token和普通access_token的区别 1、微信网页授权是通过OAuth2.0机制实现的,在用户授权给公众号后,公众号可以获取到一个网页授权特有的接口调用凭证(网页授权access_token),通过网页授权access_token可以进行授权后接口调用,如获取用户基本信息; 2、其他微信接口,需要通过基础支持中的“获取access_token”接口来获取到的普通access_token调用。

但是没有讲得很明白。其实两者的区别就是:

  • 第一,网页授权access_token只要用户允许后就可以获取用户信息,可以不关注公众号,而普通access_token没有关注公众号,获取用户信息为空;

  • 第二,两者的每日限制调用频次不同,普通access_token每日2000次,获取网页授权access_token不限次数,获取用户信息每日5万次。

Spring Boot+Vue前后端分离微信公众号网页授权解决方案_第1张图片


三、后端接入

后端采用开源工具weixin-java-tools

Spring Boot+Vue前后端分离微信公众号网页授权解决方案_第2张图片


3.1 pom.xml引入jar包

<dependency>
<groupId>com.github.binarywanggroupId>
<artifactId>weixin-java-mpartifactId>
<version>3.8.0version>
dependency>

3.2 application.yml添加配置

这里换成自己的appid和appsecret

# 微信公众号
wechat:
 mpAppId: appid
 mpAppSecret: appsecret

3.3 新建读取配置文件WechatMpProperties.java

package com.hsc.power.dm.wechat.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
* 微信公众号配置文件
*
* @author liupan
* @date 2020-05-26
*/
@Data
@Component
@ConfigurationProperties(prefix = "wechat")
public class WechatMpProperties {
   private String mpAppId;
   private String mpAppSecret;
}

3.4 新建自定义微信配置WechatMpConfig.java

package com.hsc.power.dm.wechat.config;

import me.chanjar.weixin.mp.api.WxMpService;
import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl;
import me.chanjar.weixin.mp.config.WxMpConfigStorage;
import me.chanjar.weixin.mp.config.impl.WxMpDefaultConfigImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;

/**
* 微信公众号配置
*
* @author liupan
* @date 2020-05-26
*/
@Component
public class WechatMpConfig {

   @Autowired
   private WechatMpProperties wechatMpProperties;

   /**
    * 配置WxMpService所需信息
    *
    * @return
    */
   @Bean  // 此注解指定在Spring容器启动时,就执行该方法并将该方法返回的对象交由Spring容器管理
   public WxMpService wxMpService() {
       WxMpService wxMpService = new WxMpServiceImpl();
       // 设置配置信息的存储位置
       wxMpService.setWxMpConfigStorage(wxMpConfigStorage());
       return wxMpService;
   }

   /**
    * 配置appID和appsecret
    *
    * @return
    */
   @Bean
   public WxMpConfigStorage wxMpConfigStorage() {
       // 使用这个实现类则表示将配置信息存储在内存中
       WxMpDefaultConfigImpl wxMpDefaultConfig = new WxMpDefaultConfigImpl();
       wxMpDefaultConfig.setAppId(wechatMpProperties.getMpAppId());
       wxMpDefaultConfig.setSecret(wechatMpProperties.getMpAppSecret());
       return wxMpDefaultConfig;
   }
}

3.5 新建微信用户Bean

package com.hsc.power.dm.wechat.vo;

import lombok.Data;
import me.chanjar.weixin.mp.bean.result.WxMpUser;

@Data
public class WechatUser {
   public WechatUser(WxMpUser wxMpUser, String accessToken) {
       this.setAccessToken(accessToken);
       this.setOpenid(wxMpUser.getOpenId());
       this.setUnionId(wxMpUser.getUnionId());
       this.setNickname(wxMpUser.getNickname());
       this.setLanguage(wxMpUser.getLanguage());
       this.setCountry(wxMpUser.getCountry());
       this.setProvince(wxMpUser.getCity());
       this.setCity(wxMpUser.getCity());
       this.setSex(wxMpUser.getSex());
       this.setSexDesc(wxMpUser.getSexDesc());
       this.setHeadImgUrl(wxMpUser.getHeadImgUrl());
   }

   private String openid;
   private String accessToken;
   private String unionId;
   private String nickname;
   private String language;
   private String country;
   private String province;
   private String city;
   private Integer sex;
   private String sexDesc;
   private String headImgUrl;
}

3.6 授权接口WechatController.java

  • /auth:获取授权跳转地址

  • /auth/user/info:初次授权获取用户信息

  • /token/user/info:静默授权获取用户信息

package com.hsc.power.dm.wechat.web;

import com.baomidou.mybatisplus.core.toolkit.ExceptionUtils;
import com.hsc.power.core.base.ret.Rb;
import com.hsc.power.dm.wechat.vo.WechatUser;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.api.WxConsts;
import me.chanjar.weixin.common.error.WxErrorException;
import me.chanjar.weixin.mp.api.WxMpService;
import me.chanjar.weixin.mp.bean.result.WxMpOAuth2AccessToken;
import me.chanjar.weixin.mp.bean.result.WxMpUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.net.URLEncoder;

/**
* 微信公众号接口
*
* @author liupan
* @date 2020-05-26
*/
@Slf4j
@RestController
@RequestMapping("/wechat")
public class WechatController {
   @Autowired
   private WxMpService wxMpService;

   /**
    * 获取code参数
    *
    * @param returnUrl 需要跳转的url
    * @return
    */
   @GetMapping("/auth")
   public Rb authorize(@RequestParam String authCallbackUrl, @RequestParam String returnUrl) {
       // 暂时将我们的回调地址硬编码在这里,方便一会调试
       // 获取微信返回的重定向url
       String redirectUrl = wxMpService.oauth2buildAuthorizationUrl(authCallbackUrl, WxConsts.OAuth2Scope.SNSAPI_USERINFO, URLEncoder.encode(returnUrl));
       log.info("【微信网页授权】获取code,redirectUrl = {}", redirectUrl);
       return Rb.ok(redirectUrl);
   }

   /**
    * 初次授权获取用户信息
    *
    * @param code
    * @param returnUrl
    * @return
    */
   @GetMapping("/auth/user/info")
   public Rb userInfo(@RequestParam("code") String code, @RequestParam("state") String returnUrl) {
       WxMpOAuth2AccessToken wxMpOAuth2AccessToken;
       WxMpUser wxMpUser;
       try {
           // 使用code换取access_token信息
           wxMpOAuth2AccessToken = wxMpService.oauth2getAccessToken(code);
           wxMpUser = wxMpService.oauth2getUserInfo(wxMpOAuth2AccessToken, null);
       } catch (WxErrorException e) {
           log.error("【微信网页授权】异常,{}", e);
           throw ExceptionUtils.mpe(e.getError().getErrorMsg());
       }
       // 从access_token信息中获取到用户的openid
       String openId = wxMpOAuth2AccessToken.getOpenId();
       log.info("【微信网页授权】获取openId,openId = {}", openId);

       WechatUser wechatUser = new WechatUser(wxMpUser, wxMpOAuth2AccessToken.getAccessToken());
       return Rb.ok(wechatUser);
   }

   /**
    * 静默授权获取用户信息,判断accessToken是否失效,失效即刷新accecssToken
    * @param openid
    * @param token
    * @return
    */
   @GetMapping("/token/user/info")
   public Rb getUserInfo(@RequestParam String openid, @RequestParam String token) {
       WxMpOAuth2AccessToken wxMpOAuth2AccessToken = new WxMpOAuth2AccessToken();
       wxMpOAuth2AccessToken.setOpenId(openid);
       wxMpOAuth2AccessToken.setAccessToken(token);
       boolean ret = wxMpService.oauth2validateAccessToken(wxMpOAuth2AccessToken);
       if (!ret) {
           // 已经失效
           try {
               // 刷新accessToken
               wxMpOAuth2AccessToken = wxMpService.oauth2refreshAccessToken(wxMpOAuth2AccessToken.getRefreshToken());
           } catch (WxErrorException e) {
               log.error("【微信网页授权】刷新token失败,{}", e.getError().getErrorMsg());
               throw ExceptionUtils.mpe(e.getError().getErrorMsg());
           }
       }
       // 获取用户信息
       try {
           WxMpUser wxMpUser = wxMpService.oauth2getUserInfo(wxMpOAuth2AccessToken, null);
           WechatUser wechatUser = new WechatUser(wxMpUser, wxMpOAuth2AccessToken.getAccessToken());
           return Rb.ok(wechatUser);
       } catch (WxErrorException e) {
           log.error("【微信网页授权】获取用户信息失败,{}", e.getError().getErrorMsg());
           throw ExceptionUtils.mpe(e.getError().getErrorMsg());
       }
   }
}

四、前端接入

4.1 路由拦截

noAuth配置是否需要授权页面

router.beforeEach((to, from, next) => {
 // 微信公众号授权
 if (!to.meta.noAuth) {
   // 路由需要授权
   if (_.isEmpty(store.getters.wechatUserInfo)) {
     // 获取用户信息
     if (
       !_.isEmpty(store.getters.openid) &&
       !_.isEmpty(store.getters.accessToken)
     ) {
       // 存在openid和accessToken,已经授过权
       // 判断accessToken是否过期,过期刷新token,获取用户信息
       store.dispatch('getUserInfo')
       next()
     } else {
       // todo 跳转网页授权
       // 记录当前页面url
       localStorage.setItem('currentUrl', to.fullPath)
       next({name: 'auth'})
     }
   } else {
     // todo 已经存在用户信息,需要定期更新
     next()
   }
 } else {
   // 路由不需要授权
   next()
 }
})

4.2 授权页面

{
 path: '/auth',
 name: 'auth',
 component: resolve => {
   require(['@/views/auth/index.vue'], resolve)
 },
 meta: {
   noAuth: true
 }
},
<template>template>

<script>
import config from '@/config'
import WechatService from '@/api/wechat'
export default {
 mounted() {
   WechatService.auth(config.WechatAuthCallbackUrl).then(res => {
     if (res.ok()) {
// 获取授权页面后直接进行跳转
       window.location.href = res.data
     }
   })
 }
}
script>

4.3 授权store

在vuex中进行授权和存储用户信息

import _ from 'lodash'
import WechatService from '@/api/wechat'
import localStorageUtil from '@/utils/LocalStorageUtil'
export default {
 state: {
   unionId: '',
   openid: '',
   accessToken: '',
   wechatUserInfo: {}
 },
 getters: {
   unionId: state => {
     return state.unionId || localStorageUtil.get('unionId')
   },
   openid: state => {
     return state.openid || localStorageUtil.get('openid')
   },
   accessToken: state => {
     return state.accessToken || localStorageUtil.get('accessToken')
   },
   wechatUserInfo: state => {
     return state.wechatUserInfo || localStorageUtil.get('wechatUserInfo')
   }
 },
 mutations: {
   saveWechatUserInfo: (state, res) => {
     state.wechatUserInfo = res
     // todo 保存到storage,设置一定日期,定期更新
     state.unionId = res.unionId
     state.openid = res.openid
     state.accessToken = res.accessToken
     localStorageUtil.set('unionId', res.unionId)
     localStorageUtil.set('openid', res.openid)
     localStorageUtil.set('accessToken', res.accessToken)
     // 保存userInfo,设置有效时间,默认30
     localStorageUtil.set('wechatUserInfo', res, 30)
   }
 },
 actions: {
   // 静默授权获取用户信息
   async getUserInfo({ commit, getters }) {
     const openid = getters.openid
     const token = getters.accessToken
     if (!_.isEmpty(openid) && !_.isEmpty(token)) {
       // 存在openid和accessToken,已经授过权
       // 判断accessToken是否过期,过期刷新token,获取用户信息
       const res = await WechatService.getUserInfo(openid, token)
       if (res.ok()) {
         // todo 判断res.data是否有误
         commit('saveWechatUserInfo', res.data)
       }
     }
   },
   // 初次授权获取用户信息
   async getAuthUserInfo({ commit }, { code, state }) {
     if (!_.isEmpty(code) && !_.isEmpty(state)) {
       const res = await WechatService.getAuthUserInfo(code, state)
       if (res.ok()) {
         commit('saveWechatUserInfo', res.data)
       }
     }
   }
 }
}

4.4 自定义存储工具localStorageUtil.js

localStorageUtil.js:用于设置保存有效期

在这里,用户信息设置保存30天,根据前面4.1路由拦截判断,用户信息过期,需要重新进行授权认证。感觉这种方式不太好,但是获取用户信息每月限制5万次,不想每次都去调用接口获取用户信息,这里有更好的方案吗?

import _ from 'lodash'
import moment from 'moment'
export default {
 /**
  * 获取session-storage 中的值
  * @param {*} key
  * @param {*} defaultValue
  */
 get(key, defaultValue) {
   return this.parse(key, defaultValue)
 },
 /**
  * 放入 session-storage 中,自动字符串化 obj
  * @param {*} key
  * @param {*} obj
  * @param {Integer} expires 过期时间:天
  */
 set(key, obj, expires) {
   if (expires) {
     const tmpTime = moment()
       .add(expires, 'days')
       .format('YYYY-MM-DD')
     const handleObj = { expires: tmpTime, value: obj }
     localStorage.setItem(key, JSON.stringify(handleObj))
   } else {
     if (_.isObject(obj)) {
       localStorage.setItem(key, JSON.stringify(obj))
     } else {
       localStorage.setItem(key, obj)
     }
   }
 },
 /**
  * 从 session-storage 中移除key
  * @param {*} key
  */
 remove(key) {
   localStorage.removeItem(key)
 },

 /**
  * 从 session-storage 取出key并将值对象化
  * @param {*} key
  * @param {*} defaultValue
  */
 parse(key, defaultValue) {
   let value = localStorage.getItem(key)
   if (_.isObject(value)) {
     const valueObj = JSON.parse(value)
     if (valueObj.expires) {
       // 有过期时间,判断是否过期:在现在时间之前,过期
       if (moment(valueObj.expires).isBefore(moment(), 'day')) {
         // 删除
         this.remove(key)
         // 直接返回
         return null
       }
       return valueObj.value
     }
     // 没有过期时间直接返回对象
     return valueObj
   }
   // 不是对象,返回值
   return value || defaultValue
 }
}

至此大功告成,在微信开发者工具中即可获取用户信息,亲测有效。