基于 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"

-w1095

运行 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);
            });
        }
    }
});

还是老套路,stategettersmutations(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 路由:

-w1250

可以看到,此时左侧导航栏显示的是「Login」菜单,表示用户尚未登录,此时不管点击「Login」还是「New Post」链接都会进入登录页面:

-w1232

输入测试账号邮箱和密码(test@xueyuanjun.com/password),点击登录按钮,如果邮箱或密码不匹配,会提示登录失败:

-w1203

如果匹配,则登录成功,并跳转到首页:

-w1211

此时可以看到左侧导航出现了退出菜单,表示认证成功,点击「New Post」链接即可进入文章发布页面:

-w1223

点击「Logout」链接,则会退出登录,回到未认证状态。

本教程完整代码可以从 Github 代码仓库获取:https://github.com/nonfu/demo-spa

小结 & 后续计划

好了,关于 Laravel 单页面应用的认证实现我们就简单介绍到这里,到这里,我们的 Vue 单页面应用实战也告一段落了,当然,这只是万里长征的第一步而已,学院君只能是帮大家入个门,真正想要熟练掌握单页面应用开发,还是需要经过实际项目的锤炼,在解决问题中学习和成长才是最有成效的。

这个博客应用还有一个小功能,就是用户反馈表单的提交和后端处理,我这里留个作业,希望大家可以结合前面的 Vue 表单组件开发(前端)和博客项目联系我们 & 邮件发送功能实现教程(后端)自行去实现这块代码。

如果你想要更进一步,实现一个更加复杂的、模块化架构的前后端分离项目,可以参考学院君之前发布的咖啡馆项目系列教程

当然,Laravel 全栈工程师系列还没有完,我们接下来会进入电商项目和直播项目的实战开发,敬请期待。

本博客项目还会有一个 Go 语言 Gin 框架的实现版本,等待 Go Web 编程收尾的时候发布,接下来会先填补这块空白,然后进入电商实战开发,后端还是会基于 Laravel/Gin 两套版本实现。


Vote Vote Cancel Collect Collect Cancel

<< 上一篇: 基于 Laravel + Vue + Vuex 实现博客应用文章发布功能

>> 下一篇: 没有下一篇了