『前端工程』—— 用Vuex和动态路由实现权限功能

1,765 阅读5分钟

前言

近来做了一个政府的项目,在过安全测试时,有个很严重的安全BUG。BUG的描述如下:

  • 可以通过地址栏输入地址访问登录用户的菜单权限以外的页面。
  • 可以通过修改浏览器 Local Storage 中缓存的功能权限来展示非登录用户拥有的功能权限。

当然这些安全问题在后端做权限拦截是最好的,不过公司后端比较懒不想做,那只好前端来实现真正的权限。 至于通过接口直接操作的问题就不在我的考虑范围内了。

本专栏所应用到的技术有 Vue Router 中的 router.addRoutes 实例方法和 router.beforeEach 导航守卫,Vuex 中 action 的相关用法。如果对这些用法不熟悉,建议先去看一下官网文档的介绍。

一、如何全局缓存权限数据

既然浏览器缓存不能用了,要全局缓存权限数据只能用 Vuex 了,下面来创建一个存储权限数据的 Vuex 。

1、安装 Vuex

如果你是用 Vue CLI 去搭建 Vue 项目,且默认安装,一般都会安装 Vuex 。如果没有安装,执行 cmd 命令 npm install vuex --save 安装。

在项目 src 文件夹下创建 store 文件夹,在里面创建 modules 文件夹 和 index.js 文件,在 index.js 文件引入 Vuex,并创建一个 store,将其暴露出去。

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
const debug = process.env.NODE_ENV !== 'production';
const store = new Vuex.Store({
    strict: debug,
    state: {
    },
    getters: {
    },
    mutations: {
    },
    actions: {
    },
});
export default store;

其中 Vuex.Store 构造器选项 strict 能使 Vuex.store 进入严格模式,在严格模式下,任何 mutation 处理函数以外修改 Vuex state 都会抛出错误,故在生产环境设置为 false 关闭,在开发环境设置为 true 开启。而 mutation 处理函数只能用实例方法 commit 提交,这些基本的 API 知识,可自己看官方文档

2、创建Vuex的子模块 module

index.js 文件中的 store 是主模块的 store,如果应用的所有状态会集中到主模块中,当应用变得非常复杂时,主模块的 store 对象会变得相当臃肿,且不好维护。

为了解决以上问题,Vuex 允许我们将 store 分割成模块(module)。每个模块拥有自己的 state、mutation、action、getter。

那么在 modules 文件夹创建 prower.jsuserInfo.js 文件。在里面创建一个 store,将其 export 出去。

const state = {};
const getters = {};
const mutations = {};
const actions = {};
export default {
    state,
    getters,
    mutations,
    actions,
}

然后在 index.js 中引入这些 module。

传统做法如下

import prower from './modules/prower';
import userInfo from './modules/userInfo';
const store = new Vuex.Store({
    //...
    modules: {
        prower,
        userInfo,
    }
});

比较懒的做法是利用 webpack API 中 require.context 来实现自动化引入这些 module。

require.context是用来获取一个特定的上下文,可以给 require.context 函数传入三个参数:一个要搜索的目录,一个标记表示是否还搜索其子目录, 以及一个匹配文件的正则表达式。

const require_modules = require.context('./modules', false, /\.js$/) 获取 modules 文件夹的上下文。

require.context 返回的是一个函数赋值给常量 require_modules,其有三个属性,其中 kyes() 属性是个函数返回是个数组,在控制台把打印出来为["./prower.js", "./userInfo.js"]。把里面的子项传入 require_modules 这个返回函数执行,例如 require_modules('./prower.js'),返回内容如下图所示。

可以看到其 default 属性的内容就是每个子模块(module) export 出的内容。那把这些内容添加到 Vuex.Store 构造器选项 modules 中即引入这些 module 。具体实现代码如下:

import camelCase from 'lodash/camelCase';
const require_modules = require.context('./module', false, /\.js$/);
const modules = {};
require_modules.keys().forEach(item => {
    const fileName = item.slice(2, -3);
    const module_name = camelCase(fileName);
    const module_config = require_modules(item).default;
    modules[module_name] = {
        ...module_config,
    };
});
const store = new Vuex.Store({
    //...
    modules: {
        ...modules,
    }
});
export default store;

3、权限数据的获取和处理

当可以用浏览器缓存时,对权限数据的获取和处理一般在登录页中进行操作,然后把权限数据缓存到浏览器 Local Storage 中,这样在项目每个模块中都可以获取到权限数据。

