🙆

Laravel Sanctumの処理を解剖しながら「SPA→Sanctumで接続先DB(config)の動的変更」を実装する

2024/05/16に公開

BEST FAVでエンジニアをしているkarlovicです。

BEST FAVについて

BEST FAVはグローバルなおすすめ情報サイトで、最初にインドネシアとスペインにサービス展開をしています。

他の国にも展開予定で、1つのコードで複数展開ができるように、・・・正確には、

  • サーバー(ソースコード)は同じ
  • DBだけを複数立てる
  • ウェブ環境ではidesなどサブドメインに国の識別子を入れることでDBの接続先を変える
  • CMS環境では上記をリクエストヘッダに含めることでDBの接続先を変える
    という構成で、1人月×1ヶ月半で手軽に多地域展開できるシステムを開発しました。

サブドメインから接続先を変えるのは比較的容易に実装できましたが、CMS側(Laravel Sanctum)で上記の接続先変更処理を実装するのにやや苦労したので記事にしました。

構成

サーバー: Laravel
フロント: React

  • フロント側はCRUDを持つCMSのようなもの
  • 認証にはLaravel SanctumのSPA認証(≠ APIトークン認証)を使う
  • CSRF Tokenまわりの基本的なところは他の方々が詳しく記事を書いてくださっているので省略

内容

全く同じソースコードで2つ以上のサイトを動かす。
諸般の事情により、DBのHostは同一で、databaseを増やしていくという構成。

例:
サイトA - dbAの内容を表示
サイトB - dbBの内容を表示
CMS - 同じシステムでdbA,Bの両方に書き込めるようにする(ログイン時にどちらのサイトのCMSとして開くかを指定する)

(この構成自体の是非は一旦議論の対象外...)

今回実装したいこと

Reactからログイン時の1回のみ、POST Request Headerに接続先dbAまたはdbBを埋め込み、セッションの中にログイン情報と一緒に接続先データベース情報を埋め込む。

最初にやったこと

※ React側の処理は省略します

ログイン時にセッションの中に接続先DBを入れる

LoginController.php
// 略
if (Auth::attempt($credentials, true)) {
    $request->session()->regenerate();
    $request->session()->put('db', $db);
}
// 略

対象のルーティングにミドルウェアを入れる

api.php
Route::middleware(['CustomMiddleware', 'auth:sanctum'])->group();

そのミドルウェア内でセッションのdbを読んで、そのリクエスト内での接続先dbを決定

CustomMiddleware.php
public function handle(Request $request, Closure $next): Response
{
    $db = $request->session()->get('db');

    if (empty($db)) {
        return $next($request);
    }

    Config::set('database.default', $db);

    return $next($request);
}

エラー

起きた問題

401 Unauthorized.

ログインはできたものの、その後対象のルーティング(下記)にログイン状態でSPAからリクエストしたら、401 Unauthenticated.が返却された。

api.php
// このグループへのリクエストが401エラー
Route::middleware(['CustomMiddleware', 'auth:sanctum'])->group();

直感としては、「ユーザーの権限管理・認証を各国のDBにて行なうべきなのに、国判定→DB判定の処理がそれよりあとに行われているせいで、defaultのDBで権限管理・認証が行われてしまっている」的なことが起きていそう。

原因探索

Sanctumの処理を追いかける

Sanctumが出しているエラーなのでauth:sanctumで呼ばれている処理を泣く泣く覗く。

まずはルートにかかっているミドルウェアから。

Kernel.php
protected $middlewareGroups = [
    'api' => [
        \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
    ],
];

APIルートの時(≒ api.php内のルーティングを処理する際)、EnsureFrontendRequestsAreStatefulを読みますという話。
※ なお、これはRoute::middleware()よりも先に処理される。

EnsureFrontEndRequestsAreStateful.php
public function handle($request, $next)
{
    $this->configureSecureCookieSessions();

    return (new Pipeline(app()))->send($request)->through(
        static::fromFrontend($request) ? $this->frontendMiddleware() : []
    )->then(function ($request) use ($next) {
        return $next($request);
    });
}

protected function frontendMiddleware()
{
    $middleware = array_values(array_filter(array_unique([
        config('sanctum.middleware.encrypt_cookies', \Illuminate\Cookie\Middleware\EncryptCookies::class),
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
        \Illuminate\Session\Middleware\StartSession::class,
        config('sanctum.middleware.validate_csrf_token'),
        config('sanctum.middleware.verify_csrf_token', \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class),

        // これっぽい!!
        config('sanctum.middleware.authenticate_session'),

    ])));

    array_unshift($middleware, function ($request, $next) {
        $request->attributes->set('sanctum', true);

        return $next($request);
    });

    return $middleware;
}

リクエストに対して手動で必要なミドルウェアを設定していて、この中でconfig('sanctum.middleware.authenticate_session')という認証の基盤になりそうなファイルを読んでいる。

