🔑

Laravel の TokenGuard を見直してみる

2023/09/17に公開

Guard とは?

Laravel の config/auth.php でお馴染みのコレ。

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
    ],

この driver の値 'session' は、
認証機構の種類を表しており、デフォルトでは 'session' と 'token' が用意されている他、
Sanctum を利用していれば 'sanctum' も指定可能です。
Auth::extend() で独自の Guard を定義し、ドライバとして指定することもできます。

Guard (driver) の概要

session とか token などのエイリアスが付けられていますが、実際に指しているものは、
Guard インターフェースを実装したクラスです。

'session' であれば SessionGuard 、'token' であれば TokenGuard になります。

Guard インターフェースを見れば、これらのクラスが果たしている役割をイメージしやすいでしょう。

namespace Illuminate\Contracts\Auth;

interface Guard
{
    /**
     * Determine if the current user is authenticated.
     *
     * @return bool
     */
    public function check();

    /**
     * Determine if the current user is a guest.
     *
     * @return bool
     */
    public function guest();

    /**
     * Get the currently authenticated user.
     *
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
     */
    public function user();

    /**
     * Get the ID for the currently authenticated user.
     *
     * @return int|string|null
     */
    public function id();

    /**
     * Validate a user's credentials.
     *
     * @param  array  $credentials
     * @return bool
     */
    public function validate(array $credentials = []);

    /**
     * Determine if the guard has a user instance.
     *
     * @return bool
     */
    public function hasUser();

    /**
     * Set the current user.
     *
     * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
     * @return void
     */
    public function setUser(Authenticatable $user);
}

ざっくり言えば、現在のリクエストにおいて、有効にログインしているユーザを「識別」するためのクラスです。

SessionGuard はこれをセッションにより識別しており、

namespace Illuminate\Auth;

// 略

class SessionGuard implements StatefulGuard, SupportsBasicAuth
{
    // 略

    public function user()
    {
        if ($this->loggedOut) {
            return;
        }

        if (! is_null($this->user)) {
            return $this->user;
        }

        $id = $this->session->get($this->getName());

        if (! is_null($id) && $this->user = $this->provider->retrieveById($id)) {
            $this->fireAuthenticatedEvent($this->user);
        }

        // 略

        return $this->user;
    }

    // 略
}

TokenGuard では、これを Authorization ヘッダやクエリパラメータ等から識別しています。

namespace Illuminate\Auth;

// 略

class TokenGuard implements Guard
{
    // 略

    public function user()
    {
        if (! is_null($this->user)) {
            return $this->user;
        }

        $user = null;

        $token = $this->getTokenForRequest();

        if (! empty($token)) {
            $user = $this->provider->retrieveByCredentials([
                $this->storageKey => $this->hash ? hash('sha256', $token) : $token,
            ]);
        }

        return $this->user = $user;
    }

    public function getTokenForRequest()
    {
        $token = $this->request->query($this->inputKey);

        if (empty($token)) {
            $token = $this->request->input($this->inputKey);
        }

        if (empty($token)) {
            $token = $this->request->bearerToken();
        }

        if (empty($token)) {
            $token = $this->request->getPassword();
        }

        return $token;
    }
}

なお、ユーザの「取得」自体は、どちらもコンストラクタで受け取る UserProvider クラス
($this->provider) に投げているため、
Guard 自体はモデル横断で使えるようになっています。

※Guard と UserProvider の関係については [図解] Laravel の認証周りのややこしいあれこれ。
でもう少しだけ詳しく触れています。

また、ログインやトークンの発行自体はこのクラスは担っていない、という点にも留意が必要です。
あくまでログイン済のユーザを識別することがこのクラスの責務です。

お気づきかもしれませんが、普段よく使用する

Auth::user()
Auth::id()
Auth::check()
Auth::guard(xxx)->user()
Auth::guard(xxx)->id()
Auth::guard(xxx)->check()

は、これらのクラスのメソッドを呼び出しています。
(id() や check() は、両クラスが使用している GuardHelper トレイトにあります)

TokenGuard を使ってみる

さて、TokenGuard が想定している仕様は

  • ユーザテーブルにアクセストークンを持っている
  • Bearer もしくはクエリストリングでトークンを受け取る

というもののようです。

ユーザテーブルのトークンを格納するフィールド名、
クエリストリングでトークンを受け取る場合のキーは、
ともに 'api_token' となっています。

シンプルなAPI認証であれば、このままでも十分使えそうです。

    // config/auth.php
    'guards' => [
        'api' => [
            'driver' => 'token',
            'provider' => 'users',
        ],
    ],

格納フィールド名、キー名はコンストラクタで渡せるようになっているため、
変更したい場合は、下記のように TokenGuard クラスを用いてカスタムガードを作ることができますね。

    // AuthServiceProvider
    public function boot(): void
    {
        Auth::extend('custom_token', static function ($app, $name, $config) {
            return new TokenGuard(
                provider: $app['auth']->createUserProvider($config['provider'] ?? null),
                request: $app->make('request'),
                inputKey: 'token', // トークンをクエリストリングで渡す場合のキー
                storageKey: 'access_token', // ユーザテーブルのトークンを格納するフィールド
            );
        });
    }
    // config/auth.php
    'guards' => [
        'api' => [
            'driver' => 'custom_token',
            'provider' => 'users',
        ],
    ],

TokenGuard を期限付きトークンに対応 (パターンA. TokenGuard の継承)

しかしこれだけでは、トークンの有効期限をチェックする機構がありません。

アプローチとしては、

  • TokenGuard を継承した Guard クラスを作る
  • UserProvider クラスを作りチェックを行う

