【Laravel11 Sanctum】SPA認証

Laravel

Laravel11の公式パッケージSanctumを使って、SPA(Single Page Application)のAPI認証機能を実装していきます。

Laravel - The PHP Framework For Web Artisans
Laravel is a PHP web application framework with expressive, elegant syntax. We’ve already laid the foundation — freeing ...

前提条件

  • PHP8.2以降インストール済
  • Composerインストール済
  • DBはデフォルトのSQLiteを使います
  • DB ClientはSQLite3 CLIを使います
  • APIクライアントはVue3 + axiosを使います
  • Vue3、axios どちらもCDN版を使います
  • PostmanやApidog、Thunder Client等は使いません

これからやること

  • Laravel11新規プロジェクト作成
  • Sanctumインストール
  • APIクライアントのドメイン設定
  • Middleware設定
  • CORS設定
  • マイグレーション
  • コントローラー作成
  • ビュー作成
  • ルーティング
  • ビルトインサーバ起動
  • ブラウザでの動作確認

Laravel11新規プロジェクト作成

新規プロジェクト「sanctum-app」を作成します。

composer create-project laravel/laravel:^11 sanctum-app

Sanctumインストール

プロジェクトフォルダに入ります。

cd sanctum-app/

Sanctumをインストールします。

php artisan install:api

途中でマイグレーションするか訊かれるので、「yes」か「no」を選択します。

[Enter]だけ押しても構いません。(=デフォルトのyes)

APIクライアントのドメイン設定

APIへのリクエストを許可するクライアントのドメインを設定します。

「config/sanctum.php」を開いて編集します。

デフォルトでは次のようになっています。

    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
        '%s%s',
        'localhost,localhost:3000,localhost:8000,127.0.0.1,127.0.0.1:8000,::1',
        Sanctum::currentApplicationUrlWithPort()
    ))),

ここの項目「stateful」の値として、「stateful認証」を利用する

APIクライアント(SPA)のドメイン名を、

「<ドメイン名>:<ポート番号>」の形式で、

カンマ区切りの文字列で列記します。

当記事の場合は開発環境の「localhost:8000」か「127.0.0.1:8000」となるので、デフォルトのままとします。

Middleware設定

「ステートフル認証」を利用するようにMiddlewareの設定をします。

「bootstrap/app.php」を開いて編集します。

デフォルトで次のようになっている箇所を

    ->withMiddleware(function (Middleware $middleware) {
        //
    })

次のように編集します。

    ->withMiddleware(function (Middleware $middleware) {
        $middleware->statefulApi();
    })

CORS設定

APIクライアントが、API側のサブドメインである場合は

CORS (Cross-Origin Resource Sharing)の設定が必用です。

※当記事のようにすべて「localhost」で完結する環境の場合は

※このステップは無視してください。

Laravel - The PHP Framework For Web Artisans
Laravel is a PHP web application framework with expressive, elegant syntax. We’ve already laid the foundation — freeing ...

CORS設定ファイルを編集できるようにします。

php artisan config:publish cors

「config/cors.php」が生成されます。

項目「supports_credentials」の値を「true」にします。

SSRでaxiosを使う場合は、

「resources/js/bootstrap.js」に次の設定を追記します。

axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true;

最後に、「config/session.php」を編集します。

例えば、APIが「api.example.com」、

SPAが「example.com」の場合、

項目「domain」の設定を「.example.com」とします。

※共通のルートドメインの先頭にドット「.」を付けます。

    'domain' => '.example.com',

マイグレーション

ダミーのユーザーデータを作成するために、

「database/seeders/DatabaseSeeder.php」を編集します。

    public function run(): void
    {
        User::factory(10)->create();
    }

シードオプション付きでマイグレーションしなおします。

php artisan migrate:fresh --seed

コントローラー作成

4つのコントローラーを作成します。

  • API用基底コントローラー
  • ログインAPI用コントローラー
  • ユーザー一覧取得API用コントローラー
  • SPA用コントローラー

まずは基底コントローラーを作成します。