不能用浏览器缓存时,要让项目每个模块中都可以获取到权限数据,所以在 Vuex 来缓存权限数据,但是 Vuex 有个缺点,就是一刷新页面,Vuex 里面存储的数据全部丢失。所以每次刷新页面时要重新获取一下权限数据并处理。

那么要把权限数据的获取和处理的逻辑写在全局都能调用的地方,而 Vuex 里面 actions 中可以执行异步操作,而且可以在项目每个模块中用 this.$store.dispatch('XXXX',data) 来调用 actions 的 XXXX 方法。

处理后的权限数据存储在 Vuex 的 state 中,同时定义 getters 给外部使用权限数据。

又因为 actions 中只能通过 commit 提交 mutations 来设置 state 中的数据,故要定义一下 mutations,代码实现如下,在 prower.js 文件中写入。

const state = {
    menuPower: [],//菜单权限树数据
    menuPowerMap: {},//菜单权限map,key是菜单权限id
    menuUrl: {},//菜单路径map,key是菜单路径,值为true
    apiPower: {},//功能权限map,key是功能路径
};
const getters = {
    menuPower: state => {
        return state.menuPower;
    },
    menuPowerMap: state => {
        return state.menuPowerMap;
    },
    menuUrl: state => {
        return state.menuUrl;
    },
    apiPower: state => {
        return state.apiPower;
    },
};
const mutations = {
    SET_MENUPOWER(state, data) {
        state.menuPower = data;
    },
    SET_MENUPOWERMAP(state, data) {
        state.menuPowerMap = data;
    },
    SET_MENUURL(state, data) {
        state.menuUrl = data;
    },
    SET_APIPOWER(state, data) {
        state.apiPower = data;
    },
};
const actions = {
    GET_ROLE({commit}, data) {
    	
    }
};
export default {
    state,
    getters,
    mutations,
    actions,
}

下面在 actions 中的 GET_ROLE 来书写权限数据的获取和处理的逻辑。权限数据一般是树结构数据,一般用递归来处理权限数据。

先在 GET_ROLE 中调用接口获取权限数据

import { getRole } from 'api/common';
const actions = {
    GET_ROLE({commit}, data) {
        return new Promise((resolve, reject) => {
            getRole()
            .then(res => {
                if (res.code == 200) {
                    resolve()
                }
            })
            .catch(err => {
                reject()
            })
        })   	
    }
}

1、获取功能权限map

因为权限数据中有菜单权限和功能权限,故首先把功能权限数据分离出来,实现代码如下所示:

function getApiPower(data, result) {
    data.forEach(item => {
    	if (item.type == 2 && item.url) {
    	    result[item.url] = true;
    	}
        if (item.children && Array.isArray(item.children) && item.children.length) {
    	    getApiPower(item.children, result)
    	}
    })
}

result 来储存功能权限map。用 forEach 遍历权限数据,当其 type 等 2 且 url 存在时执行 result[item.url] = true,构成一个 key是功能路径,值为true 的 map 集合。 然后判断 children 存在时再执行 getApiPower(item.children, result),这样就构成一个递归调用。一层又一层遍历权限数据来执行 result[item.url] = true,遍历结束后就得到一个功能权限的 map 集合。

2、获取一下菜单路径map

实现代码如下所示:

function getMenuUrl(data, result) {
    data.forEach(item => {
        if (item.type == 1 && item.url) {
            result[item.url] = true;
        }
        if (item.children && Array.isArray(item.children) && item.children.length) {
            getMenuUrl(item.children, result)
        }
    })
};

result 来储存功能权限map,避免权限数据被改变。用 forEach 遍历权限数据,当其 type 等 1 且 url 存在时执行 result[item.url] = true,构成一个 key是菜单路径,值为true 的 map 集合。 然后判断 children 存在时再执行 getMenuUrl(item.children, result),这样就构成一个递归调用。一层又一层遍历权限数据来执行 result[item.url] = true,遍历结束后就得到一个菜单路径的 map 集合。

3、获取一下菜单权限数据

实现代码如下所示:

function getMenuPower(data) {
    let menuPower = '';
    menuPower = data.filter(item => {
        if (item.type == 1) {
            if (item.children && Array.isArray(item.children) && item.children.length) {
                item.children = getMenuPower(item.children);
            }
            return true
        }
    })
    return menuPower
};

