Laravelのmiddleware作成方法[Sanctum]

こんにちは、技術部の Yです。今回はLaravelのmiddlewareの作成方法について書いていきます。

前提

  • Sanctumを使用したSPA認証の実装方法に関して書いています。
    • API側の実装がメインです。
    • APIとフロントは異なるサブドメインで配置されています。
    • APIトークン認証に関しては取り扱っていません。

事前準備

今回は同一レポジトリ内にAPI, フロントを配置してます。

トップ
– api (localhost:80)
– front (localhost:3000)

 

API

Sail を使って環境構築

インストール

				
					curl -s “https://laravel.build/api” | bash
				
			

下記のコンテナは今回使用しないため、docker-compose.ymlから削除しました。
redis, meilisearch, mailhog, selenium

Laravel9ではデフォルトでSanctumが入っていたため別途インストールは不要でした。

				
					// composer.json
{
    “name”: “laravel/laravel”,
    “type”: “project”,
    “description”: “The Laravel Framework.“,
    “keywords”: [“framework”, “laravel”],
    “license”: “MIT”,
    “require”: {
        “php”: “^8.0.2",
        “guzzlehttp/guzzle”: “^7.2",
        “laravel/framework”: “^9.19",
        “laravel/sanctum”: “^3.0",
        “laravel/tinker”: “^2.7"
    },
    // 省略
				
			

環境を立ち上げてルートを確認します。

				
					cd api && sail up
sail artisan route:list
				
			

以下のルーティングが確認できるはずです。

				
					GET|HEAD   sanctum/csrf-cookie
				
			

User一覧取得処理

今回下記のような仕様にして、認証処理を確認したいと思います。
– 認証前はUser一覧を取得できない
– 認証後はUser一覧を取得できる

そのためUserデータの作成と、User一覧取得処理を準備しておきます。

				
					// UserSeeder.php
<?php

namespace Database\Seeders;

use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;

class UserSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        DB::table(‘users’)->insert([
            [
                ‘name’ => ‘user’,
                ‘email’ => ‘user@example.com’,
                ‘password’ => Hash::make(‘password’),
            ],
            [
                ‘name’ => ‘user2’,
                ‘email’ => ‘user2@example.com’,
                ‘password’ => Hash::make(‘password’),
            ],
            [
                ‘name’ => ‘user3’,
                ‘email’ => ‘user3@example.com’,
                ‘password’ => Hash::make(‘password’),
            ],
            [
                ‘name’ => ‘user4’,
                ‘email’ => ‘user4@example.com’,
                ‘password’ => Hash::make(‘password’),
            ],
            [
                ‘name’ => ‘user5’,
                ‘email’ => ‘user5@example.com’,
                ‘password’ => Hash::make(‘password’),
            ],
        ]);
    }
}

				
			
				
					// DatabaseSeeder.php
<?php

namespace Database\Seeders;

// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

use Database\Seeders\UserSeeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application’s database.
     *
     * @return void
     */
    public function run()
    {
    // 追記
        $this->call([
            UserSeeder::class,
        ]);
    }
}

				
			
マイグレーションしてインサート
				
					sail artisan migrate --seed
				
			

ルーティング作成

				
					// api.php
Route::get(‘users’, function () {
    return User::all();
});
				
			
動作確認
				
					curl ‘http://localhost/api/users’ | jq
				
			
User のデータが取れていればOKです

フロント

React の雛形作成
				
					npx create-react-app front
				
			
トップページを編集 認証の動作を確認することが目的のため、最低限の実装で済ませてます。
				
					// App.js
import { useState } from ‘react’;
import axios from ‘axios’;

import ‘./App.css’;

