Vue + ElementUI 手撸后台管理网站基本框架(三)登录及导航菜单加载

  • 登录授权
    • 登录及安全
    • 为每个请求增加Token
    • Token超时及刷新机制
  • 创建系统菜单
    • 模拟菜单数据
    • 生成菜单
    • 实现点击一级菜单收回所有其他子菜单
  • NEXT——主题切换
  • 源码
  • 本系列目录

登录授权


登录及安全

从前端看来,用户的登录和授权看起来感觉十分简单,无非就是输入用户名和密码,传给后台确认登录。但其实这里面还是有很多需要注意的问题,这里简单列举一下:

  • 所有数据的传输过程应当保证安全,保证数据不会在传输过程中泄露或劫持
  • 应当有一种机制来校验请求发起人是否是之前登录的用户
  • 应当有一种过期机制使用户不能保持永久登录状态

以上这些问题实质上都是web开发中需要注意的安全性问题。虽然这些问题大部分由后端人员解决,前端只需要配合完成即可,但如果了解这些问题那就更好了。接下来我针对这三个问题逐条简单分析一下,期间涉及到的相关知识点大家可以自行搜索,此处不再进行深入说明(否则就变成安全介绍了)。

第一种问题的实质是数据传输的安全,防止信息泄露。针对于这种情况,目前最好的办法就是使用HTTPS,而不是使用HTTP。

第二种问题实际上是在Web开发中经常遇到的安全问题——跨站请求伪造(CSRF/XSRF)。即攻击者可以利用漏洞在其他网站上发送请求伪装成本站的正常请求,这样攻击者可以在用户完全不知情的情况下进行任何操作。这种问题目前的解决方法就是使用Token机制。当用户登录后服务端返回一个Token,之后的每次请求都要携带这个Token,如果在服务端Token不匹配则意味着授权失败。

第三种问题实际上就是为了让用户不能永久处于登录状态,否则用户登录一次就能获得永久授权。在实际中就是Token本身要具备时效性,要有过期机制。至于过期后是返回登录页面重新登录亦或是自动续期亦或是自动刷新,这就是由后端决定的了,前端只要配合好即可。

为了和后端人员配合好,前端必须做出一定的修改,接下来将说明这些的问题的前端解决方案。请注意,这些方法实质上都是对axios的设置。

为每个请求增加Token

在上一章节中,我们其实有提到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超时及刷新机制

Token超时自动消失的方法可以直接通过设置Cookie的失效时间来确定。这样,在每次请求发起时,校验Cookie中是否有Token,如果没有则需要对Token进行刷新;如果有则直接请求接口即可。

而Token的刷新目前总结为两种方式:

  • 每次请求时判断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