【Next.js×Laravel8】Laravel SanctumのSPA認証を実装

ゆーたろー

こんにちは、ゆーたろーです。

HRTechベンチャーのエンジニアです。
TypeScript/Vue.js(Nuxt.js)/React/Next.js/PHP/LaravelでWebアプリケーションの開発を行っています。

・プログラミングスクールメンター
・神戸で勉強会「つながる勉強会」を運営
・神戸メインのグルメインスタ運営

など色々やっています。1児のパパです。

実務でNext.jsのSPA(SSG)+Laravel8(API)のWebアプリケーションを開発しており、Laravel Sanctum(ララベル サンクタム)を使ったSPA認証を実装することができたのでまとめておきます。

始めに書いておきますが、めちゃくちゃエラーにハマりました…ドキュメント通りに進めても上手くいかないところがあったのでそれも込みで書きます。

Laravel Sanctumについてはこちらで詳しくまとめていますのでよかったらこちらも読んでみてください。

目次

環境準備

Next.jsとLaravel(API)の開発環境はDockerで構築しているという前提でこの記事を書きます。

ですのでステージング環境や本番環境ではそれぞれ設定する内容は変更する必要があります。

それぞれのアプリケーションの各コンテナのポート番号は以下のように設定しています。(一応URLごと記載)

  • Next.js:http://localhost:3001
  • Laravel(API):http://localhost:8080

Next.jsからLaravel(API)にはAxiosで通信します。

CORS設定は以下。(完成版ではありません)

<?php

return [

    // 略

    'paths' => ['api/*', 'sanctum/csrf-cookie'],

    'allowed_methods' => ['*'],

    'allowed_origins' => ['http://localhost:3001'],

    'allowed_origins_patterns' => [],

    'allowed_headers' => ['*'],

    'exposed_headers' => [],

    'max_age' => 0,

    'supports_credentials' => false,

];

Laravel Sanctumを導入する

以下2つのコマンドを実行。

# インストール
composer require laravel/sanctum

# 設定ファイル自動作成
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

# 上記コマンドで作成されるファイル
database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php
config/sanctum.php

SPA認証の場合は生成されたマイグレーションファイルは使用しないので削除してOKです。

Laravel SanctumのSPA認証を実装する

ここから本題の実装方法をまとめます。

基本的には以下のドキュメント通りに進めていけば実装ができますが、一部ドキュメント通りに行かなかった(そこでめちゃくちゃハマりましたw)のでその辺りも含めての実装方法です。

  • 公式ドキュメント
  • 日本語訳

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

APIにリクエストを行うときにLaravelセッションクッキーを使用して「ステートフル」な認証を維持するドメインを指定します。

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Stateful Domains
    |--------------------------------------------------------------------------
    |
    | Requests from the following domains / hosts will receive stateful API
    | authentication cookies. Typically, these should include your local
    | and production domains which access your API via a frontend SPA.
    |
    */

    '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) : ''
    ))),

    // 略
];

.envにSPAのドメインとポート番号を指定します。

SANCTUM_STATEFUL_DOMAINS=localhost:3001

config/sanctum.phpでデフォルトで指定されているドメイン(+ポート番号)(例えばlocalhostとかlocalhost:3000とか)の中に認証状態を維持したいSPAのドメイン+ポート番号が含めれている場合は上記設定は不要ですが、設定しておくのが確実です。

Sanctumミドルウェアの設定

apiミドルウェアグループにSanctumミドルウェアを追加します。

<?php

namespace App\Http;

use Illuminate\Foundation\Http\Kernel as HttpKernel;

class Kernel extends HttpKernel
{
   // 略