php artisan make:controller Api/BaseController

「app/Http/Controllers/Api/BaseController.php」を編集します。

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\Collection;

class BaseController extends Controller
{
    /**
     * success response method
     * 
     * @param   string                  $message
     * @param   array<string, mixed>|Collection    $data
     * @return  \Illuminate\Http\JsonResponse
     */
    public function sendResponse(string $message, array|Collection $data)
    {
        $response = [
            'success' => true,
            'message' => $message,
            'data'    => $data,
        ];

        return response()->json($response, 200);
    }

    /**
     * return error response.
     * 
     * @param   string  $message
     * @param   MessageBag|array<int|string, mixed> $errorMessages = []
     * @param   int $code = 404
     * @return  \Illuminate\Http\JsonResponse
     */
    public function sendError(
        string $message,
        MessageBag|array $data = [],
        int $code = 404
    ) {
        $response = [
            'success' => false,
            'message' => $message,
        ];

        if (!empty($data)) {
            $response['data'] = $data;
        }

        return response()->json($response, $code);
    }
}

ログインAPI用コントローラーを作成します。

php artisan make:controller Api/LoginController

「app/Http/Controllers/Api/LoginController.php」を編集します。

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Api\BaseController;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class LoginController extends BaseController
{
    /**
     * returns default response.
     * route: get('/api/login')
     *
     * @return  \Illuminate\Http\JsonResponse
     */
    public function create()
    {
        return $this->sendError(
            'Authentication Required.',
            [],
            401,
        );
    }

    /**
     * authenticate with credentials.
     * route: post('/api/login')
     *
     * @param   Request $request
     * @return  \Illuminate\Http\JsonResponse
     */
    public function login(Request $request)
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required'],
        ]);

        if (Auth::attempt($credentials)) {
            $request->session()->regenerate();
            return redirect()->intended(route('api.loggedin'));
        }

        return $this->sendError(
            'Login Failed',
            ['email' => 'The provided credentials do not match our records.'],
            401,
        );
    }

    /**
     * returns response after after login redirect.
     * route: get('/api/loggedin')
     *
     * @return  \Illuminate\Http\JsonResponse
     */
    public function loggedin()
    {
        return $this->sendResponse(
            'Logged in.',
            ['email' => 'Authenticated.'],
        );
    }
}

ユーザー一覧取得API用コントローラーを作成します。

php artisan make:controller Api/UsersController

「app/Http/Controllers/Api/UsersController.php」を編集します。

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Api\BaseController;
use App\Models\User;
use Illuminate\Http\Request;

class UsersController extends BaseController
{
    /**
     * returns list of users.
     *
     * @return  \Illuminate\Http\JsonResponse
     */
    public function index()
    {
        $users = User::all();
        return $this->sendResponse('successfully fetched.', $users);
    }
}

SPA用コントローラーを作成します。

php artisan make:controller SpaController

「app/Http/Controllers/SpaController.php」を編集します。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class SpaController extends Controller
{
    /**
     * returns spa.
     *
     * @return  \Illuminate\Http\Response
     */
    public function index()
    {
        return view('spa.index');
    }
}

ビュー作成

SPA用のビューを作成します。

php artisan make:view spa/index

