Laravel 11 with Sanctum & Vue3

從前面兩篇 Laravel Breeze with Vue 3 use Inertia In SSRLaravel 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

參考:

Laravel 11 REST API Authentication using Sanctum Tutorial

SPA Authentication using Laravel 9 Sanctum, Vue 3 and Vite

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *