Vue SPA 前端鉴权

Vue SPA 前端鉴权

随着国内越来越多的企业开始采用 Vue 来开发 SPA,原前后端耦合的模式,用户权限等逻辑都由后端来控制,但是采用 SPA 后,需要前后端一起实现鉴权策略。

所需工具

  • Vue Cli 3.*
  • Node

安装所需工具

安装 Node 环境

前往 Node 官网下载

安装 Vue Cli

1
2
3
npm install -g @vue/cli
# OR
yarn global add @vue/cli

安装完成后,需要到

创建项目

1
2
3
vue create YOUR_PROJECT_NAME
# OR
vue ui
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
George-2:Node George$ vue create spa


Vue CLI v3.8.2
┌───────────────────────────┐
│ Update available: 3.8.4 │
└───────────────────────────┘
? Please pick a preset: default (babel, eslint)


Vue CLI v3.8.2
✨ Creating project in /Users/George/Develop/Node/spa.
🗃 Initializing git repository...
⚙ Installing CLI plugins. This might take a while...

yarn install v1.13.0
info No lockfile found.
[1/4] 🔍 Resolving packages...
[2/4] 🚚 Fetching packages...
[3/4] 🔗 Linking dependencies...
[4/4] 🔨 Building fresh packages...
success Saved lockfile.
✨ Done in 207.89s.
🚀 Invoking generators...
📦 Installing additional dependencies...

yarn install v1.13.0
[1/4] 🔍 Resolving packages...
[2/4] 🚚 Fetching packages...
[3/4] 🔗 Linking dependencies...
[4/4] 🔨 Building fresh packages...

success Saved lockfile.
✨ Done in 53.93s.
⚓ Running completion hooks...

📄 Generating README.md...

🎉 Successfully created project spa.
👉 Get started with the following commands:

$ cd spa
$ yarn serve

George-2:Node George$

安装依赖

1
2
3
vue add router
vue add vuex
vue add axios

上面分别为项目添加了 vue-router、vuex、axios 依赖。

项目目录结构

项目实现

用户状态管理

因为用户可能有两种状态,一种是已登陆一种是未登陆,那么前端如何来保存用户登陆状态呢?这里可以使用 Vuex 来进行存储。但是只有 Vuex 是不够的,因为当用户手动刷新浏览器后,Vue 整个生命周期就重新加载了,所以还要配合浏览器提供的 Local StorageSession Storage 来保证用户刷新而不丢失用户状态。

store.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import Vue from 'vue'
import Vuex from 'vuex'


Vue.use(Vuex);

export default new Vuex.Store({
state: {
access_token: localStorage.getItem('access_token'),
},
mutations: {
SET_ACCESS_TOKEN: (state, data) => {
if(data === false) {
Vue.set(state, 'access_token', '');
localStorage.removeItem('access_token');
} else {
Vue.set(state, 'access_token', data.access_token);
localStorage.setItem('access_token', data.access_token);
}
}
},
actions: {
signIn({commit}, params) {
return new Promise((resolve,reject) => {
Vue.axios.post('/signin', params).then(res => {
commit('SET_ACCESS_TOKEN', res.data);
resolve(res);
}).catch(err => {
reject(err);
})
});
}
}
});

系统状态管理

在全局保存 Layout 的信息

store/modules/system.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import Vue from 'vue'
import api from '../../apis'
import * as types from '../types'

export default {
state: {
layout: {
current: "guest"
},
menu: {
active: "/"
},
installed: false,
version: ""
},
mutations: {
SET_SYSTEM: (state, data) => {
Vue.set(state, 'installed', data.installed);
Vue.set(state, 'version', data.version);
},
SET_MENU_ACTIVE: (state, data) => {
Vue.set(state.menu, 'active', data)
},
SET_LAYOUT_CURRENT: (state, data) => {
Vue.set(state.layout, 'current', data)
}
},
actions: {
fetchSystem({ commit }) {
api.system.fetch().then(response => {
commit(types.SET_SYSTEM, response.data)
});
}
}
}

请求401拦截

plugins/axios.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
"use strict";

import Vue from 'vue';
import axios from 'axios'
import store from '../store'
import router from '../router'

// Full config: https://github.com/axios/axios#request-config
// axios.defaults.baseURL = process.env.baseURL || process.env.apiUrl || '';
// axios.defaults.headers.common['Authorization'] = AUTH_TOKEN;
// axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';

let config = {
baseURL: process.env.baseURL || process.env.apiUrl || "",
timeout: 60 * 1000, // Timeout
withCredentials: true, // Check cross-site Access-Control
};

const request = axios.create(config);

