【Laravel9】SanctumのSPA認証下でXSRF-TOKEN検証が効かない時は

バナー

お久しぶりです!
元PR部長、現エンジニアのナカムーです。

エンジニア

ナカムー

今回はおひさの「技術記事」で、
実は今回でまだ2本目。

エンジニア

ナカムー

PR部長時代は雑記記事ばっか書いてました。
久々にガンバリマス。

エンジニア

ナカムー

今回はLaravelの公式ライブラリ「Sanctum」の導入時に若干トラップ臭かった部分について、
特に私が小一時間ぶち当たった部分を拾い上げ、恨みつらみを込めてネチネチとご説明していきます
(※実は参考ドキュメントの中にもチラッと書いてあるのは内緒)。

また、記事の本筋には関係ありませんが、
本件はLaravel × Reactを用いたSPA形式のシステム製造中に発生した事案です!

環境情報

Laravel Framework 9.19.0
Laravel Sanctum 2.15.1
PHP 8.1.10

そもそもSanctumって?

※「それは大体知ってるからタイトルの話しろ」って方は流し読み推奨

 

SPA認証等々を比較的簡単に実現する、Laravel公式のライブラリ。

APIトークン認証とcookieベースの認証両方の導入に対応している
(本記事内でフォーカスしているのはcookieベースの方)。

Laravelで何も考えずに認証機能を実装する時に使用する、
cookieベースの認証方式に乗っかった形でのSPA認証機能を実現する。

またこの時、SPA(例:React)側とAPI(Laravel)側のトップレベルドメイン(.jp .comなど)は
必ず同じである必要があるため注意(サブドメインが異なる分には問題ナシ)。

参考: https://readouble.com/laravel/9.x/ja/sanctum.html

その他、基本的な導入方法・注意点云々については上記参考ドキュメントおよび、
他メディア等の先人の皆様が参考記事を沢山投下してくださっているので、そちらを参照ください。

トラップ概要

今回遭遇したトラップ(トラブル?)の経緯は下記の通り。

~Sanctumの基本設定を終えて、SPAに組み込んだ動作検証の開始直後~

「よし、XSRF-TOKEN発行されてる!こいつでリクエスト元を検証してるんだな…」

「動作確認!試しにXSRF-TOKENめちゃくちゃにしてリクエストしてみるか」

「あれ、何でも受け付ける…ってか、もはやXSRF-TOKEN指定してなくても動く…?」

⇒何らかの要因でリクエストのXSRF-TOKENがチェックされていない?
⇒XSRF-TOKENはリクエストに確かに含まれていたため、受け取り側(Laravel)がおかしいと仮定して調査

補足

この状態の何がマズいかって、
Laravel(API)のエンドポイントとリクエスト内容が合っており、
且つリクエスト元のユーザが当該システム上で認証状態にさえなっていれば、

正規のリクエスト元ではなくても要求内容を受け入れてしまう

という点です。

いわゆるCSRFというやつ。
対策さえきちんと行えていれば、必要以上に恐れることはないのですが…。

(※普段何の気なしにLaravelアプリケーションを作成する時、
GET以外のフォーム内に深い意味を考えず「@csrf」ってやつを明記している方もいるかもしれませんが、
あれも上記のような攻撃に対策するために行われています)

解決方法

上記問題への対応ですが、ざっくり結論からすると「.envを見直そう」って話でした。

単純なポイントにこそ問題の根っこが潜んでいます…。

Sanctumは導入時、必要に応じて.envにいくつかの環境変数を追記する必要があります。
本件はそのうちの1つ、SANCTUM_STATEFUL_DOMAINSが悪さをしていた模様。

下記は.env内記述のイメージですが、コメントで示した通り、
ここで定義するドメインには適宜「ポート番号」まで含めて記載しないといけないようです。

# 環境のドメインにポート番号が含まれる場合はポート番号まで必要
SANCTUM_STATEFUL_DOMAINS=example.com:55555

ここでの「適宜」って何やねん

ブログ記事_07

弊社マスコットキャラクター

カール

って話ですが、「認証を維持したいフロント環境について、
ポート番号を含むドメインでのアクセスが必要かどうか」
が判断基準となります。

今回トラブった開発環境には独自のポート番号を割り当てており、
開発環境へのアクセス時も当該ポート番号を指定する必要があったため、
仮にドメインそのものが「localhost」であったとしても、ここではポート番号の指定までが必要でした。

上記の通りポート番号まで指定すると、問題の「XSRF-TOKENが仕事してない件」は解決しました。

余談

※早くも記事の本題は終わったので、暇じゃない方は以降流し読みでOK

ここでもう一点、知っておくと良さげな情報として…。
そもそも、厳密にはこのSANCTUM_STATEFUL_DOMAINSの指定は必須ではありません。

config/sanctum.phpを確認してみてください。
下記のような箇所が冒頭にあるかと思います。

/*
|--------------------------------------------------------------------------
| 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',
    Sanctum::currentApplicationUrlWithPort()
))),

ここでsprintf()が吐くドメイン群に目的とする環境のドメインが指定されていない場合は、
「.env」の「SANCTUM_STATEFUL_DOMAINS」に当該環境のドメインを明示的に記載するようにしましょう。

特にいじっていないベーシックな開発環境が相当する
localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1」のみが指定されていますので、
少しポートをいじったローカル環境や本番環境へのデプロイ時は、ほぼほぼ.envへの記載が必要になります。

また、上記sprintf()の中では下記の部分も少し気になります。
何をしているのか、少し掘っておきました。

Sanctum::currentApplicationUrlWithPort()

上記メソッドの中身は以下の通り。

/**
 * Get the current application URL from the "APP_URL" environment variable - with port.
 *
 * @return string
 */
