pekonchan/Blog

系统权限按需访问路由几个完整方案(含addRoutes的填坑)

pekonchan opened this issue · 12 comments

前言

当你的系统需要做权限验证时,往往有一个很常见的需求:系统的某些页面或者资源(按钮、操作等),需要该用户有对应的权限才能可见可用。

这就涉及到如何根据用户的权限来判断能否进入某个路由页面的问题了。

网上有很多零散的方案,并没有横向对比几种方案,且很多细节没解释到位,此处提供完整的几个方案流程,并总结优缺点,你可自行选择

本篇是针对vue-router来说明如何实现。

解决方案

根据各种资料,这里分为三种解决方案来分别描述其优缺点。

beforeEach中限制

你可以注册全部路由,在router.beforeEach中即进入路由前进行判断,即将进入的路由是有权限进入,不能的话手动重定向到某个静态路由(不需要权限就能进入的页面,即任何用户都能进入的页面,如404页或首页)

由于每个系统的权限方案不一样,判断条件也不一样,这里就仅仅简单举个例子,万变不离其宗,希望大家举一反三,触类旁通。

我们在router.beforeEach判断是否有权限进入,需要有三点:

  1. 在路由配置中做标识,告知该路由需要的权限
  2. 需要一处地方记录该用户所拥有的权限信息
  3. router.beforeEach结合第1点和第2点进行判断

1)路由配置中做标识

假设项目的权限是用ID来表示,即每个权限,用一个ID值来表示。

我采用路由配置的props项来做标识,authorityId值表示权限对应的ID值。

import Vue from 'vue';
import Router from 'vue-router';

import exam1 from 'example1.vue';
import exam2 from 'example2.vue';

Vue.use(Router);

const routes = [
    {
        path: '/exam1',
        component: exam1,
        props: {
            authorityId: 100
        }
    },
    {
        path: '/exam2',
        component: exam2,
        props: {
            authorityId: 200
        }
    }
];

const router = new Router({
    routes
});

export default router;

上面是设置路由的主文件,从中我们看到,两个页面分别有两个不同的权限ID值,要能够进入页面,就得拥有这两个权限。

如果看过我的这篇文章 如何写出一个利于扩展的vue路由配置 ,就知道我喜欢按照功能模块来把路由配置细分很多个模块,如果你是按功能模块区分权限,即一个功能模块下好多个页面都是一个权限ID,那么可以在routes数组的最后统一加上authorityId,而不用一个个都写,累赘!

routes.forEach(item => {
    item.props = {
        ...item.props,
        authorityId: 100
    };
});

2)存储权限信息

接着我们要找个地方来存储一下用户所拥有的权限信息,如果你对用户的权限信息是保存在持久化的一个地方如sessionStorage、localStorage、cookie或url中的话,刷新后还能继续能拿到这些值,那么再根据这些值控制路由访问,这是没多大问题的。但是,这种重要的信息就暴露在外面?万一别人恶心修改了,把自己不能访问的权限改成可以访问呢?

因此上述方法是不建议的。

我一般会存在vuex中,那么存在这里的话,就会面临刷新页面了,vuex的信息也会丢失的问题。

为了解决这个问题,我们同样需要保存一些信息到持久化的一个地方中,但是与上面不同的是,我们不要直接保存权限信息,而是保存一些能发请求获取权限信息的信息,常见的如用户id等。刷新后,根据保存的这些信息发请求重新获取权限信息并存储。

如这里的例子我就设置sessionStorage.setItem('userId', 1012313);

以下为存储权限信息的vuex内容:

// authority.js

import * as types from '../mutation-types';

// state
const state = {
    // 权限id值数组,null为初始化情况,如果为[]代表该用户没有任何权限
    rights: null
};

// getters
const getters = {
    rights: state => state.rights
};

// actions
const actions = {
    /**
     * 设置用户访问权限
     */
    setRights ({ commit }, value) {
        commit(types.SET_RIGHTS, value);
    }
};

// mutations
const mutations = {
    [types.SET_RIGHTS] (state, value) {
        state.rights = value;
    }
};

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

