整理进度:该文章已完善。
前言
后台项目区于其他的项目,权限验证于安全性是非常重要的,可以说是一个后台项目一开始就必须考虑和搭建的基础核心功能。我们要做到的就是:不同的权限对应着不同的路由,同时侧边栏也需根据不同的权限异步生成。思路如下
- 登录:当用户填写完账号和密码后向服务端验证是否正确,验证通过之后,服务端会返回一个token,拿到token之后(我会将这个token存贮到cookie中,保证刷新页面后能记住用户登录状态),前端会根据token再去拉取一个 user_info 的接口来获取用户的详细信息(如用户权限,用户名等等信息)
- 权限验证:通过token获取用户对应的 role,动态根据用户的 role 算出其对应有权限的路由,通过 router.addRoutes 动态挂载这些路由。
上述所有的数据和操作都是通过 vuex 全局管理控制的。(补充说明:刷新页面后的 vuex 内容会丢失,所以需要重复上述的那些操作)
登录篇
click事件触发登录操作:
this.$store.dispatch('LoginByUsername', this.loginForm).then(() => {
this.$router.push({ path: '/' }); //登录成功之后重定向到首页
}).catch(err => {
this.$message.error(err); //登录失败提示错误
});
action:
LoginByUsername({ commit }, userInfo) {
const username = userInfo.username.trim()
return new Promise((resolve, reject) => {
loginByUsername(username, userInfo.password).then(response => {
const data = response.data
Cookies.set('Token', response.data.token) //登录成功后将token存储在cookie之中
commit('SET_TOKEN', data.token)
resolve()
}).catch(error => {
reject(error)
});
});
}
登录成功后,服务端会返回一个 token(该token的是一个能唯一标示用户身份的一个key),之后我们将token存储在本地cookie之中,这样下次打开页面或者刷新页面的时候能记住用户的登录状态,不用再去登录页面重新登录了。
ps:为了保证安全性,我司现在后台所有token有效期(Expires/Max-Age)都是Session,就是当浏览器关闭了就丢失了。重新打开游览器都需要重新登录验证,后端也会在每周固定一个时间点重新刷新token,让后台用户全部重新登录一次,确保后台用户不会因为电脑遗失或者其它原因被人随意使用账号。
获取用户信息
用户登录成功之后,我们会在全局钩子router.beforeEach
中拦截路由,判断是否已获得token,在获得token之后我们就要去获取用户的基本信息了
//router.beforeEach 方法
if (store.getters.roles.length === 0) { // 判断当前用户是否已拉取完user_info信息
store.dispatch('GetInfo').then(res => { // 拉取user_info
const roles = res.data.role;
next();//resolve 钩子
})
就如前面所说的,我只在本地存储了一个用户的token,并没有存储别的用户信息(如用户权限,用户名,用户头像等)。有些人会问为什么不把一些其它的用户信息也存一下?主要出于如下的考虑:
假设我把用户权限和用户名也存在了本地,但我这时候用另一台电脑登录修改了自己的用户名,之后再用这台存有之前用户信息的电脑登录,它默认会去读取本地 cookie 中的名字,并不会去拉去新的用户信息。
所以现在的策略是:页面会先从 cookie 中查看是否存有 token,没有,就走一遍上一部分的流程重新登录,如果有token,就会把这个 token 返给后端去拉取user_info,保证用户信息是最新的。 当然如果是做了单点登录得功能的话,用户信息存储在本地也是可以的。当你一台电脑登录时,另一台会被提下线,所以总会重新登录获取最新的内容。
权限篇
权限控制的主体思路
前端会有一份路由表,它表示了每一个路由可访问的权限。当用户登录之后,通过token获取用户的role,动态根据用户的role算出其对应有权限的路由,再通过 router.addRoutes
动态挂载路由。但这些控制都只是页面级的,说白了前端再怎么做权限控制都不是绝对安全的,后端的权限验证是逃不掉的。
不同权限的用户显示不同的侧边栏和限制其所能进入的页面(也做了少许按钮级别的权限控制),后端则会验证每一个涉及请求的操作,验证其是否有该操作的权限,每一个后台的请求不管是get还是post都会让前端再请求 header 里面携带用户的token,后端会根据该 token 来验证用户是否有权限执行该操作。若没有权限则抛出一个对应的状态码,前端根据状态码做出对应的操作。
权限 前端 or 后端来控制?
路由表根据用户的权限动态生成,不采取这种方式的原因如下:
- 项目不断的迭代会异常痛苦,前端新开发一个页面还要让后端配以下路由和权限
- 虽然后端的确也有权限验证的,但它的验证其实是针对业务来划分的,比如超级编辑可以发布文章,而实习编辑只能编辑文章不能发布,但对于前端来说不管是超级编辑还是实习编辑都是有权限进入文章编辑页面的,所以前端和后端权限的划分是不太一致。
- vue2.2.0 之前异步挂载路由很麻烦。好在官方也出了新的api
备注:以上的这种形式比较适合于已经确认好的权限,比如超级管理员,管理员。如果是用户自定义的权限,那么这个权限在路由表中 meta 这个属性就不太好定义了。
addRoutes
在之前通过后端动态返回前端路由一直很难做的,因为vue-router必须是要vue在实例化之前就挂载上去的,不太方便动态改变。不过好在vue2.2.0以后新增了router.addRoutes
Dynamically add more routes to the router. The argument must be an Array using the same route config format with the routes constructor option.
具体实现
1.创建vue实例的时候将vue-router挂载,但这个时候 vue-router挂载一些登录或者不用权限的公用页面
2.当用户登录后,获取用role,将role和路由表每个页面的需要的权限做比较,生成最终用户可访问的路由表。
3.调用router.addRoutes(store.getters.addRoutes)添加用户可访问的路由
4.使用vuex管理路由表,根据vuex中可访问的路由渲染侧边栏组件
router.js
// router.js
import Vue from 'vue';
import Router from 'vue-router';
import Login from '../views/login/';
const dashboard = resolve => require(['../views/dashboard/index'], resolve);
//使用了vue-routerd的[Lazy Loading Routes
](https://router.vuejs.org/en/advanced/lazy-loading.html)
//所有权限通用路由表
//如首页和登录页和一些不用权限的公用页面
export const constantRouterMap = [
{ path: '/login', component: Login },
{
path: '/',
component: Layout,
redirect: '/dashboard',
name: '首页',
children: [{ path: 'dashboard', component: dashboard }]
},
]
//实例化vue的时候只挂载constantRouter
export default new Router({
routes: constantRouterMap
});
//异步挂载的路由
//动态需要根据权限加载的路由表
export const asyncRouterMap = [
{
path: '/permission',
component: Layout,
name: '权限测试',
meta: { role: ['admin','super_editor'] }, //页面需要的权限
children: [
{
path: 'index',
component: Permission,
name: '权限测试页',
meta: { role: ['admin','super_editor'] } //页面需要的权限
}]
},
{ path: '*', redirect: '/404', hidden: true }
];
这里我们根据 vue-router官方推荐 的方法通过meta标签来标示改页面能访问的权限有哪些。如meta: { role: ['admin','super_editor'] }
表示该页面只有admin和超级编辑才能有资格进入。
注意事项:这里有一个需要非常注意的地方就是 404
页面一定要最后加载,如果放在constantRouterMap
一同声明了404
,后面的所以页面都会被拦截到404
,详细的问题见addRoutes when you’ve got a wildcard route for 404s does not work
main.js
// main.js
router.beforeEach((to, from, next) => {
if (store.getters.token) { // 判断是否有token
if (to.path === '/login') {
next({ path: '/' });
} else {
if (store.getters.roles.length === 0) { // 判断当前用户是否已拉取完user_info信息
store.dispatch('GetInfo').then(res => { // 拉取info
const roles = res.data.role;
store.dispatch('GenerateRoutes', { roles }).then(() => { // 生成可访问的路由表
router.addRoutes(store.getters.addRouters) // 动态添加可访问路由表
next({ ...to, replace: true }) // hack方法 确保addRoutes已完成 ,set the replace: true so the navigation will not leave a history record
})
}).catch(err => {
console.log(err);
});
} else {
next() //当有用户权限的时候,说明所有可访问路由已生成 如访问没权限的全面会自动进入404页面
}
}
} else {
if (whiteList.indexOf(to.path) !== -1) { // 在免登录白名单,直接进入
next();
} else {
next('/login'); // 否则全部重定向到登录页
}
}
});
这里还有一个小hack的地方,就是router.addRoutes
之后的next()
可能会失效,因为可能next()
的时候路由并没有完全add完成,查阅文档
next(‘/‘) or next({ path: ‘/‘ }): redirect to a different location. The current navigation will be aborted and a new one will be started.
这样我们就可以简单的通过next(to)
巧妙的避开之前的那个问题了。这行代码重新进入router.beforeEach
这个钩子,这时候再通过next()
来释放钩子,就能确保所有的路由都已经挂在完成了
store/permission.js
// store/permission.js
import { asyncRouterMap, constantRouterMap } from 'src/router';
function hasPermission(roles, route) {
if (route.meta && route.meta.role) {
return roles.some(role => route.meta.role.indexOf(role) >= 0)
} else {
return true
}
}
const permission = {
state: {
routers: constantRouterMap,
addRouters: []
},
mutations: {
SET_ROUTERS: (state, routers) => {
state.addRouters = routers;
state.routers = constantRouterMap.concat(routers);
}
},
actions: {
GenerateRoutes({ commit }, data) {
return new Promise(resolve => {
const { roles } = data;
const accessedRouters = asyncRouterMap.filter(v => {
if (roles.indexOf('admin') >= 0) return true;
if (hasPermission(roles, v)) {
if (v.children && v.children.length > 0) {
v.children = v.children.filter(child => {
if (hasPermission(roles, child)) {
return child
}
return false;
});
return v
} else {
return v
}
}
return false;
});
commit('SET_ROUTERS', accessedRouters);
resolve();
})
}
}
};
export default permission;
此处通过用户的权限和之前在router.js里面的asyncRouterMap的每一个页面所需要的权限做匹配,最后返回一个该用户能够访问路由有哪些。
侧边栏
这里的侧边栏是基于 element-ui 的NavMenu 来实现的
概括:遍历之前算出来的permission_routers, 通过vuex拿到之后动态v-for渲染而已,不过这里因为有一些业务需求所以加了很多判断,比如我们在定义路由时会加很多参数
/**
* hidden: true if `hidden:true` will not show in the sidebar(default is false)
* redirect: noredirect if `redirect:noredirect` will no redirct in the breadcrumb
* name:'router-name' the name is used by <keep-alive> (must set!!!)
* meta : {
role: ['admin','editor'] will control the page role (you can set multiple roles)
title: 'title' the name show in submenu and breadcrumb (recommend set)
icon: 'svg-name' the icon show in the sidebar,
noCache: true if fasle ,the page will no be cached(default is false)
}
**/
弊端以及解决方案
弊端:动态添加的路由,并不能动态的删除。所以就会导致当用户权限发生变化的时候,或者说用户登出的时候,我们只能通过刷新页面的方式,才能清空之前注册的路由。
原理:所有的vue-router注册的路由信息都是存放在matcher之中,所以当我们想清空路由的时候,我们只要新建一个空的Router实例,将它的matcher重新赋值给我们之前定义的路由就可以了。
拓展方案
该项目中权限的实现方式是:通过获取当前用户的权限去比对路由表,生成当前用户具有的权限可访问的路由表,通过 router.addRoutes 动态挂载到 router 上。
但其实很多公司的业务逻辑可能不是这样的,举一个例子来说,很多公司的需求是每个页面的权限是动态配置的,不像本项目中是写死预设的。但其实原理是相同的。如:你可以在后台通过一个 tree 控件或者其它展现形式给每一个页面动态配置权限,之后将这份路由表存储到后端。当用户登录后得到roles,前端根据roles去像后端请求可访问的路由表,从而动态生成可访问页面,之后就是 router.addRoutes 动态挂载到 router 上,原理是相同的。只是多了一步将后端返回的路由表和本地的组件映射到一起。
相关issue
const map={
login:require('login/index').default // 同步的方式
login:()=>import('login/index') // 异步的方式
}
//你存在服务端的map类似于
const serviceMap=[
{ path: '/login', component: 'login', hidden: true }
]
//之后遍历这个map,动态生成asyncRoutes
并将 component 替换为 map[component]
指令权限
封装了一个指令权限,能简单快速的实现按钮级别的权限判断。 v-permission
思路:
- 拿到当前的用户角色列表,查看传入进来的角色在不在之中
- 在的话展示,不在则移除
此处的指令依旧适用于:传入按钮名称,然后用该按钮名称去匹配在不在这个角色所拥有的按钮列表权限之中,这样可以从另一种角度实现指令权限的控制。
import permission from './permission'
const install = function(Vue) {
Vue.directive('permission', permission)
}
if (window.Vue) {
window['permission'] = permission
Vue.use(install); // eslint-disable-line
}
permission.install = install
export default permission
import store from '@/store'
function checkPermission(el, binding) {
const { value } = binding
const roles = store.getters && store.getters.roles
if (value && value instanceof Array) {
if (value.length > 0) {
const permissionRoles = value
const hasPermission = roles.some(role => {
return permissionRoles.includes(role)
})
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el)
}
}
} else {
throw new Error(`need roles! Like v-permission="['admin','editor']"`)
}
}
export default {
inserted(el, binding) {
checkPermission(el, binding)
},
update(el, binding) {
checkPermission(el, binding)
}
}
使用
<template>
<!-- Admin can see this -->
<el-tag v-permission="['admin']">admin</el-tag>
<!-- Editor can see this -->
<el-tag v-permission="['editor']">editor</el-tag>
<!-- Editor can see this -->
<el-tag v-permission="['admin','editor']">Both admin or editor can see this</el-tag>
</template>
<script>
// 当然你也可以为了方便使用,将它注册到全局
import permission from '@/directive/permission/index.js' // 权限判断指令
export default{
directives: { permission }
}
</script>
局限
在某些情况下不适合使用 v-permission ,如元素 Tab组件,只能通过手动设置 v-if 来实现
可以使用全局权限判断函数,用法和指令 v-permission 类似
<template>
<el-tab-pane v-if="checkPermission(['admin'])" label="Admin">Admin can see this</el-tab-pane>
<el-tab-pane v-if="checkPermission(['editor'])" label="Editor">Editor can see this</el-tab-pane>
<el-tab-pane v-if="checkPermission(['admin','editor'])" label="Admin-OR-Editor">Both admin or editor can see this</el-tab-pane>
</template>
<script>
import checkPermission from '@/utils/permission' // 权限判断函数
export default{
methods: {
checkPermission
}
}
</script>
改进
上面的权限方案是根据用户当前角色去判断有无权限的,具体如何使用该指令,应该根据后端返回的数据来调整。
本示例项目中是已确定好的角色名称。那么其他情况该如何处理呢?
例如:页面权限一般与按钮权限在同一个页面。角色名称不是固定好的,可能会有很多角色,后台数据根据角色返回了对应的路由列表与按钮列表(路由列表应是大于按钮列表的,应该如果没有页面那么按钮也没地方显示)。那么此时确定好的角色名称就不适用了
解决:指令中传入这个按钮的名称,然后查看这个按钮的名称在不在该角色可以访问的按钮列表之中,如果在那么就显示。
缺点:比较复杂,每次还得看这个按钮叫什么名字。实际上必须传名字,因为如果只传角色id的话,我怎么知道这个按钮叫什么名称呢?
关于自定义指令
https://cn.vuejs.org/v2/guide/custom-directive.html
其他问题
上述弊端中提到了 动态添加的路由,并不能动态的删除。当用户权限发生变化的时候,或者用户登出的时候。我们只能通过刷新页面的方式,才能清空之前注册的路由。
首先登出的时候很好处理,那么在用户权限发生变化的时候,该如何处理呢?两种方案:
- 后端将当前的token失效,这样用户就必须重新登录。
- 当权限发生变更时,需要去触发比对,如果变化,那么会去执行 resetRouter() 方法
- 注意,需要考虑其他问题,所有通过权限去做了一些处理的地方都需要考虑,比如说根据权限生成的按钮级别的控制,该控制实际上只会在用户登录成功之后做一次加载处理,后续不会在处理了,这里就会有问题了
相关文章
问题:能否动态设置 meta ?
也就是将 所有的权限全部拿出来。然后设置给 meta .比如说 meta 中有十个角色都可以访问这个页面