vue多角色登录的实现和token的存取

本文的主要内容是如何使用vue3、elementPlus、Pinia来快速实现一个多角色登录

现在我们有一个后端采用Oauth2授权,返回JWT作为登录令牌,并且令牌里面包含用户的角色信息

我们前端要做的是检验用户是否登录,并且对于不同角色的用户我们要限制其能访问哪些页面、能看到哪些菜单

对于上述需求我们需要解决下面的问题

  • 如何设计登录页面,怎么用elementUI的表单组件做简单的校验
  • 如何封装Axios接口,怎么用封装好的接口向后端请求数据,怎么对返回内容做数据处理和异常捕获
  • 如何存取登录成功后返回的token
  • 如何利用vue-router限制不同角色登录后能看到的菜单,能访问的界面

本文将为你解决这些问题提供思路

注:如果你不关心如何创建项目环境建议直接从章节二开始阅读

一、项目创建

本节介绍,使用vite来搭建vue3项目,并使用element-plus进行开发的项目搭建和配置流程

逐步配置

本小节介绍如何一步一步配置开发过程中需要的一些依赖和插件,比如elementUI如何自动按需导入等等

如果你有经验,或者想一步配置好,请跳过此小节

1.基于vite创建项目

pnpm create vue@latest
pnpm i

安装过程中的勾选项

  • ts
  • pinia
  • vue-router
  • Prettier

2.配置setup语法糖

pnpm i vite-plugin-vue-setup-extend -D

vite.config.ts

//配置
import { defineConfig } from 'vite'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'

export default defineConfig({
  plugins: [ VueSetupExtend() ]
})

使用setup

<script setup lang="ts" name="Person">

3.配置element-ui需要的相关依赖

分别是对自动按需导入以及elementUI的图标库进行配置

#-elemntui
#-按需导入依赖的两个插件
pnpm install element-plus
pnpm install -D unplugin-vue-components unplugin-auto-import
#-elemntui图标库
#-相关的按需导入插件
pnpm install @element-plus/icons-vue
pnpm i -D unplugin-icons

vite.config.ts

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      resolvers: [
        ElementPlusResolver(),
        // 自动导入图标组件
        IconsResolver()
      ]
    }),
    Components({
      resolvers: [
        ElementPlusResolver(),
        // 自动注册图标组件
        IconsResolver({
          enabledCollections: ['ep']
        }) //ep参数是elementPlus集合
      ]
    }),
    Icons({
      autoInstall: true
    }),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

4.安装sass

pnpm i sass

快速配置

我们搭建目需要常用的依赖:

vite搭建项目时需要选择的配置

  • pinia
  • vue-router
  • eslint
  • Prettier

额外需要的插件

  • 配置使用setup
  • element-plus
  • elementUI的图标库
  • sass
  • axios

使用vite搭建项目时勾选好我们需要的配置可以帮我们省很多事,剩下的配置遵循下面的步骤

1.复制到package.json

{
  "name": "vue-learning-project",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "run-p type-check \"build-only {@}\" --",
    "preview": "vite preview",
    "build-only": "vite build",
    "type-check": "vue-tsc --build --force",
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
    "format": "prettier --write src/"
  },
  "dependencies": {
    "@element-plus/icons-vue": "^2.3.1",
    "axios": "^1.6.7",
    "element-plus": "^2.5.3",
    "pinia": "^2.1.7",
    "sass": "^1.70.0",
    "vue": "^3.3.11",
    "vue-router": "^4.2.5"
  },
  "devDependencies": {
    "@iconify-json/ep": "^1.1.14",
    "@rushstack/eslint-patch": "^1.3.3",
    "@tsconfig/node18": "^18.2.2",
    "@types/jsonwebtoken": "^9.0.5",
    "@types/node": "^18.19.3",
    "@vitejs/plugin-vue": "^4.5.2",
    "@vue/eslint-config-prettier": "^8.0.0",
    "@vue/eslint-config-typescript": "^12.0.0",
    "@vue/tsconfig": "^0.5.0",
    "eslint": "^8.49.0",
    "eslint-plugin-vue": "^9.17.0",
    "npm-run-all2": "^6.1.1",
    "prettier": "^3.0.3",
    "typescript": "~5.3.0",
    "unplugin-auto-import": "^0.17.5",
    "unplugin-icons": "^0.18.3",
    "unplugin-vue-components": "^0.26.0",
    "vite": "^5.0.10",
    "vite-plugin-vue-setup-extend": "^0.4.0",
    "vue-tsc": "^1.8.25"
  }
}