这里值得一提的是,为什么rights默认值是null而不是[],原因是用来区分是初始化状态还是真的无任何权限状态。这个有使用场景,特别是针对刷新页面。

就是当你目前在一个非权限路由页面上时,如果你刷新了页面,用户的鉴权还有效,理应还是停留在这个动态路由的页面。

那你怎么判断现在是由于刷新了页面呢,就是通过判断rightsnull而不是[],如果rights初始值本身就是[]的话,这是无法判断出来的。

那么为null是有两种情况的:

  • 从空tab或别的网站进入到eod(如输入url、sso登录跳转过来);
  • 刷新页面

所以为了进一步区分是刷新行为,则需进一步通过判断sessionStorage里有没有登陆后存储的userId信息,因为如果userId存在了代表登录了,登录了就会进行权限的设置,就自然rights会有值,就算没权限也会是个[]

上面讨论的这些判断行为,都会在router.beforeEach中体现应用到。

3)判断是否有权限进入路由

还是在路由主文件中,在全局前置守卫中做判断。

import store from '/store';

/**
 * 检查进入的路由是否需要权限控制
 * @param {Object} to - 即将进入的路由对象
 * @param {Object} from - 来自的路由对象
 * @param {Function} next - 路由跳转的函数
 */
const verifyRouteAuthority = async (to, from, next) => {
    // 获取路由的props下的authorityId信息
    const defaultConfig = to.matched[to.matched.length - 1].props.default;
    const authorityId = (defaultConfig && defaultConfig.authorityId) ? defaultConfig.authorityId : null;

    // authorityId存在,表示需要权限控制的页面
    if (authorityId) {
        // 获取vuex中存储权限信息的模块,authority为该模块名
        const authorityState = store.state.authority;
        // 为null的场景: 从空tab或别的网站进入到eod(如输入url、sso登录跳转过来);刷新页面;
        if (authorityState.rights === null) {
            const userId = sessionStorage.getItem('userId');
            //  如果是刷新了导致存储的权限路由配置信息没了,则要重新请求获取权限,判断刷新页是否拥有权限
            if (userId) {
                // 重新获取权限,以下为例子
                const res = await loginService.getRights();
                store.dispatch('setRights', res);
            } else { // 如果是非当页刷新,则跳转到首页
                next({ path: '/' });
                return true;
            }
        }

        // 如果是要进行权限控制的页面,判断是否有对应权限,无则跳转到首页
        if (!authorityState.rights.includes(authorityId)) {
            next({ path: '/' });
            return true;
        }
    }

    return false;
};

/**
 * 能进入路由页面的处理
 */
const enterRoute = async (to, from, next) => {
    // 进行权限控制校验
    const res = await verifyRouteAuthority(to, from, next);
    // 如果通不过检验已进行内部跳转,则退出该流程
    if (res) {
        return;
    }

    // 进行登录验证以及获取必要的用户信息等操作
    // ...
};

router.beforeEach((to, from, next) => {
    // 无匹配路由
    if (to.matched.length === 0) {
        // 跳转到首页 添加query,避免手动跳转丢失参数,例如token
        next({
            path: '/',
            query: to.query
        });
        return;
    }
    enterRoute(to, from, next);
});

4)退出清空权限信息

完整的一个方案,别忘了还要针对登出,清空权限信息这步。也很简单,清空,意味着把rights重新置为null,因此执行store.dispatch('setRights', null);即可

小结

  • 优点:对于注册路由的处理没有额外的操作,所有处理逻辑集中在router.beforeEach中判断
  • 缺点:注册了多余的路由(但似乎,没啥关系?)

刷新页面重新注册路由

这是一个极其简单粗暴的方式:

在网站vue app实例化时,router也初始化了,这时候只注册了静态路由(如登录页、404页等不需要权限的页面),当用户登录了之后,拿到用户的权限的接口,把这些权限的信息储存在某个持久化的地方如sessionStorage、cookie甚至url中,然后手动刷新页面location.reload,在创建路由实例时,拿到刚存的权限信息,然后才创建新的路由实例。

可能有人会问,为什么不像上一个方案说的,只存储如userId这样的信息而不是直接存权限信息。如果存了userId,再通过请求获取权限信息,这是一个异步的过程,网站vue app实例化时,router也初始化了,很难找到一个时机在router初始化前就拿到权限的信息。

由于这种方式十分简单粗暴,我个人不喜欢,体验也不好,所以我仅提供思路,具体实现就不写代码了。

  • 缺点:容易泄露权限信息,便于别人恶意篡改,除非你可以做什么加密处理把,但是还要解密,挺麻烦的;多刷新了一次,用户体验不好。

addRoutes动态注册路由

目前vue-router 3.0要实现动态路由(即视情况注册路由),仅仅提供addRoutes一个api,在官方github中也有许多人提issue希望新增一些其他实现动态路由的功能,如删除已注册路由替换同名路由等,但是维护者的回复大概意思是目前vue-router是以静态路由为主而设计的,不可能一下子就考虑很全面,一步登天,给点时间后面在慢慢完善。

下面就说,在如此背景下,如何利用addRoutes来实现动态路由,以满足权限的变动

addRoutes函数说白了,就是用来追加路由注册的。最简单的思路是,当用户登录到系统后,就根据用户的权限来追加注册TA能访问的路由。

但是,一套完整的方案,会有以下几个方面你需要考虑的:

  • 1)切换用户后,权限发生变化,注册的路由也应该要变化,理想情况是删除已注册的动态路由,然后才重新追加新路由。
  • 2)刷新页面时,如果用户鉴权还通过,那么其权限所允许的页面应该还能继续访问
  • 3)登出系统,即用户退出,需要清除已注册路由

针对问题一

上面也说了,目前vue-router不提供删除已注册路由的api,只有一个addRoutes可以动态改变注册路由,其接受一个参数,是个路由配置的数组。

那么如果不做处理,直接采用addRoutes追加注册,就会可能发生追加重复路由的情况

例如用户1拥有 a,b 权限,用户2拥有 a,c 权限。当用户1登录上了,此时路由已注册 a,b 权限对应的路由,然后用户1退出切换到用户2,通过addRoutes把 a,c 权限对应的路由追加注册了,这时候,就会重复注册了a路由,在控制台中会有警告信息。

其实如果路由都是完全一样的话,不会影响到实际应用,用户也无是无感知的,只是路由变得累赘。但是如果假设同name的路由却是对应不同的页面路径,这时候我就会有问题了。

如果你知道存在有同名name路由,存在什么隐形后果,请告诉我。

因此,我们需要找一个方案,解决可能添加重复路由的问题。

有不少资料会让你在切换用户时,在跳转到登录界面时,刷新一下页面,就会变回整个网站初始化的情况,即路由也重新初始化实例,这样登录后就再用addRoutes追加路由就好。

其实上述方案不失为一个好方案,如果你不介意会刷新一下页面的话。甚至你的登录界面就是跟系统不在一个单页面应用的话就更加不用手动刷新了(如有专门的单点登录平台),自然就能在登录后重新进入系统初始化了。

要说缺点的话:

  • 要重新刷新页面,如果系统网站本身初始化加载很慢的话,那么用户体验很差。
  • 如果你的系统权限方面比较复杂,像我开发的系统,权限不仅仅在用户之间,在用户里,不同任务下也有不同权限,这时,就不能用这种方式了,因为切换任务并不会要重新登录

如果你不喜欢上面这个简单的方案的话,不妨继续往下看

import Vue from 'vue';
import Router from 'vue-router';
Vue.use(Router);

// 创建路由实例的函数
// 这里的staticRoutes表示你系统的静态路由
const createRouter = () => {
    return new Router({
        routes: staticRoutes
    });
};
/**
 * 重置注册的路由导航map
 * 主要是为了通过addRoutes方法动态注入新路由时,避免重复注册相同name路由
 */
const resetRouter = () => {
    const newRouter = createRouter();
    router && (router.matcher = newRouter.matcher);
};

// 这是伴随vue app实例化的初始化路由实例
const router = createRouter();

export { resetRouter };
export default router;