    /**
     * The application's route middleware groups.
     *
     * @var array
     */
    protected $middlewareGroups = [
        // 略

        'api' => [
            // この1行を追加
            \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
            'throttle:api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
    ];

    // 略
}

これで/api/〜でルーティングされたAPIに対するリクエストでセッション・Cookieによる自動認証が可能となります。

Sanctumミドルウェアの詳しい仕様はこちらの記事がわかりやすいです。

CORSとCookieの設定

config/cors.phpを以下の通り修正します。

<?php

return [

    // 略

    // trueに変更
    'supports_credentials' => true,

];

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

falseのままだと、認証処理時にCORSエラーになります。(以下のエラー文がブラウザコンソールに表示されます)

Access to XMLHttpRequest at ‘http://localhost:8080/sanctum/csrf-cookie’ from origin ‘http://localhost:3001’ has been blocked by CORS policy: The value of the ‘Access-Control-Allow-Credentials’ header in the response is ” which must be ‘true’ when the request’s credentials mode is ‘include’. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

次にアプリケーションのグローバルなaxiosインスタンスでwithCredentialsオプションを有効にする必要があります。

LaravelにVue.js、Reactを組み込んだ構成の場合はresources/js/bootstrap.jsに以下を追記します。
(今回はNext.jsで作った独立したSPAなので、ログイン画面作成時にSPA側で設定します)

axios.defaults.withCredentials = true;

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

SESSION_DOMAIN=localhost

定義した環境変数はconfig/session.phpに使用されます。

<?php

use Illuminate\Support\Str;

return [
    // 略 

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

    // 略
];

これは任意ですが、セッションドライバーをfileからcookieに変更します。

SESSION_DRIVER=cookie

僕はデフォルトのfileのままにしています。(Redis使った方がいいのかな?)

ログイン処理の実装

ログイン処理を実装します。

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

axios.get('/sanctum/csrf-cookie').then(response => {
    // ログイン処理を実装する
});

/sanctum/csrf-cookieへのルーティングはLaravel Sanctumをインストールした時点で自動的に追加されます。

上記ルーティングは/vendor/laravel/sanctum/src/SanctumServiceProvider.phpによって追加されているため、routes/web.phproutes/api.phpを探しても見つかりません。

// 略
protected function defineRoutes()
{
   // 略

    Route::group(['prefix' => config('sanctum.prefix', 'sanctum')], function () {
        Route::get(
            '/csrf-cookie',
            CsrfCookieController::class.'@show'
        )->middleware('web');
    });
}

確認のため、php artisan route:listで確認しておきましょう。

ログイン処理は/loginルートにPOSTリクエストする必要があります。

独自の/loginエンドポイントを自由に作成できます。ただし、標準のLaravelが提供するセッションベースの認証サービスを使用してユーザーを認証していることを確認する必要があります。通常、これはweb認証ガードを使用することを意味します。

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

この記事ではドキュメント通りweb認証ガードを使用して手動実装していきます。(Laravel Fortifyというパッケージを使用する方法もあるようです)

ということでルーティングの追加、CORS設定の変更を行います。

Route::post('/login', [LoginController::class, 'login']);

Laravel8はこれまでのルーティングの指定方法から変更になっているので少し注意です。

return [
    // 略

    // 'login'を追加
    'paths' => ['api/*', 'login', 'sanctum/csrf-cookie'],

    // 略
];

Next.js側で実装するログイン画面は記事用なので超簡易的ですがこのような感じです。

// ログインページ

import axios from 'axios'
import { ChangeEvent, useState } from 'react'

type LoginParams = {
  email: string
  password: string
}

export default function Login() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const changeEmail = (e: ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value)
  }
  const changePassword = (e: ChangeEvent<HTMLInputElement>) => {
    setPassword(e.target.value)
  }

  const handleClick = () => {
    const loginParams: LoginParams = { email, password }
    axios
      // CSRF保護の初期化
      .get('http://localhost:8080/sanctum/csrf-cookie', { withCredentials: true })
      .then((response) => {
     // ログイン処理
        axios
          .post(
            'http://localhost:8080/login',
            loginParams,
            { withCredentials: true }
          )
          .then((response) => {
            console.log(response.data)
          })
      })
  }

  // SPA認証済みではないとアクセスできないAPI
  const handleUserClick = () => {
    axios.get('http://localhost:8080/api/user', { withCredentials: true }).then((response) => {
      console.log(response.data)
    })
  }

  return (
    <>
      <div>
        メールアドレス
        <input onChange={changeEmail} />
      </div>
      <div>
        パスワード
        <input onChange={changePassword} />
      </div>
      <div>
        <button onClick={handleClick}>ログイン</button>
      </div>
      <div>
        <button onClick={handleUserClick}>ユーザー情報を取得</button>
      </div>
    </>
  )
}