// 定义请求拦截器
request.interceptors.request.use(config => {
// 遍历请求参数
for(let key in config.params) {
// 如果参数的值为空,则删除这个 Key
if (config.params.hasOwnProperty(key) && (config.params[key] === "" || config.params[key] === null)) {
delete config.params[key];
}
}
// 如果有用户的 access token 则在请求头中加上
if (store.state.account.access_token) {
config.headers.Authorization = `Bearer ${store.state.account.access_token}`;
}
return config;
}, error => {
return Promise.reject(error);
});

// 定义响应拦截器
request.interceptors.response.use(
response => {
return response.data;
},
error => {
// 如果状态码为 401 则清空 Vuex 中的状态,并跳转到登陆页面
if (error.response.status === 401) {
store.commit('SET_PROFILE', false);
store.commit('SET_ACCESS_TOKEN', false);
router.push("/signin");
}
return Promise.reject(error.response);
}
);

Plugin.install = function(Vue) {
Vue.axios = request;
window.axios = request;
Object.defineProperties(Vue.prototype, {
axios: {
get() {
return request;
}
},
$axios: {
get() {
return request;
}
},
});
};

Vue.use(Plugin);

export default Plugin;

前端路由拦截

仅仅实现了API 401状态码拦截还是不够的,还需要对前端路由做响应的鉴权。

router/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import Vue from 'vue'
import store from '../store'
import Router from 'vue-router'

Vue.use(Router);

const router = new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'dashboard',
meta: {
requiresAuth: true
},
component: () => import('../views/Dashboard.vue'),
},
{
path: '/signin',
name: 'signin',
meta: {
requiresAuth: false
},
component: () => import('../views/SignIn.vue')
},
{
// 会匹配所有路径
path: '*',
name: "notfound",
meta: {
requiresAuth: false
},
component: () => import('../views/NotFound.vue')
}
]
});

/**
* Routing to intercept
*/
router.beforeEach((to, from, next) => {
// 如果跳转到NotFound页面则提前设置视图的Layout 为 guest
if (to.name === 'notfound') {
store.commit('SET_LAYOUT_CURRENT', 'guest');
}

// 如果从NotFound 页面返回,并且需要认证的话,则设置视图的Layout 为 backend
if (from.name === 'notfound' && to.meta.requiresAuth === true) {
store.commit('SET_LAYOUT_CURRENT', 'backend')
}

// 当用户的 access token 存在且有效,前往的路由是 登陆页面时,直接跳转到首页
if (store.state.account.access_token && to.path === '/signin') {
next({
path: "/"
})
}

/**
* 路由鉴权主要逻辑
*/
if (to.matched.some(record => record.meta.requiresAuth)) {
const access_token = store.state.account.access_token;
//通过access_token判断用户是否已经登录
if (!access_token) {
next({
path: '/signin'
})
} else {
next()
}
} else {
next()
}
});

export default router;

页面布局

由于登陆页面后台页面的布局不同所以没法服用,为了在登陆后跳转路由不重新渲染公共模块,如菜单、顶部导航条等组件,所以需要在 App.vue 中实现根据用户登陆状态动态切换页面布局的逻辑。

App.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<template>
<div id="app">
<component :is="layout.current"></component>
</div>
</template>

<script>
import {mapState} from 'vuex'
import Guest from './layouts/Guest'
import Backend from './layouts/Backend'

export default {
name: 'app',
data() {
return {
}
},
methods: {},
components: {
guest: Guest,
backend: Backend
},
computed: {
...mapState({
layout: state => state.system.layout,
profile: state => state.account.profile,
access_token: state => state.account.access_token
})
},
watch: {
// Watch 用户信息变更,切换页面布局
profile(value) {
if (value.id === '') {
this.$store.commit('SET_LAYOUT_CURRENT', 'guest');
} else {
this.$store.commit('SET_LAYOUT_CURRENT', 'backend');
}
},
/**
* 当access_token的state发生变化时
* 说明用户登录信息验证成功,并且拿到了access_token
* 此时调用fetchProfile 这个action 获取用户信息
*/
access_token(value) {
if (value == null) {
window.console.log(value);
}
},
'$route' (to, from) {
if (from.path === '/' && to.name === 'notfound') {
this.$store.commit('SET_LAYOUT_CURRENT', 'guest');
}

if (from.path === '/') {
if (to.name !== 'notfound') {
this.$store.commit('SET_LAYOUT_CURRENT', 'backend');
}
}

if (to.name === 'signin') {
this.$store.commit('SET_LAYOUT_CURRENT', 'guest');
}

if (from.name === 'notfound') {
this.$store.commit('SET_MENU_ACTIVE', to.path);
}

this.$store.commit('SET_MENU_ACTIVE', to.path);
}
}
}
</script>

<style lang="scss">

</style>

到此基本上前端有关用户鉴权的逻辑都已经实现,剩下的只要专心的去写业务逻辑即可!

评论