Vue 3 中如何對 JWT、Vuex、Axios和Vue Router 進行身份驗證實戰(zhàn)
在本教程中,我們將在Vue 3中使用JWT、Vuex、Axios、Vue Router和VeeValidate構建一個身份驗證和授權的示例。
內容包括:
- 用戶注冊和用戶登錄的JWT身份驗證流程
- 使用Vuex 4和Vue Router 4進行Vue 3身份驗證的項目結構
- 定義Vuex認證模塊
- 使用Vuex Store創(chuàng)建Vue 3身份驗證組件
- 使用VeeValidate 4實現(xiàn)響應式表單驗證
- 訪問受保護資源的Vue 3組件
- 向Vue 3 App添加動態(tài)導航欄
出發(fā)!
使用JWT的Vue 3身份驗證實戰(zhàn)
我們將構建一個Vue 3應用程序,其中包含:
- 登錄/注銷、注冊頁面。
- 表單數(shù)據(jù)在發(fā)送到后端之前由前端進行驗證。
- 根據(jù)用戶的角色(管理員、版主、用戶)自動更改導航欄項目。
截圖
– 注冊頁面:
圖片
– 表單驗證如下所示:
圖片
– 登錄頁面和個人資料頁面:
圖片
– 管理員帳戶的導航欄:
圖片
演示
下面是完整的Vue JWT身份驗證App演示(有表單驗證、檢查注冊用戶名/電子郵件重復項,并使用管理員、版主、用戶3個角色測試授權)。后端REST API使用Spring Boot。
https://www.youtube.com/embed/pPSRVu-Ysjw?rel=0
上面地視頻使用的是Vue 2和VeeValidate 2,邏輯和UI與本教程相同。
用戶注冊和用戶登錄流程
JWT身份驗證將調用2個接口服務:
- 用于用戶注冊的POST api/auth/signup
- 用于用戶登錄的POST api/auth/signin
你可以看看下面的流程,對Vue客戶端如何發(fā)出或接收請求和響應有一個大致的了解。
圖片
Vue客戶端在向受保護的資源發(fā)送請求之前,必須將JWT添加到HTTP授權標頭中。
Vue App組件圖
現(xiàn)在請看下圖:
圖片
我們知道:
– App組件是一個具有Router的容器。它從Vuex store/auth獲取應用狀態(tài)。然后導航欄可以根據(jù)狀態(tài)來顯示。App組件還會將狀態(tài)傳遞給子組件。
– Login和Register組件具有用于提交數(shù)據(jù)的表單(支持vee-validate)。我們調用Vuex store dispatch()函數(shù)來執(zhí)行登錄/注冊操作。
– Vuex操作調用auth.service方法,auth.service方法將使用axios發(fā)出HTTP請求。這些方法還可以存儲或從瀏覽器本地存儲中獲取JWT。
– home組件對所有訪客都是公開的。
– Profile組件從父組件獲取user數(shù)據(jù)并顯示用戶信息。
– BoardUser、BoardModerator、BoardAdmin組件將由Vuex狀態(tài)user.roles顯示。這些組件使用user.service獲取來自API的受保護的資源。
– user.service使用auth-header()輔助函數(shù)將JWT添加到HTTP授權標頭。auth-header()從本地存儲返回一個對象,這個對象包含當前登錄用戶的JWT。
技術
我們將用到以下模塊:
- vue 3
- vue-router 4
- Vuex 4
- axios:0.21.1
- VEE-validate 4
- bootstrap 4
- vue-fontawesome 3
項目結構
Vue 3身份驗證和授權項目的文件夾和文件結構如下:
圖片
設置Vue 3項目
在Project文件夾中打開cmd,運行命令:
vue create vue-3-authentication-jwt
你會看到一些選項,選擇Default ([Vue 3] babel, eslint)。
項目準備就緒后,運行以下命令安裝必要的模塊:
npm install vue-router@4
npm install vuex@4
npm install vee-validate@4 yup
npm install axios
npm install bootstrap@4 jquery popper.js
npm install @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/vue-fontawesome@prerelease
安裝完成后,可以檢查package.json文件中的依賴項。
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.35",
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@fortawesome/vue-fontawesome": "^3.0.0-3",
"axios": "^0.21.1",
"bootstrap": "^4.6.0",
"core-js": "^3.6.5",
"jquery": "^3.6.0",
"popper.js": "^1.16.1",
"vee-validate": "^4.3.5",
"vue": "^3.0.0",
"vue-router": "^4.0.6",
"vuex": "^4.0.0",
"yup": "^0.32.9"
},
在src文件夾中使用以下代碼創(chuàng)建plugins/font-awesome.js文件:
import { library } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faHome,
faUser,
faUserPlus,
faSignInAlt,
faSignOutAlt,
} from "@fortawesome/free-solid-svg-icons";
library.add(faHome, faUser, faUserPlus, faSignInAlt, faSignOutAlt);
export { FontAwesomeIcon };
打開src/main.js,如下修改里面的代碼:
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import "bootstrap";
import "bootstrap/dist/css/bootstrap.min.css";
import { FontAwesomeIcon } from './plugins/font-awesome'
createApp(App)
.use(router)
.use(store)
.component("font-awesome-icon", FontAwesomeIcon)
.mount("#app");
可以看到我們導入并應用了:
– Vuex的store(稍后在src/store實現(xiàn))
– Vue Router的router(稍后在src/router.js實現(xiàn))
– CSS的bootstrap
– 用于圖標的vue-fontawesome(稍后在nav中使用)
創(chuàng)建服務
在src/services文件夾中創(chuàng)建兩個服務:
圖片
身份驗證服務
該服務在axios的幫助下為HTTP請求和響應提供了三種重要方法:
- login(): POST {username, password}并將JWT保存到Local Storage
- logout():刪除來自Local Storage中的JWT
- register():POST { username, email, password}
import axios from 'axios';
const API_URL = 'http://localhost:8080/api/auth/';
class AuthService {
login(user) {
return axios
.post(API_URL + 'signin', {
username: user.username,
password: user.password
})
.then(response => {
if (response.data.accessToken) {
localStorage.setItem('user', JSON.stringify(response.data));
}
return response.data;
});
}
logout() {
localStorage.removeItem('user');
}
register(user) {
return axios.post(API_URL + 'signup', {
username: user.username,
email: user.email,
password: user.password
});
}
}
export default new AuthService();
數(shù)據(jù)服務
還有從服務器檢索數(shù)據(jù)的方法。如果要訪問受保護的資源,那么HTTP請求需要Authorization標頭。
在auth-header.js中創(chuàng)建輔助函數(shù)authHeader():
export default function authHeader() {
let user = JSON.parse(localStorage.getItem('user'));
if (user && user.accessToken) {
return { Authorization: 'Bearer ' + user.accessToken };
} else {
return {};
}
}
它檢查user項的Local Storage。
如果存在使用accessToken(JWT)登錄的user,則返回HTTP Authorization標頭。否則返回空對象。
注意:對于Node.js Express后端,請使用x-access-token標頭,如下所示:
export default function authHeader() {
let user = JSON.parse(localStorage.getItem('user'));
if (user && user.accessToken) {
// for Node.js Express back-end
return { 'x-access-token': user.accessToken };
} else {
return {};
}
}
接著在user.service.js中定義用于訪問數(shù)據(jù)的服務:
import axios from 'axios';
import authHeader from './auth-header';
const API_URL = 'http://localhost:8080/api/test/';
class UserService {
getPublicContent() {
return axios.get(API_URL + 'all');
}
getUserBoard() {
return axios.get(API_URL + 'user', { headers: authHeader() });
}
getModeratorBoard() {
return axios.get(API_URL + 'mod', { headers: authHeader() });
}
getAdminBoard() {
return axios.get(API_URL + 'admin', { headers: authHeader() });
}
}
export default new UserService();
可以看到,在請求授權的資源時,我們在authHeader()函數(shù)的幫助下添加了HTTP標頭。
定義Vuex認證模塊
我們將用于身份驗證的Vuex模塊放在src/store文件夾。
圖片
現(xiàn)在打開index.js文件,將auth.module導入到主Vuex Store。
import { createStore } from "vuex";
import { auth } from "./auth.module";
const store = createStore({
modules: {
auth,
},
});
export default store;
然后開始定義Vuex身份驗證模塊,其中包含:
- state: { status, user }
- actions: { login, logout, register }
- mutations: { loginSuccess, loginFailure, logout, registerSuccess, registerFailure }
我們使用上面定義的AuthService來發(fā)出身份驗證請求。
auth.module.js
import AuthService from '../services/auth.service';
const user = JSON.parse(localStorage.getItem('user'));
const initialState = user
? { status: { loggedIn: true }, user }
: { status: { loggedIn: false }, user: null };
export const auth = {
namespaced: true,
state: initialState,
actions: {
login({ commit }, user) {
return AuthService.login(user).then(
user => {
commit('loginSuccess', user);
return Promise.resolve(user);
},
error => {
commit('loginFailure');
return Promise.reject(error);
}
);
},
logout({ commit }) {
AuthService.logout();
commit('logout');
},
register({ commit }, user) {
return AuthService.register(user).then(
response => {
commit('registerSuccess');
return Promise.resolve(response.data);
},
error => {
commit('registerFailure');
return Promise.reject(error);
}
);
}
},
mutations: {
loginSuccess(state, user) {
state.status.loggedIn = true;
state.user = user;
},
loginFailure(state) {
state.status.loggedIn = false;
state.user = null;
},
logout(state) {
state.status.loggedIn = false;
state.user = null;
},
registerSuccess(state) {
state.status.loggedIn = false;
},
registerFailure(state) {
state.status.loggedIn = false;
}
}
};
創(chuàng)建Vue 3認證組件
繼續(xù)身份驗證組件,這些組件應該與Vuex Store一起使用,而不是直接使用axios或AuthService:
– 使用this.$store.state.auth獲取status
– 通過調度操作this.$store.dispatch()發(fā)出請求
圖片
Vue 3登錄頁面
在src/components文件夾使用以下代碼創(chuàng)建Login.vue文件:
<template>
<div class="col-md-12">
<div class="card card-container">
<img
id="profile-img"
src="http://ssl.gstatic.com/accounts/ui/avatar_2x.png"
class="profile-img-card"
/>
<Form @submit="handleLogin" :validation-schema="schema">
<div class="form-group">
<label for="username">Username</label>
<Field name="username" type="text" class="form-control" />
<ErrorMessage name="username" class="error-feedback" />
</div>
<div class="form-group">
<label for="password">Password</label>
<Field name="password" type="password" class="form-control" />
<ErrorMessage name="password" class="error-feedback" />
</div>
<div class="form-group">
<button class="btn btn-primary btn-block" :disabled="loading">
<span
v-show="loading"
class="spinner-border spinner-border-sm"
></span>
<span>Login</span>
</button>
</div>
<div class="form-group">
<div v-if="message" class="alert alert-danger" role="alert">
{{ message }}
</div>
</div>
</Form>
</div>
</div>
</template>
<script>
import { Form, Field, ErrorMessage } from "vee-validate";
import * as yup from "yup";
export default {
name: "Login",
components: {
Form,
Field,
ErrorMessage,
},
data() {
const schema = yup.object().shape({
username: yup.string().required("Username is required!"),
password: yup.string().required("Password is required!"),
});
return {
loading: false,
message: "",
schema,
};
},
computed: {
loggedIn() {
return this.$store.state.auth.status.loggedIn;
},
},
created() {
if (this.loggedIn) {
this.$router.push("/profile");
}
},
methods: {
handleLogin(user) {
this.loading = true;
this.$store.dispatch("auth/login", user).then(
() => {
this.$router.push("/profile");
},
(error) => {
this.loading = false;
this.message =
(error.response &&
error.response.data &&
error.response.data.message) ||
error.message ||
error.toString();
}
);
},
},
};
</script>
<style scoped>
...
</style>
此頁面有一個包含2個Field,即username和password的Form。使用VeeValidate 4.x來驗證輸入,如果存在無效字段,則顯示錯誤消息。
我們使用Vuex Store—this.$store.state.auth.status.loggedIn檢查用戶登錄狀態(tài)。如果狀態(tài)為true,則使用Vue Router將用戶定向到Profile頁面:
created() {
if (this.loggedIn) {
this.$router.push('/profile');
}
},
在handleLogin()函數(shù)中,我們將'auth/login' Action調度到Vuex Store。如果登錄成功,則轉到Profile頁面,否則顯示錯誤消息。
Vue 3注冊頁面
注冊頁面類似于登錄頁面。
不一樣的是,表單驗證需要提供更多詳細信息:
- username:必填,最小長度:3,最大長度:20
- email: 必填, email, 最大長度:50
- password:必填,最小長度:6,最大長度:40
而表單提交,則調度'auth/register' Vuex Action。
components/Register.vue
<template>
<div class="col-md-12">
<div class="card card-container">
<img
id="profile-img"
src="http://ssl.gstatic.com/accounts/ui/avatar_2x.png"
class="profile-img-card"
/>
<Form @submit="handleRegister" :validation-schema="schema">
<div v-if="!successful">
<div class="form-group">
<label for="username">Username</label>
<Field name="username" type="text" class="form-control" />
<ErrorMessage name="username" class="error-feedback" />
</div>
<div class="form-group">
<label for="email">Email</label>
<Field name="email" type="email" class="form-control" />
<ErrorMessage name="email" class="error-feedback" />
</div>
<div class="form-group">
<label for="password">Password</label>
<Field name="password" type="password" class="form-control" />
<ErrorMessage name="password" class="error-feedback" />
</div>
<div class="form-group">
<button class="btn btn-primary btn-block" :disabled="loading">
<span
v-show="loading"
class="spinner-border spinner-border-sm"
></span>
Sign Up
</button>
</div>
</div>
</Form>
<div
v-if="message"
class="alert"
:class="successful ? 'alert-success' : 'alert-danger'"
>
{{ message }}
</div>
</div>
</div>
</template>
<script>
import { Form, Field, ErrorMessage } from "vee-validate";
import * as yup from "yup";
export default {
name: "Register",
components: {
Form,
Field,
ErrorMessage,
},
data() {
const schema = yup.object().shape({
username: yup
.string()
.required("Username is required!")
.min(3, "Must be at least 3 characters!")
.max(20, "Must be maximum 20 characters!"),
email: yup
.string()
.required("Email is required!")
.email("Email is invalid!")
.max(50, "Must be maximum 50 characters!"),
password: yup
.string()
.required("Password is required!")
.min(6, "Must be at least 6 characters!")
.max(40, "Must be maximum 40 characters!"),
});
return {
successful: false,
loading: false,
message: "",
schema,
};
},
computed: {
loggedIn() {
return this.$store.state.auth.status.loggedIn;
},
},
mounted() {
if (this.loggedIn) {
this.$router.push("/profile");
}
},
methods: {
handleRegister(user) {
this.message = "";
this.successful = false;
this.loading = true;
this.$store.dispatch("auth/register", user).then(
(data) => {
this.message = data.message;
this.successful = true;
this.loading = false;
},
(error) => {
this.message =
(error.response &&
error.response.data &&
error.response.data.message) ||
error.message ||
error.toString();
this.successful = false;
this.loading = false;
}
);
},
},
};
</script>
<style scoped>
...
</style>
Profile頁面
此頁面從Vuex Store獲取當前用戶并顯示信息。如果用戶未登錄,則定向到登錄頁面。
components/Profile.vue
<template>
<div class="container">
<header class="jumbotron">
<h3>
<strong>{{currentUser.username}}</strong> Profile
</h3>
</header>
<p>
<strong>Token:</strong>
{{currentUser.accessToken.substring(0, 20)}} ... {{currentUser.accessToken.substr(currentUser.accessToken.length - 20)}}
</p>
<p>
<strong>Id:</strong>
{{currentUser.id}}
</p>
<p>
<strong>Email:</strong>
{{currentUser.email}}
</p>
<strong>Authorities:</strong>
<ul>
<li v-for="role in currentUser.roles" :key="role">{{role}}</li>
</ul>
</div>
</template>
<script>
export default {
name: 'Profile',
computed: {
currentUser() {
return this.$store.state.auth.user;
}
},
mounted() {
if (!this.currentUser) {
this.$router.push('/login');
}
}
};
</script>
創(chuàng)建用于訪問資源的Vue組件
這些組件將使用UserService來請求數(shù)據(jù)。
圖片
主頁
這是一個公共頁面。
components/Home.vue
<template>
<div class="container">
<header class="jumbotron">
<h3>{{ content }}</h3>
</header>
</div>
</template>
<script>
import UserService from "../services/user.service";
export default {
name: "Home",
data() {
return {
content: "",
};
},
mounted() {
UserService.getPublicContent().then(
(response) => {
this.content = response.data;
},
(error) => {
this.content =
(error.response &&
error.response.data &&
error.response.data.message) ||
error.message ||
error.toString();
}
);
},
};
</script>
基于角色的頁面
我們有3個頁面用于訪問受保護的數(shù)據(jù):
- BoardUser頁面調用UserService.getUserBoard()
- BoardModerator頁面調用UserService.getModeratorBoard()
- BoardAdmin頁面調用UserService.getAdminBoard()
請看下面的示例。
components/BoardUser.vue
<template>
<div class="container">
<header class="jumbotron">
<h3>{{ content }}</h3>
</header>
</div>
</template>
<script>
import UserService from "../services/user.service";
export default {
name: "User",
data() {
return {
content: "",
};
},
mounted() {
UserService.getUserBoard().then(
(response) => {
this.content = response.data;
},
(error) => {
this.content =
(error.response &&
error.response.data &&
error.response.data.message) ||
error.message ||
error.toString();
}
);
},
};
</script>
定義Vue Router的路由
現(xiàn)在我們?yōu)閂ue 3應用程序定義所有路由。
src/router.js
import { createWebHistory, createRouter } from "vue-router";
import Home from "./components/Home.vue";
import Login from "./components/Login.vue";
import Register from "./components/Register.vue";
// lazy-loaded
const Profile = () => import("./components/Profile.vue")
const BoardAdmin = () => import("./components/BoardAdmin.vue")
const BoardModerator = () => import("./components/BoardModerator.vue")
const BoardUser = () => import("./components/BoardUser.vue")
const routes = [
{
path: "/",
name: "home",
component: Home,
},
{
path: "/home",
component: Home,
},
{
path: "/login",
component: Login,
},
{
path: "/register",
component: Register,
},
{
path: "/profile",
name: "profile",
// lazy-loaded
component: Profile,
},
{
path: "/admin",
name: "admin",
// lazy-loaded
component: BoardAdmin,
},
{
path: "/mod",
name: "moderator",
// lazy-loaded
component: BoardModerator,
},
{
path: "/user",
name: "user",
// lazy-loaded
component: BoardUser,
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;
向Vue app添加導航欄
這是應用程序中包含導航欄的根容器。我們要添加router-view。
src/App.vue
<template>
<div id="app">
<nav class="navbar navbar-expand navbar-dark bg-dark">
<a href="/" class="navbar-brand">bezKoder</a>
<div class="navbar-nav mr-auto">
<li class="nav-item">
<router-link to="/home" class="nav-link">
<font-awesome-icon icon="home" /> Home
</router-link>
</li>
<li v-if="showAdminBoard" class="nav-item">
<router-link to="/admin" class="nav-link">Admin Board</router-link>
</li>
<li v-if="showModeratorBoard" class="nav-item">
<router-link to="/mod" class="nav-link">Moderator Board</router-link>
</li>
<li class="nav-item">
<router-link v-if="currentUser" to="/user" class="nav-link">User</router-link>
</li>
</div>
<div v-if="!currentUser" class="navbar-nav ml-auto">
<li class="nav-item">
<router-link to="/register" class="nav-link">
<font-awesome-icon icon="user-plus" /> Sign Up
</router-link>
</li>
<li class="nav-item">
<router-link to="/login" class="nav-link">
<font-awesome-icon icon="sign-in-alt" /> Login
</router-link>
</li>
</div>
<div v-if="currentUser" class="navbar-nav ml-auto">
<li class="nav-item">
<router-link to="/profile" class="nav-link">
<font-awesome-icon icon="user" />
{{ currentUser.username }}
</router-link>
</li>
<li class="nav-item">
<a class="nav-link" @click.prevent="logOut">
<font-awesome-icon icon="sign-out-alt" /> LogOut
</a>
</li>
</div>
</nav>
<div class="container">
<router-view />
</div>
</div>
</template>
<script>
export default {
computed: {
currentUser() {
return this.$store.state.auth.user;
},
showAdminBoard() {
if (this.currentUser && this.currentUser['roles']) {
return this.currentUser['roles'].includes('ROLE_ADMIN');
}
return false;
},
showModeratorBoard() {
if (this.currentUser && this.currentUser['roles']) {
return this.currentUser['roles'].includes('ROLE_MODERATOR');
}
return false;
}
},
methods: {
logOut() {
this.$store.dispatch('auth/logout');
this.$router.push('/login');
}
}
};
</script>
使用font-awesome-icon可以使得導航欄看起來更專業(yè)。
而且導航欄還可以根據(jù)從Vuex Store state檢索到的當前用戶的roles而動態(tài)變化。
處理未經(jīng)授權的訪問
如果你想在每次觸發(fā)導航操作時檢查授權狀態(tài),只需在src/router.js中添加router.beforeEach(),如下所示:
router.beforeEach((to, from, next) => {
const publicPages = ['/login', '/register', '/home'];
const authRequired = !publicPages.includes(to.path);
const loggedIn = localStorage.getItem('user');
// trying to access a restricted page + not logged in
// redirect to login page
if (authRequired && !loggedIn) {
next('/login');
} else {
next();
}
});
為Vue App配置端口
由于大多數(shù)HTTP Server使用CORS配置,接受僅限于某些站點或端口的資源共享,因此我們還需要為App配置端口。
在項目根文件夾中,創(chuàng)建包含以下內容的vue.config.js文件:
module.exports = {
devServer: {
port: 8081
}
}
我們將app設置為運行在端口8081上。
結論
今天,我們學習了很多有趣的內容,學習了如何使用Axios、Vuex和Vue Router構建支持JWT身份驗證和授權的Vue應用程序。