ログイン処理を担当するコントローラーはこちらも簡易的に以下の(最低限の)内容にしています。

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class LoginController extends Controller
{
    public function login(Request $request): JsonResponse
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => 'required',
        ]);

        if (Auth::attempt($credentials)) {
            $request->session()->regenerate();
            return response()->json(['name' => Auth::user()->email], 200);
        }

        throw new Exception('ログインに失敗しました。再度お試しください');
    }
}

ここまでの状態でログインを行えば、ログインが成功してブラウザコンソールにログインユーザーのメールアドレスが表示されると思います。

ルートの保護

Laravel Sanctumでの認証が必要なルーティングを設定する場合はsanctum認証ガードを指定します。

<?php

use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\QuestionnaireController;
use App\Http\Controllers\SampleController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

// auth:apiをauth:sanctumに変更
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

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

この修正によりログイン画面(pages/login.tsx)の以下の部分にはSPA認証後でないとリクエストできないようになります。
(認証前だと401エラーになります)

 // SPA認証済みではないとアクセスできないAPI
  const handleUserClick = () => {
    axios.get('http://localhost:8080/api/user', { withCredentials: true }).then((response) => {
      console.log(response.data)
    })
  }

ログイン後であれば、ブラウザコンソールにログインユーザーの情報が表示されます。

ブラウザをリロードしても認証状態が維持されます。

補足

ドキュメント通りに進めてうまくいなかったことがあるのでいくつか書いておきます。

CSRF保護初期化時にSRF-TOKENのCookieが保存されない

/sanctum/csrf-cookieルートへのリクエスト後にブラウザにXSRF-TOKENのCookieが保存されなかったことです。

対応方法としてはAxiosの設定に{ withCredentials: true }を追加することで解決しました。

// CSRF保護の初期化 
axios.get('http://localhost:8080/sanctum/csrf-cookie', { withCredentials: true })

上記ルーティングからのレスポンスヘッダーにSet-Cookieヘッダーが含まれていたのですが、{ withCredentials: true }を設定しないとブラウザにCookieが保存されないようです。以下の記事参考。

ログイン後にsanctum認証ガードを設定したAPIにリクエストできない

ログイン後に/api/userにGETすると、401エラーになりました。

この原因は.envに設定していたSANCTUM_STATEFUL_DOMAINSの値をLaravel側のポート番号にしていたことでした。

SANCTUM_STATEFUL_DOMAINS=localhost:8080

認証状態を維持するアプリケーションはNext.jsなのでLaravelにしたらダメですよね。。初めは上記環境変数の意味を正確に理解できていなかったのでここも結構詰まりました。(なお、localhostのみでも同様にエラーになります)

参考記事

最後に

社内に聞ける人がおらず、悪戦苦闘しまくり時間も溶かしてしまいましたが、なんとか実装できてよかったです…

Laravel7では、SPA認証のログイン処理にweb認証ガードを使うような記載はないので大抵の記事はログイン処理はroutes/api.phpに定義していますが、Laravel8からはweb認証ガードを使うように書かれているのでここも意外と手こずりました。

ちなみに、ログイン処理をルーティングをroutes/api.phpに定義して/api/loginにしても問題なく動作しました。

この記事が気に入ったら
フォローしてね!

よかったらシェアしてね!

この記事を書いた人

上場グループのHRTechベンチャーで働くWebエンジニアです。
新卒で入社した大手重工メーカを4年で退職し、2020年4月からエンジニアとキャリアチェンジしました。

仕事ではTypeScript/Vue.js(Nuxt.js)/Laravelを主に使っています。

プログラミングスクールの講師やデザイン関連のお仕事もさせてもらっています。

神戸で「つながる勉強会」という勉強会を月1で運営しています。
https://tsunagaru-kobe.connpass.com/

お仕事のご依頼、ご相談はお問い合わせページもしくはTwitterのDMからお願いします。

コメント

コメントする

目次
閉じる