Vue routing permission control

Posted by er0x on Wed, 26 Jan 2022 06:16:18 +0100

When we are working on the background management system, we will all involve the problem of how to dynamically display the menu tree on the left side of the system. At present, they are basically RBAC solutions, that is, Role-Based Access Control. Permissions are associated with roles. Users get the permissions of these roles by becoming members of appropriate roles. This greatly simplifies the management of permissions.

vue has many excellent background management system templates. These open source projects provide the idea of RBAC permission control, but in actual projects, the way of writing dead roles may not be suitable.

After reading a lot of solutions on the Internet, I feel that they all have disadvantages. Many of them are schemes in which the front end first registers the complete routing table in the project, and then filters the display through the background return tree. In fact, this method only hides the left menu, but the routing has been registered. Users can easily enter the page after guessing the access path, There is no real dynamic route loading

The following is the solution I designed-

Let's take a look at the completed system structure for easy understanding. Users bind roles (one to many) and role bind menus (one to many)

User menu

Select role

Role menu

Select the menu. Since the project is multi system, there will be two subsystems: ADMIN and HMI, which will be explained later

Resource Management (I don't call it menu management here, because it involves various subsystems. I call it module. There is a menu under the module and a button under the menu)

Well, after reading a few pictures, you may also understand that this is a typical RBAC. How does the internal work

To dynamically add routes, that is, only routes with permissions can be registered in Vue instances. The core is the two methods of Vue router addRoutes and navigation hook beforeEach



The general idea is that in the beforeEach method (that is, judge before each route jump), if the routing table has been loaded, register the routing table in the instance; if not, pull the routing table from the back end and register it in the instance. Then why not just load it once when logging in? This is because if it is loaded only once during login, the registered routing table will be lost when the web page is refreshed, so we implement it in the beforeEach hook

 
// permission.js, this file is in main JS. The idea here is to follow the example of element admin

import router from './router'
import store from './store'
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css' // progress bar style
import Cookies from 'js-cookie'
import screenfull from 'screenfull'

router.beforeEach(async (to, from, next) => {
    const token = Cookies.get('token')
    NProgress.start()
    if (token) {
        //If you are already logged in, skip to the login page and redirect to the home page
        if (to.path === '/login') {
            next({ path: '/' })
            NProgress.done()
        } else {
            if (!store.state.authorized) {
                try {
                    router.addRoutes(await store.dispatch('setAccessRoutes'))
                    store.dispatch('setAllDict')
                    next({ ...to, replace: true })
                } catch (e) {
                    Cookies.remove('token')
                    Cookies.remove('userInfo')
                    next({ path: '/login' })
                    NProgress.done()
                }
            } else {
                next()
                //The full screen parameter determines whether the page is full screen
                if (!screenfull.isEnabled) return
                if (to.meta && to.meta.fullScreen) {
                    screenfull.request().catch(() => null)
                } else {
                    if (screenfull.isFullscreen) {
                        screenfull.exit()
                    }
                }
            }
        }
    } else {
        next(to.path !== '/login' ? { path: '/login' } : true)
    }
})

router.afterEach(() => {
    NProgress.done()
})

Because the route is dynamically registered, the initial route of the project will be very simple. As long as the basic route is provided, other routes are dynamically registered after returning from the server

 
// router.js

import Vue from 'vue'
import Router from 'vue-router'
import Login from 'modules/Login'
import NoPermission from 'modules/NoPermission'
Vue.use(Router)

// Fixed NavigationDuplicated Problem
const originalPush = Router.prototype.push
Router.prototype.push = function push(location, onComplete, onAbort) {
    if (onComplete || onAbort) return originalPush.call(this, location, onComplete, onAbort)
    return originalPush.call(this, location).catch(err => err)
}

const createRouter = () =>
    new Router({
        mode: 'history',
        scrollBehavior: () => ({ y: 0 }),
        routes: [
            {
                path: '/',
                redirect: '/platform'
            },
            {
                path: '/noPermission',
                component: NoPermission
            },
            {
                path: '/login',
                component: Login
            }
        ]
    })

const router = createRouter()

export function resetRouter() {
    const newRouter = createRouter()
    router.matcher = newRouter.matcher // reset router
}

export default router

webpack Dynamic compilation was not supported before, so many projects maintain a mapping table in the routing table as follows:

const routerMap = {
    user: () => import('/views/user'),
    role: () => import('/views/role'),
    ...
}

 

I think this is not nice. The latest version of Vue cli integrated webpack can support dynamic import, so all routing information can be configured in the database. The front end does not need to maintain a router mapping table. If you are an old version of CLI, you can use dynamic import

Let's take a look at the following magical file. You can also browse it roughly and see the following explanation

 
// menu.json

//ID: any rule, as long as it is unique. The ID is written in the front end instead of being generated every time the database is imported, because the previous association relationship will be lost if it is regenerated every time the database is imported
//Title: title of the menu
//name: unique ID
//type:'MD' represents the module (subsystem), 'MN' represents the menu and 'BT' represents the button. If you need to control the button authority, you need to configure it to the BT level
//Icon: icon of the menu
//uri: routing address of the menu
//componentPath: the menu is in the path of the corresponding front-end item in the subsequent store JS will see the usage, that is, as mentioned above, there is no need to write a routerMap
//hidden: whether the menu is displayed on the left when it is used as a menu. Some menus, such as the details page of a list, need to be registered in the instance, but do not need to be displayed in the menu bar on the left
//noCache: since the project page adds cache control, this field is used to judge whether the current page needs caching
//fullScreen: some menus are displayed in full screen when entering. For example, some large screen display pages are configured through this field
//children: the same as the above fields

[
    {
        "id": "00b82eb6e50a45a495df301b0a3cde8b",
        "title": "SV ADMIN",
        "name": "ADMIN",
        "type": "MD",
        "children": [
            {
                {
                "id": "06f1082640a0440b97009d536590cf4f",
                "title": "system management",
                "name": "system",
                "icon": "el-icon-setting",
                "uri": "/system",
                "componentPath": "modules/Layout",
                "type": "MN",
                "children": [
                    {
                        "id": "b9bd920263bb47dbbfbf4c6e47cc087b",
                        "title": "user management ",
                        "name": "principal",
                        "uri": "principal",
                        "componentPath": "views/system/principal",
                        "type": "MN",
                        "children": [
                            { "id": "b37f971139ca49ab8c6506d4b30eddb3", "title": "newly added", "name": "create", "type": "BT" },
                            { "id": "d3bcee30ec03432db9db2da999bb210f", "title": "edit", "name": "edit", "type": "BT" },
                            { "id": "7c2ce28dcedf439fabc4ae9ad94f6899", "title": "delete", "name": "delete", "type": "BT" },
                            { "id": "bdf4d9e8bf004e40a82b80f0e88c866c", "title": "Change Password", "name": "resetPwd", "type": "BT" },
                            { "id": "ba09f8a270e3420bb8877f8def455f6f", "title": "Select role", "name": "setRole", "type": "BT" }
                        ]
                    },
                    {
                        "id": "c47c8ad710774576871739504c6cd2a8",
                        "title": "Role management",
                        "name": "role",
                        "uri": "role",
                        "componentPath": "views/system/role",
                        "type": "MN",
                        "children": [
                            { "id": "81c0dca0ed2c455d9e6b6d0c86d24b10", "title": "newly added", "name": "create", "type": "BT" },
                            { "id": "19a2bf03e6834d3693d69a70e919d55e", "title": "edit", "name": "edit", "type": "BT" },
                            { "id": "6136cc46c45a47f4b2f20e899308b097", "title": "delete", "name": "delete", "type": "BT" },
                            { "id": "ad5cf52a78b54a1da7c65be74817744b", "title": "Setup menu", "name": "setMenu", "type": "BT" }
                        ]
                    },
                    {
                        "id": "8b5781640b9b4a5cb28ac616da32636c",
                        "title": "resource management",
                        "name": "resource",
                        "uri": "resource",
                        "componentPath": "views/system/resource",
                        "type": "MN",
                        "children": [
                            { "id": "d4182147883f48069173b7d173e821dc", "title": "newly added", "name": "create", "type": "BT" },
                            { "id": "935fcb52fffa45acb2891043ddb37ace", "title": "edit", "name": "edit", "type": "BT" },
                            { "id": "3f99d47b4bfd402eb3c787ee10633f77", "title": "delete", "name": "delete", "type": "BT" }
                        ]
                    }
                ]
            },
            }
        ]
    },
    {
        "id": "fc8194b529fa4e87b454f970a2e71899",
        "title": "SV HMI",
        "name": "HMI",
        "type": "MD",
        "children": [
            { "id": "eb5370681213412d8541d171e9929c84", "title": "Start detection","name": "001" },
            { "id": "06eb36e7224043ddbb591eb4d688f438", "title": "Equipment information","name": "002" },
            { "id": "76696598fd46432aa19d413bc15b5110", "title": "AI model base","name": "003" },
            { "id": "2896f3861d9e4506af8120d6fcb59ee1", "title": "Maintenance and repair","name": "004" },
            { "id": "91825c6d7d7a457ebd70bfdc9a3a2d81", "title": "continue","name": "005" },
            { "id": "24694d28b2c943c88487f6e44e7db626", "title": "suspend","name": "006" },
            { "id": "225387753cf24781bb7c853ee538d087", "title": "end","name": "007" }
        ]
    }
]

The above is the front-end routing configuration information. As mentioned earlier, routing is returned by the back-end. Why does the front-end still have a menu file?

Because all the contents in the route need to be used by the front end, such as the chart displayed in the menu, the front-end path corresponding to the menu, and so on Since it has a large relationship with the front end, it is more suitable for the front end to maintain the file, rather than allowing the back end to configure XML or liquibase. Every time you modify the menu, you should notify your background to update the database, and then notify each background when switching multiple environments Backstage may not be happy to X you Then you want to change a small icon, you have to be careful to be hated by your backstage boss

Of course, if it is deployed in multiple environments, it is better to use liquibase in the background, but the front end can play by itself in the development mode. When multiple environments (different databases) need to be deployed at the same time, you can give the sql statement to the back end. The article will mention how to export sql

Question:

Since the front-end has the file, does it mean that the source code of routing is exposed again? What's the difference between it and others' path guessing access? It's not agreed to pull the menu information from the database. You and I use this json directly. How about the database? Don't worry

Answer:

  1. This is only the configuration file used by the front-end to mock. This content will not be packaged during build.

  2. Before these associations of user role menus have been established, menus can only be established through mock. At this time, the front-end configuration file can be read directly Well, it's delicious. See the store below for details JS approach

  3. The menu is always maintained by the front end. When you need to go online, the front end can set the menu through node JSON generates SQL statements and imports them into the database. It will be introduced later

The next step is how to register these routing tables

 
// store.js

import Vue from 'vue'
import Vuex from 'vuex'
import Cookie from 'js-cookie'
import NotFound from 'modules/NotFound'
import { resetRouter } from '../router'
import { getUserResourceTree, getDictAllModel } from 'apis'
import { deepClone } from 'utils/tools'

//Is here_ Testing is used to determine whether to pull the real menu of the database or directly use the front-end menu JSON, which is very useful before the association relationship of resources has been established
import { IS_TESTING } from '@/config'
import { Message } from 'element-ui'

Vue.use(Vuex)

//Production accessible routing table
const createRouter = (routes, cname = '') => {
    return routes.reduce((prev, { type, uri: path, componentPath, name, title, icon, redirectUri: redirect, hidden, fullScreen, noCache, children = [] }) => {
        //If it's a menu item, register it in the route
        if (type === 'MN') {
            prev.push({
                path,
                //Here is the dynamic import of webpack. Is it so easy? Mom, don't worry. I'll write another routerMap and put it in the source code
                component: () => import(`@/${componentPath}`),
                name: (cname + '-' + name).slice(1),
                props: true,
                redirect,
                meta: { title, icon, hidden: hidden === 'Y', type, fullScreen: fullScreen === 'Y', noCache: noCache === 'Y' },
                children: children.length ? createRouter(children, cname + '-' + name) : []
            })
        }
        return prev
    }, [])
}

//Production authority button table
const createPermissionBtns = router => {
    let btns = []
    const c = (router, name = '') => {
        router.forEach(v => {
            v.type === 'BT' && btns.push((name + '-' + v.name).slice(1))
            return v.children && v.children.length ? c(v.children, name + '-' + v.name) : null
        })
        return btns
    }
    return c(router)
}

export default new Vuex.Store({
    state: {
        collapse: false, //Whether the menu bar shrinks
        authorized: false, //Is the authorization menu pulled
        dict: {},
        accsessRoutes: [], //Registered routes
        permissionBtns: [], //Button with permission
        navTags: [], //Tag navigation list
        cachedViews: [] //Cached pages
    },
    getters: {
        collapse: state => state.collapse,
        cachedViews: state => state.cachedViews,
        accsessRoutes: state => state.accsessRoutes,
        //Menu bar (filter hidden)
        menuList: state => {
            const filterMenus = menus => {
                return menus.filter(item => {
                    if (item.children && item.children.length) {
                        item.children = filterMenus(item.children)
                    }
                    return item.meta && !item.meta.hidden
                })
            }
            return filterMenus(deepClone(state.accsessRoutes))
        },
        navTags: state => state.navTags
    },
    mutations: {
        SET_ACCSESS_ROUTES(state, accsessRoutes) {
            state.authorized = true
            state.accsessRoutes = accsessRoutes
        },
        SET_ALL_DICT(state, dict) {
            state.dict = dict
        },
        SET_PERMISSION_BTNS(state, btns) {
            state.permissionBtns = btns
        },
        SET_COLLAPSE(state, flag) {
            state.collapse = flag
        },
        SET_CACHED_VIEWS(state, cachedViews) {
            state.cachedViews = cachedViews
        },
        //Log out
        LOGOUT: state => {
            state.cachedViews = []
            state.authorized = false
            resetRouter()
            Cookie.remove('token')
            Cookie.remove('userInfo')
        }
    },
    actions: {
        setAccessRoutes: ({ commit }) => {
            return new Promise(async (resolve, reject) => {
                //The 404 page chooses to register after dynamically adding routes because if you register in the project at the beginning, the 404 will be limited to match after addRoutes, resulting in a BUG
                const routerExt = [
                    { path: '*', redirect: '/404' },
                    { path: '/404', component: NotFound }
                ]
                //The interface logic of getUserResourceTree is to query the resources contained in the current login role and filter out the sub nodes (including menus and buttons) under the module name (here ADMIN)
                const res = await (IS_TESTING ? import('@/mock/menu.json') : getUserResourceTree('ADMIN'))
                if (!res) return reject()
                let router
                if (IS_TESTING) {
                    //The 0 is selected here because my system is the first subsystem of the large system, which is in the menu JSON can see
                    router = res[0].children
                } else {
                    if (!res.data.length) {
                        reject()
                        return Message.error('The user does not configure the menu or the menu configuration is incorrect. Please check and try again~')
                    } else {
                        router = res.data
                    }
                }
                const accessRoutes = createRouter(router).concat(routerExt)
                commit('SET_ACCSESS_ROUTES', accessRoutes)
                commit('SET_PERMISSION_BTNS', createPermissionBtns(router))
                resolve(accessRoutes)
            })
        },
        setAllDict: async ({ commit }) => {
            if (IS_TESTING) return
            const res = await getDictAllModel()
            if (!res) return
            commit('SET_ALL_DICT', res.data)
        },
        logout: ({ commit }) => {
            return new Promise(resolve => {
                commit('LOGOUT')
                resolve()
            })
        }
    }
})

Well, the last step is how to put the menu. When going online JSON becomes the SQL of the database, and then is can be_ Change testing to false to really pull the menu of the database

 

// createMenu.js
const fs = require('fs')
const path = require('path')
const chalk = require('chalk')
const execSync = require('child_process').execSync //Synchronization subprocess
const resolve = dir => path.join(__dirname, dir)
const format = (data = new Date(), fmt = 'yyyy-MM-dd') => {
    let o = {
        'M+': data.getMonth() + 1, //Month
        'd+': data.getDate(), //Day
        'h+': data.getHours(), //Hours
        'm+': data.getMinutes(), //Cent
        's+': data.getSeconds(), //Second
        'q+': Math.floor((data.getMonth() + 3) / 3), //Quarter
        S: data.getMilliseconds() //Millisecond
    }
    if (/(y+)/.test(fmt)) {
        fmt = fmt.replace(RegExp.$1, (data.getFullYear() + '').substr(4 - RegExp.$1.length))
    }
    for (var k in o) {
        if (new RegExp('(' + k + ')').test(fmt)) {
            fmt = fmt.replace(RegExp.$1, RegExp.$1.length === 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length))
        }
    }
    return fmt
}
//Directory location of exported files
const SQL_PATH = resolve('./menu.sql')
//Get the user name of git configured globally to track who exported the SQL (whose QAQ is the problem pot)
const myname = execSync('git show -s --format=%cn')
    .toString()
    .trim()
