🔓

Laravel のパスワードリマインダ (password reset) を複数 Guard に対応させる

2022/06/12に公開

前提条件

  • Laravel 8.x
  • Laravelの組み込み認証サービスを使っている
  • 複数の Model を使用した、複数の Guard がある

前提条件の詳細

config/auth.php がこんな感じになっていて、

    'guards' => [
        'student' => [
            'driver' => 'session',
            'provider' => 'student_guard_provider',
        ],
        'teacher' => [
            'driver' => 'session',
            'provider' => 'teacher_guard_provider',
        ],
        'admin' => [
            'driver' => 'session',
            'provider' => 'admin_guard_provider',
        ],
    ],
    
    'providers' => [
        'student_guard_provider' => [
            'driver' => 'student_user_provider',
            'model' => App\Models\Student::class,
        ],
        'teacher_guard_provider' => [
            'driver' => 'teacher_user_provider',
            'model' => App\Models\Teacher::class,
        ],
        'admin_guard_provider' => [
            'driver' => 'admin_user_provider',
            'model' => App\Models\Admin::class,
        ],
    ],

ログイン画面の実装が、こんな感じです(フォーム画面を表示する部分は割愛)。

    /**
     * ログイン試行
     *
     * @param SigninRequest $request
     * @return \Illuminate\Http\RedirectResponse
     */
    public function signin(SigninRequest $request)
    {
        $credentials = $request->only(['email', 'password']);

        if (Auth::guard('admin')->attempt($credentials)) {
            $request->session()->regenerate();

            return redirect()->intended(route('admin.home'));
        }

        return back()->withErrors(['email' => 'メールアドレスかパスワードが間違っています。']);
    }

概ね Laravel 8.x 認証 : ユーザーを手動で認証する のサンプルそのままで、
これを各 Guard 向けに用意しています。

Laravel のパスワードリマインダってどうなってたっけ。

Laravel純正パスワードリマインダーの使用方法については Laravel 8.x パスワードリセット : ルート あたりに書かれています。

公式のサンプルコードはクロージャにゴリゴリ書かれていますが、
Controller (と FormRequest) を使った記述に変換するとこんな感じです。

    /**
     * リセットメール送信実行
     *
     * @param ReminderRequest $request
     * @return \Illuminate\Http\RedirectResponse
     */
    public function reminder(ReminderRequest $request)
    {
        $status = Password::sendResetLink(
            $request->only('email')
        );

        if ($status === Password::RESET_LINK_SENT) {
            return redirect()->route(('admin.password.reminder.sent'));
        } else {
            return back()->with('message.error', __($status));
        }
    }
    
    /**
     * リセット実行
     *
     * @param ResetRequest $request
     * @return \Illuminate\Http\RedirectResponse
     */
    public function reset(ResetRequest $request)
    {
        $status = Password::reset(
            $request->only('email', 'password', 'password_confirmation', 'token'),
            function ($user, $password) {
                $user->forceFill([
                    'password' => Hash::make($password)
                ])->setRememberToken(Str::random(60));

                $user->save();

                event(new PasswordReset($user));
            }
        );

        if ($status === Password::PASSWORD_RESET) {
            return redirect()->route(('admin.signin'))->with('message.success', 'パスワードを再設定しました。新しいパスワードでログインしてください。');
        } else {
            return back()->with('message.error', __($status));
        }
    }

おもむろに登場する Password ファサード に全てぶん投げられていて、
手を加える余地がないように見えます。

というわけで Password ファサードの実態を知るべく、
このあたり↓を探索すると、

/vendor/laravel/framework/src/Illuminate/Auth/Passwords/PasswordBrokerManager.php
/vendor/laravel/framework/src/Illuminate/Auth/Passwords/PasswordBroker.php

どうやら、Auth が複数の Guard を持てるように、
Password も、複数の Broker なるものを持てるようです。

なお、「ブローカー」と聞くと、闇でパスワードを売り捌く怖い人達を連想しますが、
元々は、単なる手続きの仲介人的な語感のようです。

実装

モデル

前提として、パスワードリマインダに使う Model は、
CanResetPasswordContract インターフェイスを実装する必要があります。

基本的には CanResetPassword トレイトを使用するだけでOKです。

テーブル

Password::sendResetLink() は、パスワードリセット専用のトークンを生成し、
対象ユーザのメールアドレスとともに、 password_resets テーブルに格納しています。

