Create SPA authentication Using Laravel Sanctum and Vue.js
In this guide, we will focus on SPA authentication in a simple Vue.js app using Laravel Sanctum. Laravel Sanctum provides a featherweight authentication system for SPAs (single page applications), mobile applications, and simple, token-based APIs.
How does Laravel Sanctum work?
Laravel Sanctum utilizes Laravel's cookie-based session authentication to verify users. Here's the workflow:
First, request a CSRF cookie from Sanctum, which permits you to make CSRF-protected requests to normal endpoints.
Now, request the
/login
endpoint. It issues a cookie that has the user's session.Any requests to API now include this cookie, so the user is authenticated for the lifetime of that session.
Create a Laravel Project
Create a new Laravel project by running either of the following commands on your terminal:
laravel new [name]
# or
composer create-project — prefer-dist laravel/laravel [name]
Run the following command to serve Laravel locally.
php artisan serve
In order to authenticate, your SPA and API must share the same top-level domain. However, they may be placed on different subdomains.
So, here don't use 127.0.0.1
, use localhost
instead. We will use this later on when we configure our Sanctum domains and CORS origins.
Now, add database credentials in the .env
file as showing below.
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_sanctum
DB_USERNAME=root
DB_PASSWORD=root
Authentication with laravel/ui
laravel/ui
package is optional you can skip this. I am using this because it is going to create quick authentication scaffolding that will save some time.
Run the following command to install laravel/ui
package:
composer require laravel/ui
Then generate the authentication scaffolding:
php artisan ui bootstrap --auth
Install Laravel Sanctum
Run the following command to install laravel/sanctum
package:
composer require laravel/sanctum
Now publish the configuration files and migrations.
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
Run the migrations:
php artisan migrate
Add Sanctum’s Middleware
Now add the EnsureFrontendRequestsAreStateful
middleware to your api
middleware group, in app/Http/Kernel.php
.
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
'api' => [
EnsureFrontendRequestsAreStateful::class,
'throttle:60,1',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
This ensures that requests made to API can utilize session cookies since that is the way Sanctum authenticates when making requests.
Configure Sanctum
Open up the config/sanctum.php
file. It's important that we set the stateful key to contain a list of domains that we're accepting authenticated requests from.
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,127.0.0.1')),
Luckily, localhost
is already in there, so we're all set. You should change this when deploying to production, so you must add SANCTUM_STATEFUL_DOMAINS
to your .env
file with a comma-separated list of allowed domains.
Change the session driver
In .env
, update your session driver to cookie.
SESSION_DRIVER=cookie
Configure CORS
Head over to your config/cors.php
config file and update the paths to look like this:
'paths' => [
'api/*',
'/login',
'/logout',
'/sanctum/csrf-cookie'
],
Also, set the supports_credentials
option to true
.
'supports_credentials' => true
Sanctum middleware
Right now, in routes/api.php
, we have the auth:api
middleware set for the example API route Laravel provides. This won't do, we'll need Sanctum to get the session cookie and to validate if a user is authenticated or not.
So, use the auth:sanctum
middleware instead:
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
Install Vue CLI and create a project
If you don't already have the Vue CLI installed, it's simple:
npm install -g @vue/cli
Then create a new project.
vue create app-name
Once that's installed, go into the Vue project directory and run the npm run serve
command to get your Vue up and running.
Notice we're on localhost
once more. Our Laravel API and Vue app match up so we shouldn't run into any issues.
Install Bootstrap in the Vue App
Run the following command to install bootstrap
, jquery
and popper.js
:
npm install jquery popper.js bootstrap
Now, import bootstrap in src/main.js
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import "bootstrap";
import "bootstrap/dist/css/bootstrap.min.css";
Vue.config.productionTip = false;
new Vue({
router,
render: h => h(App)
}).$mount("#app");
In the above snippet, you may have noticed that it has imported ./router
, which is not created yet. So, our next step is to create routes.
Create src/router/index.js
and add the following snippet in that file.
import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../views/Home.vue";
import Login from "../views/Login.vue";
import Register from "../views/Register.vue";
import Dashboard from "../views/Dashboard.vue";
Vue.use(VueRouter);
const routes = [
{
path: "/",
name: "Home",
component: Home
},
{
path: "/login",
name: "Login",
component: Login,
meta: { guestOnly: true }
},
{
path: "/register",
name: "Register",
component: Register,
meta: { guestOnly: true }
},
{
path: "/dashboard",
name: "Dashboard",
component: Dashboard,
meta: { authOnly: true }
}
];
const router = new VueRouter({
mode: "history",
base: process.env.BASE_URL,
routes
});
function isLoggedIn() {
return localStorage.getItem("auth");
}
router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.authOnly)) {
// this route requires auth, check if logged in
// if not, redirect to login page.
if (!isLoggedIn()) {
next({
path: "/login",
query: { redirect: to.fullPath }
});
} else {
next();
}
} else if (to.matched.some(record => record.meta.guestOnly)) {
// this route requires auth, check if logged in
// if not, redirect to login page.
if (isLoggedIn()) {
next({
path: "/dashboard",
query: { redirect: to.fullPath }
});
} else {
next();
}
} else {
next(); // make sure to always call next()!
}
});
export default router;
In this route, we have routes for home, login, register, and dashboard. So, our next step is to create components for these pages.
Create the Home component
Clear our the views/Home.vue
component, so you just have a plain homepage.
<template>
<div>
Home
</div>
</template>
<script>
export default {
name: 'Home',
components: {
//
}
}
</script>
Create a Login in page
Create a new file, views/Login.vue
with a simple login-in form.
<template>
<div class="home col-5 mx-auto py-5 mt-5">
<h1 class="text-center">Login</h1>
<div class="card">
<div class="card-body">
<div class="form-group">
<label for="email">Email address:</label>
<input
type="email"
v-model="form.email"
class="form-control"
id="email"
/>
<span class="text-danger" v-if="errors.email">
{{ errors.email[0] }}
</span>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input
type="password"
v-model="form.password"
class="form-control"
id="password"
/>
<span class="text-danger" v-if="errors.password">
{{ errors.password[0] }}
</span>
</div>
<button @click.prevent="login" class="btn btn-primary btn-block">
Login
</button>
</div>
</div>
</div>
</template>
<script>
import User from "../apis/User";
export default {
data() {
return {
form: {
email: "",
password: ""
},
errors: []
};
},
methods: {
login() {
User.login(this.form)
.then(() => {
this.$root.$emit("login", true);
localStorage.setItem("auth", "true");
this.$router.push({ name: "Dashboard" });
})
.catch(error => {
if (error.response.status === 422) {
this.errors = error.response.data.errors;
}
});
}
}
};
</script>
Create a Register page
Create a new file, views/Register.vue
with a simple register form.
<template>
<div class="home col-5 mx-auto py-5 mt-5">
<h1 class="text-center">Register</h1>
<div class="card">
<div class="card-body">
<div class="form-group">
<label for="name">Name:</label>
<input
type="text"
v-model="form.name"
class="form-control"
id="name"
/>
<span class="text-danger" v-if="errors.name">
{{ errors.name[0] }}
</span>
</div>
<div class="form-group">
<label for="email">Email address:</label>
<input
type="email"
v-model="form.email"
class="form-control"
id="email"
/>
<span class="text-danger" v-if="errors.email">
{{ errors.email[0] }}
</span>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input
type="password"
v-model="form.password"
class="form-control"
id="password"
/>
<span class="text-danger" v-if="errors.password">
{{ errors.password[0] }}
</span>
</div>
<div class="form-group">
<label for="password_confirmation">Confirm Password:</label>
<input
type="password"
v-model="form.password_confirmation"
class="form-control"
id="password_confirmation"
/>
<span class="text-danger" v-if="errors.password_confirmation">
{{ errors.password_confirmation[0] }}
</span>
</div>
<button
type="submit"
@click.prevent="register"
class="btn btn-primary btn-block"
>
Register
</button>
</div>
</div>
</div>
</template>
<script>
import User from "../apis/User";
export default {
data() {
return {
form: {
name: "",
email: "",
password: "",
password_confirmation: ""
},
errors: []
};
},
methods: {
register() {
User.register(this.form)
.then(() => {
this.$router.push({ name: "Login" });
})
.catch(error => {
if (error.response.status === 422) {
this.errors = error.response.data.errors;
}
});
}
}
};
</script>
Create a Dashboard page
Clear our the views/Dashboard.vue
component, so you just have a dashboard page.
<template>
<div class="home col-8 mx-auto py-5 mt-5">
<h1>Dashboard</h1>
<div class="card">
<div class="card-body" v-if="user">
<h3>Hello, {{ user.name }}</h3>
<span>{{ user.email }}</span>
</div>
</div>
</div>
</template>
<script>
import User from "../apis/User";
export default {
data() {
return {
user: null
};
},
mounted() {
User.auth().then(response => {
this.user = response.data;
});
}
};
</script>
Handling API
While building up a web application, we need to make some API calls to get or update the data we are using. Typically, the calls are called directly into the code. It might happen that in another file we need to do precisely the same request so we simply use the same code. This a common situation with repetitive code. So if something changed in the server, we need to update two functions in two files: that is inconvenient.
For a better solution, create a src/apis/Api.js
file and add the following code:
import axios from "axios";
let Api = axios.create({
baseURL: "http://localhost:8000/api"
});
Api.defaults.withCredentials = true;
export default Api;
Next, create a src/apis/Csrf.js
file and the following code:
import Api from "./Api";
import Cookie from "js-cookie";
export default {
getCookie() {
let token = Cookie.get("XSRF-TOKEN");
if (token) {
return new Promise(resolve => {
resolve(token);
});
}
return Api.get("/csrf-cookie");
}
};
Next, to handle user APIs, create src/apis/User.js
import Api from "./Api";
import Csrf from "./Csrf";
export default {
async register(form) {
await Csrf.getCookie();
return Api.post("/register", form);
},
async login(form) {
await Csrf.getCookie();
return Api.post("/login", form);
},
async logout() {
await Csrf.getCookie();
return Api.post("/logout");
},
auth() {
return Api.get("/user");
}
};
Finally, we have created the SPA app with Vue.js and Laravel Sanctum. Run npm run serve
command and try this app in your browser.
Hello, I have followed your tutorial and almost set up on my local too. But I got a error because I am using laravel 8 and vue 3 and bootstrap 5. I manage to fix other error except one - which is as below on console.
Uncaught TypeError: this.$root.$on is not a function
on Navigation.vue because $on is removed on vue 3 hereand another warning on console is
I hope you will help me out. regards.