function App() {
  const [email, setEmail] = useState(‘’);
  const [password, setPassword] = useState(‘’);
  const [users, setUsers] = useState([]);

  const login = () => {}
  const logout = () => {}
  const getUsers = () => {
    http.get(’http://localhost/api/users').then((res) => {
      setUsers(res.data);
    })
  }
  const reset = () => {setUsers([])}
  const onChangeEmail = (e) => setEmail(e.target.value);
  const onChangePassword = (e) => setPassword(e.target.value);

  return (
    <div className=“App”>
      <nav>
        <button onClick={login}>ログイン</button>
        <button onClick={logout}>ログアウト</button>
        <button onClick={getUsers}>User 一覧</button>
        <button onClick={reset}>リセット</button>
      </nav>
        <br />
      <div>
        <label>email</label>
        <input type=“text” value={email} onChange={onChangeEmail}/>
        <label>password</label>
        <input type=“password” value={password} onChange={onChangePassword}/>
      </div>
      <div>
        {
          users.map((user) => {
            return (
              <p key={user.email}>{user.name}</p>
            )
          })
        }
      </div>
    </div>
  );
}

export default App;


export default App;
				
			
User 一覧ボタンを押すと、Userの名前が表示され、
blank
リセットボタンを押すとUserの名前が表示されなくなる。
blank
という処理を実装しました。 ここまでで準備は終了です。

SPA認証

ドキュメントを参考に進めていきます。

ファーストパーティドメインの設定

「ステートフル」な認証を維持するドメインを指定します。

sanctum.phpのstatefulに設定内容が書かれています。

				
					// sanctum.php
‘stateful’ => explode(‘,’, env(‘SANCTUM_STATEFUL_DOMAINS’, sprintf(
‘%s%s’,
‘localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1’,
Sanctum::currentApplicationUrlWithPort()
))),
				
			

今回開発環境のフロントはlocalhost:3000で動作しているため、特に変更しませんが、
ドメインを設定している場合や本番環境では、.envのSANCTUM_STATEFUL_DOMAINSに設定が必要です。

Sanctumミドルウェアの設定

ミドルウェアの設定です。
EnsureFrontendRequestsAreStatefulを有効にします。

				
					// diff Kernel.php
         ‘api’ => [
-            // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
+            \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
             ‘throttle:api’,
             \Illuminate\Routing\Middleware\SubstituteBindings::class,
         ],
				
			
EnsureFrontendRequestsAreStatefulミドルウェアの一部ソースコードです。
				
					// EnsureFrontendRequestsAreStateful.php
    public function handle($request, $next)
    {
        $this->configureSecureCookieSessions();

        return (new Pipeline(app()))->send($request)->through(static::fromFrontend($request) ? [
            function ($request, $next) {
                $request->attributes->set(‘sanctum’, true);

                return $next($request);
            },
            config(‘sanctum.middleware.encrypt_cookies’, \Illuminate\Cookie\Middleware\EncryptCookies::class),
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            config(‘sanctum.middleware.verify_csrf_token’, \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class),
        ] : [])->then(function ($request) use ($next) {
            return $next($request);
        });
    }

				
			
static::fromFrontend($request)では、 先ほど設定したconfig(’sanctum.stateful)の値をもとにSPA(フロント)からのリクエストであるかを判定しています。 trueと判定された場合に3, 4つほどミドルウェアを適応しています。 ここで適用しているミドルウェアは‘web’のmiddlewareGroupsにも設定されているものの一部です。

CORSとクッキー

今回はフロントとAPIのドメインが異なるため、corsの設定が必要です。
この値をtrueにしてあげないと、異なるドメインからのリクエスト時にエラーになってしまいます。

				
					// cors.php
‘supports_credentials’ => true,
				
			

ルート保護

ここで、「認証後にのみ、User一覧を取得できる」という仕様を実装したいと思います。

auth:sanctumというミドルウェアが用意されていますので、ルートに対して適用します。

				
					// diff api.php