ちなみに後で問題になるのだが、この中でStartSession::classのミドルウェアが適用されており、セッションがスタートする。
※ ネットに書いてある「セッション使うならそもそもapiガードじゃない方がいいのでは?」論は、Sanctum SPA認証を使う上では避けられないことである。

config/sanctum.php

'middleware' => [
    'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
    'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
    'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
],

たらい回されてはいるが、AuthenticateSession.phpで処理をしている。

AuthenticateSession.php
public function handle(Request $request, Closure $next): Response
{
    if (! $request->hasSession() || ! $request->user()) {
        return $next($request);
    }

    $guards = Collection::make(Arr::wrap(config('sanctum.guard')))
        ->mapWithKeys(fn ($guard) => [$guard => $this->auth->guard($guard)])
        ->filter(fn ($guard) => $guard instanceof SessionGuard);

    $shouldLogout = $guards->filter(
        fn ($guard, $driver) => $request->session()->has('password_hash_'.$driver)
    )->filter(
        fn ($guard, $driver) => $request->session()->get('password_hash_'.$driver) !==
                                $request->user()->getAuthPassword()
    );

    if ($shouldLogout->isNotEmpty()) {
        $shouldLogout->each->logoutCurrentDevice();

        $request->session()->flush();

        throw new AuthenticationException('Unauthenticated.', [...$shouldLogout->keys()->all(), 'sanctum']);
    }

    return tap($next($request), function () use ($request, $guards) {
        if (! is_null($request->user())) {
            $this->storePasswordHashInSession($request, $guards->keys()->first());
        }
    });
}

平たくいうと、Laravelの$request->user()を使って、request->userが存在する場合はsessionにpassword_hashを持たせて通過、そうでない場合は持たせずに通過させる。で、そもそもrequest->userが存在しない場合などはLaravel側の認証で弾かれるというもの。
おそらく、このhash_passwordの有無で別の場所でログイン判定を行い、ない場合は401を出しているものと予想される。

上記コードをデバッグしたところ、ログイン中であるにもかかわらず最初のif文で早期退場していた。

if (! $request->hasSession() || ! $request->user()) {
    return $next($request);
}

$request->user()がNULLとなっていたのが原因であった。(本来、ログイン中であればログイン中ユーザーのModelクラス->toArray()したものが存在する)
401 UnauthorizedエラーはSanctumのエラーではなくLaravelのエラーだった(><)

Laravelが$request->user()でやっていること

① 前提:ログインした際に、セッションの中にログイン時中のユーザーidをlogin_{$guard}_セッション名というキーの中に保存している。

// ログイン中に下記を実行
dd($request->session()->all());

// 結果
// [
//    'login_web_hogehogehogehoge': 1, // ログイン中のユーザーのid
// ]

② その値を使って、Userモデルを取得(詳細は割愛)

=> DBが正しくミドルウェアで設定できておらず、別のDBから取得していたせいでNULLになっていた。

原因まとめ

api.phpmiddleware('auth:sanctum')で動くミドルウェアAuthenticateSessionの中で$request->user()の結果がNULLであったこと。

動的なDBの設定がうまくできていなさそう。

解決

CustomMiddlewareが先に動いてDBを決めていると思っていたが、実際にはSanctumのミドルウェア(Ensure...)が先に動いていた。
ミドルウェアの実行順は、ルートミドルウェア => ルート内でのミドルウェアであるため。

したがって、DB_CONNECTIONが設定されておらず、ログイン中であるにもかかわらず$request->user()がNULLとなっていた。

ので、次のようにしてクリア。

① api.phpからCustomMiddlewareの処理を削除し、

api.php
Route::middleware(['CustomMiddleware', 'auth:sanctum'])->group();

api.php
Route::middleware('auth:sanctum')->group();

② より次元の高いルートに対してCustomMiddlewareを設定、

Kernel.php
protected $middlewareGroups = [
    'api' => [
        // 上に配置
        \App\Http\Middleware\CustomMiddleware::class,
        // 略
        \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
        // 略
    ],
]

※ この際、CustomMiddlewareをEnsure...ミドルウェアの上に置いてあげないと、Ensure...の中で呼ばれる$request->user()のDB接続先が変わらないので注意

③ 本来はEnsureFrontendRequestsAreStatefulによってSessionがスタートしていたが、CustomMiddlewareを上に置いた都合でRequestがセッションを持たずエラーとなってしまうため、次のミドルウェアを追加。

Kernel.php
'api' => [
        // CustomMiddlewareでSessionを使うためのミドルウェア
        \App\Http\Middleware\EncryptCookies::class,
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
        \Illuminate\Session\Middleware\StartSession::class,

        // 上に配置
        \App\Http\Middleware\CustomMiddleware::class,
        // 略
        \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
        // 略
],

本来はSanctum様をみならって、CustomMiddlewareの中で依存する3つのミドルウェアを読み込んであげるべきだが、とりあえず上記で十分解決はしたので一旦ok。

Discussion