Laravel API SanctumでSPA認証する

Laravel Sanctum（サンクタム、聖所）は、SPA(シングルページアプリケーション)、モバイルアプリケーション、およびシンプルなトークンベースのAPIの軽量認証システムです。

## 環境

- https://github.com/ucan-lab/docker-laravel
- PHP: 8.1.5
- Laravel: 9.9.0
- Laravel Sanctum: 2.15.1
  - Laravel8.6以降は標準搭載

※Laravel8以前はLaravel Sanctumが標準インストールされているので、補足を追記しています。

## 公式系

- https://readouble.com/laravel/9.x/ja/sanctum.html
- https://github.com/laravel/sanctum
- https://laravel.com/docs/9.x/sanctum
- https://laravel-news.com/tag/sanctum

## 関連記事

https://qiita.com/ucan-lab/items/c72f1fe32543d3e8add3

https://qiita.com/ucan-lab/items/827a9f05317a6e4d8636

https://qiita.com/ucan-lab/items/1d540645aa29112a418e

https://qiita.com/ucan-lab/items/d1c561b0d7ca2c792fa1

https://qiita.com/ucan-lab/items/685290df8faa498df5bf

## Laravel Sanctumの仕組み

### SPA認証(クッキー認証)

- SPA(Single Page Application)等のJavaScript APIとの認証に使用する
- HasApiTokensトレイト、personal_access_tokensテーブルは<font color="Red">**不要**</font>
- VueやReact等のシングルページアプリケーション(SPA)を認証する
- Laravel標準のクッキーベースのセッション認証サービスを使用する
- Laravelの`web`認証ガードを利用する
- CSRF保護、セッション認証の利点、XSSを介した認証資格情報の漏洩を保護する
- 最初にクッキー認証をチェックし、存在しなければAPIトークン認証を試みます
- SPAとAPIが同じトップレベルドメインを共有している必要がある(サブドメインが異なるのはOK)
- CORS設定により、サーバー間のリクエストが制約を受けることがある
- `HttpOnly` 属性を付けることでクッキーへのJavaScriptアクセスを防止(XSS対策)

### APIトークン認証(今回は実装しません)

- 主にスマホアプリのネイティブアプリや外部システムとの認証に使用する
- HasApiTokensトレイト、personal_access_tokensテーブルは必要
- ユーザーにAPIトークンを発行する
- GitHubの個人アクセストークンのようなイメージ
- Sanctumを使用してトークンを生成および管理ができる
- トークンは通常、非常に長い有効期限(年)を設定する
- ユーザーはいつでも手動でトークンを取り消すことができる
- ユーザーAPIトークンを単一のデータベーステーブルに保存する
- `Authorization`ヘッダを介してAPIトークンを認証する
- VueやReact等のSPA(Single Page Application)ではセキュリティ観点から利用しない

SPA認証においてはセキュリティ観点から**APIトークン認証を使用するべきではありません**