上面是创建路由的一份代码,除了resetRouter,其余部分跟你原本创建路由的代码并无什么不同。而resetRouter的作用就是解决重复问题的关键(router.matcher = newRouter.matcher),这句相当于重置了路由映射关系,抹去了已注册的路由映射关系,跟新的路由实例的映射一样。

因此,在每次通过addRoutes追加注册路由前,都要使用resetRouter方法来重置一下路由映射,再追加。但是这样仍然是不能百分百避免重复问题,为什么呢?

以上述代码为例子,如果staticRoutes中有一个路由是拥有children子路由的,如

{
    path: '/tsp',
    name: 'TSP',
    component: TSP,
    children: [
        {
            path: 'analysis',
            name: 'analysis',
            component: Analysis
        }
    ]
}

然后你要追加的路由刚好就是在这children中的子路由的话,你就需要追加整个nameTSP的路由了,这时就会发生重复追加已存在的TSPAnalysis的路由了。

为了避免该问题,人为的约定,静态路由staticRoutes中不能是有可能被追加路由(包含子孙路由)的路由。

真要发生上面要追加在子路由的情况,那么把该TSP路由在初始化路由实例后,然后手动追加一次,假装是静态路由,这样在使用resetRouter重置后就不包含TSP路由了,然后再追加这个TSP路由就不会警告重复了。

针对问题二

这个问题,我们在第一个方案中也说过了,关于刷新带来的问题以及思考。

思路我们有了,那么在代码的具体什么时机进行操作呢?由于需要进行异步请求,所以不适宜在路由实例初始化时进行,我们在beforeEach中做处理,以下为例子(具体说明是注释中):

先看vuex中定义存储权限信息的关键代码

// authority.js

const state = {
    functionModules: null, // 功能模块权限id值数组,null为初始化情况,如果为[]代表该用户没有任何权限
};

const getters = {
    functionModules: state => state.functionModules
};

const actions = {
    /**
     * 设置用户所拥有的的功能模块访问权限
     */
    setFunctionModules ({ commit, state }, value) {
        // ... 这里省略了实现代码,因为此节重点不在这,后面再详说
    }
};

const mutations = {
    // 设置用户所拥有的的功能模块访问权限
    [types.SET_FUNCTION_MODULES] (state, value) {
        state.functionModules = value;
    }
};

下面是在beforeEach的处理逻辑

router.beforeEach((to, from, next) => {
    // 判断是否有匹配路由
    // 由于刷新了页面,路由重新初始化,只有静态路由被注册了,
    // 所以进入这个动态路由页面时,是找不到路由匹配项的
    if (to.matched.length === 0) {
        // 获取存储的用来获取权限信息的信息
        const userId = sessionStorage.getItem('userId');
        // 如果是刷新了导致存储的权限路由配置信息没了,则要重新请求获取权限,判断刷新页是否拥有权限
        // 这里的store.state.authority.functionModules是vuex中存在权限信息的state,是个数组
        // 刷新页面会变回初始值,例子中是null
        // 这个条件判断的目的是区分 1.用户胡乱输入根本不会存在的路由 2. 在某个动态路由上刷新了页面
        // functionModules为null,且保存了userId就代表是第二种情况,
        // 因为如果userId存在了代表登录了,就自然functionModules会有值,就算没权限也会是个[]
        if (store.state.authority.functionModules === null && userId) {
            // 重新获取权限,以下为例子
            http.get('/rights').then(res => {
                // vuex中用于保存权限信息的action
                store.dispatch('setFunctionModules', res);
                router.replace(to);
            });
            return;
        }
        // 跳转到首页 添加query,避免手动跳转丢失参数,例如token
        next({
            path: '/',
            query: to.query
        });
        return;
    }
    // ... 其余的一些有匹配路由的操作
});

针对问题三

登出系统,即用户退出,需要清除已注册的动态路由。由于问题二的解决,也需要清除在vuex中的存储信息。

这个问题其实没啥难度的,清空动态路由,用上述的resetRouter即可,清空vuex的信息就置为初始值就。