//Functions to export SQL
function createSQL(data, name = '', pid = '0', arr = []) {
    data.forEach(function(v, d) {
        if (v.children && v.children.length) {
            createSQL(v.children, name + '-' + v.name, v.id, arr)
        }
        arr.push({
            id: v.id,
            created_at: format(new Date(), 'yyyy-MM-dd hh:mm:ss'),
            modified_at: format(new Date(), 'yyyy-MM-dd hh:mm:ss'),
            created_by: myname,
            modified_by: myname,
            version: 1,
            is_delete: 'N',
            code: (name + '-' + v.name).slice(1),
            name: v.name,
            title: v.title,
            icon: v.icon,
            uri: v.uri,
            sort: d + 1,
            parent_id: pid,
            type: v.type,
            component_path: v.componentPath,
            redirect_uri: v.redirectUri,
            full_screen: v.fullScreen === 'Y' ? 'Y' : 'N',
            hidden: v.hidden === 'Y' ? 'Y' : 'N',
            no_cache: v.noCache === 'Y' ? 'Y' : 'N'
        })
    })
    return arr
}

fs.readFile(resolve('src/mock/menu.json'), 'utf-8', (err, data) => {
    const menuList = createSQL(JSON.parse(data))
    const sql = menuList
        .map(sql => {
            let value = ''
            for (const v of Object.values(sql)) {
                value += ','
                value += v ? `'${v}'` : null
            }
            return 'INSERT INTO `t_sys_resource` VALUES (' + value.slice(1) + ')' + '\n'
        })
        .join(';')
    const mySQL =
        'DROP TABLE IF EXISTS `t_sys_resource`;' +
        '\n' +
        'CREATE TABLE `t_sys_resource` (' +
        '\n' +
        '`id` varchar(64) NOT NULL,' +
        '\n' +
        "`created_at` timestamp NULL DEFAULT NULL COMMENT 'Creation time'," +
        '\n' +
        "`modified_at` timestamp NULL DEFAULT NULL COMMENT 'Update time'," +
        '\n' +
        "`created_by` varchar(64) DEFAULT NULL COMMENT 'Creator'," +
        '\n' +
        "`modified_by` varchar(64) DEFAULT NULL COMMENT 'Updater'," +
        '\n' +
        "`version` int(11) DEFAULT NULL COMMENT 'Version (leguan lock)'," +
        '\n' +
        "`is_delete` char(1) DEFAULT NULL COMMENT 'Logical deletion'," +
        '\n' +
        "`code` varchar(150) NOT NULL COMMENT 'code'," +
        '\n' +
        "`name` varchar(50) DEFAULT NULL COMMENT 'name'," +
        '\n' +
        "`title` varchar(50) DEFAULT NULL COMMENT 'title'," +
        '\n' +
        "`icon` varchar(50) DEFAULT NULL COMMENT 'Icon'," +
        '\n' +
        "`uri` varchar(250) DEFAULT NULL COMMENT 'route'," +
        '\n' +
        "`sort` int(11) DEFAULT NULL COMMENT 'sort'," +
        '\n' +
        "`parent_id` varchar(64) DEFAULT NULL COMMENT 'father id'," +
        '\n' +
        "`type` char(2) DEFAULT NULL COMMENT 'type'," +
        '\n' +
        "`component_path` varchar(250) DEFAULT NULL COMMENT 'Component path'," +
        '\n' +
        "`redirect_uri` varchar(250) DEFAULT NULL COMMENT 'Redirect path'," +
        '\n' +
        "`full_screen` char(1) DEFAULT NULL COMMENT 'Full screen'," +
        '\n' +
        "`hidden` char(1) DEFAULT NULL COMMENT 'hide'," +
        '\n' +
        "`no_cache` char(1) DEFAULT NULL COMMENT 'cache'," +
        '\n' +
        'PRIMARY KEY (`id`),' +
        '\n' +
        'UNIQUE KEY `code` (`code`) USING BTREE' +
        '\n' +
        ") ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='resources';" +
        '\n' +
        sql
    fs.writeFile(SQL_PATH, mySQL, err => {
        if (err) return console.log(err)
        console.log(chalk.cyanBright(`Congratulations on creating sql Statement succeeded at: ${SQL_PATH}`))
    })
})

 
// package.json
"scripts": {
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "dev": "vue-cli-service serve",
    "menu": "node createMenu"
  },

When you need to generate SQL, just execute {npm run menu}

For convenience, the above SQL will delete the resource table first and then recreate it. Remember to back up before importing the database.

Is the whole process so easy?so easy?so easy?

After the backstage watch is built, the front end plays by itself. Does it smell self-sufficient?

Turn from https://mp.weixin.qq.com/s/tauwwpFT2XhDk2G6e5ejpA

Topics: Front-end Vue