从前端看来,用户的登录和授权看起来感觉十分简单,无非就是输入用户名和密码,传给后台确认登录。但其实这里面还是有很多需要注意的问题,这里简单列举一下:
以上这些问题实质上都是web开发中需要注意的安全性问题。虽然这些问题大部分由后端人员解决,前端只需要配合完成即可,但如果了解这些问题那就更好了。接下来我针对这三个问题逐条简单分析一下,期间涉及到的相关知识点大家可以自行搜索,此处不再进行深入说明(否则就变成安全介绍了)。
第一种问题的实质是数据传输的安全,防止信息泄露。针对于这种情况,目前最好的办法就是使用HTTPS,而不是使用HTTP。
第二种问题实际上是在Web开发中经常遇到的安全问题——跨站请求伪造(CSRF/XSRF)。即攻击者可以利用漏洞在其他网站上发送请求伪装成本站的正常请求,这样攻击者可以在用户完全不知情的情况下进行任何操作。这种问题目前的解决方法就是使用Token机制。当用户登录后服务端返回一个Token,之后的每次请求都要携带这个Token,如果在服务端Token不匹配则意味着授权失败。
第三种问题实际上就是为了让用户不能永久处于登录状态,否则用户登录一次就能获得永久授权。在实际中就是Token本身要具备时效性,要有过期机制。至于过期后是返回登录页面重新登录亦或是自动续期亦或是自动刷新,这就是由后端决定的了,前端只要配合好即可。
为了和后端人员配合好,前端必须做出一定的修改,接下来将说明这些的问题的前端解决方案。请注意,这些方法实质上都是对axios
的设置。
在上一章节中,我们其实有提到Token的携带方法,即接口权限控制部分。这里不在细说,直接上之前的代码即可。不过有一点需要注意的是Token也需要同步记录到Cookie中,否则页面刷新后,js内部的变量值将还原成初始状态。如果记录到Cookie后,则在页面刷新后可以直接从Cookie中取值,再赋给js变量。
const service = axios.create()
// http request 拦截器
// 每次请求都为http头增加Authorization字段,其内容为Token
service.interceptors.request.use(
config => {
config.headers.Authorization = `${Token}`
return config
}
);
export default service
Token超时自动消失的方法可以直接通过设置Cookie的失效时间来确定。这样,在每次请求发起时,校验Cookie中是否有Token,如果没有则需要对Token进行刷新;如果有则直接请求接口即可。
而Token的刷新目前总结为两种方式:
这两种方式在新Token的获取上实际并没有太大的区别,无非就是自动调用接口及跳到单独页面调用接口而已。但是如果页面中当前有多个请求被发起,那么则会出现较大的差异。前者需要对所有请求进行延迟处理,保证接口新Token的接口获取到数据后再执行之前的请求,否则这些请求将因Token超时直接失败;后者则是需要中断当前的所有的请求,并立即跳转到对应页面。
查看axios中断请求方法,在官方文档中搜索cancelToken:axios
// 第一种方式的校验函数
// 设置getToken锁,如果当前正在获取新Token, 则其他请求做延迟处理
var getTokenLock = false
function checkToken(callback){
// 检测Token是否过期
if(!hasToken()){
// 如果当前有请求正在获取Token
if(getTokenLock){
setTimeout(function(){
checkToken(callback)
}, 500)
} else {
getTokenLock = true
getNewToken().then(() => {
callback()
getTokenLock = false
})
}
} else {
// token未过期
callback()
}
}
// axios 拦截器
service.interceptors.request.use(
config => {
checkToken(function(){
config.headers.Authorization = `${Token}`
})
return config
}
);
var CancelToken = axios.CancelToken
var cancel
// 第二种方式的校验函数
function checkToken(callback){
// 检测Token是否过期
if(!hasToken()){
// 中断当前请求
cancel()
// 跳转到固定的授权页面
router.push('/auth')
} else {
// token未过期
callback()
}
}
// axios拦截器
service.interceptors.request.use(
config => {
config.cancelToken = new CancelToken(function executor(c) {
cancel = c;
})
checkToken(function(){
config.headers.Authorization = `${Token}`
})
return config
}
);
在创建菜单前,我们需要先确定一下我们菜单中具体的细节:当点击菜单时,只能有一个子菜单保持展开;如果点击一级菜单,其他子菜单也应该收回。而在element官网的展示中,第二个需求并没有实现。所以这里我会逐步说明如何实现这样的菜单。
上个章节中,我们已经模拟过一次用户登录后的返回数据。不过当时只是根据该数据进行权限判断,并没有在UI上生成对应的系统菜单。这里我们再来看一下模拟的菜单列表数据
var data = [
{
path: '/home',
name: '首页'
},
{
name: '系统组件',
child: [
{
name: '介绍',
path: '/components'
},
{
name: '功能类',
child: [
{
path: '/components/permission',
name: '详细鉴权'
},
{
path: '/components/pageTable',
name: '表格分页'
}
]
}
]
}
]
看着这样的数据,我们要把它生成UI菜单,其实质就是递归。
为了实现我们需要的菜单,这里列举三个非常重要的属性:
router模式:激活导航时以 index 字段作为 path 进行路由跳转。
default-active:当前激活的导航,以index字段为值。如果组件渲染前有默认值,则渲染后会按照该值来展开的导航。
unique-opened:保证只有一个子菜单展开。同样以index字段作为索引,如果某些菜单的index重复或者没有则会使该功能失效
想要同时使用者三种模式,则必须保证所有菜单节点即el-menu-item
都具有index,且index不能重复。所以我们最主要的就是在递归过程中生成不同的index:对那些非叶子节点(即菜单分组)直接算出index,叶子节点(即实际的展示页面)则直接将跳转路径赋值给index。
所以通过递归方式得到的菜单树结构应该是这样的(注意index
字段的不同):
<el-menu-item :index="/home">首页el-menu-item>
<el-submenu :index="1">
<template slot="title">系统组件template>
<el-menu-item :index="/components">介绍el-menu-item>
<el-submenu :index="1-1">
<template slot="title">功能类template>
<el-menu-item :index="/components/permission">详细鉴权el-menu-item>
<el-menu-item :index="/components/pageTable">表格分页el-menu-item>
el-submenu>
el-submenu>
...
el-menu>
在明确递归后应该产生何种结构的树后,我们就可以开始编写菜单生成的代码了。
// nav.vue
// navList为菜单列表数据
<template>
<el-menu router unique-opened :default-active="onRoutes">
// 循环navList数组,将每项的值及index传给nav-item组件
<nav-item v-for="(item, index) in navList" :item="item" :navIndex="String(index)" :key="index">
</nav-item>
</el-menu>
</template>
export default {
computed: {
// 首次进入页面时展开当前页面所属的菜单
onRoutes(){
return this.$route.path
}
}
}
// navItem.vue
// 如果当前item中有子节点
if="item.child && item.child.length" :index="navIndex">
<template slot="title">{{ item.name }}template>
<nav-item v-for="(subItem,i) in item.child" :key="navIndex+'-'+i" :navIndex="navIndex+'-'+i" :item="subItem" >
nav-item>
el-submenu>
// 如果当前item不含有子节点
<el-menu-item v-else :index="item.path">{{ item.name }}el-menu-item>
template>
<script>
export default {
// 递归组件必须有name
name: 'navItem',
props: ['item','navIndex']
}
script>
由于我们过于苛刻的要求,element官网中并没有提供相关的示例,同时也没有提供任何可以手动关闭菜单的方法。所以这里我们需要通过ref
来调取官网文档中未标明,但确存在于
组件中的方法。
// nav.vue
// 增加ref字段,直接访问子组件方法。同时注册select事件,当菜单点击时触发
"navbar" :default-active="onRoutes" @select="selectMenu">
<nav-item v-for="(item, n) in navList" :item="item" :navIndex="String(n)" :key="n">
el-menu>
template>
export default {
computed: {
onRoutes(){
return this.$route.path
}
},
methods: {
selectMenu(index, indexPath){
let openedMenus = this.$refs.navbar.openedMenus
let openMenuList
// 如果点击的是二级菜单,则获取其后已经打开的菜单
if(indexPath.length > 1){
let parentPath = indexPath[indexPath.length-2]
openMenuList = openedMenus.slice(openedMenus.indexOf(parentPath)+1)
} else{
openMenuList = openedMenus
}
openMenu = openMenu.reverse()
openMenu.forEach((ele) => {
this.$refs.navbar.closeMenu(ele)
})
}
}
}
在下一章中将讲述如何实现主题切换,同时欢迎大家讨论更好的方法,或者解决文章中提出的问题。感激不尽。
当前源码地址:https://github.com/harsima/vue-backend
请注意,该源码会不断更新(因为工作很忙不能保证定期更新)。源码涉及到的东西有超出本篇教程的部分,请酌情阅读。