一、项目需求
在前端后台管理系统中权限验证和安全性非常重要,通常会在用户登录成功后根据用户权限动态添加相应路由 及渲染功能菜单,其中最常见的前端框架是vue-element-admin,很多项目都是基于这个框架或借鉴开发的,在vue-element-admin官方文档中的实现是前端提前写好异步挂载的总路由表,规定好每个路由能进入的角色,登录成功后端返回角色权限,再比对权限过滤路由动态添加。经过一个项目实战,把其中走过的流程和遇到的一些坑进行记录。
(本案例也是基于vue-element-admin进行分析和实现)
二、实现思路
权限控制这部分内容主要分为两块:
- 登录:当用户填写完账号和密码后向服务端验证是否正确,验证通过之后,服务端会返回一个token,拿到token之后(通常会将这个token存贮到cookie中,保证刷新页面后能记住用户登录状态),前端会根据token再去拉取一个 user_info 的接口来获取用户的详细信息(如用户权限,用户名等等信息)。或者更简洁的方法是登陆成功后同时获取用户信息和token,并将其存储下来。
- 权限验证:通过token获取用户对应的 role,根据用户的 role 算出其对应有权限的路由,通过 router.addRoutes 动态挂载这些路由。
上述所有的数据和操作都可以通过vuex全局管理控制,整个实现思路也可以细化为以下几部分:
1、前端会有一份路由表,它表示了每一个路由可访问的权限。
主要是在src/router/index.js 进行路由表的配置,将所有人可见的页面路由都放在 constantRoutes,将需要权限才可以访问的页面路由放在asyncRoutes中,并进行角色权限设置
当用户登录之后,通过 token 获取用户的 role ,动态根据用户的 role 算出其对应有权限的路由,再通过router.addRoutes动态挂载路由。但这些控制都只是页面级的,前端做权限控制都不是绝对安全的,后端的权限验证无法避免。
2、后端会对每一个涉及请求的操作验证其是否有该操作的权限,为此我们基于axios重新封装了get和post方法,每一个后台的请求都会让前端在请求 header里面携带用户的 token和userId,后端会根据该 token 来验证用户是否有权限执行该操作。若没有权限则抛出一个对应的状态码,前端检测到该状态码,做出相对应的操作。
3、这样的优点在于真正做到前后端分离,由前端对开发的页面进行路由配置,而不是路由表是于后端根据用户的权限动态生成的
三、实现方法
1、权限验证
(一)首先在src/permission.js文件中配置路由守卫,用户登录成功之后,在全局钩子router.beforeEach中拦截路由,判断是否已获得token,在获得token之后去获取用户的基本信息
import router from './router'
import store from './store'
import { Message } from 'element-ui'
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css' // progress bar style
import { getToken } from '@/utils/auth' // get token from cookie
import getPageTitle from '@/utils/get-page-title'
NProgress.configure({ showSpinner: false }) // NProgress Configuration
//白名单
const whiteList = ['/login', '/auth-redirect'] // no redirect whitelist
// 路由拦截器
router.beforeEach(async(to, from, next) => {
// start progress bar
NProgress.start()
// set page title
// 设置目标页面的title(从目标路由的meta中获取title)
document.title = getPageTitle(to.meta.title)
// determine whether the user has logged in
// 获取store里面的登录令牌,有令牌,表示可以登录
//从cookies中获取token
const hasToken = getToken()
//判断token是否存在
if (hasToken) {
//判断是否是登录
if (to.path === '/login') {
// if is logged in, redirect to the home page
// 如果有登录,并且目标路径是 /login,路由到首页
next({ path: '/' })
NProgress.done() // hack: https://github.com/PanJiaChen/vue-element-admin/pull/2939
} else {
// determine whether the user has obtained his permission roles through getInfo
// 从vuex中获取权限
const hasRoles = store.getters.roles && store.getters.roles.length > 0
// 如果存在就放行
if (hasRoles) {
next()
} else {
try {
// get user info
// note: roles must be a object array! such as: ['admin'] or ,['developer','editor']
// 派发user/getInfo action, 获取当前用户的角色信息
// roles 必须是一个数组
// 获取用户的权限信息 存到vuex里面
const { roles } = await store.dispatch('user/getInfo')
// generate accessible routes map based on roles
//根据用户的角色信息,派发到permission/generateRoutes action. 生成动态路由表
const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
// dynamically add accessible routes
// 挂载动态路由,添加到路由
router.addRoutes(accessRoutes)
// hack method to ensure that addRoutes is complete
// set the replace: true, so the navigation will not leave a history record
next({ ...to, replace: true })
} catch (error) {
// remove token and go to login page to re-login
// 清空token
await store.dispatch('user/resetToken')
Message.error(error || 'Has Error')
//跳转登录
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
}
} else {
/* has no token*/
//如果token不存在,判断是否存在白名单中
if (whiteList.indexOf(to.path) !== -1) {
// in the free login whitelist, go directly
next()
} else {
// other pages that do not have permission to access are redirected to the login page.
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
})
router.afterEach(() => {
// finish progress bar
NProgress.done()
})
上面代码主要修改模板中的导航守卫,判断是否从后台拉取了当前用户可访问的路由表,如果拉取了,则路由放行,如果没拉取,则拉取并进行路由比对过滤出可访问的路由表,最后挂载路由放行 路由比对处理,也就是上面代码中的store.dispatch('permission/generateRoutes'.....
(二)其次在src/store/modules/permission.js文件中,通过对用户的权限和之前在router.js里面asyncRouterMap的每一个页面所需要的权限做匹配,最后返回一个该用户能够访问路由有哪些
// store/module/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;
在这里也可以写自己的逻辑,主要是通过GenerateRoutes函数生成最终可访问的路由表。
2、侧边栏渲染
最后一个涉及到权限的地方就是侧边栏,不过在前面的基础上已经很方便就能实现动态显示侧边栏,这里侧边栏基于element-ui的NavMenu来实现,就是遍历之前算出来的permission_routers,通过vuex拿到之后动态v-for渲染。
3、Axios拦截器
服务端对每一个请求都会验证权限,所以这里我们针对业务封装了一下请求。首先我们通过request拦截器在每个请求头里面塞入token,好让后端对请求进行权限验证。并创建一个respone拦截器,当服务端返回特殊的状态码,我们统一做处理,如没权限或者token失效等操作。
import axios from 'axios'
import { Message } from 'element-ui'
import store from '@/store'
import { getToken } from '@/utils/auth'
// 创建axios实例
const service = axios.create({
baseURL: process.env.BASE_API, // api的base_url
timeout: 5000 // 请求超时时间
})
// request拦截器
service.interceptors.request.use(config => {
// Do something before request is sent
if (store.getters.token) {
config.headers['X-Token'] = getToken() // 让每个请求携带token--['X-Token']为自定义key 请根据实际情况自行修改
}
return config
}, error => {
// Do something with request error
console.log(error) // for debug
Promise.reject(error)
})
// respone拦截器
service.interceptors.response.use(
response => response,
/**
* 下面的注释为通过response自定义code来标示请求状态,当code返回如下情况为权限有问题,登出并返回到登录页
* 如通过xmlhttprequest 状态码标识 逻辑可写在下面error中
*/
// const res = response.data;
// if (res.code !== 20000) {
// Message({
// message: res.message,
// type: 'error',
// duration: 5 * 1000
// });
// // 50008:非法的token; 50012:其他客户端登录了; 50014:Token 过期了;
// if (res.code === 50008 || res.code === 50012 || res.code === 50014) {
// MessageBox.confirm('你已被登出,可以取消继续留在该页面,或者重新登录', '确定登出', {
// confirmButtonText: '重新登录',
// cancelButtonText: '取消',
// type: 'warning'
// }).then(() => {
// store.dispatch('FedLogOut').then(() => {
// location.reload();// 为了重新实例化vue-router对象 避免bug
// });
// })
// }
// return Promise.reject('error');
// } else {
// return response.data;
// }
error => {
console.log('err' + error)// for debug
Message({
message: error.message,
type: 'error',
duration: 5 * 1000
})
return Promise.reject(error)
})
export default service
另外很重要的一点是后端会对每一个涉及请求的操作验证其是否有该操作的权限,为此我们基于axios重新封装了get和post方法,每一个后台的请求都会让前端在请求 header里面携带用户的 token和userId,后端会根据该 token 来验证用户是否有权限执行该操作。若没有权限则抛出一个对应的状态码,前端检测到该状态码,做出相对应的操作。
//封装get和post方法,在请求header中携带TOKEN和USERID
export default class Request{
static pget(url){
let TOKEN = getToken();
let USERID = getUsrId();
let headers = {TOKEN,USERID}
if(url !=`${BASE_URI}login` && Token)
headers.TOKEN = TOKEN;
return new Promise((resolve,reject)=>{
axios.get(url,{
headers,
timeout: 500000
}).then(res=>{
if(res){
const {data} = res;
if(data && data.code == "000000"){}
if(data && data.code == "009999"){
window.location.href = "/#/"+"login"
}
resolve(data)
}else{
console.log()
}
})
.catch(error=>{
if(error.response){
resolve(error.response.status)
}else if(error.request){
resolve('error requset')
}else{
resolve(error.messsage)
}
});
})
}
static _post(url,data,method = 'get'){
let TOKEN = getToken();
let USERID = getUsrId();
let headers = {TOKEN,USERID}
if(url !=`${BASE_URI}login` && Token)
headers.TOKEN = TOKEN;
return new Promise((resolve,reject)=>{
axios.post(url,data,{
headers,
timeout: 500000
}).then(res=>{
if(res){
const {data} = res;
if(data && data.code == "000000"){}
if(data && data.code == "009999"){
window.location.href = "/#/"+"login"
}
resolve(data)
}else{
console.log()
}
})
.catch(error=>{
if(error.response){
resolve(error.response.status)
}else if(error.request){
resolve('error requset')
}else{
resolve(error.messsage)
}
});
})
}
static _get(url,data){
return this.pget(url,data);
}
static post(url,data){
return this._post(BASE_URI + url + '',data);
}
static get(url,data){
return this._get(BASE_HOST + url ,data);
}
}
其他细节问题有待补充......