238
198

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Laravel API SanctumでSPA認証する

Last updated at Posted at 2021-06-17

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

環境

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

公式系

関連記事

Laravel Sanctumの仕組み

SPA認証(クッキー認証)

  • SPA(Single Page Application)等のJavaScript APIとの認証に使用する
  • HasApiTokensトレイト、personal_access_tokensテーブルは不要
  • 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トークン認証を使用するべきではありません

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

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

今回やること

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

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

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

$ composer require laravel/sanctum

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

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

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

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

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

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

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

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

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

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

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

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

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

.env
SANCTUM_STATEFUL_DOMAINS=www.example.com
  • SANCTUM_STATEFUL_DOMAINS にはフロント側のFQDN(ホスト名+ドメイン名)を設定します。
    • 基本は APP_URL に設定している値に合わせると良いです
  • ローカルのSPA環境が定義済み(config/sanctum.php)のFQDNを使用していればこの設定は不要です。
  • ローカル環境でも config/sanctum.php に定義されていないFQDNを使用している場合は下記のように SANCTUM_STATEFUL_DOMAINS 環境変数を設定します。
.env
SANCTUM_STATEFUL_DOMAINS=localhost:8000
ファーストパーティドメインの設定(補足)

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

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

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 にドメインとポート番号を設定してください。

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

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

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 ミドルウェアの補足です。

vendor/laravel/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.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);
    }
}

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

  • セッションクッキーのセキュア設定を強制
  • 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とクッキー(別のサブドメインで実行する場合)

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

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

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

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

config/session.php
    'domain' => env('SESSION_DOMAIN', null),
.env
SESSION_DOMAIN=.example.com
  • ※ドメインの先頭に . を付けます。
  • null の場合、サブドメイン間でCookieの共有ができません。
  • localhost で確認する場合は不要です。

CSRF保護(Laravel側)

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

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

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

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

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

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

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

ログイン

  • /login ルートに POST リクエストを手動実装する
  • web 認証ガードを使用する
  • Laravelが提供するセッションベースの認証サービス
  • ユーザーのセッションが期限切れになった場合、後続のリクエストは401か419HTTPエラーを返却する
    • 401, 419エラーが出た場合はSPA側でログインページにリダイレクトする必要がある

補足: ルートの保護の例

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

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をセッションドライバーとして利用すると良いでしょう。

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

コントローラの作成

$ 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 はシングルアクションコントローラのひな形を生成します。

app/Http/Requests/Auth/LoginRequest.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'],
        ];
    }
}
app/Http/Controllers/Auth/LoginController.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();
    }
}
app/Http/Controllers/Auth/LogoutController.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.',
        ]);
    }
}
app/Http/Controllers/Api/MeController.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,
            ],
        ]);
    }
}

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

ルーティングの定義

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

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');
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.phppathsloginlogout を追記します。

config/cors.php
    'paths' => [
        'api/*',
        'login', // 追記
        'logout', // 追記
        'sanctum/csrf-cookie',
    ],
$ 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を使ってデモユーザーを追加します。

$ 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で行ってます。

注意点①

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_URLhttp://localhost を入れておいてください。

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

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

ScreenShot 2022-04-29 2.32.48.png

Bodyタブは raw を選択してJSON形式で送ります。
ScreenShot 2022-04-29 2.33.47.png

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

ScreenShot 2022-04-29 2.38.21.png

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

ScreenShot 2022-04-29 2.39.59.png

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

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

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

ScreenShot 2022-04-29 2.41.23.png

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

テストコードの作成

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

$ php artisan make:test Http/Controllers/Auth/LoginControllerTest
$ php artisan make:test Http/Controllers/Auth/LogoutControllerTest
$ php artisan make:test Http/Controllers/Api/MeControllerTest
tests/Feature/Http/Controllers/Auth/LoginControllerTest.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.'],
                ],
            ]);
    }
}
tests/Feature/Http/Controllers/Auth/LogoutControllerTest.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();
    }
}
tests/Feature/Http/Controllers/Api/MeControllerTest.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.',
            ]);
    }
}

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

テストコードの実行

$ 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です。
テスト用のデータベースを利用していない場合、データベースの中身がテストするたびにクリアされてしまうのでテスト用のデータベースコンテナを追加すると便利です。

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

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

補足: sanctum:prune-expired

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

238
198
13

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
238
198

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?