保存后一次性安装

pnpm i

2.复制到vite.config.ts

一次性配好常用插件的配置文件

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    VueSetupExtend(),
    AutoImport({
      resolvers: [ElementPlusResolver(), IconsResolver()]
    }),
    Components({
      resolvers: [
        ElementPlusResolver(),
        IconsResolver({
          enabledCollections: ['ep']
        })
      ]
    }),
    Icons({
      autoInstall: true
    })
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

可能遇到的问题

1使用自动按需导入插件导入使用elementui有一些组件经常会无法正常导入或者丢失样式,解决的办法是需要我们在main.ts手动导入

main.ts

//elemnetui部分组件无法正常按需导入,我们手动导入
//这些组件的使用频率还是很高的,推荐一次配置好
import 'element-plus/theme-chalk/el-loading.css'
import 'element-plus/theme-chalk/el-message.css'
import 'element-plus/theme-chalk/el-notification.css'
import 'element-plus/theme-chalk/el-message-box.css'

import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.mount('#app')

注意事项

配置完成后如何正确使用elemtui的图标库?

按上面的步骤无论是一步一步自己配置,还是复制本文的模板快速配置,我们都需要注意elemtui图标库的使用方法

以使用House这个图标为例

比如用House图标时要使用IEp前缀也就是IEpHouse
<el-icon><IEpHouse /></el-icon>

二、目录设计

这些目录基本上是vite创建项目的时候自动生成的,主要关注一下api、utils这是我额外创建的,一个用来保存接口函数,一个用来保存一些经常用到的工具函数

src
    ├─api          存放向后端请求的接口
    ├─assets       一些静态文件
    ├─components   组件
    ├─router       路由
    ├─stores       Pinia
    ├─utils        工具
    └─views        主要视图

三、多角色登录

我们在开始前,先说明一下开发需求

我们要为一个使用Oauth2协议,登录使用JWT作为令牌,并且采用RBAC模型来做权限关联的后端项目来实现一个多角色登录,下面说明一些我们都需要做哪些事情:

  • 使用AXIOS做好API接口供登录界面来向后端发请求
  • 在localStorage为用户存储登录成功后,后端返回的JWT令牌
  • 使用Pinia来配合管理和使用JWT令牌
  • 使用AXIOS的拦截器为每个请求添加Authorization请求头内容是JWT令牌
  • 使用vue-router的路由守卫来检查用户的登录状态,以及为部分路由添加角色限制
  • 自己编写条件判断来控制不同角色能看到的菜单

Pinia存取Token

先不着急思考如何向后端发请求获取token,我们先聊聊,登录成功后帮用户拿到了token我们怎么存怎么取

我们通过一个变量,一个方法,两个计算属性来实现对token灵活方便合理的存取

  • token变量

    拿到token先在Pinia的token变量里存一份

  • saveToken方法

    定义一个方法将token存在localStorage里面

  • getToken计算属性

    我们真正用token的时候不是通过token变量而是通过计算属性,先取pinia的token变量中取找,再到localStorage里找

  • getUserObj计算属性

    这个方法用于对 JWT token进行解码,拿出里面的用户信息,方便我们后续做权限校验使用,这里的token来自getToken,可以理解为一种依赖关系

stores/token.ts

import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { tokenPayloadDecoding, type userData } from '@/utils/jwt'

export const useTokenStore = defineStore('mytoken', () => {
  const token = ref('')

  const getToken = computed(() => {
    return token.value || window.localStorage.getItem('TokenInfo') || ''
  })

  const getUserObj = computed((): userData | null => {
    const token = getToken.value
    if (token == '') {
      return null
    } else {
      try {
        const UserJson = tokenPayloadDecoding(token)
        return UserJson
      } catch (error) {
        return null
      }
    }
  })

  function saveToken(data: string) {
    window.localStorage.setItem('TokenInfo', data)
  }

  return { token, getToken, getUserObj, saveToken }
})

补充tokenPayloadDecoding是我再/utils/jwt里面定义的一个数据处理工具,来取到token里的用户数据

export interface userData {
  user_name: string
  id: number
  user_status: number
  super_admin: boolean
  exp: number
  role: Array<string>
}