しかし、純正の password_resets テーブルは、email カラムしかキーが無いので、
複数 Model ・複数テーブルでユーザを管理している(=Guard間でメールアドレスが重複している可能性がある)以上、これでは足りません。

対象の Model の数だけ、パスワードリセット用のテーブルを作ってしまうのが簡単です。

    public function up()
    {
        Schema::create('s_admin_password_resets', function (Blueprint $table) {
            $table->string('email')->index();
            $table->string('token');
            $table->timestamp('created_at')->nullable();
        });
	
	Schema::create('s_student_password_resets', function (Blueprint $table) {
            $table->string('email')->index();
            $table->string('token');
            $table->timestamp('created_at')->nullable();
        });

        Schema::create('s_teacher_password_resets', function (Blueprint $table) {
            $table->string('email')->index();
            $table->string('token');
            $table->timestamp('created_at')->nullable();
        });
    }

config/auth.php

以下の箇所で Broker を定義します。

provider には、Guard で使用しているユーザプロバイダー名を、
table には、上記で作成したテーブル名を記述します。

    'passwords' => [
        'student' => [
            'provider' => 'student_guard_provider',
            'table' => 's_student_password_resets',
            'expire' => 60,
            'throttle' => 60,
        ],
        'teacher' => [
            'provider' => 'teacher_guard_provider',
            'table' => 's_teacher_password_resets',
            'expire' => 60,
            'throttle' => 60,
        ],
        'admin' => [
            'provider' => 'admin_guard_provider',
            'table' => 's_admin_password_resets',
            'expire' => 60,
            'throttle' => 60,
        ],
    ],

コントローラ

Password::sendResetLink()
Password::reset()

となっていた部分を

Password::broker('ブローカー名')->sendResetLink()
Password::broker('ブローカー名')->reset()

と書き換えるだけです。

    /**
     * リセットメール送信実行
     *
     * @param ReminderRequest $request
     * @return \Illuminate\Http\RedirectResponse
     */
    public function reminder(ReminderRequest $request)
    {
        $status = Password::broker('admin')->sendResetLink(
            $request->only('email')
        );

        if ($status === Password::RESET_LINK_SENT) {
            return redirect()->route(('admin.password.reminder.sent'));
        } else {
            return back()->with('message.error', __($status));
        }
    }
    
    /**
     * リセット実行
     *
     * @param ResetRequest $request
     * @return \Illuminate\Http\RedirectResponse
     */
    public function reset(ResetRequest $request)
    {
        $status = Password::broker('admin')->reset(
            $request->only('email', 'password', 'password_confirmation', 'token'),
            function ($user, $password) {
                $user->forceFill([
                    'password' => Hash::make($password)
                ])->setRememberToken(Str::random(60));

                $user->save();

                event(new PasswordReset($user));
            }
        );

        if ($status === Password::PASSWORD_RESET) {
            return redirect()->route(('admin.signin'))->with('message.success', 'パスワードを再設定しました。新しいパスワードでログインしてください。');
        } else {
            return back()->with('message.error', __($status));
        }
    }

Auth::xxx() と Auth::guard('ガード名')->xxx() の関係と、
まるっきり同じですね。

broker() の指定なしで Password ファサードを呼んだ場合は、
こちらも Auth 同様、 config/auth.php の defaults に定義された
Broker が使用されます。

    'defaults' => [
        'guard' => 'student', //これがデフォルト Guard
        'passwords' => 'student', //これがデフォルト Broker
    ],

パスワードリセットメール

パスワードリセット画面を Guard ごとに作る以上、
必然的に送信されるメールも分ける必要があります。

メールを送信する処理は、CanResetPassword トレイトの sendPasswordResetNotification() メソッドがやってくれているので、
下記に記載されている通り、各モデルでこれをオーバーライドすれば OK です。

Laravel 8.x パスワードリセット : リセットメールカスタマイズ

オーバーライド元では Notification が使われていますが、
Mailable を使ってメール送信しても何ら問題はないです。

まとめ

わかってしまえば何ということはありませんでした。

PasswordBrokerManager.php と PasswordBroker.php のソースは一見難解でしたが、
AuthManager と Guard の関係と同じだと気付いたら、
あとはほとんど雰囲気で理解できました。

デザインパターンの強みが活きていますね。

Discussion