- [JWTは使うべきではない　〜 SPAにおける本当にセキュアな認証方式 〜](https://qiita.com/nyandora/items/8174891f52ec0ea15bc1)
- [SPAのログイン認証のベストプラクティスがわからなかったのでわりと網羅的に研究してみた〜JWT or Session どっち？〜](https://qiita.com/Hiro-mi/items/18e00060a0f8654f49d6)
- [SPAセキュリティ入門～PHP Conference Japan 2021](https://www.slideshare.net/ockeghem/phpconf2021spasecurity)

### モバイルアプリケーション認証(今回は実装しません)

- HasApiTokensトレイト、personal_access_tokensテーブルは必要
- ユーザーのメールアドレスorユーザー名、パスワード、デバイス名を受け入れるエンドポイントを作成する
- モバイルアプリケーション(iOS, Android等)の「ログイン」画面からトークンエンドポイントにリクエストを送信する
- デバイス名は「Nuno'siPhone12」等のユーザーが認識できる名前にする(デバイス名自体は任意の文字列)
- エンドポイントはプレーンテキストのAPIトークンを返却する
- APIトークンはモバイルデバイス内に保存する
- 認証が必要なエンドポイントは`Authorization`ヘッダに`Bearer`トークンを渡す

## 今回やること

SPA認証のみ、またはAPIトークン認証のみに利用することはまったく問題ありません。
Sanctumを利用しているからと言って両方の機能を使用する必要はありません。

今回はSPA認証に絞って進めていきたいと思います。

## インストール(Laravel8.6以前)

https://readouble.com/laravel/8.x/ja/sanctum.html#installation

```shell-session
$ composer require laravel/sanctum
```

※Laravel8.6以降からLaravel Sanctumが標準でインストールされるので不要です。

Composerパッケージマネージャーを介してLaravel Sanctumをインストールします。

```shell-session
$ php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
```

※Laravel8.6以降からLaravel Sanctumが標準でインストールされるので不要です。

下記のファイルが生成されます。

- [config/sanctum.php](https://github.com/laravel/sanctum/blob/2.x/config/sanctum.php)
- [database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php](https://github.com/laravel/sanctum/blob/2.x/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php)

今回はAPIトークンを利用しないので `database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php` は不要なので削除してokです。

今回はAPIトークンは利用しないのでマイグレーションの実行は不要です。
`User`モデルに`HasApiTokens`トレイトを追加する手順も不要です。

## Sanctumのマイグレーションを無効化する

SPA認証を使う場合は、 `personal_access_tokens` テーブルは不要です。
合っても困りはしませんが不要なので無効化します。

無効化手順は別記事で紹介しています。

https://qiita.com/ucan-lab/items/6a3481b389a886115ada

## SPA認証(クッキー認証)の実装

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

まず、SPA(Vue, React等)からリクエストを行うドメインを設定します。

```.env
SANCTUM_STATEFUL_DOMAINS=www.example.com
```

- `SANCTUM_STATEFUL_DOMAINS` にはフロント側のFQDN(ホスト名+ドメイン名)を設定します。
  - 基本は `APP_URL` に設定している値に合わせると良いです
- ローカルのSPA環境が定義済み(config/sanctum.php)のFQDNを使用していればこの設定は<font color="Red">**不要**</font>です。
- ローカル環境でも `config/sanctum.php` に定義されていないFQDNを使用している場合は下記のように `SANCTUM_STATEFUL_DOMAINS` 環境変数を設定します。

```.env
SANCTUM_STATEFUL_DOMAINS=localhost:8000
```

<details><summary>ファーストパーティドメインの設定(補足)</summary><div>

`SANCTUM_STATEFUL_DOMAINS` の環境変数は `config/sanctum.php` の `stateful` に設定されます。

デフォルト値は下記になってます。

```php:config/sanctum.php
    '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) : ''
    ))),
```

ごちゃごちゃしてますが、最終的には下記の配列になってます。

```
>>> config('sanctum.stateful')
=> [
     "localhost",
     "localhost:3000",
     "127.0.0.1",
     "127.0.0.1:8000",
     "::1",
     "localhost",
   ]
```

各環境に合わせて `.env` にドメインとポート番号を設定してください。

</div></details>

### Sanctumミドルウェアを `api` ミドルウェアグループに追加

Sanctumのミドルウェアを `app/Http/Kernel.php` の `api` ミドルウェアグループに追加します。
SPAからの受信リクエストがLaravelのセッションクッキーを使用して認証できるようになります。
また、サードパーティまたはモバイルアプリケーションからのリクエストがAPIトークンを使用して認証できるようにする役割を果たします。

```php:app/Http/Kernel.php
class Kernel extends HttpKernel
{
    protected $middlewareGroups = [
        'api' => [
            \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, // 追記
            'throttle:api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
    ];
}
```

※Laravel8.6以降は追記箇所がコメントアウトになっているので、有効化してください。

#### 補足: Sanctumミドルウェア(必須)

`EnsureFrontendRequestsAreStateful` ミドルウェアの補足です。

<details><summary>vendor/laravel/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php クリックして表示</summary><div>

```php:vendor/laravel/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php
<?php

namespace Laravel\Sanctum\Http\Middleware;

use Illuminate\Routing\Pipeline;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;

class EnsureFrontendRequestsAreStateful
{
    /**
     * Handle the incoming requests.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  callable  $next
     * @return \Illuminate\Http\Response
     */
    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);
        });
    }

    /**
     * Configure secure cookie sessions.
     *
     * @return void
     */
    protected function configureSecureCookieSessions()
    {
        config([
            'session.http_only' => true,
            'session.same_site' => 'lax',
        ]);
    }

    /**
     * Determine if the given request is from the first-party application frontend.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return bool
     */
    public static function fromFrontend($request)
    {
        $domain = $request->headers->get('referer') ?: $request->headers->get('origin');

        if (is_null($domain)) {
            return false;
        }

        $domain = Str::replaceFirst('https://', '', $domain);
        $domain = Str::replaceFirst('http://', '', $domain);
        $domain = Str::endsWith($domain, '/') ? $domain : "{$domain}/";

        $stateful = array_filter(config('sanctum.stateful', []));

        return Str::is(Collection::make($stateful)->map(function ($uri) {
            return trim($uri).'/*';
        })->all(), $domain);
    }
}
```

</div></details>

ここでやってる内容は下記の通りです。

- セッションクッキーのセキュア設定を強制
- refererヘッダーまたはoriginヘッダーのチェック
  - SPAからこのヘッダーが送られないと認証エラーになる
  - Postmanで動作確認するときに注意する
- `web` ミドルウェアグループで使用しているの一部のミドルウェアを導入
  - クッキー暗号化ミドルウェア(`\App\Http\Middleware\EncryptCookies`)
    - Cookieを暗号化する
    - 攻撃者がCookieにアクセスできたとしても、その内容を変更するとCookieが返送されたときにサーバーによって拒否する
  - レスポンスにクッキー付与(`\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse`)
    - Cookieファサードでキューに入れられたCookieを処理する
  - セッション有効化(`\Illuminate\Session\Middleware\StartSession`)
    - Laravelセッションとセッションクッキーを設定し、レスポンスに追加する
  - CSRFトークン検証(`\App\Http\Middleware\VerifyCsrfToken`)
    - CSRFトークンがすべて正常であることを確認する

### CORSとクッキー(別のサブドメインで実行する場合)

```php:config/cors.php
    'supports_credentials' => true,
```

レスポンスヘッダの `Access-Control-Allow-Credentials` が `true` を返すようになります。

- https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials

SPA側で `axios` を使う場合は `withCredentials` オプションを有効にする必要があります。

- https://github.com/axios/axios

Laravelアプリケーションのセッションクッキードメイン設定をします。

```php:config/session.php
    'domain' => env('SESSION_DOMAIN', null),
```

```.env
SESSION_DOMAIN=.example.com
```

- ※ドメインの先頭に `.` を付けます。
- `null` の場合、サブドメイン間でCookieの共有ができません。
- `localhost` で確認する場合は<font color="Red">**不要**</font>です。

### CSRF保護(Laravel側)

Laravel Sanctumをインストールした時点で下記のエンドポイントが追加されています。

```shell-session
$ php artisan route:list
  GET|HEAD   sanctum/csrf-cookie ................................................................................... Laravel\Sanctum › CsrfCookieController@show
```

### CSRF保護(SPA側のログイン画面)

SPAを認証するには、SPAの「ログイン」ページで最初に`/sanctum/csrf-cookie`エンドポイントにリクエストを送信して、アプリケーションのCSRF保護を初期化する必要があります。

```javascript
axios.get('/sanctum/csrf-cookie').then(response => {
    // ログイン…
})
```

このエンドポイントにGETリクエストするとCSRFトークンを含む`XSRF-TOKEN`クッキー付きレスポンスが返却されます。

このクッキーに入っているトークンを `X-XSRF-TOKEN` ヘッダに入れてSPA側からリクエストする必要があります。
(AxiosやAngular HttpClientなどの一部のHTTPクライアントライブラリでは自動的に行います。)

### ログイン

- `/login` ルートに `POST` リクエストを手動実装する
- `web` 認証ガードを使用する
- [Laravelが提供するセッションベースの認証サービス](https://readouble.com/laravel/8.x/ja/authentication.html#authenticating-users)
- ユーザーのセッションが期限切れになった場合、後続のリクエストは401か419HTTPエラーを返却する
  - 401, 419エラーが出た場合はSPA側でログインページにリダイレクトする必要がある

### 補足: ルートの保護の例

`routes/api.php` のAPIルートに `sanctum` 認証ガードを指定します。
受信リクエストがSPAからのステートフル認証済みリクエストとして認証されるか、リクエストがサードパーティからのものである場合は有効なAPIトークンヘッダを含むことを保証します。

```php:routes/api.php
use Illuminate\Http\Request;

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

### 補足: セッションの保存先設定(任意)

`SESSION_DRIVER` のデフォルト値は `file` です。
Google App Engineなどを使ってるとインスタンス間でセッションを共有できなくなります。

セッションドライバーを `cookie` に変更してクライアント側でクッキーにセッションを入れて対応します。

```.env
SESSION_DRIVER=cookie
```

セッションドライバーを `cookie` にする場合ですが、クッキーの機能として容量は4Kバイトまでで20個までしか保存できない制約があります。
認証情報以外にも何かしらの値をクッキーに保存していくとすぐ制約に引っかかってしまいます。
その場合はRedisをセッションドライバーとして利用すると良いでしょう。

- [Laravel 9.x HTTPセッション](https://readouble.com/laravel/9.x/ja/session.html#configuration)

### SPA認証(クッキー認証)の実装

#### コントローラの作成

```shell-session
$ php artisan make:request Auth/LoginRequest
$ php artisan make:controller -i Auth/LoginController
$ php artisan make:controller -i Auth/LogoutController
$ php artisan make:controller -i Api/MeController
```

`-i` はシングルアクションコントローラのひな形を生成します。

<details><summary>app/Http/Requests/Auth/LoginRequest.php クリックして表示</summary><div>

```php:app/Http/Requests/Auth/LoginRequest.php
<?php declare(strict_types=1);

namespace App\Http\Requests\Auth;

use Illuminate\Foundation\Http\FormRequest;

final class LoginRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules(): array
    {
        return [
            'email' => ['required', 'email'],
            'password' => ['required', 'min:6'],
        ];
    }
}
```

</div></details>

<details><summary>app/Http/Controllers/Auth/LoginController.php クリックして表示</summary><div>

```php:app/Http/Controllers/Auth/LoginController.php
<?php declare(strict_types=1);

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse;

final class LoginController extends Controller
{
    /**
     * @param AuthManager $auth
     */
    public function __construct(
        private readonly AuthManager $auth,
    ) {
    }

    /**
     * @param LoginRequest $request
     * @return JsonResponse
     * @throws AuthenticationException
     */
    public function __invoke(LoginRequest $request): JsonResponse
    {
        $credentials = $request->only(['email', 'password']);

        if ($this->auth->guard()->attempt($credentials)) {
            $request->session()->regenerate();

            return new JsonResponse([
                'message' => 'Authenticated.',
            ]);
        }

        throw new AuthenticationException();
    }
}
```

</div></details>

<details><summary>app/Http/Controllers/Auth/LogoutController.php クリックして表示</summary><div>

```php:app/Http/Controllers/Auth/LogoutController.php
<?php declare(strict_types=1);

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class LogoutController extends Controller
{
    /**
     * @param AuthManager $auth
     */
    public function __construct(
        private readonly AuthManager $auth,
    ) {
    }

    /**
     * @param Request $request
     * @return JsonResponse
     */
    public function __invoke(Request $request): JsonResponse
    {
        if ($this->auth->guard()->guest()) {
            return new JsonResponse([
                'message' => 'Already Unauthenticated.',
            ]);
        }

        $this->auth->guard()->logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return new JsonResponse([
            'message' => 'Unauthenticated.',
        ]);
    }
}
```

</div></details>

<details><summary>app/Http/Controllers/Api/MeController.php クリックして表示</summary><div>

```php:app/Http/Controllers/Api/MeController.php
<?php declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class MeController extends Controller
{
    /**
     * @param Request $request
     * @return JsonResponse
     */
    public function __invoke(Request $request): JsonResponse
    {
        $user = $request->user();

        return new JsonResponse([
            'data' => [
                'id' => $user->id,
                'name' => $user->name,
                'email' => $user->email,
            ],
        ]);
    }
}
```

</div></details>

以上でコントローラの実装は完了です。

#### ルーティングの定義

作成したコントローラに対応するルーティングを実装します。
公式の例に倣ってログインログアウトは`web`ルートに作成します。

```php:routes/web.php
<?php declare(strict_types=1);

use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\LogoutController;
use Illuminate\Support\Facades\Route;

Route::post('/login', LoginController::class)->name('login');
Route::post('/logout', LogoutController::class)->name('logout');
```

```php:routes/api.php
<?php declare(strict_types=1);

use App\Http\Controllers\Api\MeController;
use Illuminate\Support\Facades\Route;

Route::group(['middleware' => ['auth:sanctum']], function () {
    Route::get('/me', MeController::class);
});
```

`config/cors.php` の `paths` に `login` と `logout` を追記します。

```php:config/cors.php
    'paths' => [
        'api/*',
        'login', // 追記
        'logout', // 追記
        'sanctum/csrf-cookie',
    ],
```

```shell-session
$ php artisan route:list

  GET|HEAD   / ................................................................................................................................................. 
  POST       _ignition/execute-solution .......................................... ignition.executeSolution › Spatie\LaravelIgnition › ExecuteSolutionController
  GET|HEAD   _ignition/health-check ...................................................... ignition.healthCheck › Spatie\LaravelIgnition › HealthCheckController
  POST       _ignition/update-config ................................................... ignition.updateConfig › Spatie\LaravelIgnition › UpdateConfigController
  GET|HEAD   api/me ........................................................................................................................... Api\MeController
  POST       login ................................................................................................................ login › Auth\LoginController
  POST       logout ............................................................................................................. logout › Auth\LogoutController
  GET|HEAD   sanctum/csrf-cookie ................................................................................... Laravel\Sanctum › CsrfCookieController@show
```

続けてエンドポイントの動作確認を行なっていきます。

### 補足: デモユーザーの追加

tinkerを使ってデモユーザーを追加します。

```shell-session
$ php artisan tinker

App\Models\User::factory()->create(['email' => 'demo1@example.com']);
App\Models\User::factory()->create(['email' => 'demo2@example.com']);
App\Models\User::factory()->create(['email' => 'demo3@example.com']);
```

シーダーに書いてもokです。
デフォルトパスワードは `password` です。
Laravelフレームワークのバージョンによっては `secret` になってる場合もあります。

### 補足: Postmanを使うに当たっての注意点

動作確認は[Postman](https://www.postman.com)で行ってます。

#### 注意点①

Postmanで`PUT`, `PATCH`, `DELETE`などのメソッドを実行したい場合ですが、 `form-data` ではなく `raw` でJSON形式でAPIリクエストを行うようにしてください。
`form-data` でリクエストを送ると `_method` キー設定されず、意図した動作にならない場合があります。

#### 注意点②

ログインの流れとして、 `/sanctum/csrf-cookie` で返却されたXSRFクッキーの中にあるXSRFトークンを保存し、`/login`へリクエストを送る際に `X-XSRF-TOKEN`ヘッダにXSRFトークン入れて送る必要があります。

手動でやるのは面倒なので、`Pre-request Script`を書きました。
Postmanの環境変数 `APP_URL` に `http://localhost` を入れておいてください。

```js
let csrfRequestUrl = pm.environment.get('APP_URL') + '/sanctum/csrf-cookie';
pm.sendRequest(csrfRequestUrl, function(err, res, {cookies}) {
    let xsrfCookie = cookies.one('XSRF-TOKEN');
    if (xsrfCookie) {
        let xsrfToken = decodeURIComponent(xsrfCookie['value']);
        pm.request.headers.upsert({
            key: 'X-XSRF-TOKEN',
            value: xsrfToken,
        });                
        pm.environment.set('XSRF-TOKEN', xsrfToken);
    }
});
```

Postmanの環境変数 `XSRF-TOKEN` を設定してます。

### `/login` エンドポイントの動作確認

新しく作られたエンドポイントをPostmanで実行してみます。

![ScreenShot 2022-04-29 2.33.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/4df08a7c-add0-d66a-4c5f-48a7e01fbca8.png)

200レスポンスが返ってきているのでokです。
Cookieに`X-XSRF-TOKEN`が設定していることに注目してください。

![ScreenShot 2022-04-29 2.32.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/61d99f8c-f7d3-81ab-8203-e83ff058fd79.png)

Bodyタブは `raw` を選択してJSON形式で送ります。
![ScreenShot 2022-04-29 2.33.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/4b39246a-61cc-b169-c684-9424e30c7398.png)

`Pre-request Script` タブに `/sanctum/csrf-cookie` を事前に叩くコードを仕込んでいます。

![ScreenShot 2022-04-29 2.38.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/973f0cdb-3616-17e5-472f-d84122d39fbb.png)

### `/api/me` エンドポイントの動作確認

![ScreenShot 2022-04-29 2.39.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/02a12f4f-6a95-4997-efd2-966a21c19638.png)

`/api/*` のエンドポイントを実行するときは `Referer` もしくは `Origin` のどちらかのヘッダを必ず指定する必要があります。

:::note info
`GET`メソッドの場合は `X-XSRF-TOKEN` は不要です。
:::

### `/logout` エンドポイントの動作確認

![ScreenShot 2022-04-29 2.41.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/f6764c5c-9826-50f9-7c42-2335f9b94b17.png)

`/login` エンドポイントも同様ですが、Laravelの`web`ルートに定義した場合は `Referer` と `Origin` のヘッダは不要です。
また `POST`, `PUT`, `PATCH`, `DELETE` 等の `GET` メソッド以外を実行する場合は `X-XSRF-TOKEN` が必要です。

## テストコードの作成

- https://readouble.com/laravel/9.x/ja/sanctum.html#testing
- https://readouble.com/laravel/9.x/ja/http-tests.html

Laravel Sanctum のテストコードを書きます。
今回はAPIトークンを利用しないので、Laravel標準のHTTPテストの`actingAs`メソッドを利用します。

```shell-session
$ php artisan make:test Http/Controllers/Auth/LoginControllerTest
$ php artisan make:test Http/Controllers/Auth/LogoutControllerTest
$ php artisan make:test Http/Controllers/Api/MeControllerTest
```

<details><summary>tests/Feature/Http/Controllers/Auth/LoginControllerTest.php クリックして表示</summary><div>

```php:tests/Feature/Http/Controllers/Auth/LoginControllerTest.php
<?php declare(strict_types=1);

namespace Tests\Feature\Http\Controllers\Auth;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

final class LoginControllerTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @return void
     */
    public function testSuccess(): void
    {
        User::factory()->create(['email' => 'test@example.com']);

        $params = [
            'email' => 'test@example.com',
            'password' => 'password',
        ];

        $this->postJson('/login', $params)
            ->assertStatus(200)
            ->assertJson([
                'message' => 'Authenticated.',
            ]);
    }

    /**
     * @return void
     */
    public function testUnauthenticated(): void
    {
        $params = [
            'email' => 'test@example.com',
            'password' => 'password',
        ];

        $this->postJson('/login', $params)
            ->assertStatus(401)
            ->assertJson([
                'message' => 'Unauthenticated.',
            ]);
    }

    /**
     * @return void
     */
    public function testUnprocessableEntity(): void
    {
        $this->postJson('/login', [])
            ->assertStatus(422)
            ->assertJson([
                'message' => 'The email field is required. (and 1 more error)',
                'errors' => [
                    'email' => ['The email field is required.'],
                    'password' => ['The password field is required.'],
                ],
            ]);
    }
}
```

</div></details>

<details><summary>tests/Feature/Http/Controllers/Auth/LogoutControllerTest.php クリックして表示</summary><div>

```php:tests/Feature/Http/Controllers/Auth/LogoutControllerTest.php
<?php declare(strict_types=1);

namespace Tests\Feature\Http\Controllers\Auth;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

final class LogoutControllerTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @return void
     */
    public function testSuccess(): void
    {
        $user = User::factory()->create(['email' => 'test@example.com']);

        $this->actingAs($user)
            ->postJson('/logout')
            ->assertStatus(200)
            ->assertJson([
                'message' => 'Unauthenticated.',
            ]);

        $this->assertGuest();
    }

    /**
     * @return void
     */
    public function testAlreadyUnauthenticated(): void
    {
        $this->postJson('/logout')
            ->assertStatus(200)
            ->assertJson([
                'message' => 'Already Unauthenticated.',
            ]);

        $this->assertGuest();
    }
}
```

</div></details>

<details><summary>tests/Feature/Http/Controllers/Api/MeControllerTest.php クリックして表示</summary><div>

```php:tests/Feature/Http/Controllers/Api/MeControllerTest.php
<?php declare(strict_types=1);

namespace Tests\Feature\Http\Controllers\Api;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

final class MeControllerTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @return void
     */
    public function testSuccess(): void
    {
        $user = User::factory()->create([
            'email' => 'test@example.com'
        ]);

        $this->actingAs($user)
            ->getJson('/api/me')
            ->assertStatus(200)
            ->assertJson([
                'data' => [
                    'id' => $user->id,
                    'name' => $user->name,
                    'email' => $user->email,
                ],
            ]);
    }

    /**
     * @return void
     */
    public function testUnauthenticated(): void
    {
        $this->getJson('/api/me')
            ->assertStatus(401)
            ->assertJson([
                'message' => 'Unauthenticated.',
            ]);
    }
}
```

</div></details>

テストコードは補足することはないですが、ログイン、ログアウト、認証中ユーザー取得のテストを追加してます。

## テストコードの実行

```shell-session
$ php artisan test

   PASS  Tests\Unit\ExampleTest
  ✓ that true is true

   PASS  Tests\Feature\ExampleTest
  ✓ the application returns a successful response

   PASS  Tests\Feature\Http\Controllers\Auth\LoginControllerTest
  ✓ success
  ✓ unauthenticated
  ✓ unprocessable entity

   PASS  Tests\Feature\Http\Controllers\Auth\LogoutControllerTest
  ✓ success
  ✓ already unauthenticated

   PASS  Tests\Feature\Http\Controllers\Api\MeControllerTest
  ✓ success
  ✓ unauthenticated

  Tests:  9 passed
  Time:   1.78s
```

テストを実行して正常に通ればokです。
テスト用のデータベースを利用していない場合、データベースの中身がテストするたびにクリアされてしまうのでテスト用のデータベースコンテナを追加すると便利です。

- [Docker × Laravel テスト用のデータベースコンテナを構築する](https://qiita.com/ucan-lab/items/42c1814d8bd69895374c)

### 補足: $this->postJson(), $this->getJson()

`$this->postJson()`, `$this->getJson()` を指定しないと例外発生時はHTTPレスポンスが返ってきてしまいます。
`$this->post()`, `$this->get()` でテスト書きたい場合は下記の記事を参考にミドルウェアを作ると良いです。

https://qiita.com/ucan-lab/items/1d540645aa29112a418e

### 補足: sanctum:prune-expired

`php artisan sanctum:prune-expired` コマンドはpersonal_access_tokens テーブルで有効期限の切れたトークンのレコードを削除します。
つまり、今回のSPA認証では使用しないコマンドです。