基于 Laravel Sanctum + Vuex + Vue 路由导航守卫实现 SPA 用户认证
为了简化流程,我们这里仅支持博客管理员登录并发布文章,也就是前面创建用户 ID 为 1 的测试账号,不提供普通用户的注册登录。
在基于 Laravel + Vue 构建的单页面应用中,后端可以使用 Laravel 官方提供的 Sanctum 扩展包实现 API 认证,前端可以基于 Vue Router 提供的导航守卫结合 Vuex 实现用户认证。
下面我们就在博客应用中演示这个前后端认证的实现过程。
Laravel Sanctum
首先在 Laravel 后端基于 Sanctum 实现 API 接口认证。
安装配置 Sanctum
要使用 Sanctum,需要先通过 Composer 安装这个扩展包:
composer require laravel/sanctum
然后发布对应的配置文件和数据库迁移文件:
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
运行 php artisan migrate
让数据库变更生效,不过我们这个单页面应用认证其实是用不到这张数据表的,因为前后端代码位于同一个项目中,且根域名一致,可以基于 Session + Cookie 进行认证,不需要颁发访问令牌进行 API 认证。
接下来,我们打开 config/sanctum.php
配置文件,配置要进行认证的请求域名:
'stateful' => explode(',', env(
'SANCTUM_STATEFUL_DOMAINS',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1'
)),
这里我们在本地进行测试,使用的是 127.0.0.1:8000
这个 IP + 端口号,已经在默认配置域名中了,所以无需进行额外的调整(实际项目开发需要在 .env
中通过 SANCTUM_STATEFUL_DOMAINS
配置前端应用域名,多个域名可以通过逗号分隔)。
Sanctum 认证中间件
此外还需要到 app/Http/Kernel.php
中添加如下这个 Sanctum 认证中间件到 api
中间件组:
protected $middlewareGroups = [
...
'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
...
],
];
以便基于 Session + Cookie 或者访问令牌对 API 请求进行认证。
完成上述配置后,就可以在 routes/api.php
中对需要认证的 API 路由应用基于 Sanctum 的认证中间件了:
use App\Http\Resources\User as UserResource;
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return new UserResource($request->user());
});
Route::middleware('auth:sanctum')->post('/posts/store', [PostController::class, 'store']);
这样一来,未认证的普通用户就不能通过 /api/posts/store
API 接口发布文章了。
关于 Sanctum 认证中间件的底层原理,可以参考学院君之前发布的这篇 Sanctum 使用入门教程。
实现登录/退出路由
当然了,我们还要为用户登录和退出提供入口路由,否则的话谁也不能发布文章了。为了简化流程,我们不借助 Laravel 官方提供的任何认证脚手架代码,而是自己手动实现登录和退出逻辑。
创建一个认证控制器 AuthController
:
php artisan make:controller AuthController
在其中编写登录和退出方法如下:
public function login(Request $request)
{
$credentials = $request->validate([
'email' => 'required|string',
'password' => 'required|string',
]);
if (Auth::guard()->attempt($credentials)) {
return response()->json(['success' => true]);
}
return response()->json(['success' => false]);
}
public function logout(Request $request)
{
Auth::guard()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
}
前面说了,我们这个单页面应用的场景可以基于 Session + Cookie 机制实现 API 认证,所以这里直接使用 Auth::guard()
作为认证守卫(底层对应的是 SessionGuard
),和普通的 Web 页面登录认证并无区别。
然后在 routes/web.php
中为这两个方法注册 POST 请求路由:
use App\Http\Controllers\AuthController;
Route::post('/login', [AuthController::class, 'login'])->name('login');
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
至此,我们就完成了 Laravel 后端所有的认证功能实现代码。接下来,进入前端 Vue 页面组件,实现前端认证功能实现代码。
通过 Vuex 定义认证状态管理
开始之前,我们还是先在 resources/js/api.js
中定义上述后端接口调用方法:
// 将 CSRF TOKEN 设置到 Cookie
setCsrfCookie() {
return axios.get('/sanctum/csrf-cookie');
},
// 登录认证 API
login(formData) {
return axios.post('/login', formData);
},
// 退出登录 API
logout() {
return axios.post('/logout');
},
// 获取认证用户信息 API
getUserInfo() {
return axios.get('/api/user');
}
后面三个好理解,第一个 setCsrfCookie
方法是干啥的?原来,在使用 Sanctum 进行认证之前,需要先将 CSRF 令牌设置到 Cookie,以便保护认证路由,这个请求就是干这事的,你可以参考官方文档进一步了解。
然后我们在 resources/js/stores.js
中定义用户认证状态管理代码(为了简化流程,我们将用户认证和文章数据状态混在了一起,其实可以拆分开,具体实现可以参考 Vuex 官方文档或者进阶版 Laravel + Vue 前后端分离项目中的 Vuex 模块构建教程):
export default new Vuex.Store({
state: {
...
userAuthenticated: false
},
getters: {
...
getUserAuthenticated(state) {
return state.userAuthenticated;
}
},
mutations: {
...
setUserAuthenticated(state, authenticated) {
state.userAuthenticated = authenticated;
}
},
actions: {
...
loadLoginPage() {
PostAPI.setCsrfCookie().then(resp => {
// 成功添加 CSRF TOKEN 到 Cookie
}).catch((err) => {
console.log(err)
});
},
userLogin(context, formData) {
return new Promise((resolve, reject) => {
PostAPI.login(formData).then(resp => {
if (resp.data.success === true) {
// 登录成功
context.commit('setUserAuthenticated', true);
resolve(resp);
}
reject(resp);
}).catch(err => {
reject(err);
})
});
},
userLogout(context) {
return new Promise((resolve, reject) => {
PostAPI.logout().then(resp => {
context.commit('setUserAuthenticated', false);
resolve(resp);
}).catch(err => {
reject(err);
});
});
},
loadUserAuthenticated(context) {
PostAPI.getUserInfo().then(resp => {
// 响应状态码为 200 表明用户认证成功,可以通过 resp.data 获取用户信息
context.commit('setUserAuthenticated', true);
}).catch(err => {
console.log(err);
});
}
}
});
还是老套路,state
、getters
、mutations
(setters)、actions
四件套,主要的状态变更在 actions
中实现:
- 在登录页面加载之前调用
loadLoginPage
可以添加 CSRF 令牌到 Cookie; - 调用
loadUserAuthenticated
方法会加载认证用户信息,如果用户已认证则将userAuthenticated
状态值设置为true
,我们可以在每个 Vue 页面组件创建时通过调用这个方法来初始化用户认证状态; - 调用
userLogin
方法会对/login
后端路由发送 POST 请求完成用户认证,登录成功后会将userAuthenticated
状态值设置为true
。需要注意的是,这个方法返回的是一个 Promise 对象,以便可以在调用该方法的地方自定义登录成功或失败后的回调逻辑; - 调用
userLogout
方法会对/logout
后端路由发送 POST 请求完成用户退出,退出成功后会将userAuthenticated
状态值设置为false
。和userLogin
方法一样,这个方法返回的也是一个 Promise 对象,原因和userLogin
方法一样。
做好这些准备工作后,我们需要创建一个 Vue 登录组件,并且在访问 /new
前端路由发布文章前,先对用户认证状态进行判断,如果用户处于未登录状态,则需要跳转到登录页面,登录后才能发布文章。
编写登录页面组件
先在 resources/js/components
目录下新建 Login.vue
作为登录页面组件:
<template>
<div class="bg-white shadow-md rounded mt-6 px-8 pt-6 pb-8 mb-6 flex flex-col">
<flash-message v-if="submitted" :success="authenticated">
<template slot="success">User Login Successfully! </template>
<template slot="error">User Login Failed! </template>
</flash-message>
<div class="mb-4">
<label class="block text-grey-darker text-sm font-bold mb-2">
Email
</label>
<input v-model="email" class="shadow appearance-none border rounded w-full py-2 px-3 text-grey-darker" type="email" placeholder="Email">
</div>
<div class="mb-6">
<label class="block text-grey-darker text-sm font-bold mb-2">
Password
</label>
<input v-model="password" class="shadow appearance-none border border-red rounded w-full py-2 px-3 text-grey-darker mb-3" type="password">
</div>
<div class="flex items-center justify-between">
<button @click="login" type="button" class="border border-green-500 bg-green-500 text-white rounded-md px-4 py-2 m-2 transition duration-500 ease select-none hover:bg-green-600 focus:outline-none focus:shadow-outline">
立即登录
</button>
</div>
</div>
</template>
<script>
import FlashMessage from "./common/FlashMessage";
export default {
components: {FlashMessage},
data() {
return {
email: '',
password: '',
submitted: false
}
},
created() {
this.$store.dispatch('loadLoginPage');
},
computed: {
authenticated() {
return this.$store.getters.getUserAuthenticated;
}
},
methods: {
login() {
let formData = new FormData();
formData.append('email', this.email);
formData.append('password', this.password);
this.$store.dispatch('userLogin', formData).then(resp => {
// 存储登录状态到 localStorage 以便直接通过 URL 访问可以在前端路由中识别登录状态
localStorage.setItem('authenticated', '1');
// 登录成功后跳转到首页
this.$router.push('/');
}).catch(err => {
console.log(err)
});
this.submitted = true;
}
}
}
</script>
这里面,我们也引入了 Flash 消息子组件用于告知用户登录成功还是失败,表单模板很简单,不做过多介绍了。
在登录页面 created
钩子函数中,我们调用了 loadLoginPage
通过 /sanctum/csrf-cookie
路由添加 CSRF 令牌到 Cookie。
当用户填写完登录表单点击登录按钮后,会调用 login
方法,进而触发 Vuex Store actions 中定义的 userLogin
方法发送登录请求,我们拿到这个方法返回的 Promise 进行回调处理,如果登录成功,则将登录状态存储到 localStorage,以便直接通过 URL 访问即可在 Vue Router 路由中就能拿到登录状态,然后将用户重定向到首页。
这里做了简单化处理,更严谨的做法是还需要存储用户认证 Session 过期时间(如果是基于访问令牌认证的话,则是访问令牌的过期时间),以便在拿到认证状态后基于过期时间做进一步确认,如果已过期,则该用户也处于未认证状态,不过由于后端存在这样的校验逻辑,也不会造成大的问题。
如果登录失败,则通过计算属性拿到的 authenticated
状态值为 false,会渲染 Flash 消息组件提示用户登录失败。
通过 Vue Router 导航守卫保护认证路由
在 resources/js/routes.js
中注册这个登录页面对应的路由 /login
:
{
path: '/login',
component: require('./components/Login').default,
beforeEnter: (to, from, next) => {
if (localStorage.getItem('authenticated')) {
// 已认证跳转到首页
next('/');
} else {
next();
}
}
},
这里我们使用了 Vue Router 的路由独享导航守卫对 /login
路由进行保护:如果已经认证,则跳转到首页,否则进入登录页面。
同理,我们还需要对文章发布路由 /new
进行保护:
{
path: '/new',
component: require('./components/NewPost').default,
beforeEnter: (to, from, next) => {
if (localStorage.getItem('authenticated')) {
// 已认证可以访问该路由
next();
} else {
// 否则跳转到登录页面认证
next('/login');
}
}
},
与登录路由相反,如果用户已认证,可以正常访问文章发布页面发布文章,否则会跳转到登录页面。
可以看到 Vue Router 的导航守卫和 Laravel 的认证中间件功能差不多。
之所以引入 localStorage 而不是 Vuex Store 读取用户认证状态,是因为如果直接通过 URL 访问 Vue 页面组件而不是单页面应用不同页面之间的跳转访问只能加载 Store 中的默认状态值。
基于 Vue 组件重构前端布局视图
接下来,我们需要在博客应用前端视图模板中添加用户登录入口,为了提高代码复用性,也为了统一管理用户登录退出状态,我们将之前 resources/views/app.blade.php
视图模板中的布局视图重构为基于 Vue 组件实现。
在 resources/js/components
目录下新建一个 Layout.vue
,将前端布局视图代码迁移过来:
<template>
<div class="container px-8">
<main class="flex">
<!-- 侧边栏(导航菜单) -->
<navigation :authenticated="authenticated"></navigation>
<!-- 主体内容 -->
<div class="w-4/5 pt-12 px-4 py-4">
<!-- 路由匹配到的组件将渲染在这里 -->
<router-view></router-view>
</div>
</main>
<!-- 底部内容 -->
<bottom></bottom>
</div>
</template>
<script>
import Navigation from './common/Navigation';
import Bottom from './common/Bottom';
export default {
components: {Navigation, Bottom},
created() {
this.$store.dispatch('loadUserAuthenticated');
},
computed: {
authenticated() {
return this.$store.getters.getUserAuthenticated;
}
}
}
</script>
这里将左侧导航菜单和底层版权声明拆分为两个独立的子组件,并且在组件创建时就通过 Vuex Store Action 中定义的 loadUserAuthenticated
方法加载认证用户信息,如果用户已经登录的话,则将用户认证状态设置为 true
(其实这个逻辑也可以通过 localStorage 实现,避免每次刷新页面请求后端接口,同理,为了代码严谨,也需要加上过期时间逻辑),然后我们把这个状态值作为 props 属性传递给 common
子目录下的导航组件 Navigation.vue
:
<template>
<aside class="w-1/5 pt-8">
<section class="mb-10">
<div class="flex flex-col w-full md:w-64 text-gray-700 bg-white dark-mode:text-gray-200 dark-mode:bg-gray-800 flex-shrink-0">
<div class="flex-shrink-0 px-8 py-4 flex flex-row items-center justify-between">
<img src="/images/logo.png" alt="学院君博客"/>
</div>
<nav class="flex-grow md:block px-4 pb-4 md:pb-0 md:overflow-y-auto">
<router-link class="block px-4 py-2 mt-2 text-sm font-semibold text-gray-900 bg-transparent rounded-lg dark-mode:bg-gray-700 dark-mode:hover:bg-gray-600 dark-mode:focus:bg-gray-600 dark-mode:focus:text-white dark-mode:hover:text-white dark-mode:text-gray-200 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:shadow-outline" to="/home">Home</router-link>
<router-link class="block px-4 py-2 mt-2 text-sm font-semibold text-gray-900 bg-transparent rounded-lg dark-mode:bg-transparent dark-mode:hover:bg-gray-600 dark-mode:focus:bg-gray-600 dark-mode:focus:text-white dark-mode:hover:text-white dark-mode:text-gray-200 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:shadow-outline" :to="{ name: 'category', params: { name: 'php' }}">PHP</router-link>
<router-link class="block px-4 py-2 mt-2 text-sm font-semibold text-gray-900 bg-transparent rounded-lg dark-mode:bg-transparent dark-mode:hover:bg-gray-600 dark-mode:focus:bg-gray-600 dark-mode:focus:text-white dark-mode:hover:text-white dark-mode:text-gray-200 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:shadow-outline" :to="{ name: 'category', params: { name: 'golang' }}">Golang</router-link>
<router-link class="block px-4 py-2 mt-2 text-sm font-semibold text-gray-900 bg-transparent rounded-lg dark-mode:bg-transparent dark-mode:hover:bg-gray-600 dark-mode:focus:bg-gray-600 dark-mode:focus:text-white dark-mode:hover:text-white dark-mode:text-gray-200 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:shadow-outline" :to="{ name: 'category', params: { name: 'javascript' }}">Javascript</router-link>
<router-link class="block px-4 py-2 mt-2 text-sm font-semibold text-gray-900 bg-transparent rounded-lg dark-mode:bg-transparent dark-mode:hover:bg-gray-600 dark-mode:focus:bg-gray-600 dark-mode:focus:text-white dark-mode:hover:text-white dark-mode:text-gray-200 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:shadow-outline" to="/about">About</router-link>
<router-link class="block px-4 py-2 mt-2 text-sm font-semibold text-gray-900 bg-transparent rounded-lg dark-mode:bg-transparent dark-mode:hover:bg-gray-600 dark-mode:focus:bg-gray-600 dark-mode:focus:text-white dark-mode:hover:text-white dark-mode:text-gray-200 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:shadow-outline" to="/feedback">Feedback</router-link>
<router-link class="block px-4 py-2 mt-2 text-sm font-semibold text-gray-900 bg-transparent rounded-lg dark-mode:bg-transparent dark-mode:hover:bg-gray-600 dark-mode:focus:bg-gray-600 dark-mode:focus:text-white dark-mode:hover:text-white dark-mode:text-gray-200 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:shadow-outline" to="/new">New Post</router-link>
<router-link v-if="!authenticated" class="block px-4 py-2 mt-2 text-sm font-semibold text-gray-900 bg-transparent rounded-lg dark-mode:bg-transparent dark-mode:hover:bg-gray-600 dark-mode:focus:bg-gray-600 dark-mode:focus:text-white dark-mode:hover:text-white dark-mode:text-gray-200 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:shadow-outline" to="/login">Login</router-link>
<a href="javascript:void(0);" v-else @click="logout" class="block px-4 py-2 mt-2 text-sm font-semibold text-gray-900 bg-transparent rounded-lg dark-mode:bg-transparent dark-mode:hover:bg-gray-600 dark-mode:focus:bg-gray-600 dark-mode:focus:text-white dark-mode:hover:text-white dark-mode:text-gray-200 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:shadow-outline">Logout</a>
</nav>
</div>
</section>
</aside>
</template>
<script>
export default {
props: ['authenticated'],
methods: {
logout() {
this.$store.dispatch('userLogout').then(resp => {
localStorage.removeItem('authenticated');
this.$router.push('/');
}).catch(err => {
console.log(err);
});
}
}
}
</script>
在这里,会根据父组件传递的 authenticated
属性值对登录和退出导航菜单进行条件渲染(其他菜单和之前一样,不需要做任何调整),如果用户未认证则显示登录菜单,否则显示退出菜单,点击退出菜单链接会调用 logout
方法,进而触发 Vuex Store Action 中定义的 userLogout
方法,发送用户退出请求,退出成功后,会将用户认证状态设置为 false
,同时移除 localStorage 中保存的用户认证信息。
由于 Layout
作为布局组件会被所有页面组件继承,所以这套逻辑会在所有博客应用页面生效。
最后,我们通过 Vue Router 来实现这个继承逻辑,重新组织 routes.js
中定义的路由如下即可:
export default {
mode: 'history',
linkActiveClass: 'font-bold',
routes: [
{
path: '/',
redirect: '/home',
component: require('./components/Layout').default,
children: [
{
path: '/home',
component: require('./components/Home').default,
},
{
path: '/about',
component: require('./components/About').default
},
{
path: '/feedback',
component: require('./components/Feedback').default
},
{
path: '/login',
component: require('./components/Login').default,
beforeEnter: (to, from, next) => {
if (localStorage.getItem('authenticated')) {
next('/');
} else {
next();
}
}
},
{
path: '/new',
component: require('./components/NewPost').default,
beforeEnter: (to, from, next) => {
if (localStorage.getItem('authenticated')) {
next();
} else {
next('/login');
}
}
},
{
path: '/post/:id',
name: 'post',
component: require('./components/Post').default
},
{
path: '/:name',
name: 'category',
component: require('./components/Category').default
}
]
}
]
}
将 app.blade.php
视图模板中的布局代码调整如下:
<div id="app">
<router-view></router-view>
</div>
体验用户认证功能
重新编译前端资源,访问 http://127.0.0.1:8000
,页面会跳转到 /home
路由:
可以看到,此时左侧导航栏显示的是「Login」菜单,表示用户尚未登录,此时不管点击「Login」还是「New Post」链接都会进入登录页面:
输入测试账号邮箱和密码(test@xueyuanjun.com
/password
),点击登录按钮,如果邮箱或密码不匹配,会提示登录失败:
如果匹配,则登录成功,并跳转到首页:
此时可以看到左侧导航出现了退出菜单,表示认证成功,点击「New Post」链接即可进入文章发布页面:
点击「Logout」链接,则会退出登录,回到未认证状态。
本教程完整代码可以从 Github 代码仓库获取:https://github.com/nonfu/demo-spa。
小结 & 后续计划
好了,关于 Laravel 单页面应用的认证实现我们就简单介绍到这里,到这里,我们的 Vue 单页面应用实战也告一段落了,当然,这只是万里长征的第一步而已,学院君只能是帮大家入个门,真正想要熟练掌握单页面应用开发,还是需要经过实际项目的锤炼,在解决问题中学习和成长才是最有成效的。
这个博客应用还有一个小功能,就是用户反馈表单的提交和后端处理,我这里留个作业,希望大家可以结合前面的 Vue 表单组件开发(前端)和博客项目联系我们 & 邮件发送功能实现教程(后端)自行去实现这块代码。
如果你想要更进一步,实现一个更加复杂的、模块化架构的前后端分离项目,可以参考学院君之前发布的咖啡馆项目系列教程。
当然,Laravel 全栈工程师系列还没有完,我们接下来会进入电商项目和直播项目的实战开发,敬请期待。
本博客项目还会有一个 Go 语言 Gin 框架的实现版本,等待 Go Web 编程收尾的时候发布,接下来会先填补这块空白,然后进入电商实战开发,后端还是会基于 Laravel/Gin 两套版本实现。
1 Comment
直播项目
是哪一个?是Swoole入门到实战
的聊天项目吗?