Laravel5.7のMulti Auth環境で正しくパスワードリセットを動作させる


[この記事](https://qiita.com/zaburo/items/56ad432d5e6acb41852f)の続きとして書きます。
ここでいうMulti Authとは、userとadminで認証情報を保持するテーブルを分け、個別にlogin, logoutする機能です。

Multi AuthにおけるLogin, Logoutについては過去に沢山記事を書いてきたのですが、パスワードリセットについては「同じようにやれば動くだろう」と後回しにしてきました。が、実際は、

* ルーティングはどのように設定すればよいのか？
* コントローラはどのように記述すればよいのか？
* パスワードリセットのURLとかはどのように切り替えればいいのか？

等、考えなければいけないことはいろいろあります。それを試してみます。

## 準備

### ディレクトリ構造

記述はディレクトリ構造に依存します。
make:authすると、

app/Http/Controllers/Authができますが、これはuser用に利用し、admin用に
app/Http/Controllers/Admin/Authを作成して利用します。admin以下のAuthは基本、make:authでできたAuthをコピーして利用します。

その他view周りもいじる必要がありますが、詳細は[前提となるページ](https://qiita.com/zaburo/items/56ad432d5e6acb41852f)を見て下さい。

### メール送信

パスワードリセットはメール送信を行う必要があります。
今回はmailtrapを利用して確認します。mailtrapでアカウントを取得し、.envにアカウントとパスワードを設定するだけで利用できます。

## 実装

### 概要

パスワードリセット機能は、４つのルートを２つのコントローラー（2つのView）で処理することで実現されています。

* ForgotPasswordController.phpはパスワード忘れ画面を表示しメールを送信までを担当します(下記1,2）
* ResetPasswordController.phpはメールに添付されたURLがクリックされたらリセットフォームを表示しリセットをする機能を担当します（下記3,4）

主な流れは、

1. Loginページにて、Forgot Your Password?リンクをクリックすると、GETでpassword/resetが呼ばれshowLinkRequestFormでemail確認Formが表示される。
2. email確認Formにてメールを入力しSend Password Reset Linkボタンを押すとPOSTでpassword/emailが呼ばれsendResetLinkEmailにて、リセット用のメールが送信される。
3. リセットメールのURLをクリックするとGETで、/password/reset/{token}が呼ばれ、showResetFormでリセット用のFormが表示される。
4. リセットFormにてemailと新パワードを入れてReset Passwordボタンを押すとPOSTにてpassword/resetが呼ばれresetが実行される。

### ルーティング（抜粋）

prefixにadminを指定しているので、実際は/admin/password/reset等となります。

```diff:web.php
Route::group(['prefix' => 'admin'], function(){
    //home
    Route::get('home', 'Admin\HomeController@index')->name('admin.home');
    //login
    Route::get('login', 'Admin\Auth\LoginController@showLoginForm')->name('admin.login');
    Route::post('login', 'Admin\Auth\LoginController@login')->name('admin.login');
    //logout
    Route::post('logout', 'Admin\Auth\LoginController@logout')->name('admin.logout');
    //Register
    Route::get('register', 'Admin\Auth\RegisterController@showRegisterForm')->name('admin.register');
    Route::post('register', 'Admin\Auth\RegisterController@register')->name('admin.register');
    //reset
+    Route::post('password/email', 'Admin\Auth\ForgotPasswordController@sendResetLinkEmail')->name('admin.password.email');
+    Route::get('password/reset', 'Admin\Auth\ForgotPasswordController@showLinkRequestForm')->name('admin.password.request');
+    Route::post('password/reset', 'Admin\Auth\ResetPasswordController@reset')->name('admin.password.update');
+    Route::get('password/reset/{token}', 'Admin\Auth\ResetPasswordController@showResetForm')->name('admin.password.reset');
});
```

### Password Reset (broker)の設定

Password Resetはリセット用のtokenを保存しておくテーブルが必要です。
そのテーブルはconefig/auht.php内で設定しています（[前の記事](https://qiita.com/zaburo/items/56ad432d5e6acb41852f)）。

なお、リセット用のテーブルはuserと共有できるため、わざわざguard別に作成する必要はありません（実装に依存しますが）。

```diff:config/auth.php
'passwords' => [
    'users' => [
        'provider' => 'users',
        'table' => 'password_resets',
        'expire' => 60,
    ],
+    'admins' => [
+        'provider' => 'admins',
+        'table' => 'password_resets',
+        'expire' => 60,
+    ],
],
```

### ForgetPasswordController.php

コントローラーを実装します。基本的にadminsで正しく動作するようにオーバーライドするだけです。

```diff:ForgetPasswordController.php
<?php

+namespace App\Http\Controllers\Admin\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;

class ForgotPasswordController extends Controller
{
    use SendsPasswordResetEmails;

    public function __construct()
    {
+        $this->middleware('guest:admin');
    }

+    public function showLinkRequestForm()
+    {
+        return view('admin.auth.passwords.email');
+    }

+    protected function guard()
+    {
+        return \Auth::guard('admin');
+    }

+    public function broker()
+    {
+        return \Password::broker('admins');
+    }
}
```

### Viewの変更：admin.auth.passwords.email

パスワードリセットするmailアドレスを表示するフォームです。
admin用に作成したテンプレートの読み込みと、フォーム送信先をadmin以下に変更しています。

```diff
+@extends('layouts.admin')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">{{ __('Reset Password') }}</div>

                <div class="card-body">
                    @if (session('status'))
                        <div class="alert alert-success" role="alert">
                            {{ session('status') }}
                        </div>
                    @endif

+                    <form method="POST" action="{{ route('admin.password.email') }}">
                        @csrf

                        <div class="form-group row">
                            <label for="email" class="col-md-4 col-form-label text-md-right">{{ __('E-Mail Address') }}</label>

                            <div class="col-md-6">
                                <input id="email" type="email" class="form-control{{ $errors->has('email') ? ' is-invalid' : '' }}" name="email" value="{{ old('email') }}" required>

                                @if ($errors->has('email'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('email') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row mb-0">
                            <div class="col-md-6 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Send Password Reset Link') }}
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
```

### ResetPasswordController.php

続いてリンククリック後の機能です。
こちらも基本的にはadmin以下で動作するよう各機能をオーバーライドしています。

```diff
<?php

+namespace App\Http\Controllers\Admin\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords;
+use Illuminate\Http\Request;

class ResetPasswordController extends Controller
{

    use ResetsPasswords;

+    protected $redirectTo = '/admin/home';

    public function __construct()
    {
+        $this->middleware('guest:admin');
    }

+    public function showResetForm(Request $request, $token = null)
+    {
+        return view('admin.auth.passwords.reset')->with(['token' => $token, 'email' => $request->email]);
+    }

+    protected function guard()
+    {
+        return \Auth::guard('admin');
+    }

+    public function broker()
+    {
+        return \Password::broker('admins');
+    }

}
```

### Viewの変更：admin.auth.passwords.reset

パスワードリセットフォーム表示用です。
admin用に作成したテンプレートの読み込みと、フォーム送信先をadmin以下に変更しています。

```diff
+@extends('layouts.admin')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">{{ __('Reset Password') }}</div>

                <div class="card-body">
+                    <form method="POST" action="{{ route('admin.password.update') }}">
                        @csrf

                        <input type="hidden" name="token" value="{{ $token }}">

                        <div class="form-group row">
                            <label for="email" class="col-md-4 col-form-label text-md-right">{{ __('E-Mail Address') }}</label>

                            <div class="col-md-6">
                                <input id="email" type="email" class="form-control{{ $errors->has('email') ? ' is-invalid' : '' }}" name="email" value="{{ $email ?? old('email') }}" required autofocus>

                                @if ($errors->has('email'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('email') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="password" class="col-md-4 col-form-label text-md-right">{{ __('Password') }}</label>

                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control{{ $errors->has('password') ? ' is-invalid' : '' }}" name="password" required>

                                @if ($errors->has('password'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('password') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="password-confirm" class="col-md-4 col-form-label text-md-right">{{ __('Confirm Password') }}</label>

                            <div class="col-md-6">
                                <input id="password-confirm" type="password" class="form-control" name="password_confirmation" required>
                            </div>
                        </div>

                        <div class="form-group row mb-0">
                            <div class="col-md-6 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Reset Password') }}
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

```

### リセットURLの変更

ここまでのコードでページ遷移等はうまく動きます。が、メールで送信されるリセット用のURLがhostname/password/reset/xxxxとuser用のままでadmin用として機能してくれません。URLの変更を行います。

#### 送信機能のオーバーライド

メール送信はResetPasswordNotificationのtoMail()で行われているので、その内容をadmin用にオーバーライドします。
app以下にNotificationsというフォルダを作成し、以下を、AdminPasswordResetNotification.phpとして保存します。

```php
<?php
namespace App\Notifications;

use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification;
use Illuminate\Notifications\Messages\MailMessage;

class AdminPasswordResetNotification extends ResetPasswordNotification
{
    public function toMail($notifiable)
    {
        return (new MailMessage)
            ->line('You are receiving this email because we received a password reset request for your account.')
            ->action('Reset Password', url(config('url').route('admin.password.reset', $this->token, false)))
            ->line('If you did not request a password reset, no further action is required.');
    }
}
```

### モデルの変更

モデルにてAdminPasswordResetNotification.phpでの設定が利用されるよう必要な記述を行います。

```diff
<?php

namespace App;

use Illuminate\Notifications\Notifiable;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;

+use App\Notifications\AdminPasswordResetNotification;

class Admin extends Authenticatable
{
    use Notifiable;

    protected $fillable = [
        'name', 'email', 'password',
    ];

    protected $hidden = [
        'password', 'remember_token',
    ];

+    public function sendPasswordResetNotification($token)
+    {
+        $this->notify(new AdminPasswordResetNotification($token));
+    }
}
```

これでOKです。

## 動作確認

上記の処理をしてもURLはlocalhost等（ポート無し）で送信されます。php artisan serveを利用している場合、必要に応じてlocalhost:8000等にリンクを変換して動作確認する必要があります。

## その他

今回は5.7から追加されたverify mailの送信については試せていません。試したら別途記事化します。

## 参考

* https://blog.muva.tech/part-viii-admin-password-reset-in-our-multiple-authentication-system-for-laravel-5-4/
* https://qiita.com/uenoryo/items/34077c7d39660009672c