-Route::get(‘users’, function () {
+Route::middleware(‘auth:sanctum’)->get(‘users’, function () {
     return User::all();
 });
				
			
「User一覧」ボタンを押しても401Unauthorizedエラーが返ってきてUser一覧が表示されないはずです。

認証

ログイン、ログアウト処理の実装(API)

簡単なログインログアウトの処理を実装します。

				
					// LoginController.php
<?php

namespace App\Http\Controllers;

use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class LoginController extends Controller
{
    /**
     * @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 response()->json(Auth::user());
        }
        return response()->json([], 401);
    }

    /**
     * @param  Request  $request
     * @return \Illuminate\Http\JsonResponse
     */
    public function logout(Request $request)
    {
        Auth::logout();

        $request->session()->invalidate();

        $request->session()->regenerateToken();

        return response()->json(true);
    }
}

				
			
ルーティング追加
				
					// diff api.php
+use App\Http\Controllers\LoginController;

+Route::post(‘login’, [LoginController::class, ‘login’]);
+Route::post(‘logout’, [LoginController::class, ‘logout’]);
				
			

CSRF保護の初期化

フロント側です。
axiosのインスタンスを作成しておきます。

withCredentials: trueと設定することで、後述のXSRF-TOKENをリクエスト時に送信してくれます。

				
					// diff App.js
+  const http = axios.create({
+    baseURL: ‘http://localhost’,
+    withCredentials: true,
+  });
+
				
			

認証処理の前に、/sanctum/csrf-cookieに対してリクエストして、アプリケーションのCSRF保護を初期化する必要があります。

				
					const login = () => {
  axios.get(‘/sanctum/csrf-cookie’).then((res) => {
    // ログイン処理
  })
}
				
			

レスポンスの内容を確認すると、XSRF-TOKENがSet-Cookieに設定されていることがわかります。

blank
後続のリクエスト時に、axiosがこの値をX-XSRF-TOKENヘッダに設定してリクエストしてくれています。

ログイン処理(フロント)

ログイン処理の実装です。
				
					// diff App.js
+  const login = () => {
+    http.get(‘/sanctum/csrf-cookie’).then((res) => {
+      http.post(‘/api/login’, {email, password}).then((res) => {
+        console.log(res);
+      })
+    })
+  }
-  const login = () => {}
				
			

ブラウザでemail, passwordを正しく入力し、「ログイン」ボタンを押してみます。
ステータス200で返ってきているので認証に成功していそうです。

blank
確認のため、「User一覧」ボタンを押してみます。
blank
認証後にしか表示できないUser一覧が表示できているため、問題なさそうです。

ログアウト処理(フロント)

ログアウト処理の実装です。
				
					diff App.js
+  const logout = () => {
+    http.post(‘/api/logout’).then((res) => {
+      console.log(res);
+    })
   }
-  const logout = () => {}
				
			
「ログアウト」ボタンを押してみます。 こちらもステータス200で返ってきているのでログアウトに成功してそうです。
blank
「User一覧」ボタンを押してみると401Unauthorizedエラーが返ってきました。 Userの一覧も表示されないので問題なさそうです。
blank
以上でSanctumを使ったSPA認証の一通りを実装できました!

トラブルシューティング

実装の中で詰まったところや、気をつけるところを残しておきます。

axiosがXSRF-TOKENを設定してくれない

/sanctum/csrf-cookieのレスポンスで返ってきたXSRF-TOKENを次回リクエスト時に設定してくれないとCSRF token mismatch.と419エラーになってしまいます。

もともとaxiosのpostメソッドの第3引数にwithCredentials: trueを指定して動かしてましたが、XSRF-TOKENを設定してくれませんでした。
axios.createでインスタンス作成時に指定してあげることで解決できました。

(きちんとフロントエンドを実装する場合、1つ1つのpostメソッドに対してwithCredentialsを設定することはないと思いますので、本来詰まるところではないのかもしれないですが。。)

セッションクッキードメイン設定

今回localhostだったために設定していなかったですが、本番環境ではセッションクッキードメイン設定が必要です。 ここで設定した値は、Set-Cookieのdomain属性に設定されます。
				
					// session.php
‘domain’ => env(‘SESSION_DOMAIN’),
				
			
公式ドキュメントにも記載されていますが、サブドメインをサポートするために、先頭にドット(.)をつけます。
				
					// .env
SESSION_DOMAIN=.domain.com
				
			

感想

ネットに落ちてる参考記事やQAの回答は、根本的解決でない場合がありました。 そのため、設定している値がどこで使用されているかを確認したり、適用されているミドルウェアの処理を追ってみることが大事だと思いました。

スーパーソフトウエアの採用情報

あなたが活躍できるフィールドと充実した育成環境があります

blank
blank