Laravel 9.x Sanctum でマルチログインの実現

概要

Laravel9.xで標準インストールされているSanctumで

  • 管理者テーブルのユーザ
  • 顧客テーブルのユーザ

といった別々のテーブルのユーザを別々にログインできるようにしたい。

問題点

マニュアルや、設定ファイルに記載の通り設定しても以下のような問題が発生する。

  • Web画面とAPI両方とも利用するようなアプリケーションで正しく解決されない
  • 未ログインユーザのリダイレクト先(デフォルトでログイン画面)が一つしか設定できない

解決されない書き方

1. Authの設定ファイルを修正

以下のように書き換える

※尚、以下に記載している各テーブルについては、Laravelのマニュアルに記載されている通り設定する必要がある。

config/auth.php

// (省略)....

'guards' => [
    'user-web' => [
        'driver' => 'session', // 標準のSessionGuard
        'provider' => 'users', // 顧客テーブル名
    ],

    'user' => [
        'driver' => 'sanctum', // SanctumのGuard
        'provider' => 'users', // 顧客テーブル
    ],

    'admin-web' => [
        'driver' => 'session', // 標準のSessionGuard
        'provider' => 'admins', // 管理者テーブル
    ],

    'admin' => [
        'driver' => 'sanctum', // SanctumのGuard
        'provider' => 'admins', // 管理者テーブル
    ],
],

'providers' => [
    'users' => [
        'driver' => 'eloquent',
        'model' => App\Models\User::class,
    ],

    'admins' => [
        'driver' => 'eloquent',
        'model' => App\Models\Admin::class,
    ],
],

// (省略)...

driver => 'session' について

最初から標準で設定されているSessionGuardについては、APIのみを利用する場合にも以下の理由から必要になる。

  • SPA認証ではCSRFクッキーが必須になってしまう
  • SanctumのGuardは、attempt/logoutといったメソッドが実装されていないため、ログイン認証ができない

2. Sanctumの設定ファイルを修正

config/sanctum.php

// (省略)...

'guard' => ['user-web', 'admin-web'],

// (省略)...

3. 認証を試す

route/web.php


use Illuminate\Support\Facades\Auth;

Route::get('/user/login', function (Request $request) {
    echo <<<EOS
        <form action="/usr/auth" method="POST">
            <input type="text" name="email"><br>
            <input type="password" name="password"><br>
            <input type="submit" value="login>
        </form>
    EOS;
});

Route::post('/user/auth', function (Request $request) {
    if (Auth::guard('user-web')->attempt($request->only(['email', 'password']))) {
        return redirect('/user/dashboard');
    } else {
        echo "NG";
    }
});

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

Route::get('/admin/login', function (Request $request) {
    echo <<<EOS
        <form action="/usr/auth" method="POST">
            <input type="text" name="email"><br>
            <input type="password" name="password"><br>
            <input type="submit" value="login>
        </form>
    EOS;
});

Route::post('/admin/auth', function (Request $request) {
    if (Auth::guard('user-web')->attempt($request->only(['email', 'password']))) {
        return redirect('/admin/dashboard');
    } else {
        echo "NG";
    }
});

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

一見うまくいったように見えるが...

これを実行して試してみると、"/user/login"でログインをしているのに、"/admin/dashboard"にアクセスすることができてしまう。