export function tokenPayloadDecoding(Token: string): userData {
  try {
    const tokenList = Token.split('.')
    const data = tokenList[1]
    const decodedString = atob(data)
    const jsondata = JSON.parse(decodedString)
    return jsondata
  } catch (error) {
    throw new Error('Token parsing Error')
  }
}

在这里插入图片描述

后端接口分析

在创建Axios接口之前,我们先来看看Ouath2的后端登录接口有哪些要求

在这里插入图片描述

这个后端要求我们必须传输的字段是username、password

同时要注意,它要的数据可不是JSON哦

在这里插入图片描述

举个例子

username=mario&password=123456

封装Axios接口

首先创建一个.env文件,保存我们的后端地址

VITE_API_URL=http://127.0.0.1:8000

接着我们在utils.requests文件下创建axios的实例

并且我们用到了拦截器,为我们的每一个请求添加Authorization请求头,内容是JWT令牌,这是本文后端校验的要求也是Oauth2的要求,实际开发还是要看自己的后端具体怎么要求

src/utils/requests.ts

import axios, { type AxiosRequestHeaders } from 'axios'
import { useTokenStore } from '@/stores/token'

const axio = axios.create({
  baseURL: import.meta.env.VITE_API_URL
})

//请求拦截器来添加token
axio.interceptors.request.use((config) => {
  if (config.headers) {
    config.headers = {} as AxiosRequestHeaders
  }
  const store = useTokenStore()
  config.headers.Authorization = `Bearer ${store.getToken}`
  //拦截器修改完config还要返回
  return config
})

export default axio

然后我们创建在api.user文件下封装我们的登录接口

我的习惯是再嵌套一层async function,在哪里调用再在哪里通过.then和.catch做数据处理和错误捕获

import axio from '@/utils/request'

interface loginInfo {
  username: string
  password: string
  scope?: string
}

interface LoginResult {
  access_token: string
  token_type: string
}

export async function login(loginInfo: loginInfo): Promise<LoginResult> {
  const response = await axio.post(
    '/login/token',
    `username=${loginInfo.username}&password=${loginInfo.password}`
  )
  return response.data
}

ElementPlus表单

有了请求接口,有了存储位置和工具,我们就要开始着手做登录表单

element-ui的表单时可以绑定一些简单的校验规则的,也是非常的方便,使用方法我下面的代码里有具体的注释

还有一些要注意的点

  • 要注意时如何使用封装好的接口来发送请求,怎么接收数据保存token,怎么捕获异常弹出错误
  • 表单的button按钮我们使用了loading来为button按钮添加状态,当点击button后让其处于loading状态,等成功收到请求结果或者请求失败之后,再解除loading状态,防止用户重复发送请求

views/login/login.vue

<script setup lang="ts">
//表单响应式数据
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { reactive, ref } from 'vue'
import { login } from '@/api/user'
import { useTokenStore } from '@/stores/token'
import { useRouter, useRoute } from 'vue-router'

const tokenStore = useTokenStore()

const isLoading = ref(false)

const router = useRouter()

const route = useRoute()

const form = reactive({
  username: 'user',
  password: 'maluyao123'
})

//定义表单校验规则,传入的泛型是Elementui已经定义好的类型
//参数解释:trigger:"blur"表示在失去光标的时候触发校验
//可以定义多个校验{pattern:正则,message:"不符合规则",trigger:"blur"}
const rules = reactive<FormRules>({
  username: [{ required: true, message: '用户名不能为空', trigger: 'blur' }],
  password: [
    { required: true, message: '密码不能为空', trigger: 'blur' },
    { min: 6, message: '密码最小6位', trigger: 'blur' }
  ]
})

//获取表单元素,定义的泛型是ELementUI为我们封装好的表单实例类型
const formRef = ref<FormInstance>()

//首先对表单的内容做二次校验,校验失败要做错误捕获,捕获完毕仍要thorw抛出错误停止代码运行
//登录成功后跳转到 / 页面,或者触发路由守卫前的页面
async function onSubmit() {
  await formRef.value?.validate().catch((err) => {
    ElMessage.error('表单校验失败')
    throw err
  })
  login(form)
  //请求成功使用pinia对token进行存储
    .then((res) => {
      const userToken: string = res.access_token
      tokenStore.token = userToken
      tokenStore.saveToken(userToken)
      isLoading.value = false
      router.push((route.query.redirect as string) || '/')
    })
    .catch((error) => {
      if (error.response.status === 401) ElMessage.error('登录失败账号或密码错误')
      else ElMessage.error('出错了,登录失败,请稍后再试')
      isLoading.value = false
    })
}
</script>