「resources/views/spa/index.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">
        <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
        <title>SPA | Stateful Authentication with Sanctum</title>
        <script src="https://unpkg.com/vue-cookies@1.8.4/vue-cookies.js"></script>
        <script src="https://cdn.tailwindcss.com"></script>
        <script src="https://cdn.jsdelivr.net/npm/axios@1.6.7/dist/axios.min.js"></script>
    </head>
    <body>
        <div id="app">
            <ul class="mx-4 mt-4 px-4 py-4 border border-gray-600 rounded-md">
                <li>
                    <label class="pr-2">email</label>
                    <input type="text" v-model="email" class="border border-gray-500 rounded-sm hover:bg-yellow-200" />
                    <span
                        v-if="validation.email.length > 0"
                        class="ml-4 px-4 py-2 bg-red-200 text-red-600 text-sm"
                    >
                        @{{ validation.email }}
                    </span>
                </li>
                <li class="mt-4">
                    <label class="pr-2">password</label>
                    <input type="password" v-model="password" class="border border-gray-500 rounded-sm hover:bg-yellow-200" />
                    <span
                        v-if="validation.password.length > 0"
                        class="ml-4 px-4 py-2 bg-red-200 text-red-600 text-sm"
                    >
                        @{{ validation.password }}
                    </span>
                </li>
                <li class="mt-2 flex items-center">
                    <button
                        class="mx-2 my-2 px-2 py-1 text-white font-bold bg-green-600 hover:bg-green-500 rounded-md"
                        @click="login"
                    >
                        login
                    </button>
                    <p v-show="loginMessage.length > 0">@{{ loginMessage }}</p>
                </li>
            </ul>
            <p
                v-if="fetchUsersMessage.length > 0"
                class="ml-4 mt-2 text-green-600 text-md"
            >
                @{{ fetchUsersMessage }}
            </p>
            <ul v-if="users.length > 0" class="ml-4 mt-2">
                <li v-for="user in users" class="my-2 px-2 hover:bg-green-200">
                    @{{ user.id }}: @{{ user.name }} &lt;@{{ user.email }}&gt;
                </li>
            </ul>
        </div>
        <x-spa.index-script />
    </body>
</html>

Vue.jsを切り出したBladeテンプレートを作成します。

※CDN版Vue.js + Bladeなので見易さ重視で分割しています。

php artisan make:view components/spa/index-script

「resources/views/components/spa/index-script.blade.php」を編集します。

<script>
    Vue.createApp({
        data () {
            return {
                email: '',
                password: '',
                validation: {
                    validated: false,
                    email: '',
                    password: '',
                },
                xsrf_token: '',
                urlBase: location.protocol + '//' + location.host,
                isLoggedIn: false,
                loginMessage: '',
                users: [],
                fetchUsersMessage: '',
            };
        },
        methods: {
            // バリデーション
            validate () {
                this.validation = {
                    validated: false,
                    email: '',
                    password: '',
                };
                var error = false;
                if (!/[^\@]+\@.*[^\.]{3,}\.[^\.]{2,}/.test(this.email)) {
                    this.validation.email = '正しいemailを入力してください。';
                    error = true;
                }
                if (this.password.length < 8) {
                    this.validation.password = 'passwordは8文字以上で入力してください。';
                    error = true;
                }
                this.validation.validated = !error;
                console.log('Validated:', this.validation.validated);
            },
            // CSRF Protecton 初期化
            async initCsrfProtection () {
                const url = this.urlBase + '/sanctum/csrf-cookie';
                const res = await axios.get(url);
                // COOKIEから「XSRF-TOKEN」を取得
                this.xsrf_token = $cookies.get('XSRF-TOKEN');
            },
            // ログイン処理
            async login () {
                this.users = [];
                this.fetchUsersMessage = '';
                this.isLoggedin = false;
                // バリデーション
                this.validate();
                if (!this.validation.validated) {
                    this.loginMessage = '';
                    return;
                }
                this.loginMessage = 'ログイン処理中です';
                // CSRF Protection 初期化
                this.initCsrfProtection();
                // ログイン処理
                const url = this.urlBase + '/api/login';
                const res = await axios.post(
                    url,
                    {
                        email: this.email,
                        password: this.password,
                    }
                ).then(
                    (response) => {
                        if (response.data.success) {
                            console.log('Logged in.');
                            this.loginMessage = 'ログイン成功';
                            this.isLoggedin = true;
                            this.fetchUsers();
                        } else {
                            console.log('Login failed.');
                            this.loginMessage = 'ログイン失敗';
                        }
                    }
                ).catch(
                    (error) => {
                        console.log('Error: Login failed.');
                        this.loginMessage = 'ログイン失敗';
                        console.error(error);
                    }
                );
            },
            // ユーザー一覧取得
            async fetchUsers () {
                this.fetchUsersMessage = 'ユーザー一覧取得処理中...';
                const url = this.urlBase + '/api/users';
                const res = axios.get(url)
                    .then((response) => {
                        console.log(response);
                        if (response.data.success) {
                            console.log('Fetching users succeeded.');
                            this.fetchUsersMessage = 'ユーザー一覧取得成功';
                            this.users = response.data.data;
                        } else {
                            console.log('Fetching users failed.');
                            this.fetchUsersMessage = 'ユーザー一覧取得失敗';
                        }
                    })
                    .catch((error) => {
                        console.error(error);
                        this.fetchUsersMessage = 'ユーザー一覧取得エラー';
                    });
            },
        },
        mounted: function () {
            // axios設定
            axios.defaults.withCredentials = true;
            axios.defaults.withXSRFToken = true;
        },
    }).mount('#app');
