人気のPHP WEBアプリケーションフレームワークLaravelのTipsを記録していきます

Laravel Sanctum によるSPA認証とLaravelサーバとフロントエンドのローカルサーバーを別IPでのテスト

● Laravel アプリの初期化と Sanctum のインストール

前提条件

フロントエンドとサーバーサイドで別サーバーを立てる場合は同じトップレベルドメインにつ所属している必要があります。
つまりフロントエンドだけ http://127.0.0.1 といった環境では sanctum の認証はうまく動作しません。
token error (The MAC is invalid) となります。

うまくいく例:
サーバーサイド : api.test.com
フロントエンド : local.test.com

Laravel Sanctum には 「1.APIトークン認証」「2. SPA認証(セッション+クッキー)」の2つの認証機能があります。
今回は 2. SPA認証を実装してテストしてみます

composer  create-project laravel/laravel my_app
cd my_app
# 以下 sanctum のインストール
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate


● apiミドルウェアに認証チェックを追加

これにより /api/xxxxx のすべてのURLに認証チェックが入ることになります。

app/Http/Kernel.php:42

        'api' => [
            \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,  // ● Laravel Sanctum 追加
            'throttle:api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],


● CORの設定

config/cors.php 次の設定を変更します

    'paths' => ['api/*', 'sanctum/csrf-cookie', 'api-register', 'api-login', 'api-logout'], // ● 3つのエンドポイントを追加
    'allowed_origins_patterns' => ['/localhost:?[0-9]*/'],  // ● localhost:3000 追加
    'supports_credentials' => true,     // ● trueに変更


● セッションCookieの設定

config/session.php:158 次の設定を確認します

    'domain' => env('SESSION_DOMAIN', null),

.envファイルの SESSION_DOMAIN の値を読み取る設定となっているので、 .env の設定を変更します。

サブドメインに対応するために先頭を . にします
例: dev.myhost.com の場合

.env

# config/session.php の SESSION 設定
SESSION_DOMAIN=".myhost.com"


● sanctumの設定

config/sanctum.php:16 の次の設定を確認します

    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
        '%s%s',
        'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
        env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : ''
    ))),

これは次のホスト名をファーストパーティーとして認識させます

localhost
localhost:3000
127.0.0.1
127.0.0.1:8000
::1
env('APP_URL')

開発や実際に動作させるサーバー名がこちらのリスト↑ にない場合は追加するか、 .env の5行目の APP_URL を変更します


● 「ユーザー登録API」「ログインAPI」の作成

app/Http/Controllers/ApiAuthController.php を以下の内容で作成

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Models\User;
use \Symfony\Component\HttpFoundation\Response;