public static function currentApplicationUrlWithPort()
{
    $appUrl = config('app.url');

    return $appUrl ? ','.parse_url($appUrl, PHP_URL_HOST).(parse_url($appUrl, PHP_URL_PORT) ? ':'.parse_url($appUrl, PHP_URL_PORT) : '') : '';
}

config/app.phpから’url’(=.envのAPP_URL)を引いて、
ドメインとポート番号を明示的に取得して結合したものを返却…。

未指定の場合も想定して、至れり尽くせりしてくれてますね
(※parse_urlの詳細が気になる方はこちらを参照ください)。

上記から分かるのは、

  1. .envのAPP_URLにサイトURLの明記があり、
  2. Laravel(バックエンド)とフロント側のドメイン・ポートが同じ場合、

厳密にはSANCTUM_STATEFUL_DOMAINSの明記は不要といったところでしょうか。

…と、余談に興が乗りすぎましたが、個人的には恐らく環境毎の管理も明示的にしやすくなるので、
SANCTUM_STATEFUL_DOMAINS」はいついかなる時でも.envに記載しておきたい気はします。

前述のポート番号トラップにだけ、どうかお気を付けて…。

関連ミドルウェアざっくり調査

前述のトラップで検証が効いていなかったのは分かった…のですが、
具体的に「何がどうなって効いていなかった」のか気になったので、少し掘り下げておきました。

EnsureFrontendRequestsAreStateful

まず、Laravel Sanctum導入にあたって適用する(コメントアウトを外す)ミドルウェア
EnsureFrontendRequestsAreStatefulの内容が、ざっと下記の通り。

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

先に結論だけ書いておくと、
今回は上記ミドルウェア下部のfromFrontend()メソッドでfalse判定が返された結果、
XSRF-TOKENのチェックに関連するミドルウェアの適用がまるっとスルーされていたようです。

XSRF-TOKENのチェックに関連するミドルウェア」もあとで簡単に見ていきますが、
ひとまず元凶のfromFrontend()メソッドを先に解剖しておきましょう。こいつめ。

fromFrontend()概要

まず初っ端で、リクエストのリファラまたはオリジン(以降「ドメイン情報」と表記)を受け取っています。
Sanctumを導入したAPIのリクエストヘッダにドメイン情報が必要とされているのは、
恐らくこちらのメソッド内処理のためですね。

その後、受け取ったドメイン情報を評価可能な形式(「ドメイン:ポート」の形式)にこねくり回します。

  • プロトコルの情報(https://またはhttp://)を除く
  • 末尾に「/」がなければ追加

最後に、前述したconfig/sanctum.php内の’stateful’を参照し、
それらの末尾に「/*」を必ず追加したものとドメイン情報を比較した結果をbool値として返却します。

ここで返却されるbool値が、すなわち
Sanctumの意図したフロント環境からのリクエストかどうか(=必要なミドルウェアを適用するかどうか)
の判断結果になっていると考えて良さそうです。

その他関連ミドルウェア

ここでは、具体的にトークンやらセッションやらをゴニョゴニョする
ミドルウェア達の役割をざっと調べた結果を書いておきます。

これらは別段Sanctum特有~とかではなく、
普通にLaravelでwebアプリケーションを作成するときにwebルートで使用されているものです。

app/Http/Kernel.phpの$middlewareGroupsに定義されている’web’を見ると、
今から説明していくミドルウェア達が確かに定義されていることが分かります。

/**
 * The application's route middleware groups.
 *  
 * @var array<string, array<int, class-string|string>>
 */
 protected $middlewareGroups = [
    'web' => [
        \App\Http\Middleware\EncryptCookies::class, // これと
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, // これと
        \Illuminate\Session\Middleware\StartSession::class, // これと
        \Illuminate\View\Middleware\ShareErrorsFromSession::class,
        \App\Http\Middleware\VerifyCsrfToken::class, // これ
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],

    'api' => [
        // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
        'throttle:api',
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],
];

ちなみに、一部ブログ記事等の内容を読み違えると、
「これらのミドルウェアを’api’定義の方にコピペしましょう」
的なニュアンスに受け取れてしまうこともあるので注意です。

上記の対応でも一見すると確かにそれっぽく動くのですが、
本来その役割はEnsureFrontendRequestsAreStatefulが担ってくれています。
しかも、それらミドルウェアを適用するべきリファラ・オリジンかどうかチェックしてくれるオマケ付き。

また、上記対応では下手すると同じミドルウェアが2重に適用される結果になったりするので、
特にセッション管理周りでしばしばトラブります。
Sanctumを使う時は、素直にEnsureFrontendRequestsAreStatefulさんにお任せしておきましょう。

余談は程々に、各ミドルウェアの簡易説明に進みます。

EncryptCookies

「Encrypt」=「暗号化する」の意。
その名の通り、受け取った/返却するcookieの暗号化と復号化を担っているようです。

AddQueuedCookiesToResponse

返却するレスポンスヘッダに必要なcookieをセットする役割を担っています。

StartSession

その名の通り、セッションの起点となるミドルウェアです。

何らかの経緯でこいつが誤って二重に指定されたりすると、
セッションが爆発します(意訳:セッション情報が大量に作成されたりします)。

VerifyCsrfToken

今回まさに動いてほしかった「XSRF-TOKENの検証」処理を担当します。

お察しの通り、簡易説明では大体どれも名前の通りでしたので、参考程度にお願いします。

最後に

今回は比較的レアケース?な事例を起点に、最低限の備忘録として執筆してみました。

Laravelはあまりに有名すぎるフレームワークですが、
弊社はまだドキュメントの少ないツール・技術等々であっても臆せず取り入れていく土壌を持っております。

少しでも興味を持っていただけた方は、下記よりご応募ください!お待ちしております。

採用サイトバナー