Laravel 11 with Sanctum & Vue3
從前面兩篇 Laravel Breeze with Vue 3 use Inertia In SSR 與 Laravel Jetstream with Vue 3 use Inertia In SSR都是SSR的整合,這篇則是以單純 Laravel 與 Vue 3 的整合,加這 Sanctum 使用 cookie 的認證機制 (Sanctum uses Laravel’s cookie-based session authentication to authenticate users from your client.)。
系統環境:
- Lavavel 11
- PHP 8.2
Laravel Sanctum 認證過程說明
Laravel Sanctum 為 SPA(單頁應用程式)、行動應用程式和簡單的基於 Token(憑證) 的 API 提供了一個輕量級的身份驗證系統。 Sanctum 允許應用程式的每個使用者為其帳戶產生多個 API Token(憑證)。 這些Token(憑證)可以被授予指定允許令牌執行哪些操作的能力/範圍。
使用 Laravel 基於 cookie 的會話驗證來對用戶端的使用者進行身份驗證,下面是認證作業過程。
- 從客戶端上的 Sanctum 要求 CSRF cookie,這允許向正常端點(例如 /login)發出受 CSRF 保護的請求。
- 向正常的 Laravel / Login 登入端點發出請求(request)。
- Laravel 發出一個保存使用者會話的 cookie。
- 現在,對 API 的任何請求都包含此 cookie,因此使用者在請求(request) 的生命週期內都經過身份驗證。
建置專案
Step01: 建立一個新的 Laravel 11 專案
composer create-project laravel/laravel laravel-vue3
Step02: 設定資料庫連線
laravel 11 版本以後, 在檔案 .env 中預設的資料庫為 sqlite,可以不用修改就可以直接使用
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
若要使用 mysql 那就得修改如下
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=<DATABASE NAME>
DB_USERNAME=<DATABASE USERNAME>
DB_PASSWORD=<DATABASE PASSWORD>
Step03: 安裝 Sanctum API
透過下列的指令安裝 Laravel Sanctum
php artisan install:api
它會改寫 bootstrap\app.php。
並且它也會建立 Sanctum 的配置檔
config/sanctum.php
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];
也會加入一條 api: routes/api.php 路由
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
Step04: Sanctum 的設定
在此步驟中,我們必須配置三個位置:模型(model)、服務提供者( service provider)和身分驗證設定檔(auth config file)。 因此,您只需在這些文件中進行以下更改:在 User 模型中(model),我們加入了 Sanctum 的 HasApiTokens 類別。
app/Models/User.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasFactory, Notifiable, HasApiTokens;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
Step05: 建立 API Routes
在此步驟中,將為登入(login)、註冊(register)和取得用戶資料(user) REST API 建立 API 路由。 因此,在該文件中新增一條新路線。
routes/api.php
<?php
use App\Http\Controllers\API\AuthController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::controller(AuthController::class)->group(function(){
Route::post('register', 'register');
Route::post('login', 'login');
});
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
Step06: 建立控制器 Controller Files
在這一步中,我們建立了一個名為 BaseController 和 RegisterController 的新控制器。 在 Controllers 資料夾中建立了一個名為「API」的新資料夾,因為將擁有單獨的 API 控制器。
使用指令建立在目錄API下的兩個 contorller: BaseController & AuthController,它會註冊在 Laravel 中,切記要註冊,不然當你執行 php artisan route:list 時會出現檔案不存在的錯誤訊息。
php artisan make:controller API\BaseController
php artisan make:controller API\AuthController
app/Http/Controllers/API/BaseController.php
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class BaseController extends Controller
{
/**
* success response method.
*
* @return \Illuminate\Http\Response
*/
public function sendResponse($result, $message)
{
$response = [
'success' => true,
'data' => $result,
'message' => $message,
];
return response()->json($response, 200);
}
/**
* return error response.
*
* @return \Illuminate\Http\Response
*/
public function sendError($error, $errorMessages = [], $code = 404)
{
$response = [
'success' => false,
'message' => $error,
];
if(!empty($errorMessages)){
$response['data'] = $errorMessages;
}
return response()->json($response, $code);
}
}
app/Http/Controllers/API/AuthController.php
<?php
namespace App\Http\Controllers\API;
use Illuminate\Http\Request;
use App\Http\Controllers\API\BaseController as BaseController;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Validator;
use Illuminate\Http\JsonResponse;
class AuthController extends BaseController
{
/**
* Register api
*
* @return \Illuminate\Http\Response
*/
public function register(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'name' => 'required',
'email' => 'required|email',
'password' => 'required',
'c_password' => 'required|same:password',
]);
if($validator->fails()){
return $this->sendError('Validation Error.', $validator->errors());
}
$input = $request->all();
$input['password'] = bcrypt($input['password']);
$user = User::create($input);
$success['token'] = $user->createToken('MyApp')->plainTextToken;
$success['name'] = $user->name;
return $this->sendResponse($success, 'User register successfully.');
}
/**
* Login api
*
* @return \Illuminate\Http\Response
*/
public function login(Request $request): JsonResponse
{
if(Auth::attempt(['email' => $request->email, 'password' => $request->password])){
$user = Auth::user();
$success['token'] = $user->createToken('MyApp')->plainTextToken;
$success['name'] = $user->name;
return $this->sendResponse($success, 'User login successfully.');
}
else{
return $this->sendError('Unauthorised.', ['error'=>'Unauthorised']);
}
}
}
Step07: 執行測試 Run Application Server
正常來說,依下列指令已經可以運作,可以使用 postman 來作 API 認證測試。
// 安裝 laravel 相關套件
composer install
// 執行 Laravel 服務
php artisan serve
PostMan 測試
註冊(Register)
Authorization 不需要設定
Headers 中, 要能接受 application/json 格式
在 Boby 要將傳入後台的 request 值填入,此時 AuthController.php 中的 register 函數會新增帳號,並回傳 token 及 name。
登入帳號(Login)
Authorization 不需要設定
在 Body 中,輸入登入的帳號及密碼,送出 request 後,會回傳 token 及 name
取得帳號訊息(getLoginUser)
在 Authorization 中選擇 Bearer Token ,並填入登入成功後回傳的 token,送出 reuqest 後,就可以得到此 token 的資訊了。
安裝 Vue 及 Vite 設定 (Vue Installation and Vite Config)
設定前端(Setup Frontend)
安裝 Vue Vite plugin,可以依下列官網的指令
npm install --save-dev @vitejs/plugin-vue
但必需手動修改 vue.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
laravel({
input: [
'resources/sass/app.scss',
'resources/js/app.js',
],
refresh: true,
}),
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
},
}),
],
resolve: {
alias: {
vue: 'vue/dist/vue.esm-bundler.js',
},
},
});
預設情況下,Laravel 外掛提供了一個通用別名Aliases來幫助您開始運行並輕鬆匯入應用程式的資源:
{
'@' => '/resources/js'
}
也可以執行下列指令比較方便
// Setting up the Laravel and Vue Environment
composer require laravel/ui // 安裝 laravel/ui 的套件
php artisan ui vue // 安裝 vue 的套件
npm install
當使用 php artisan ui vue 產生前端程式碼時,在 resources/js/components/ExampleComponent.vue 下產生了一個範例元件。
安裝 Vue Router
在為登入、註冊和登入後頁面建立其他元件之前,先安裝 vue-router。當加入 Vue Router 時,登入、註冊和登入後頁面映射到路由上,讓 Vue Router 知道在哪裡渲染它們。
npm -D install vue-router
建立用於登入和註冊的元件。
在 resources/js/components 資料夾名稱中建立一個檔案 Login.vue。
<template>
<div class="container h-100">
<div class="row h-100 align-items-center">
<div class="col-12 col-md-6 offset-md-3">
<div class="card shadow sm">
<div class="card-body">
<h1 class="text-center">Login</h1>
<hr/>
<form action="javascript:void(0)" class="row" method="post">
<div class="col-12" v-if="Object.keys(validationErrors).length > 0">
<div class="alert alert-danger">
<ul class="mb-0">
<li v-for="(value, key) in validationErrors" :key="key">{{ value[0] }}</li>
</ul>
</div>
</div>
<div class="form-group col-12">
<label for="email" class="font-weight-bold">Email</label>
<input type="text" v-model="auth.email" name="email" id="email" class="form-control">
</div>
<div class="form-group col-12 my-2">
<label for="password" class="font-weight-bold">Password</label>
<input type="password" v-model="auth.password" name="password" id="password" class="form-control">
</div>
<div class="col-12 mb-2">
<button type="submit" :disabled="processing" @click="login" class="btn btn-primary btn-block">
{{ processing ? "Please wait" : "Login" }}
</button>
</div>
<div class="col-12 text-center">
<label>Don't have an account? <router-link :to="{name:'register'}">Register Now!</router-link></label>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import router from '@/router'
export default {
name:"login",
data(){
return {
auth:{
email:"",
password:""
},
validationErrors:{},
processing:false
}
},
methods:{
async login(){
this.processing = true
await axios.post('/api/login',this.auth).then(({data})=>{
// JSON.stringify() :物件變 JSON
localStorage.setItem('user',JSON.stringify(data))
router.push({name:'dashboard'})
}).catch(({response})=>{
if(response.status===422){
this.validationErrors = response.data.errors
}else{
this.validationErrors = {}
alert(response.data.message)
}
}).finally(()=>{
this.processing = false
})
},
}
}
</script>
在 resources/js/components 資料夾名稱中建立一個檔案 Register.vue 。
<template>
<div class="container h-100">
<div class="row h-100 align-items-center">
<div class="col-12 col-md-6 offset-md-3">
<div class="card shadow sm">
<div class="card-body">
<h1 class="text-center">Register</h1>
<hr/>
<form action="javascript:void(0)" @submit="register" class="row" method="post">
<div class="col-12" v-if="Object.keys(validationErrors).length > 0">
<div class="alert alert-danger">
<ul class="mb-0">
<li v-for="(value, key) in validationErrors" :key="key">{{ value[0] }}</li>
</ul>
</div>
</div>
<div class="form-group col-12">
<label for="name" class="font-weight-bold">Name</label>
<input type="text" name="name" v-model="user.name" id="name" placeholder="Enter name" class="form-control">
</div>
<div class="form-group col-12 my-2">
<label for="email" class="font-weight-bold">Email</label>
<input type="text" name="email" v-model="user.email" id="email" placeholder="Enter Email" class="form-control">
</div>
<div class="form-group col-12">
<label for="password" class="font-weight-bold">Password</label>
<input type="password" name="password" v-model="user.password" id="password" placeholder="Enter Password" class="form-control">
</div>
<div class="form-group col-12 my-2">
<label for="c_password" class="font-weight-bold">Confirm Password</label>
<input type="c_password" name="c_password" v-model="user.c_password" id="c_password" placeholder="Enter Password" class="form-control">
</div>
<div class="col-12 mb-2">
<button type="submit" :disabled="processing" class="btn btn-primary btn-block">
{{ processing ? "Please wait" : "Register" }}
</button>
</div>
<div class="col-12 text-center">
<label>Already have an account? <router-link :to="{name:'login'}">Login Now!</router-link></label>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import router from '@/router'
export default {
name:'register',
data(){
return {
user:{
name:"",
email:"",
password:"",
c_password:""
},
validationErrors:{},
processing:false
}
},
methods:{
async register(){
this.processing = true
await axios.post('/api/register',this.user).then(response=>{
localStorage.clear()
this.validationErrors = {}
console.log(response.data)
localStorage.setItem('user',JSON.stringify(response.data))
router.push({name:'dashboard'})
}).catch(({response})=>{
if(response.status===422){
this.validationErrors = response.data.errors
}else{
this.validationErrors = {}
alert(response.data.message)
}
}).finally(()=>{
this.processing = false
})
}
}
}
</script>
Layout (佈局)元件:Dashboard.vue 的版面元件
為所有經過身份驗證的頁面建立 Layout (佈局)元件。 因此,不需要在所有頁面元件中新增頁首、頁尾和任何其他元件,因此這裡建立了一個名為 Dashboard.vue 的版面元件。 在元件中,新增頁首、頁尾和路由器視圖,以便每個元件都會在此路由器視圖中呈現。
resources/js/components/layouts/Default.vue
<template>
<div>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="https://polinwei.com/laravel-11-with-sanctum/" target="_blank">PolinWEI Blog</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavDropdown">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<router-link :to="{name:'dashboard'}" class="nav-link">Home <span class="sr-only">(current)</span></router-link>
</li>
</ul>
<div class="d-flex">
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ user?.data?.name }}
</a>
<div class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="javascript:void(0)" @click="logout">Logout</a>
</div>
</li>
</ul>
</div>
</div>
</div>
</nav>
<main class="mt-3">
<router-view></router-view>
</main>
</div>
</template>
<script>
export default {
name:"default-layout",
data(){
return {
user:[]
}
},
mounted() {
this.user = JSON.parse(localStorage.getItem('user'))
},
methods:{
logout(){
setTimeout(() => {
localStorage.clear()
this.$router.push({name:"login"})
}, 150);
}
}
}
</script>
resources/js/components/Dashboard.vue
<template>
<div class="container">
<div class="row">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header">
<h3>Dashboard</h3>
</div>
<div class="card-body">
<p class="mb-0">You are logged in as <b>{{ user?.data?.name }}</b></p>
<p class="mb-0">You Detail is below<br/> <b>{{ userData }}</b></p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name:"dashboard",
data(){
return {
user:[],
userData:[]
}
},
async mounted() {
this.user = JSON.parse(localStorage.getItem('user'))
let token = this.user?.data.token;
const authHeader = {
'Authorization': 'Bearer ' + token,
'X-REQUEST-TYPE': 'axios'
}
let config = {headers:authHeader}
await axios.get('/api/user',config ).then(({data})=>{
this.userData=data;
router.push({name:'dashboard'})
}).catch(({response})=>{
if(response){
this.validationErrors = {}
alert(response?.data.message)
}
})
}
}
</script>
為這些頁面元件新增至路由器(vue-router)
新增檔案 resources/js/router/index.js
import { createWebHistory, createRouter } from 'vue-router'
/* Guest Component */
const Login = () => import('@/components/Login.vue')
const Register = () => import('@/components/Register.vue')
/* Guest Component */
/* Layouts */
const DahboardLayout = () => import('@/components/layouts/Default.vue')
/* Layouts */
/* Authenticated Component */
const Dashboard = () => import('@/components/Dashboard.vue')
/* Authenticated Component */
const routes = [
{
name: "login",
path: "/login",
component: Login,
meta: {
middleware: "guest",
title: `Login`
}
},
{
name: "register",
path: "/register",
component: Register,
meta: {
middleware: "guest",
title: `Register`
}
},
{
path: "/",
component: DahboardLayout,
meta: {
middleware: "auth"
},
children: [
{
name: "dashboard",
path: '/',
component: Dashboard,
meta: {
title: `Dashboard`
}
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes, // short for `routes: routes`
})
export default router
將 router 加入 resources/js/app.js
import './bootstrap';
import '../sass/app.scss'
import { createApp } from 'vue';
const app = createApp({});
import ExampleComponent from './components/ExampleComponent.vue';
app.component('example-component', ExampleComponent);
import Router from '@/router'
app.use(Router)
app.mount('#app');
建立 Vue 首頁
新增 resources/views/appHome.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SPA Authentication using Laravel 11 Sanctum, Vue 3 and Vite - PolinWEI Blogs</title>
<!-- Fonts -->
<link href="https://fonts.bunny.net/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
@vite(['resources/js/app.js'])
</head>
<body>
<div id="app">
<router-view></router-view>
</div>
</body>
</html>
web.php 和 api.php 路由檔案中定義路由
現在在 web.php 和 api.php 路由檔案中定義路由。 轉到路由資料夾並打開 web.php 檔案並更新以下路由:
routes/web.php
<?php
use Illuminate\Support\Facades\Route;
// use Illuminate\Support\Facades\Auth;
Route::get('/', function () {
return view('welcome');
});
Route::get('{any}', function () {
return view('appHome');
})->where('any', '.*');
Auth::routes();
routes/api.php
<?php
use App\Http\Controllers\API\AuthController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::controller(AuthController::class)->group(function(){
Route::post('register', 'register');
Route::post('login', 'login');
});
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
// 安裝 laravel 相關套件
composer install
// 執行 Laravel 服務
php artisan serve
// 安裝 npm 套件
npm install
// 執行 vite
npm run dev
或
npm run build
登入測試
http://localhost:8000/
點選 Register
點選 Login
登入後的首頁
程式碼
程式碼: https://github.com/polinwei/laravel-vue3
附註
參考: CORS and Cookies
php artisan config:publish cors
參考:
發佈留言