我为啥这里一提,就是为了提示你还有这么一个流程,别忘记了,一整套完整的方案不能漏了这个。

addRoutes的缺陷

上述基本已经描述完一整套实现动态路由的解决方案。但是有些小细节,可以注意一下,提高方案的全面性。

关于addRoutes的详细解释,官方文档也是简单一笔带过,实际动态注入路由是怎么一回事,你会不会觉得注入后,我们写配置里的routes选项值,就是添加了我们追加的内容?很遗憾,并不是这样的。

我们在控制台上打印路由实例router,可以看到其下有个options属性,里面有个routes属性。这个就是我们创建路由实例时的routes选项内容。我们以为通过addRoutes动态注册路由后,新注册的内容也会出现在这个属性里,但结果却是没有。

$router.options.routes的内容只会是在创建实例时生成,后面追加的不会出现在这里。这意味着,在这个版本下的vue-router你没法通过路由实例对象来获知当前已注册的所有路由。假设你的系统有需要利用当然已注册的所有路由来转一些处理的话,你此时就没有这个数据了。因此,我们要自己做一个备份,记录当前已注册的路由,以防不时之需。

我们在刚才的vuex文件中存储这个已注册路由信息,并补充具体的setFunctionModules逻辑

// authority.js

import staticRoutes from '@/router/staticRoutes.js';

// 由于vuex的检查机制,不允许存在在mutation外部能改变state值的可能性(特别是赋值类型是数组或对象时),所以要深拷贝一下
const _staticRoutes = JSON.parse(JSON.stringify(staticRoutes));

const state = {
    functionModules: null,
    // 当前已注册的路由,因为通过addRoutes追加的路由不会更新到router对象上,需要自己做记录,以免不时之需
    // _staticRoutes为系统的静止路由
    registeredRoutes: _staticRoutes
};

const getters = {
    functionModules: state => state.functionModules,
    registeredRoutes: state => state.registeredRoutes
};

const actions = {
    /**
     * 设置用户所拥有的的功能模块访问权限
     */
    setFunctionModules ({ commit, state }, value) {
        // 如果和旧值一样,那么就不需重新注册路由
        // 这里举例的系统的权限信息是由一个个权限id组成的数组,所以用以下逻辑判断是否重复,具体项目具体实现
        if (state.functionModules) {
            const _functionModules = state.functionModules.concat();
            _functionModules.sort(Vue.common.numCompare);
            value.sort(Vue.common.numCompare);
            if (_functionModules.toString() === value.toString()) {
                return;
            }
        }
        // 如果没有任何权限
        if (value.length === 0) {
            resetRouter(); // 重置路由映射
            return;
        }
        // 根据权限信息生成动态路由配置
        // createRoutes函数不展开说明,具体项目具体实现
        const dynamicRoutes = createRoutes();
        resetRouter(); // 重置路由映射
        router.addRoutes(dynamicRoutes); // 追加权限路由
         // 由于vuex的检查机制,不允许存在在mutation外部能改变state值的可能性(特别是赋值类型是数组或对象时),所以要深拷贝一下
        const _dynamicRoutes = JSON.parse(JSON.stringify(dynamicRoutes));
        // 记录当前已注册的路由配置
        commit(types.SET_REGISTERED_ROUTES, [..._staticRoutes, ..._dynamicRoutes]);
        // 保存权限信息
        commit(types.SET_FUNCTION_MODULES, value);
    }
};

const mutations = {
    // 生成当前已注册的路由副本
    [types.SET_REGISTERED_ROUTES] (state, value) {
        state.registeredRoutes = value;
    },
    // 设置用户所拥有的的功能模块访问权限
    [types.SET_FUNCTION_MODULES] (state, value) {
        state.functionModules = value;
    }
};

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

对了,如果在VUEX中存储了当前注册路由信息的话,在问题三中,退出登录,也要清除这个信息,把它置为默认情况,即只有静态路由的情况。

// 重置已注册的路由副本
[types.RESET_REGISTERED_ROUTES] (state) {
    state.registeredRoutes = _staticRoutes;
}

还有一点可能需要知道:

如果通过addRoutes加入的新路由有在静态路由中的某个路由children中,那么$router.options.routes会更新上去。

小结

以上即为一个完整的动态加载路由的方案,这个方案中要注意的东西,要处理好的细节,都已一一说明了。

总结

三个方案都已经说明了,优缺点大家也能知道。没有说哪个方案更好,甚至最好的方案,选择的标准就是:能满足你项目需求的,在你接受缺陷范围内的最简单的方案 ,这就是对你来说最好的方案。

如果对你有帮助,可点赞支持下。

未经允许,请勿私自转载

搜到很多都是说 router.matcher = createRouter().matcher, 然而我并没有成功, 不是很懂官方为什么只有一个 add, 哪怕多给一个 clear 都好啊. 还有个必须把 * 放在最后这种限制(当使用 history 的时候)

一开始初始的 router 里面通常是有 * => 404 的, 结果导致后面 add 进去的根本就路由不到, 只能再刷新一下页面重新 create 一次才行

搜到很多都是说 router.matcher = createRouter().matcher, 然而我并没有成功, 不是很懂官方为什么只有一个 add, 哪怕多给一个 clear 都好啊. 还有个必须把 * 放在最后这种限制

一开始初始的 router 里面通常是有 * => 404 的, 结果导致后面 add 进去的根本就路由不到, 只能再刷新一下页面重新 create 一次才行

createRouter().matcher我试过是可以的,是不是你代码的顺序后面在哪个地方给覆盖了什么的。官方的负责人说过了,目前是不打算支持可以动态修改路由的,add方法也仅仅是给大家一个最基础最简单的单纯加路由的应用,复杂的场景是不支持的,只是我们自己拿来用作权限控制而已。 * => 404这个我还真没用到,一般都是用redirect
如我这里写的缺点情况,一般是真的不建议使用addRoutes方式来做路由控制,当然需求使然的另当别论。 还是第一种方式是缺陷最少的

如果用户登录成功后获取到用户权限后添加路由,然后跳转到某一功能页面后存储浏览器书签以便下一次进入网页。这时候如果用户打开书签,但是功能页面并没有注册路由,页面是一片空白该如何处理,大神的文章好像并没有提及这一点

router.matcher = createRouter().matcher 无效

如果用户登录成功后获取到用户权限后添加路由,然后跳转到某一功能页面后存储浏览器书签以便下一次进入网页。这时候如果用户打开书签,但是功能页面并没有注册路由,页面是一片空白该如何处理,大神的文章好像并没有提及这一点

登录的时候返回一个accesstoken存起来,路由跳转的时候判断有没有这个accesstoken,没有就跳登录页,这样存书签访问就会跳登录页,避免空白了

如果用户登录成功后获取到用户权限后添加路由,然后跳转到某一功能页面后存储浏览器书签以便下一次进入网页。这时候如果用户打开书签,但是功能页面并没有注册路由,页面是一片空白该如何处理,大神的文章好像并没有提及这一点

抱歉,现在才注意到你的提问。

一般来说打开没有注册的路由页面,对于当时来说就是无效地址,应该要跳转到一个不需要权限的页面,如登录页。可以手动指定跳转,也可以用vue-router里的redirect

very good

同样是无效 router.matcher = createRouter().matcher

所以直接用第一种方案好了 addRoutes做了更多的额外工作

vue-router的一段示例

router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAuth)) {
    // this route requires auth, check if logged in
    // if not, redirect to login page.
    if (!auth.loggedIn()) {
      next({
        path: '/login',
        query: { redirect: to.fullPath }
      })
    } else {
      next()
    }
  } else {
    next() // 确保一定要调用 next()
  }
})

同样是无效 router.matcher = createRouter().matcher

同样是无效 router.matcher = createRouter().matcher

如果用户登录成功后获取到用户权限后添加路由,然后跳转到某一功能页面后存储浏览器书签以便下一次进入网页。这时候如果用户打开书签,但是功能页面并没有注册路由,页面是一片空白该如何处理,大神的文章好像并没有提及这一点

用导航守卫就好了,一般情况下完全没必要用addRoutes。所以最优选应该是方案一