リモート開発メインのソフトウェア開発企業のエンジニアブログです

Laravel で remember_token を使わない方法

最近少し Laravel を使う機会が増えたので小ネタ (タイトルの件) を書きます。検証に使った Laravel のバージョンは 11.x ですが 5.3.27 以降で使えると思われます。

公式ドキュメント Database Considerations – Authentication によると Eloquent model (プロジェクト初期化時は App\Models\User として作られる) をユーザーとしたデフォルトの認証機構を使う場合、当該モデルのテーブルには100文字以上の文字列型である remember_token と言うカラムの存在が必要と書かれています。試しにこのカラムを欠いた状態で認証を実装すると、ログアウト時にエラーが発生します:

public function logout(Request $request): RedirectResponse
{
    Auth::logout(); // ここでエラー

    // 以下略    
}

そのまま、App\Models\User に remember_token と言う属性 (カラム) が無いと言う旨のエラーです。余談ですがローカル環境などで Model::preventAccessingMissingAttributes(); が設定されていないとこのエラーは発生しない場合がありますが、本番環境などでは発生するでしょう。

ちなみにログイン時 (Auth::attempt; 第2引数の $remember が false である限り) には問題ありません。ログアウトの時にのみ発生する様です。

これだと困るケース

新規プロジェクトなら (Remember Me 機構を使おうが使わまいが) 普通にマイグレーションでこのカラムが入るので問題にはなりませんが、既存のプロジェクトでデータベースのデータ移行なしにフレームワークを Laravel に移行したい場合に問題が生じます。今回直面したのはまさにこれでした。

Moba Pro

解決方法

User で getRememberTokenName をオーバーライドし、空文字列を返す様にします:

public function getRememberTokenName()
{
    return ''; // Remember Me は使わない
}

なぜこれで解決するのか

この問いに答える為に、そもそもなぜエラーが発生するのかを確認します。Auth::logout() では通常内部では \Illuminate\Auth\SessionGuard の logout が実行されます。その内部を見てみると、以下の処理が存在します:

// https://github.com/laravel/framework/blob/7db7ea950d3df663391718730f4490a4e189234a/src/Illuminate/Auth/SessionGuard.php#L606-L608

if (! is_null($this->user) && ! empty($user->getRememberToken())) {
    $this->cycleRememberToken($user);
}

要はログアウトしたユーザーが Remember トークンを持つ場合、そのトークンをリフレッシュしようと言う内容です。そして見ての通り $user->getRememberToken() の部分で remember_token カラムの参照が行われる為、存在しない場合は先のエラーが発生する訳です。

では本題に戻ってなぜあのオーバーライドで解決するのでしょうか?それは User が継承している \Illuminate\Auth\Authenticatable trait の実装を見てみると分かります。getRememberToken はここに実装されています:

// https://github.com/laravel/framework/blob/7db7ea950d3df663391718730f4490a4e189234a/src/Illuminate/Auth/Authenticatable.php#L76-L81

protected $rememberTokenName = 'remember_token';

public function getRememberTokenName()
{
    return $this->rememberTokenName;
}

public function getRememberToken()
{
    if (! empty($this->getRememberTokenName())) {
        return (string) $this->{$this->getRememberTokenName()};
    }
}

getRememberTokenName が empty なら getRememberToken は属性 (カラム) の参照をする事なく null を返す訳です。また先の SessionGuard を見返すと $user->getRememberToken() が empty ならトークンのリフレッシュは行われないので更新形でエラーが発生する心配もありません。

修正の妥当性

getRememberToken の挙動ですが、最初からそうだった訳ではなくこの PR によって変更された様です。さらに元となった Issue を見ると、当時 Laravel の認証機構で SessionGuard だけが “remember_token” の有無をチェックしていないと言う指摘もありました。それ以上は調べていませんが、結局現在 SessionGuard はログイン時も含めてトークンの有無を確認していて一貫性があり妥当と判断しました。ただし Laravel Contracts の interface 上ではその事に言及がない為、長い間この振る舞いが保証される訳ではない点に留意が必要です。(と言うより現状の Illuminate\Auth\Authenticatable が既に Contracts interface と宣言上のシグネチャが異なる)

余談: getRememberToken や $rememberTokenName をオーバーライドしていない理由

先ほどの Authenticatable の実装を見ると getRememberTokenName ではなくこの2つの方法でも同じ事ができるのですが、今回は getRememberTokenName を使った理由を余談まで。

getRememberToken をオーバーライドしない理由

Authenticatable には setRememberToken と言うメソッドがあり、これも getRememberToken 同様 getRememberTokenName のチェックをしている為です:

public function setRememberToken($value)
{
    if (! empty($this->getRememberTokenName())) {
        $this->{$this->getRememberTokenName()} = $value;
    }
}

SessionGurad の実装的には getRememberToken が空欄ならこのメソッドも呼ばれるケースは無さそうなのですが念の為。

$rememberTokenName をオーバーライドしない理由

であれば、 getRememberTokenName の実態である $rememberTokenName をオーバーライドしてもいいのでは? (行数も少なくなる; なんなら先述の PR ではその手法を掲示している) と思われるかもしれません。これに関してはその通りではあるのですが Laravel の様にクラスのプロパティに型をついていない場合、オーバーライド時に異なる型を付けてもエラーが発生しない為です。つまり以下はいずれも (クラス定義時に) エラーになりません:

protected $rememberTokenName = true;

protected $rememberTokenName = null;

protected $rememberTokenName = 123;

また PHPStan でもこの問題を検出できない為、(繰り返しますが Laravel の様にプロパティに型をつけていない場合は) なるべくプロパティのオーバーライドは使わないようにしています。(実は PHPStan でできます!と言う場合あとでこっそり教えてください)

反対にメソッドであれば例え返り値のタイプヒントが付いていなくても PHPStan が (level 3以上であれば) エラーを検出する事ができます:

public function getRememberTokenName()
{
    return null; // 親クラスは PHPDoc で `@return string` を付けているので PHPStan が "Method App\Models\User::getRememberTokenName() should return string but returns null." と言うエラーを出力する 
}

おわりに

今回は Laravel の認証ユーザー (Eloquent) でテーブルのカラムに “remember_token” が無くても動作する方法を経緯とかを含めて調査しました。このテクニック自体はネットで検索するとたくさん出てくるのですが内部のコードや経緯などについて言及されていない事が多かったので改めて自分で調べてみました。ところで Laravel はいつになったらタイプヒントをするのでしょうか。

← 前の投稿

次の投稿 →

Laravel Sail 初期化時に使用する PHP のバージョンを指定する

コメントを残す