<template>
  <div class="login">
    <el-form
      :model="form"
      :rules="rules"
      ref="formRef"
      label-width="120px"
      label-position="top"
      size="large"
    >
      <h2>登录</h2>
      <!-- prop参数用于分配表单规则 -->
      <el-form-item label="账号" prop="username">
        <el-input v-model="form.username" />
      </el-form-item>

      <el-form-item label="密码" prop="password">
        <el-input v-model="form.password" />
      </el-form-item>

      <el-form-item>
        <el-button type="primary" @click="onSubmit" :loading="isLoading">登录</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<style scoped lang="scss">
.login {
  background-color: #ccc;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
}
.el-form {
  width: 300px;
  background-color: #fff;
  padding: 30px;
  border-radius: 10px;

  .el-form-item {
    margin-top: 20px;
  }
  .el-button {
    width: 100%;
    margin-top: 30px;
  }
}
</style>

在这里插入图片描述

路由守卫

我们的路径中可以设定meta,meta要是一个对象

你可以理解为路径添加meta是在为路径添加要求,meta是个对象,它的元素是一个个键值对,键值对的值可以是一个Boolean也可以是个Array,因此meta中的元素,可能是代表我要为这个路径添加一个要求或者是一组要求

import { createRouter, createWebHistory } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import { useTokenStore } from '@/stores/token'
import { tokenVertify } from '@/utils/jwt'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/login',
      name: 'login',
      component: () => import('@/views/login/LoginView.vue')
    },
    {
      path: '/',
      name: 'home',
      //我们要求requiersAuth: true,要求用户必须登录,这里帮一次就行了,这同样会约束它的children路由
      meta: { requiersAuth: true },
      component: AppLayout,
      children: [
        {
          path: 'permission-test',
          name: 'permissionTest',
          component: () => import('@/views/PermissionTestVue.vue'),
          //在meta里绑定这个路径需要的角色列表
          //用户要访问这个路径,在token中用户必须有几个角色的信息才行
          meta: { role: ['test_role1','test_role2'] }
        },
        {
          path: '/:xxx(.*)*',
          name: 'errorPage',
          component: () => import('@/views/error/ErrorPage.vue')
        }
      ]
    },
    {
      path: '/error',
      name: 'error',
      component: () => import('@/views/error/ErrorPage.vue')
    }
  ]
})


router.beforeEach((to, from, next) => {
  //to.meta能获取将要前往的路由的meta数据,我们来判断其需不需要requiersAuth
  //如果有requiersAuth就要去看用户是否有token看用户是否是登录状态
  if (to.meta.requiersAuth) {
    //获取store的时候不能过早,就应该再这里
    const store = useTokenStore()
    const tokenVT: boolean = tokenVertify(store.getToken)
    if (!tokenVT) {
      next({ name: 'login', query: { redirect: to.fullPath } })
    } 
      //然后看meta里面是否有role,有role就说明路径需要指定的角色来访问
      else if (to.meta.role) {
      if (!store.getUserObj) {
        next({ name: 'error', query: { redirect: to.fullPath } })
      } else {
        const superAdmin = store.getUserObj.super_admin
        const routerRole = to.meta.role as string[]
        const roleVertify: boolean = routerRole.every((role) =>
          store.getUserObj?.role.includes(role)
        )
        //校验不通过的条件是,既不是超级管理员,有没有全部的role权限
        if (!superAdmin && !roleVertify) {
          next({ name: 'error', query: { redirect: to.fullPath } })
        }
      }
    }
  }
  next()
})

export default router

根据角色控制菜单的显示

这是主页界面的一个侧边栏,我习惯放在components文件夹下,我们现在想要实现根据用户token中不同的role来控制,用户能看到哪些菜单,不能看到哪些菜单

实现方式非常简单,从pinia拿用户的role,然后为菜单添加不同的v-if做判断就行

<script setup lang="ts">
import { useTokenStore } from '@/stores/token'
import { ref } from 'vue'

const store = useTokenStore()

const allMenusControl = ref(false)

