🐣

Laravel Sanctum SPA認証の実装

2022/09/21に公開

この記事で扱う内容

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

環境

macOs Monterey 12.5.1
PHP 8.1.10
Laravel Framework 9.30.1
Sanctum 3.0.1

事前準備

今回は同一レポジトリ内に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の名前が表示され、

リセットボタンを押すとUserの名前が表示されなくなる。

という処理を実装しました。

ここまでで準備は終了です。

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を有効にします。

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というミドルウェアが用意されていますので、ルートに対して適用します。

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);
    }
}

ルーティング追加

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をリクエスト時に送信してくれます。

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に設定されていることがわかります。

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

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

ログイン処理の実装です。

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で返ってきているので認証に成功していそうです。

確認のため、「User一覧」ボタンを押してみます。

認証後にしか表示できないUser一覧が表示できているため、問題なさそうです。

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

ログアウト処理の実装です。

App.js
+  const logout = () => {
+    http.post('/api/logout').then((res) => {
+      console.log(res);
+    })
   }
-  const logout = () => {}

「ログアウト」ボタンを押してみます。
こちらもステータス200で返ってきているのでログアウトに成功してそうです。

「User一覧」ボタンを押してみると401Unauthorizedエラーが返ってきました。
Userの一覧も表示されないので問題なさそうです。

以上で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属性に設定されます。

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

公式ドキュメントにも記載されていますが、サブドメインをサポートするために、先頭にドット(.)をつけます。

SESSION_DOMAIN=.domain.com

感想

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

参考

https://readouble.com/laravel/9.x/ja/sanctum.html
https://laravel.com/docs/9.x/authentication
https://qiita.com/ucan-lab/items/3e7045e49658763a9566
https://dev.to/nicolus/laravel-sanctum-explained-spa-authentication-45g1
https://www.youtube.com/watch?v=8Uwn5M6WTe0

Discussion