系統許可權按需訪問路由幾個完整方案(含addRoutes的填坑)

pekonchan發表於2019-10-19

前言

當你的系統需要做許可權驗證時,往往有一個很常見的需求:系統的某些頁面或者資源(按鈕、操作等),需要該使用者有對應的許可權才能可見可用。

這就涉及到如何根據使用者的許可權來判斷能否進入某個路由頁面的問題了。

網上有很多零散的方案,並沒有橫向對比幾種方案,且很多細節沒解釋到位,此處提供完整的幾個方案流程,並總結優缺點,你可自行選擇

本篇是針對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 home from 'home.vue';
import exam1 from 'example1.vue';
import exam2 from 'example2.vue';

Vue.use(Router);

const routes = [
    {
        path: '/',
        component: home
    },
    {
        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或別的網站進入到你的網站(如輸入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會更新上去。

小結

以上即為一個完整的動態載入路由的方案,這個方案中要注意的東西,要處理好的細節,都已一一說明了。

總結

三個方案都已經說明了,優缺點大家也能知道。沒有說哪個方案更好,甚至最好的方案,選擇的標準就是:能滿足你專案需求的,在你接受缺陷範圍內的最簡單的方案 ,這就是對你來說最好的方案。

如果對你有幫助,可點贊支援下。

未經允許,請勿私自轉載

相關文章