笔记内容转载自 AcWing 的 SpringBoot 框架课讲义,课程链接:AcWing SpringBoot 框架课。
本节实现登录与注册前端页面,并将 JWT 令牌存储在浏览器的 LocalStorage 中以实现登录状态的持久化。
1. 实现登录页面
打开我们的前端项目代码,在 src/views/user
目录下创建 account
目录,然后创建 UserAccountLoginView
和 UserAccountRegisterView
组件。
我们需要在全局存一些信息,例如每个页面都需要知道当前登录用户的信息,这就需要用到 Vue 的一个特性叫做 vuex
。在 src/store
目录下创建 user.js
:
import $ from "jquery";
export default {
state: {
// 存储的信息
id: "",
username: "",
photo: "",
jwt_token: "",
is_login: false,
},
getters: {
},
mutations: {
// 用来修改数据
updateUser(state, user) {
state.id = user.id;
state.username = user.username;
state.photo = user.photo;
state.is_login = user.is_login;
},
updateJwtToken(state, jwt_token) {
state.jwt_token = jwt_token;
},
},
actions: {
login(context, data) {
$.ajax({
url: "http://localhost:3000/user/account/login/",
type: "POST",
data: {
username: data.username,
password: data.password,
},
success(resp) {
if (resp.result === "success") {
context.commit("updateJwtToken", resp.jwt_token);
data.success(resp); // 成功后的回调函数
}
},
error(resp) {
data.error(resp); // 失败后的回调函数
},
});
},
getInfo(context, data) {
$.ajax({
url: "http://localhost:3000/user/account/info/",
type: "GET",
headers: {
// 不是固定的,是官方推荐的写法,Authorization是在我们的后端JwtAuthenticationTokenFilter类中设置的
Authorization: "Bearer " + context.state.jwt_token,
},
success(resp) {
if (resp.result === "success") {
context.commit("updateUser", {
...resp,
is_login: true,
});
data.success(resp);
}
},
error(resp) {
data.error(resp);
},
});
},
},
modules: {
},
};
然后需要将其引入到 store
目录下的 index.js
中:
import {
createStore } from "vuex";
import ModuleUser from "./user";
export default createStore({
state: {
},
getters: {
},
mutations: {
},
actions: {
},
modules: {
user: ModuleUser,
},
});
现在就可以实现我们的登录前端页面 UserAccountLoginView
:
<template>
<div class="container">
<div class="card" style="margin-top: 20px;">
<div class="card-header">
<h3 style="display: inline-block;">Login</h3>
<div style="float: right; height: 2.5rem; line-height: 2.5rem;">
<span>还没有账号?</span>
<router-link :to="{ name: 'user_account_register' }" style="text-decoration: none;">
去注册 >
</router-link>
</div>
<div style="clear: both;"></div>
</div>
<div class="card-body">
<div class="row justify-content-md-center">
<div class="col-md-5">
<div class="card" style="margin: 6rem auto; box-shadow: 5px 5px 20px #aaa;">
<div class="card-header text-center">
<h1>用户登录</h1>
</div>
<div class="card-body">
<div class="row justify-content-md-center">
<div class="col col-md-8">
<!-- @submit后的prevent是阻止掉submit的默认行为,防止组件间的向上或向下传递 -->
<form style="margin: 1rem;" @submit.prevent="login">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input v-model="username" type="text" class="form-control" id="username" placeholder="请输入用户名" />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input v-model="password" type="password" class="form-control" id="password" placeholder="请输入密码" />
</div>
<div style="font-size: 1rem; color: red;">
{
{ error_message }}
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 10px;">
登录
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import {
useStore } from "vuex";
import {
ref } from "vue";
import router from "@/router/index";
export default {
setup() {
const store = useStore();
let username = ref("");
let password = ref("");
let error_message = ref("");
const login = () => {
error_message.value = "";
store.dispatch("login", {
// 使用dispatch调用store的actions中的函数
username: username.value, // ref变量取值用.value
password: password.value,
success(resp) {
// actions中的回调函数会返回resp
console.log(resp);
store.dispatch("getInfo", {
success(resp) {
console.log(resp);
router.push({
name: "home" }); // 跳转至home页面
},
});
},
error(resp) {
console.log(resp);
error_message.value = "The username or password is wrong!";
},
});
};
return {
username,
password,
error_message,
login,
};
},
};
</script>
<style scoped></style>
我们的导航栏也要根据登录状态显示不同的内容,可以用 v-if
和 v-else
来根据条件决定是否显示内容:
<template>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<router-link class="navbar-brand" :to="{ name: 'home' }">King of Bots</router-link>
<div class="collapse navbar-collapse" id="navbarText">
...
<ul class="navbar-nav" v-if="$store.state.user.is_login">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{
{ $store.state.user.username }}
</a>
<ul class="dropdown-menu">
<li>
<router-link class="dropdown-item" :to="{ name: 'user_mybots_index' }">My Bots</router-link>
</li>
<li><hr class="dropdown-divider" /></li>
<li><a class="dropdown-item" href="#">退出</a></li>
</ul>
</li>
</ul>
<ul class="navbar-nav" v-else>
<li class="nav-item">
<router-link :class="route_name == 'user_account_login' ? 'nav-link active' : 'nav-link'" :to="{ name: 'user_account_login' }">登录</router-link>
</li>
<li class="nav-item">
<router-link :class="route_name == 'user_account_register' ? 'nav-link active' : 'nav-link'" :to="{ name: 'user_account_register' }">注册</router-link>
</li>
</ul>
</div>
</div>
</nav>
</template>
<script>
import {
useRoute } from "vue-router";
import {
computed } from "vue";
export default {
setup() {
const route = useRoute();
let route_name = computed(() => route.name);
return {
route_name,
};
},
};
</script>
<style scoped></style>
还有别忘了更新路由,即 src/router
目录下的 index.js
:
import {
createRouter, createWebHistory } from "vue-router";
import PKIndexView from "@/views/pk/PKIndexView";
import RecordIndexView from "@/views/record/RecordIndexView";
import RanklistIndexView from "@/views/ranklist/RanklistIndexView";
import MyBotsIndexView from "@/views/user/mybots/MyBotsIndexView";
import NotFoundView from "@/views/error/NotFoundView";
import UserAccountLoginView from "@/views/user/account/UserAccountLoginView";
import UserAccountRegisterView from "@/views/user/account/UserAccountRegisterView";
const routes = [
...
{
path: "/user/account/login/",
name: "user_account_login",
component: UserAccountLoginView,
},
{
path: "/user/account/register/",
name: "user_account_register",
component: UserAccountRegisterView,
},
...
];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;
2. 实现退出登录功能
在上一节中我们没有实现退出登录的后端 API,我们的 jwt token
完全是存在用户本地的,令牌中会存有过期时间,服务器端能够判断令牌是否过期,因此不用管后端的退出登录。那么如果用户想自己退出登录也很简单,直接将 jwt token
删除即可,无需向后端发送请求,没有令牌后就无法访问后端服务器了。
现在我们是将令牌存在浏览器的内存中,一刷新自动就会重置,之后我们会将其存到 LocalStorage 中,这样即使用户刷新或者关闭浏览器都不会自动退出登录状态。
我们先来实现主动退出登录功能,在 store
目录的 user.js
中添加清空 state
的操作:
import $ from "jquery";
export default {
state: {
// 存储的信息
id: "",
username: "",
photo: "",
jwt_token: "",
is_login: false,
},
getters: {
},
mutations: {
// 用来修改数据
...
clearState(state) {
state.id = "";
state.username = "";
state.photo = "";
state.jwt_token = "";
state.is_login = false;
},
},
actions: {
...
logout(context) {
context.commit("clearState");
},
},
modules: {
},
};
然后在 NavBar
中调用函数:
<template>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<router-link class="navbar-brand" :to="{ name: 'home' }">King of Bots</router-link>
<div class="collapse navbar-collapse" id="navbarText">
...
<ul class="navbar-nav" v-if="$store.state.user.is_login">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{
{ $store.state.user.username }}
</a>
<ul class="dropdown-menu">
<li>
<router-link class="dropdown-item" :to="{ name: 'user_mybots_index' }">My Bots</router-link>
</li>
<li><hr class="dropdown-divider" /></li>
<li><a class="dropdown-item" href="#" @click="logout">退出</a></li>
</ul>
</li>
</ul>
...
</div>
</div>
</nav>
</template>
<script>
import {
useRoute } from "vue-router";
import {
computed } from "vue";
import {
useStore } from "vuex";
import router from "@/router/index";
export default {
setup() {
const route = useRoute();
let route_name = computed(() => route.name);
const store = useStore();
const logout = () => {
store.dispatch("logout");
router.push({
name: "home" }); // 跳转至home页面
};
return {
route_name,
logout,
};
},
};
</script>
<style scoped></style>
3. 设置前端页面授权机制
现在我们的前端页面还没有任何的访问限制,例如在未登录状态下也可以访问任意的页面。当未登录时访问任何页面都应该重定向到登录页面。
页面的授权控制可以在 router
中通过 beforeEach
函数实现,当我们每次在通过 router
进入某个页面之前都会先调用该函数,函数有三个参数:to
表示要跳转到哪个页面,from
表示从哪个页面跳转过去,next
表示页面执行的下一步跳转操作。
我们每次在跳转到某个页面之前需要先判断一下该页面是否需要登录,如果需要登录且当前处于未登录状态则跳转至登录页面。因此我们就需要在每个页面中存储是否需要授权的信息,可以定义在任意名字的变量中,一般可以把额外信息放在 meta
域中。
修改后的 router/index.js
如下:
import {
createRouter, createWebHistory } from "vue-router";
import PKIndexView from "@/views/pk/PKIndexView";
import RecordIndexView from "@/views/record/RecordIndexView";
import RanklistIndexView from "@/views/ranklist/RanklistIndexView";
import MyBotsIndexView from "@/views/user/mybots/MyBotsIndexView";
import NotFoundView from "@/views/error/NotFoundView";
import UserAccountLoginView from "@/views/user/account/UserAccountLoginView";
import UserAccountRegisterView from "@/views/user/account/UserAccountRegisterView";
import store from "@/store/index";
const routes = [
{
path: "/",
name: "home",
redirect: "/pk/", // 如果是根路径则重定向到对战页面
meta: {
requestAuth: true,
},
},
{
path: "/pk/",
name: "pk_index",
component: PKIndexView,
meta: {
requestAuth: true,
},
},
{
path: "/record/",
name: "record_index",
component: RecordIndexView,
meta: {
requestAuth: true,
},
},
{
path: "/ranklist/",
name: "ranklist_index",
component: RanklistIndexView,
meta: {
requestAuth: true,
},
},
{
path: "/user/mybots/",
name: "user_mybots_index",
component: MyBotsIndexView,
meta: {
requestAuth: true,
},
},
{
path: "/user/account/login/",
name: "user_account_login",
component: UserAccountLoginView,
meta: {
requestAuth: false,
},
},
{
path: "/user/account/register/",
name: "user_account_register",
component: UserAccountRegisterView,
meta: {
requestAuth: false,
},
},
{
path: "/404/",
name: "404",
component: NotFoundView,
meta: {
requestAuth: false,
},
},
{
path: "/:catchAll(.*)",
name: "others",
redirect: "/404/", // 如果不是以上路径之一说明不合法,重定向到404页面
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to, from, next) => {
if (to.meta.requestAuth && !store.state.user.is_login) {
alert("Please login!");
next({
name: "user_account_login" });
} else {
next(); // 如果不需要授权就直接跳转即可
}
});
export default router;
4. 实现注册页面
注册页面 UserAccountRegisterView
的实现其实就和登录页面基本一致,多加一个确认密码输入框即可。注册时不会修改前端的 state
值,因此也无需将 register
函数实现在 store/user.js
中:
<template>
<div class="container">
<div class="card" style="margin-top: 20px;">
<div class="card-header">
<h3 style="display: inline-block;">Login</h3>
<div style="float: right; height: 2.5rem; line-height: 2.5rem;">
<span>还没有账号?</span>
<router-link :to="{ name: 'user_account_login' }" style="text-decoration: none;">
去登录 >
</router-link>
</div>
<div style="clear: both;"></div>
</div>
<div class="card-body">
<div class="row justify-content-md-center">
<div class="col-md-5">
<div class="card" style="margin: 6rem auto; box-shadow: 5px 5px 20px #aaa;">
<div class="card-header text-center">
<h1>用户注册</h1>
</div>
<div class="card-body">
<div class="row justify-content-md-center">
<div class="col col-md-8">
<!-- @submit后的prevent是阻止掉submit的默认行为,防止组件间的向上或向下传递 -->
<form style="margin: 1rem;" @submit.prevent="register">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input v-model="username" type="text" class="form-control" id="username" placeholder="请输入用户名" />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input v-model="password" type="password" class="form-control" id="password" placeholder="请输入密码" />
</div>
<div class="mb-3">
<label for="confirmedPassword" class="form-label">Confirmed Password</label>
<input v-model="confirmedPassword" type="password" class="form-control" id="confirmedPassword" placeholder="请再次输入密码" />
</div>
<div style="font-size: 1rem; color: red;">
{
{ error_message }}
</div>
<div class="success_message" style="font-size: 1rem; color: green;">
{
{ success_message }}
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 10px;">
注册
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import $ from "jquery";
import {
ref } from "vue";
import {
useStore } from "vuex";
import router from "@/router/index";
export default {
setup() {
const store = useStore();
let username = ref("");
let password = ref("");
let confirmedPassword = ref("");
let error_message = ref("");
let success_message = ref("");
const register = () => {
error_message.value = "";
$.ajax({
url: "http://localhost:3000/user/account/register/",
type: "POST",
data: {
username: username.value,
password: password.value,
confirmedPassword: confirmedPassword.value,
},
success(resp) {
console.log(resp);
if (resp.result === "success") {
success_message.value = "Success! Go to home page after 3 seconds...";
$(".success_message").fadeIn(); // 渐变出现注册成功的提示
setTimeout(() => {
// 2秒后将注册成功的提示渐变消去
$(".success_message").fadeOut();
}, 2000);
setTimeout(() => {
// 3秒后自动登录并跳转至首页,此处计时与上面同时进行
success_message.value = "";
store.dispatch("login", {
username: username.value,
password: password.value,
success() {
store.dispatch("getInfo", {
success() {
router.push({
name: "home" }); // 跳转至home页面
},
});
},
});
}, 3000);
} else {
error_message.value = resp.result;
}
},
error(resp) {
console.log(resp);
},
});
};
return {
username,
password,
confirmedPassword,
error_message,
success_message,
register,
};
},
};
</script>
<style scoped></style>
5. 登陆状态的持久化
我们可以将登录后获得的 jwt token
存放在浏览器的一小块硬盘空间 LocalStorage 中,首先在 store/user.js
中修改:
import $ from "jquery";
export default {
...
actions: {
login(context, data) {
$.ajax({
url: "http://localhost:3000/user/account/login/",
type: "POST",
data: {
username: data.username,
password: data.password,
},
success(resp) {
if (resp.result === "success") {
localStorage.setItem("jwt_token", resp.jwt_token); // 将令牌存到LocalStorage中实现登录状态持久化
context.commit("updateJwtToken", resp.jwt_token);
data.success(resp); // 成功后的回调函数
}
},
error(resp) {
data.error(resp); // 失败后的回调函数
},
});
},
getInfo(context, data) {
$.ajax({
url: "http://localhost:3000/user/account/info/",
type: "GET",
async: false,
...
});
},
logout(context) {
localStorage.removeItem("jwt_token");
context.commit("clearState");
},
},
modules: {
},
};
我们在 login
函数中将登录成功后收到的 jwt token
存在 LocalStorage 中,在 logout
函数中清除 LocalStorage 中的 jwt token
。需要特别注意的是 getInfo
函数中添加了 async: false
,这是表示将该 Ajax 请求变为同步的,具体作用在之后讲解。
现在当我们要跳转到某个链接前可以先取出 LocalStorage 中的 jwt token
,判断是否存在并且未过期,如果有效则在跳转之前直接调用 store/user.js
中的 updateJwtToken
更新浏览器内存中的 jwt token
,并通过 getInfo
函数更新用户信息。还是在 router/index.js
中的 router.beforeEach
函数中实现:
import {
createRouter, createWebHistory } from "vue-router";
import PKIndexView from "@/views/pk/PKIndexView";
import RecordIndexView from "@/views/record/RecordIndexView";
import RanklistIndexView from "@/views/ranklist/RanklistIndexView";
import MyBotsIndexView from "@/views/user/mybots/MyBotsIndexView";
import NotFoundView from "@/views/error/NotFoundView";
import UserAccountLoginView from "@/views/user/account/UserAccountLoginView";
import UserAccountRegisterView from "@/views/user/account/UserAccountRegisterView";
import store from "@/store/index";
const routes = [
...
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to, from, next) => {
const jwt_token = localStorage.getItem("jwt_token");
let jwt_token_valid = false; // jwt_token是否存在且有效
if (jwt_token) {
// jwt_token存在
store.commit("updateJwtToken", jwt_token);
store.dispatch("getInfo", {
success() {
// jwt_token有效
jwt_token_valid = true;
},
error() {
alert("Invalid token! Please login!");
store.dispatch("logout"); // 清除浏览器内存和LocalStorage中的jwt_token
next({
name: "user_account_login" });
},
});
}
if (to.meta.requestAuth && !store.state.user.is_login && !jwt_token_valid) {
alert("Please login!");
next({
name: "user_account_login" });
} else {
next(); // 如果不需要授权就直接跳转即可
}
});
export default router;
注意,在第一个 if
语句中调用了 store
的 getInfo
函数,由于 Ajax 的回调函数默认是异步的,因此第二个 if
语句会在 success
回调函数执行前就被执行了,这会导致 jwt_token_valid
还没被更新,从而被判断成未登录状态,直接跳转至登录页面,所以我们在前面将 getInfo
函数中的 Ajax 设置为同步,保证了以上代码的正确执行逻辑。