class ApiAuthController extends Controller
{
    public function register(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'name'        => 'required',
            'email'       => 'required|email',
            'password'    => 'required'
        ]);

        if ($validator->fails()) {
            return response()->json('validation error', Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        User::create([
            'name' =>  $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);

        return response()->json('User registration completed', Response::HTTP_OK);
    }

    public function login(Request $request)
    {
        $credentials = $request->validate([
            'email' => 'required|email',
            'password' => 'required'
        ]);

        // ● cookie
        if (Auth::attempt($credentials)) {
            $request->session()->regenerate();
            return new JsonResponse(['message' => 'ログインしました']);
        }
        if ( env('APP_ENV') === 'local' ){
            $email = $request->get('email');
            $password = $request->get('password');
            return response()->json("User Not Found or password don't match. (email:{$email})(password:{$password}) ", Response::HTTP_INTERNAL_SERVER_ERROR);
        }
        else {
            return response()->json("User Not Found or password don't match.", Response::HTTP_INTERNAL_SERVER_ERROR);
        }

        // ● token
        // if (Auth::attempt($credentials)) {
        //     $user = User::whereEmail($request->email)->first();

        //     $user->tokens()->delete();
        //     $token = $user->createToken("login:user{$user->id}")->plainTextToken;

        //     return response()->json(['token' => $token], Response::HTTP_OK);
        // }
        // return response()->json("User Not Found or password don't match.", Response::HTTP_INTERNAL_SERVER_ERROR);
    }
}

routes/web.php に以下を追加

// (SPAクッキー認証)ユーザー登録 / ログイン / ログアウト
Route::post('/api-register', [\App\Http\Controllers\ApiAuthController::class, 'register']);
Route::post('/api-login'   , [\App\Http\Controllers\ApiAuthController::class, 'login']);
Route::post('/api-logout'   , [\App\Http\Controllers\ApiAuthController::class, 'logout']);


● 検証用の Vue.js (cli)ファイルを作成

公開フォルダーに検証用のhtml , js ファイルを置きます。 Vue.js と axios を使用して検証します。

public/test/test-login.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
</head>
<body>
    <div id="app">
        <mycomponent></mycomponent>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/es6-promise@4/dist/es6-promise.auto.min.js"></script>
    <script src="./test-api.js"></script>
    <script>
        new Vue({
            el: '#app'
        });
    </script>
</body>
</html>

public/test/test-api.js

Vue.component('mycomponent', {
    data: function () {
        return {
            user: {},
            userState: 'ログインチェック前'
        }
    },
    mounted: function () {
        var self = this;
        // ログインチェック実行
        self.getUser();
    },
    methods: {
        doLogin: function (event) {
            var self = this;
            const instance = axios.create({
                withCredentials: true
            })

            const formdata = {};
            formdata.email = event.target.elements.email.value;
            formdata.password = event.target.elements.password.value;

            instance.get('https://YOUR-SERVER.COM/sanctum/csrf-cookie/')
                .then(function (response) {

                    instance.post('https://YOUR-SERVER.COM/api-login', formdata)
                        .then(function (response) {
                            console.log('● api-login result');
                            console.log(response);
                            alert('ログインしました');
                            // ログインチェック実行
                            self.getUser();
                        })
                        .catch(function (error) {
                            alert('api-login エラー');
                        });
                });
        },
        getUser: function () {
            var self = this;
            this.userState = '問い合わせ中 ...............';

            const instance = axios.create({
                withCredentials: true
            })

            setTimeout(() => {
                instance.get('https://YOUR-SERVER.COM/api/user/')
                    .then(function (response) {
                        console.log('● ログイン中のユーザー情報');
                        console.log(response.data);
                        self.user = response.data;
                        self.userState = `ログイン中 ( ${response.data.name} / ${response.data.email} )`;
                    })
                    .catch(function (error) {
                        self.userState = '未ログイン';
                        self.user = {};
                    });
            }, 500);
        },
        doLogout: function (event) {
            var self = this;
            const instance = axios.create({
                withCredentials: true
            })
            const formdata = {};
            instance.post('https://YOUR-SERVER.COM/api-logout', formdata)
                .then(function (response) {
                    alert('ログアウトしました');
                    // ログインチェック実行
                    self.getUser();
                })
                .catch(function (error) {
                    alert('api-logout エラー');
                });
        }
    },

    template: `
    <div>
    <form action="/api-login/" @submit.prevent="doLogin">
        <h5>ログイン</h5>
        <input type="text" name="email" value="">
        <input type="text" name="password" value="">
        <button type="submit">Vue.jsによるログイン実行</button>

        <hr>

        <h5>ログインユーザーの取得</h5>
        <button type="button" @click="getUser">ユーザー情報取得</button>
        <div style="display:inline-block"> → {{userState}}</div>

        <div v-if="user.id">
        <h5>ログインユーザーのログアウト</h5>
        <button type="button" @click="doLogout">ログアウト</button>
        </div>
    </form>
    </div>
    `
});

● テスト用ユーザーの作成

tinker を起動して以下のコードでユーザを作成します。

php artisan tinker
\DB::table("users")->insert([
    'name'                => 'テストユーザー' ,
    'email'               => 'test@user.com' ,
    'password'            => \Hash::make('1234') ,
]);

これで以下の情報でログインすることができます

ID : test@user.com
PASSWORD : 1234

● WEBブラウザからテストの実行

こちらのURLにウェブブラウザでアクセスします

https://YOUR-SERVER.COM/test/test-login.html

Vue.js を通して axios から以下のエンドポイントへxhrを投げます

/sanctum/csrf-cookie/ (getメソッド)(チェック無し) CSRF-TOKEN を暗号化した XSRF-TOKEN を取得します。

/api-login  (postメソッド)(1.csrfチェック)ログインの実行
/api-logout (postメソッド)(1.csrfチェック)ログインの実行

/api/user (getメソッド)(1.csrfチェック無し 2.sanctumログインチェック)ログインの実行

/api/ で始まるURLのみ「2.sanctumログインチェック」が入ります。
/api/ 以外のURLのpostメソッドのみ「1.csrfチェック」が入ります。

● SSL環境でのローカルマシン( https://:local.myhost.com:3000 )からテストの実行

testディレクトリをローカルマシンの適当なところにダウンロードしてきてそこにExpressサーバーを起動するserver.jsを作成します

server.js ( local.myhost.com )は適宜読み替えてください。

'use strict';
const express = require('express');
const serveIndex = require('serve-index');
const fs = require('fs');

// ==============================サーバ名とポートをセット
const host = 'local.myhost.com';
const port = 3000;
// ==============================サーバ名とポートをセット

const app = express();
const server = require('https').createServer({
    key: fs.readFileSync('./privatekey.pem'),
    cert: fs.readFileSync('./cert.pem'),
}, app)
app.use(express.static('.'));
app.use(serveIndex('.', {icons: true}));
// app.listen(port, host);

server.listen(port, host, () => console.log(`Server Started    https://${host}:${port}`))
openssl req -x509 -newkey rsa:2048 -keyout privatekey.pem -out cert.pem -nodes -days 365
npm init -y
npm i -S express serve-index
node server.js

でローカルサーバーを起動します
こちらからも同様にアクセスができるかどうか検証しましょう。

httpsサーバについてはこちらも参考にしてください
Macのローカルマシンの Express で https:// なサーバを立ち上げて Google Chromeからアクセスする|プログラムメモ

● /etc/hosts の書き換え

0.0.0.0          local.myhost.com

と書き換えて、ローカルマシンを騙します。 これで https://local.myhost.com:3000 ローカルのマシンにアクセスできます

● ローカルサーバーでCORエラーとなる場合

config/cors.php を確認しましょう

    'paths' => ['api/*', 'sanctum/csrf-cookie', 'api-register', 'api-login', 'api-logout'], // ● 3つのエンドポイントを追加
    'allowed_origins_patterns' => ['/localhost:?[0-9]*/'],  // ● localhost:3000 追加
    'supports_credentials' => true,     // ● trueに変更

● ローカルサーバーでCSRF token mismatch.エラーとなる場合

.env を本番環境とlocalhost では切り替える必要があります。

ローカルからアクセスする場合の .env

( myhost.com は適宜読み替えてください)

# config/session.php の SESSION 設定 (サブドメインを除いたドット始まりで記述する。  api.myhost.com の場合 .myhost.com と記述する)
SESSION_DOMAIN=".myhost.com"

# (●local) フロントエンドをローカルマシンにする場合は必ず設定。ドメインとポート番号を記述すること。
# フロントエンドのマシンが https://front.myhost.com:3000  の場合  front.myhost.com:3000 と記述すること
SANCTUM_STATEFUL_DOMAINS=local.myhost.com:3000


# (●local)  SESSION_SAME_SITE は次のうちから選択  ("lax", "strict", "none", null ) デフォルトは "lax"
SESSION_SAME_SITE=none


# (●local)  http:// なサイトから  xhr で送受信するときにCookieをやり取りしたい場合は false をセットしてアンセキュアにする デフォルトは true
# local,本番いずれも 特に変更しなくてデフォルトの true のままで良い
SESSION_SECURE_COOKIE=true
添付ファイル1
No.2072
01/27 15:51

edit

添付ファイル