基于 Vue 的前端权限管理
我这里 vue-admin-template 这个项目为示例来讲解!!!
下载链接:
(一)发展
- 前后端未分离:权限管理主要通过过滤器或拦截器来进行,若用户不拥有某一类权限或者属于某一种角色的话,就不能访问某一个页面。
- 前后端分离:前端做页面的跳转,后端提供数据。前端展示是给用户看的,所有显示或隐藏的菜单并不是为了实现权限管理,而是为了给用户好的体验,也正因为如此,我们并不能依靠前端隐藏控件来实现权限管理,这样对安全性还是有很大的影响的。
- 真正的数据安全管理是在后端实现的。后端在设计后端接口的时候就需要考虑到权限的问题,要确保每一个接口都是在满足某种权限的基础上才能访问,简言之即使将接口全部暴露,如果角色对不上,依旧无法访问。(就好像你面前有几道门,也不是你想进哪个就进哪个的,你还是只能进你手中钥匙能打开的那扇门)
- 前端为了良好的用户体验,需要将用户不能访问的接口或者菜单隐藏起来。
- 针对用户直接在地址栏输入路径跳转
(1)没有做任何额外处理:就算能够成功进入,也无需担心数据会泄露,因为角色不对依旧无法访问相关的接口。
(2)使用 Vue 中的前置路由导航守卫:监听页面的跳转,若用户想要去一个未获授权的页面,直接在前置路由导航守卫中拦截下来。重定向到登录页面,或者停留在当前页,不让用户跳转,也可以给用户一些未获授权的提示。
(二)理解权限管理
前端的所有操作,都是为了提高用户体验,不是为了数据安全,真正的权限校验要在后端来做。
(三)前端权限控制
-
理解
前后端权限控制,各自所控制的对象、控制目的和控制手段是不一样的。在实际的项目中,前端的权限控制也是必要的。
-
意义
(1)提升突破权限的门槛:前端权限控制是系统安全的排头兵。可以防范手动输 url、控制台发请求、开发者工具改数据等方式的入侵。
(2)过滤越权请求,减轻服务端压力:省钱,不该发的请求就不让它发出去。
(3)提升用户体验:特定的用户展示特定的内容,该这类用户看的内容绝对不让其他用户看见。增强了用户的视觉效果,提升了用户体验。
(四)前端控制到底指什么?
-
概念
前端权限归根结底是请求的发起权,请求的发起可能由页面加载触发,也可能由页面上的按钮点击触发。
-
最终实现的目标
(1)路由方面:用户登录后只能看到自己有权访问的导航菜单,也只能访问自己有权访问的路由地址,否则将跳转 4xx 提示页
(2)视图方面:用户只能看到自己有权浏览的内容和有权操作的控件
(3)请求方面:最后再加上请求控制作为最后一道防线,路由可能配置失误,按钮可能忘了加权限,这种时候请求控制可以用来兜底,越权请求将在前端被拦截。
(五)基于 Vue 框架来实现权限管理
-
实现准备
这里主要是在 vue-admin-template-master 项目的基础上,实现我们的权限系统。(当未设置权限的时候我们进入系统是可以访问到所有模块的)
-
将整个系统分为 4 个权限:
(1) admin:可以访问所有的模块
(2) director:可以访问学生信息模块、Example 下面的 table 模块、Form 模块
(3)manager:可以访问学生信息模块、Example 下面的 tree 模块、Nested 下面的 Menu1-1 以及 menu 1-2-1 模块
(4) staff:只能访问学生信息模块 -
具体实现
(1)首先,当用户点击了登陆按钮,用户所输入的账号密码会传输到服务器,在服务器进行验证,验证通过后,从后端会返回当前角色的权限列表。
-
登录按钮对应的事件处理:
// /src/views/login/index.vue // 点击事件开始 handleLogin() { this.$refs.loginForm.validate(valid => { if (valid) { // 所有的逻辑从这里开始,首先在本地验证用户输入的账号密码格式是否正确,正确的话需要发送到服务器,服务器为我们返回对应的权限表 this.loading = true // 向 action 提交 user/login this.$store.dispatch('user/login', this.loginForm).then(() => { // 跳转到首页 // this.$router.push({ path: this.redirect || '/' }) location.href = 'http://localhost:9528/'; this.loading = false }).catch(() => { this.loading = false }) } else { console.log('error submit!!') return false } }) }
(2)验证用户的账号密码格式是否正确,如果格式没问题,那么就准备连接服务器,在服务器那边进行账号密码的验证了。
-
引入相关方法:
import {getRoleJson, _getRoleList} from '../../utils/permission'
-
向仓库提交了一个名为 user/login 的 action。进入仓库对应的action:
// /src/store/modules/user.js // user login login({ commit }, userInfo) { // 获取用户输入的账号和密码 const { username, password } = userInfo return new Promise((resolve, reject) => { // 调用 login 方法把账号密码传递过去,login 是一个接口,因为涉及到异步操作,所以会返回一个 promise login({ username: username.trim(), password: password }).then(response => { const { data } = response // 获取到从服务器返回的数据 {token: "admin-token"} console.log(data,'data'); let role = null; // 这里应该连接服务器,将用户数据的账号密码发送到服务器,然后拿到权限数据,这一步省略,直接在客户端进行设置 if(username === 'admin' && password === '111111'){ role = 'admin' } else if(username === 'director' && password === '111111'){ role = 'director'; } else if(username === 'manager' && password === '111111'){ role = 'manager'; } else if(username === 'staff' && password === '111111'){ role = 'staff'; } data.token = role; commit('SET_TOKEN', data.token) // 将服务器返回的 token 存储到 vuex 中 setToken(data.token) localStorage.setItem("admin_token", role);// 将 token 在本地也存储一份 resolve() }).catch(error => { reject(error) }) }) },
我们首先将用户的账号和密码发送到服务器进行验证,之后服务器会为我们提供该账号密码所对应的具体权限。这里我们就省去了连接后端服务器那一步,直接在这里根据用户的账号和密码来给予对应的角色名称。
将获取到的角色信息分别在 vuex 和 localStorage 里面存储了一份。此时逻辑回到我们登录组件的 handleLogin 方法
location.href = 'http://localhost:9528/'; // 跳转首页
(3)成功跳转首页后,还有事儿做
-
引入需要的方法
import { getRoleJson, _getRoleList } from '../src/utils/permission' import { asyncRouterMap } from '../src/router/index'
-
设置导航守卫,打消用户想要随意跳转页面的意图:
// /src/main.js // 前置导航守卫 router.beforeEach(async (to, from, next) => { // console.log(to.path); // next() if (to.path.toLowerCase() == "/login") { next(); } else { let { state, commit } = store; // 如果添加过直接next() if (state.isAddRouter) { next(); } // 如果没添加 else { try { let token = localStorage.getItem("admin_token"); if (token) { // 从接口获取权限列表 let roleList = await getRoleJson(token); let list = _getRoleList(asyncRouterMap, roleList); // // console.log("控制权限", list); router.addRoutes(list); // 改变状态,表示已经配置过权限 commit("update", { name: "isAddRouter", value: true }); // 把筛选后的权限存入vuex里,用于渲染左侧菜单 commit("update", { name: "asyncRoutes", value: list }); if(localStorage.currentPath.toLowerCase() === '/login'){ next({ path: list[0].children[0].path }); } else { next({path : localStorage.currentPath}) } } else { next({ path: "/login" }); } } catch (e) { next({ path: "/login" }); console.log(e); } } } });
在得到了当前角色对应的路由配置后,我们调用 *router.addRoutes* 来动态的添加上这些路由,然后修改 *vuex* 中 *isAddRouter* 为 *true*,表示已经配置过路由了。*asyncRoutes* 用于存储当前角色的路由配置表。 然后 *localStorage.currentPath* 的值来决定是跳转到首页的第一个模块还是跳转到 *currentPath* 所存储的路由值。 *localStorage.currentPath* 主要就是解决用户刷新浏览器问题的。因为用户刷新浏览器后,*vuex* 中的数据会消失,所以会重新走一遍上面的流程,生成动态路由和动态侧边栏。但是刷新之后应该保持用户之前所在的页面,所以这里我是用 *localStorage.currentPath* 来记录了一下。 自此,我们的动态路由就已经配置好了,如果用户手动在浏览器栏中输入路由表中不存在的路由,则会跳转到 *login* 页面。
-
在 store 文件夹中添加对应的内容
// /src/store/index const store = new Vuex.Store({ modules: { app, settings, user, student, tagsView // 标签页 }, getters, state: { userInfo: {}, // 存储用户的信息 isAddRouter: false, // 是否动态的添加过路由 asyncRoutes: [] // 动态的路由表 }, actions: {}, mutations: { // 公共方法 update(state, obj) { let { name, value } = obj; state[name] = value; } } })
-
设置前置导航守卫的思维逻辑:
首先,判断用户是否去登陆页面,如果是去登陆页面,直接放行。 如果不是去登陆页,接下来就要去仓库中看一下动态路由是否已经配置,如果已经配置完成,说明是早就进入了后台系统,只不过是模块页面之间的跳转,所以这里也直接放行。 如果进入 else,那么说明动态路由并没有配置,那么说明是第一次从登陆页跳转到首页,那么这个时候我们就需要进行动态路由和侧边栏的生成。 当然,在此之前我们还需要判断是否含有 token,如果
(4)当带有token 进行访问时,首先根据 token(因为 token 存储的是角色信息)获取到对应角色能访问到的路由表。
-
新建一个
permission.js
路由表文件,设置了不同角色能够访问的路由,然后封装了一个 getRoleJson 的方法,根据传入的角色名称来返回对应的路由数组。// src/utils/permission.js let roleList = { admin: [ { path: "/dashboard" }, { path: "/table" }, { path: "/example" }, { path: "/tree" }, { path: "/editStu/index/:id" }, { path: "/form" }, // 自己写的页面的路由 { path: "/stuManageSystem" }, { path: "/studentInfo" }, { path: "/stuManageSystem/studentUpdate/:id" }, { path: "/menu1" }, { path: "/menu2" }, { path: "/menu1-1" }, { path: "/menu1-2" }, { path: "/menu1-3" }, { path: "/menu1-2-1" }, { path: "/menu1-2-2" }, { path: "/external" }, ], director: [ { path: "/dashboard" }, { path: "/table" }, { path: "/example" }, { path: "/editStu/index/:id" }, { path: "/form" }, // 自己写的页面的路由 { path: "/stuManageSystem" }, { path: "/studentInfo" }, { path: "/stuManageSystem/studentUpdate/:id" }, ], manager: [ { path: "/dashboard" }, { path: "/table" }, { path: "/editStu/index/:id" }, { path: "/tree" }, { path: "/menu1" }, { path: "/menu2" }, { path: "/menu1-1" }, { path: "/menu1-2-1" }, // 自己写的页面的路由 { path: "/stuManageSystem" }, { path: "/studentInfo" }, { path: "/stuManageSystem/studentUpdate/:id" }, ], staff: [ { path: "/dashboard" }, { path: "/table" }, { path: "/editStu/index/:id" }, // 自己写的页面的路由 { path: "/stuManageSystem" }, { path: "/studentInfo" }, { path: "/stuManageSystem/studentUpdate/:id" }, ] }; /** * role0 // 所有权限 * role1 // 没有操作日志权限 * role2 // 只有组织架构权限 * role3 // 只有日志权限 * * -----------假设这是调用接口返回的权限列表------------- */ export const getRoleJson = (url) => { return new Promise(resolve => { return resolve(roleList[url] || roleList.admin); }); }; // 这里需要注意的是要除了要加上一些默认有的路由,还需要将我们自己写的模块的路径也写上,否则当我们系统没有判断我们没有访问权限的时候就会重定向登录页面
-
添加拥有权限的四个角色
// /src/utils/validate /** * @param {string} str * @returns {Boolean} */ export function validUsername(str) { // 设置权限 const valid_map = ['admin', 'director', 'manager', 'staff'] return valid_map.indexOf(str.trim()) >= 0 }
-
设置四个角色对应的token
// /mock/user.js const tokens = { admin: { token: 'admin' }, editor: { token: 'editor-token' }, director : { token: 'director' }, manager : { token: 'manager' }, staff : { token: 'staff' }, } const users = { 'admin': { roles: ['admin'], introduction: 'I am a super administrator', avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', name: 'Super Admin' }, 'director': { roles: ['director'], introduction: 'I am a super administrator', avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', name: 'Super Admin' }, 'manager': { roles: ['manager'], introduction: 'I am a super administrator', avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', name: 'Super Admin' }, 'staff': { roles: ['staff'], introduction: 'I am a super administrator', avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', name: 'Super Admin' }, 'editor-token': { roles: ['editor'], introduction: 'I am an editor', avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', name: 'Normal Editor' } }
(5)调用 _getRoleList 方法来得到路由表所对应的路由配置有哪些。
-
递归判断路由:_getRoleList 方法接收两个参数,系统所有路由树和当前角色所对应的路由表,然后通过递归来确定当前角色真正的路由配置,将其返回。
// src/utils/permission.js /** * 递归判断路由,删除没有的权限 * @param {Array} tree // 系统所有路由树 * @param {Array} roles // 后端返回的权限列表 * @return Array */ export function _getRoleList(tree, roles) { let finalTree = [...tree]; let fun = list => { for (let i = 0; i < list.length; i++) { let c = list[i]; if (roles.findIndex(d => d.path == c.path) > -1 || c.hidden) { if (c.children && c.children.length) { fun(c.children); } } else { let { meta: { children } } = c; if (children && children.length) { let isHave = children.findIndex(e => roles.findIndex(f => f.path == e) > -1); // 如果不存在-删除,如果存在-继续递归 if (isHave == -1) { list.splice(i, 1); i--; } else { if (c.children && c.children.length) { fun(c.children); } } } else { list.splice(i, 1); i--; } } } }; fun(finalTree); return finalTree; }
(6)动态的侧边栏的生成
-
修改 SideBar 下面的 index.vue 组件
// /src/layout/components/Sidebar/index routes() { // return this.$router.options.routes return this.$store.state.asyncRoutes; },
-
这里我们创建了一个名为 routes 的计算属性,从仓库中获取到动态路由。在模板中通过 v-for 来循环渲染 sidebar-item 组件即可。
<sidebar-item v-for="route in routes" :key="route.path" :item="route" :base-path="route.path" />
(7)路由的配置。在整个路由文件中我们分为固定路由和动态路由
-
**固定路由:**这一部分路由是无论哪一个角色用户登陆,都能够访问到的路由。
// /srv/router/index const constantRoutes = [ { path: '/login', component: () => import('@/views/login/index'), hidden: true }, { path: '/404', component: () => import('@/views/404'), hidden: true }, ];
-
**动态路由:**根据角色来决定哪些路由可以添加到路由表里面。
// /src/router/index // 动态路由 export const asyncRouterMap = [ { path: '/dashboard', component: Layout, redirect: '/dashboard', meta: { title: "", icon: "el-icon-menu", children: ["/dashboard"] }, children: [{ path: '/dashboard', name: 'Dashboard', component: () => import('@/views/dashboard/index'), meta: { title: 'Dashboard', icon: 'dashboard' } }] }, { path: '/example', component: Layout, redirect: '/example/table', name: 'Example', meta: { title: 'Example', icon: 'el-icon-s-help', children: ["/table", "/tree"] }, children: [ { path: '/table', name: 'Table', component: () => import('@/views/table/index'), meta: { title: 'Table', icon: 'table' } }, { path: '/tree', name: 'Tree', component: () => import('@/views/tree/index'), meta: { title: 'Tree', icon: 'tree' } } ] }, { path: '/form', component: Layout, meta: { title: 'Form', icon: 'el-icon-s-help', children: ["/index"] }, children: [ { path: '/index', name: 'Form', component: () => import('@/views/form/index'), meta: { title: 'Form', icon: 'form' } } ] }, // 学生管理系统,配置路由(这里是加了权限之后的最终写法,之前没有写重定向,也没有在修改模块前面加上主路径, // 而且也没有在 meta 中写 children(这个不写好像也没影响)) /* 注意:这里的学生管理下面有两个子模块,我们首先 redirect 重定向到的是学生信息这个模块,由于页面我们实现 的是点击修改之后跳转到修改这个模块,所以我们要在路径前面把主路径写上。如:/stuManageSystem/studentUpdate/:id 否则当我们点击修改的时候会调回登录,因为如果不加,是没有找到这个一个动态路由的 */ { path: '/stuManageSystem', redirect:'/stuManageSystem/studentInfo', component: Layout, alwaysShow: true, // 一直显示跟路由 // 有多个板块的时候可以加这个,不然下拉板块的标题就没有 meta: { title: 'StudentManage', icon: 'el-icon-s-help', children: ["/studentInfo", "/stuManageSystem/studentUpdate/:id"] }, children: [ // 学生详情 { path: '/studentInfo', name: 'StudentInfo', component: () => import('@/views/stuManageSystem/studentInfo'), meta: { title: '学生信息', icon: 'el-icon-user' } }, // 新增学生 // 修改学生 { path: '/stuManageSystem/studentUpdate/:id', name: 'StudentUpdate', hidden: true, // 隐藏模块 component: () => import('@/views/stuManageSystem/studentUpdate'), meta: { title: '修改学生', icon: 'el-icon-edit' } } ] }, { path: '/nested', component: Layout, redirect: '/nested/menu1', name: 'Nested', meta: { title: 'Nested', icon: 'nested', children: ["/menu1", "/menu2"] }, children: [ { path: '/menu1', component: () => import('@/views/nested/menu1/index'), // Parent router-view name: 'Menu1', meta: { title: 'Menu1' }, meta: { title: 'Menu1', children: ["/menu1-1", "/menu1-2", "/menu1-3"] }, children: [ { path: '/menu1-1', component: () => import('@/views/nested/menu1/menu1-1'), name: 'Menu1-1', meta: { title: 'Menu1-1' } }, { path: '/menu1-2', component: () => import('@/views/nested/menu1/menu1-2'), name: 'Menu1-2', meta: { title: 'Menu1-2', children: ["/menu1-2-1", "/menu1-2-2"] }, children: [ { path: '/menu1-2-1', component: () => import('@/views/nested/menu1/menu1-2/menu1-2-1'), name: 'Menu1-2-1', meta: { title: 'Menu1-2-1' } }, { path: '/menu1-2-2', component: () => import('@/views/nested/menu1/menu1-2/menu1-2-2'), name: 'Menu1-2-2', meta: { title: 'Menu1-2-2' } } ] }, { path: '/menu1-3', component: () => import('@/views/nested/menu1/menu1-3'), name: 'Menu1-3', meta: { title: 'Menu1-3' } } ] }, { path: '/menu2', component: () => import('@/views/nested/menu2/index'), name: 'Menu2', meta: { title: 'menu2' } } ] }, { path: 'external-link', component: Layout, meta: { title: 'External Link', icon: 'link', children: ["/external"] }, children: [ { path: 'https://panjiachen.github.io/vue-element-admin-site/#/', meta: { title: 'External Link', icon: 'link' } } ] }, // 404 page must be placed at the end !!! // 404——> login 统一跳转到 login 登录页面 { path: '*', redirect: '/login', hidden: true } ]
(8)最后将有一个文件中的内容注释,重新派发一个 action
-
注释掉前置路由导航守卫
const whiteList = ['/login'] // no redirect whitelist store.dispatch('user/getInfo') // 在这之后的 router.beforeEach 之后的内容都注释掉
-