</script>

ルーティング

「routes/app.php」を編集します。

<?php

use App\Http\Controllers\Api\LoginController;
use App\Http\Controllers\Api\UsersController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');

Route::get('/login', [LoginController::class, 'create'])->name('login');
Route::post('/login', [LoginController::class, 'login'])->name('api.login.store');
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/loggedin', [LoginController::class, 'loggedin'])->name('api.loggedin');
    Route::get('/users', [UsersController::class, 'index'])->name('api.users');
});

「routes/web.php」を編集します。

<?php

use App\Http\Controllers\SpaController;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});
Route::get('/spa', [SpaController::class, 'index'])->name('spa');

ビルトインサーバー起動

LaravelビルトインのWEBサーバーを起動します。

php artisan serve

ブラウザでの動作確認

WEBブラウザで次のURLにアクセスします。

http://127.0.0.1:8000/

Laravelデフォルトページが表示され、右上に「Log in」が表示されています。このリンクをクリックしてみます。

ログイン前のJSONが出力されています。

次のURLにアクセスしてみます。

http://127.0.0.1:8000/api/users

ログイン前のページにリダイレクトされました。

SPAにアクセスしてみます。

http://127.0.0.1:8000/spa

ログインフォームが表示されました。

何も入力せずに「login」ボタンをクリックしてみます。

バリデーションが機能しています。

存在しないユーザーのemailとパスワードを適当に入力して

「login」ボタンをクリックしてみます。

ログイン失敗しました。

今度は、DBから適当にemailをコピペします。

パスワードには「password」を入力して「login」ボタンをクリックしてみます。

ログイン成功し、ユーザー一覧取得も成功し、

表示も反映されました。

仕組みのざっくり説明

SanctumによるSPAの認証の仕組みは次のようになっています。

1.API Clientが「/sanctum/csrf-cookie」にアクセス

  → XSRF-TOKENが初期化され発行される

  → XSRF-TOKENがSESSIONに保存される

  → API Client側でクッキーに保存される

2.API ClientがAPIへアクセス

  → リクエストヘッダにXSRF-TOKENを含める

3.Sanctumでの認証

  → CORSの照合(あれば)

  → XSRF-TOKENをSESSIONと照合

  → 不整合なら route(‘login’)にリダイレクト

  → 整合ならコントローラーの処理に移行

といったところです。

ちなみに、デフォルトの設定では、

発行された「XSRF-TOKEN」は、DBの「sessions」テーブルに暗号化されて保存されます。

「sessions」テーブルの該当レコードを削除することで「XSRF-TOKEN」を無効にできます。

有効期限の設定もありますが、当記事では言及しません。

以上です。

参考サイト

▼Laravel公式

Laravel - The PHP Framework For Web Artisans
Laravel is a PHP web application framework with expressive, elegant syntax. We’ve already laid the foundation — freeing ...

▼Laravel 11 REST API Authentication using Sanctum Tutorial

Laravel 11 REST API Authentication using Sanctum Tutorial
laravel 11 sanctum API authentication example, laravel 11 rest api using sanctum, laravel 11 sanctum spa api example, la...

コメント

タイトルとURLをコピーしました