また、未ログイン状態で/*/dashboardにアクセスしても、各々のログイン画面へ遷移できない。(App\Http\Middleware\Authenticate の redirectTo メソッド参照)

原因

Sanctumの認証が発生する流れは、以下のようになる。

  1. SPA認証や、Web認証が出来ていることを確認
  2. 出来ていない場合は、Sanctumが発行したAccessTokenがheaderに存在するか確認

そのため、記載した通り、Sanctumの設定ファイルの'guard'に、SessionGuardの名称を記載する必要があった。

しかし、それが原因で、adminだろうがuserだろうが一緒くたに認証を通してしまう。

かといって、これを設定しない場合、SPA認証が通らなくなってしまう。

解決策

特定のSessionGuardと特定のSanctumGuardを自分で紐づけて、全ての認証を分けるようなカスタムGuardを作成する。

1. SanctumのGuardクラスを参考にバイパスするGuard処理を作成する

app\Guards\BypassGuard.php

namespace App\Guards;

use Illuminate\Contracts\Auth\Factory as AuthFactory;

class BypassGuard
{
    private $config;
    private $auth;

    public function __construct(AuthFactory $auth, array $config)
    {
        $this->config = $config;
        $this->auth = $auth;
    }

    public function __invoke()
    {
        $sessionGuard = $this->config['session'];
        $sanctumGuard = $this->config['sanctum'];

        // 設定値に従ってsanctum.phpのguardを書き換える
        config(['sanctum.guard' => [$sessionGuard]]);

        // 設定値に入力したguardを実行
        return $this->auth->guard($sanctumGuard)->user();
    }
}

2. サービスプロバイダに登録する

基本的にvendor/laravel/sanctum/SunctumServiceProvider.phpに記載されているものをほとんどそのまま記述する形にする。

app\Providers\AppServiceProvider.php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Auth;
use Illuminate\Auth\RequestGuard;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }

    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        Auth::resolved(function ($auth) {
            $auth->extend('proxy-sanctum', function ($app, $name, array $config) use ($auth) {
                return tap($this->createGuard($auth, $config), function ($guard) {
                    app()->refresh('request', $guard, 'setRequest');
                });
            });
        });
    }

    /**
     * Register the guard.
     *
     * @param  \Illuminate\Contracts\Auth\Factory  $auth
     * @param  array  $config
     * @return RequestGuard
     */
    protected function createGuard($auth, $config)
    {
        return new RequestGuard(
            new \App\Guards\BypassGuard($auth, $config),
            request(),
            $auth->createUserProvider($config['provider'] ?? null)
        );
    }
}

※実際にはRequestGuardクラスの拡張として作成したかったが、Laravel\Sanctum\Guardクラスの実装に合わせた(その方が面倒がないため)

3. 認証失敗時のリダイレクト先を設定する

継承元の Illuminate\Auth\Middleware\AUthenticate を参考に以下のように修正

app/Http/Middleware/Authenticate.php

namespace App\Http\Middleware;

use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Auth\AuthenticationException;

class Authenticate extends Middleware
{

    // リダイレクト先を、'設定したGuardの名称' => 'ログインしたいルート名'で記載する
    protected $redirectTo = [
        'user' => 'user.login',
        'admin' => 'admin.login',
    ];

    protected function redirectToMultiLogin($request, $guards)
    {
        if (! $request->expectsJson()) {
            foreach ($guards as $guard) {
                if (isset($this->redirectTo[$guard])) {

                    // URLにパラメータを設定している場合の為にparametersも設定しておく
                    return route($this->redirectTo[$guard], $request->route()->parameters());
                }
            }
        }
    }

    /**
     * Handle an unauthenticated user.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  array  $guards
     * @return void
     *
     * @throws \Illuminate\Auth\AuthenticationException
     */
    protected function unauthenticated($request, array $guards)
    {
        throw new AuthenticationException(
            'Unauthenticated.', $guards, $this->redirectToMultiLogin($request, $guards)
        );
    }
}

4. Authの設定ファイルを再度修正

config/auth.php

// (省略)....

'guards' => [

    // BypassGuardを追加
    'user' => [
        'driver' => 'proxy-sanctum',
        'session' => 'user-web',
        'sanctum' => 'user-api',
    ],

    // BypassGuardを追加
    'admin' => [
        'driver' => 'proxy-sanctum',
        'session' => 'admin-web',
        'sanctum' => 'admin-api',
    ],

    'user-web' => [
        'driver' => 'session', // 標準のSessionGuard
        'provider' => 'users', // 顧客テーブル名
    ],

    // 名称を変更
    'user-api' => [
        'driver' => 'sanctum', // SanctumのGuard
        'provider' => 'users', // 顧客テーブル
    ],

    'admin-web' => [
        'driver' => 'session', // 標準のSessionGuard
        'provider' => 'admins', // 管理者テーブル
    ],

    // 名称を変更
    'admin-api' => [
        'driver' => 'sanctum', // SanctumのGuard
        'provider' => 'admins', // 管理者テーブル
    ],
],

'providers' => [
    'users' => [
        'driver' => 'eloquent',
        'model' => App\Models\User::class,
    ],

    'admins' => [
        'driver' => 'eloquent',
        'model' => App\Models\Admin::class,
    ],
],

// (省略)...

5. 再度認証を試す

試行用のroute.phpの変更をしないように修正したので、そのまま試す。

これで上手く遷移ができるようになるはず。

若干強引な作りになったが、既存ライブラリに手を入れずに解決できた。

以上