の2つがありそうです。

期限チェックは Guard の責務といって良さそうですので、まずは、
TokenGuard を継承してカスタマイズしてみます。

namespace App\Auth;

use Illuminate\Auth\TokenGuard;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Http\Request;

class ExpiringTokenGuard extends TokenGuard implements Guard
{
    // トークンの有効期限を保持しているユーザテーブルのフィールド
    protected string $storageExpiredKey;

    public function __construct(
        UserProvider $provider,
        Request $request,
        $inputKey = 'token',
        $storageKey = 'access_token',
        $storageExpiredKey = 'access_token_expired',
        $hash = false)
    {
        $this->storageExpiredKey = $storageExpiredKey;

        parent::__construct($provider, $request, $inputKey, $storageKey, $hash);
    }

    public function user()
    {
        if (! is_null($this->user)) {
            return $this->user;
        }

        $user = null;

        $token = $this->getTokenForRequest();

        if (! empty($token)) {
            $user = $this->provider->retrieveByCredentials([
                $this->storageKey => $this->hash ? hash('sha256', $token) : $token,
            ]);
        }

        // トークンの有効期限チェック
        if (
            $user
            && $user->{$this->storageExpiredKey}
            && $user->{$this->storageExpiredKey}->lte(now())
        ) {
            return null;
        }

        return $this->user = $user;
    }
}

コメントを入れた箇所以外は、TokenGuard の内容そのままです。

これを AuthServiceProvider で「カスタムガード」として登録し、config に設定します。

    // AuthServiceProvider
    public function boot(): void
    {
        Auth::extend('expiring_token', static function ($app, $name, $config) {
            return new ExpiringTokenGuard(
                provider: $app['auth']->createUserProvider($config['provider'] ?? null),
                request: $app->make('request'),
            );
        });
    }
    // config/auth.php
    'guards' => [
        'api' => [
            'driver' => 'expiring_token',
            'provider' => 'users',
        ],
    ],

簡単ですね。

TokenGuard を期限付きトークンに対応 (パターンB. UserProvider の定義)

UserProvider クラスを用いた方法も見てきましょう。

namespace App\Auth;

use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;

class TokenUserProvider extends EloquentUserProvider implements UserProvider
{
    public function retrieveByCredentials(array $credentials): Authenticatable|null
    {
        $user = parent::retrieveByCredentials(['access_token' => $credentials['api_token']]);

        if (
            $user
            && $user->access_token_expired?->lte(now())
        ) {
            return null;
        }

        return $user;
    }
}

デフォルトで使用される EloquentUserProvider を継承して、
ユーザの取得後に有効期限のチェックを入れています。
(access_token_expired が万一 null であった場合の例外処理はお好みで。)

これを今度は AuthServiceProvider で「カスタムプロバイダ」として登録し、config に設定します。

    // AuthServiceProvider
    public function boot(): void
    {
        Auth::provider('token_user_provider', static function ($app, array $config) {
            return new TokenUserProvider($app['hash'], $config['model']);
        });
    }
    // config/auth.php
    'guards' => [
        'api' => [
            'driver' => 'token',
            'provider' => 'token_users',
        ],
    ],
    'providers' => [
        'token_users' => [
            'driver' => 'token_user_provider',
            'model' => App\Models\User::class,
        ],
    ],

この場合、クエリストリングのキーを変更する必要がなければ(あるいはトークンをクエリストリングを受け取る必要がなければ)、
driver はデフォルトの 'token' のままで足ります。

やや責務の逸脱を感じますが、トークンを別テーブルで管理している場合など、より深くカスタマイズする必要がある場合は、
このアプローチの方が見通しが良いかもしれません。

TokenGuard は deprecated なのか?

ところで、Laravel 7.x までは、デフォルトの config/auth.php は以下のようになっていました。

    /*
    |--------------------------------------------------------------------------
    | Authentication Guards
    |--------------------------------------------------------------------------
    |
    | Next, you may define every authentication guard for your application.
    | Of course, a great default configuration has been defined for you
    | here which uses session storage and the Eloquent user provider.
    |
    | All authentication drivers have a user provider. This defines how the
    | users are actually retrieved out of your database or other storage
    | mechanisms used by this application to persist your user's data.
    |
    | Supported: "session", "token"
    |
    */

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],

        'api' => [
            'driver' => 'token',
            'provider' => 'users',
            'hash' => false,
        ],
    ],

ところが、8.x 以降は、'token' を用いたサンプルが消え、
コメントの 'Supported' からも消えました。

    /*
    |--------------------------------------------------------------------------
    | Authentication Guards
    |--------------------------------------------------------------------------
    |
    | Next, you may define every authentication guard for your application.
    | Of course, a great default configuration has been defined for you
    | here which uses session storage and the Eloquent user provider.
    |
    | All authentication drivers have a user provider. This defines how the
    | users are actually retrieved out of your database or other storage
    | mechanisms used by this application to persist your user's data.
    |
    | Supported: "session"
    |
    */

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
    ],

同時に、config/sanctum.php がデフォルトで入るようになったため、
従来の TokenGuard を捨て、Sanctum をデファクトとする方向に舵を切ったのでしょう。

しかし Sanctum には、複数ガード対応が煩わしかったり、
リフレッシュトークンの機構を持っていなかったりと、
要件次第では大いに過不足があります。

シンプルなトークン認証であれば、TokenGuard ベースのカスタマイズも、
候補に入れてよいのではないでしょうか。

※もし正式に deprecated と宣言されている情報があれば、教えてください。

Discussion