item.type == 1 作为条件过滤出菜单权限数据。处理权限数据的第一层很简单,一个 filter 搞定,但是其中每个父级权限都有子级权限,放在 children 中,那要用 filter 遍历 children ,遍历完成后得到的值重新赋值给 children 。故在 getMenuPower 函数中设置一个变量 menuPower 缓存 filter 的处理结果,并返回,然后当 children 存在时再执行 item.children = getMenuPower(item.children),这样就构成一个递归调用,一层又一层遍历权限数据把菜单权限数据过滤出来。

4、菜单权限数据的排序

菜单权限数据一般都有顺序字段,所以要排序一下,实现代码如下:

function sortMenuPower(data) {
    data.sort((a, b) => {
        if (a.sort < b.sort) {
            return -1;
        } else if (a.sort == b.sort) {
            return 0;
        } else {
            return 1;
        }
    })
    data.forEach(item => {
        if (item.children && Array.isArray(item.children) && item.children.length) {
            sortMenuPower(item.children)
        }
    })
};

判断 children 存在时再执行 sortMenuPower(item.children),这样就构成一个递归调用,一层又一层遍历菜单权限数据进行排序。

5、获取菜单权限map

最后处理一下菜单权限数据,获取菜单权限 map ,其 key 是菜单权限 id 。

function handleMenuPowerMap(data, result) {
    data.forEach(item => {
    	result[item.authNodeId] = item;
        if (item.children && Array.isArray(item.children) && item.children.length) {
            handleMenuPowerMap(item.children, result)
        }
    })
};

result 来储存菜单权限map,用 forEach 遍历权限数据,执行 result[item.url] = true,构成一个 key是菜单权限 id ,值为菜单权限 的 map 集合。 然后判断 children 存在时再执行 handleMenuPowerMap(item.children, result),这样就构成一个递归调用。一层又一层遍历权限数据来执行 result[item.authNodeId] = item,遍历结束后就得到一个菜单权限的 map 集合。

6、在 GET_ROLE action 中处理权限数据

import { getRole } from 'api/common';
const actions = {
    GET_ROLE({commit}, data) {
        return new Promise((resolve, reject) => {
            getRole()
              .then(res => {
                  if (res.code == 200) {
                      if (res.data) {
                      let apiPower = {};
                      getApiPower(res.data, apiPower);
                      commit('SET_APIPOWER', apiPower);

                      let menuUrl = {};
                      getMenuUrl(res.data, menuUrl);
                      commit('SET_MENUURL', menuUrl);

                      let menuPower = {};
                      menuPower = getMenuPower(res.data);
                      sortMenuPower(menuPower);
                      commit('SET_MENUPOWER', menuPower);

                      let menuPowerMap = {};
                      handleMenuPowerMap(menuPower, menuPowerMap);
                      commit('SET_MENUPOWERMAP', menuPowerMap);
                      resolve()
                  } else {
                      reject('none');
                  }
                }
              })
              .catch(err => {
                  reject()
              })
        })   	
    }
}

二、如何动态添加路由

其中BUG之一,可以通过地址栏输入地址访问登录用户的菜单权限以外的页面。这是因为路由是静态路由造成的,如果知道该页面地址,可以把该地址输入浏览器直接访问。虽然后端也会做限制,获取不到任何数据,但是能访问静态页面也是个安全隐患。

以上BUG可以用动态路由解决,其是用 Vue Router 中的一个实例方法 router.addRoutes 来实现,其参数必须是一个符合 routes 选项要求的数组。

使用时要注意,静态路由文件中不能有404路由,而要通过 router.addRoutes 一起动态添加进去。具体实现代码如下:

import routeData from 'router/routeData';
const actions = {
    GET_ROLE({commit}, data) {
        return new Promise((resolve, reject) => {
            getRole()
              .then(res => {
                  if (res.code == 200) {
                      //...
                      const noFind = {
                          path: '*',
                          redirect: {
                              path: '/404'
                          }
                      }
                      routeData['/layout'].children = [];
                      for (let k in menuUrl) {
                          if (k == '父级路径') {
                              routeData['/layout'].children.push(routeData['父级路径']);
                              routeData['/layout'].children.push(routeData['子路径']);
                          }else {
                              if (routeData[k]) {
                                  routeData['/layout'].children.push(routeData[k])
                              }
                          }
                      }
                      let layout = routeData['/layout'];
                      const routes = [layout, noFind];
                      vm.$router.options.routes.push(...routes);
                      vm.$router.addRoutes(routes);
                      resolve()
                  } else {
                      reject('none');
                  }
              })
              .catch(err => {
                  reject()
              })
        })   	
    }
}