if (store.getUserObj !== null) {
  allMenusControl.value = true
}
</script>

<template>
  <el-menu class="layoutMenu" router>
    <el-menu-item index="1">
      <el-icon><IEpClock /></el-icon>
      <span>主要业务</span>
    </el-menu-item>

    <el-menu-item index="/user-manage">
      <el-icon><IEpUser /></el-icon>
      <span>用户管理</span>
    </el-menu-item>

    <el-menu-item index="/role-manage">
      <el-icon><IEpLocation /></el-icon>
      <span>角色管理</span>
    </el-menu-item>

    <el-menu-item index="/permission-manage">
      <el-icon><IEpLock /></el-icon>
      <span>权限管理</span>
    </el-menu-item>

    <el-menu-item
      index="/permission-test"
      v-if="
        store.getUserObj?.super_admin ||
        (allMenusControl && store.getUserObj?.role.includes('test_role'))
      "
    >
      <el-icon><IEpdocument /></el-icon>
      <span>权限测试</span>
    </el-menu-item>
  </el-menu>
</template>

<style scoped lang="scss">
.layoutMenu {
  height: calc(100vh - 60px);
}
.el-menu-item {
  font-size: 17px;
}
</style>

退出登录状态

这里只讲实现退出登录的事件函数怎么写,至于你把触发退出登录事件的按钮放在呢由你自己决定

思路就是ElMessageBox弹出对话框,当用户确定退出后再退出,退出登录使用我们pinia里面存的saveToken方法同时给localstorage里面和pinia里面的token全赋值成空字符串,最后给用户定向到登录界面就行

import { useTokenStore } from '@/stores/token'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'

const router = useRouter()

const toeknStore = useTokenStore()

async function handLogout() {
  ElMessageBox.confirm('确定要退出登录吗?', '退出登录', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  })
    .then(() => {
      // 执行退出操作清空token
      ElMessage({
        type: 'success',
        message: '已退出登录'
      })
      useTokenStore().saveToken('')
      router.push('/login')
    })
    .catch(() => {
      ElMessage({
        type: 'info',
        message: '取消退出'
      })
      return new Promise(() => {})
    })
}

退出登录使用我们pinia里面存的saveToken方法同时给localstorage里面和pinia里面的token全赋值成空字符串,最后给用户定向到登录界面就行

import { useTokenStore } from '@/stores/token'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'

const router = useRouter()

const toeknStore = useTokenStore()

async function handLogout() {
  ElMessageBox.confirm('确定要退出登录吗?', '退出登录', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  })
    .then(() => {
      // 执行退出操作清空token
      ElMessage({
        type: 'success',
        message: '已退出登录'
      })
      useTokenStore().saveToken('')
      router.push('/login')
    })
    .catch(() => {
      ElMessage({
        type: 'info',
        message: '取消退出'
      })
      return new Promise(() => {})
    })
}
  • 10
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
VueVuex中实现Token存储,可以将Token保存在Vuex的状态管理器中,这样在整个应用程序中都可以访问到Token。下面是一个简单的示例: 1. 首先,在Vuex中创建一个module,用于存储Token: ```javascript // store/modules/auth.js const state = { token: null } const mutations = { setToken(state, token) { state.token = token } } export default { state, mutations } ``` 2. 在Vue组件中使用Vuex的mapState和mapMutations方法,将Token存储到Vuex中: ```javascript // Login.vue <template> <div> <input type="text" v-model="username"> <input type="password" v-model="password"> <button @click="login">Login</button> </div> </template> <script> import { mapState, mapMutations } from 'vuex' export default { data() { return { username: '', password: '' } }, computed: { ...mapState({ token: state => state.auth.token }) }, methods: { ...mapMutations({ setToken: 'auth/setToken' }), login() { // 发送登录请求,获取Token const token = 'xxxxx' this.setToken(token) } } } </script> ``` 3. 在需要验证Token的请求中,从Vuex中获取Token: ```javascript // api.js import axios from 'axios' import store from '@/store' const api = axios.create({ baseURL: 'https://api.example.com', headers: { 'Content-Type': 'application/json' } }) api.interceptors.request.use(config => { const token = store.state.auth.token if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, error => { return Promise.reject(error) }) export default api ``` 以上是一个简单的实现方法,当然,实际应用中还需要考虑Token的过期时间和刷新等问题。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值