其中 routeData 是一个以路由路径为 key ,值是一个路由规则,具体结构如下所示:

function load(component) {
    return () => import(`views/${component}`)
}
const routeData = {
    '/layout':{
        path: '/layout',
        name: 'layout',
        component: load('layout'),
        children:[]
    },
    '/home': {
        path: '/home',
        name: 'home',
        component: load('home'),
        meta: {
            title: '首页'
        },
    },
    '父级路由路径': {
        path: '父级路由路径',
        name: '父级路由名称',
        component: load('父级页面所示文件路径'),
    },
    '子级路由路径': {
        path: '子级路由路径',
        name: '子级路由名称',
        component: load('子级页面所示文件路径'),
    },
}
export default routeData;

为什么要以路由路径为 key,是因为路由路径是固定不变,如果在系统权限配置中修改路由路径,也得去代码中修改路由路径,并编译打包重新发布。

执行 routeData['/layout'].children = [] 每次添加路由前,要先把原先的路由清空,防止重复添加。

执行 const routes = [layout, noFind] 把要添加的路由数据变成数组,因为 router.addRoutes 接收参数只能是数组。

执行 vm.$router.options.routes.push(...routes); vm.$router.addRoutes(routes); 来动态添加路由。

另外也要配置一份静态路由作为默认路由,配置如下:

function load(component) {
    return () => import(`views/${component}`)
}
const routes = [
    {
        path: '/404',
        name: '404',
        component: load('404'),
        meta: {
            title: '404页面'
        }
    },
    {
        path: '/',
        name: 'login',
        component: load('login'),
        meta: {
            title: '登录'
        }
    },
];
export default routes;

可以在里面配置一些不需要权限控制的页面的路由,比如 400页面、登录页面等等。

三、获取权限数据和动态添加路由的时机

一般情况下,登录成功肯定要去获取一下权限数据和动态添加路由,其逻辑是写在 Vuex 的 actions 的 GET_ROLE 中,故执行 this.$store.dispatch('GET_ROLE'),又因为在其中创建一个 Promise 来监听其执行结果状态,这样就可以实现当权限数据获取完毕和动态路由添加完毕后跳转到首页去,实现代码如下:

this.$store.dispatch('GET_ROLE').then(res => {
    this.$router.push({
        path: '/home'
    })
})

在不能用浏览缓存的情况,每次刷新页面后都要去执行 this.$store.dispatch('GET_ROLE') 来重新获取权限数据和动态添加路由,每次刷新页面都会重新进入路由,故可以用路由的导航守卫来监听,实现代码如下:

router.beforeEach((to, from, next) => {
    if (to.path == '/' || to.path == '/404') {
        next();
    } else {
        setTimeout(() => {
            const menuUrl = vm.$store.getters.menuUrl;
            if (JSON.stringify(menuUrl) === '{}') {
                vm.$store.dispatch('GET_ROLE');
            }
            next();
        }, 100)
    }
});

访问静态路由里面的页面时直接执行 next() 跳转。此外要利用 setTimeout 做个异步才能获取到 Vue 实例化对象 vm,判断 Vuex 中 menuUrl 是否为空对象,为空对象时执行vm.$store.dispatch('GET_ROLE')重新获取权限数据和动态添加路由,最后记得执行 next() 才能跳转。

四、权限数据的使用

至于菜单权限数据怎么渲染成头部菜单栏或者侧边菜单栏,因为使用不同 UI 组件有不同的写法,这里就不做介绍了。

至于功能权限数据,因为每个页面都会使用到,故在权限数据用全局混入引入,在 mixins文件夹中 index.js 文件中写入

import { mapGetters } from 'vuex';
export default {
    computed: {
        ...mapGetters(['apiPower']),
    },
}

在页面中,如下示例代码,这样使用

<el-button type="primary" @click="handleAdd" v-if="apiPower['功能权限路径']">添加</el-button>

五、后续

其实还要一种办法,就是先把权限等相关数据用 AES 加密(推荐使用 crypto-js 插件)后,在缓存到浏览器中,每次读取这些数据时再解密一下。不过这种方法安全性比本专栏介绍要低一些。