Laravelのパスワードリセットをユーザー名など、メールアドレス以外で行うようにする

Laravelの標準のAuthではパスワードリセットはメールアドレスで行いますが、これをユーザー名などに変更したいことがあると思います。

#背景
私が現在開発しているシステムでは元々はDB上ではメールアドレスがユニークで、ユーザーの識別（ログインやパスワードリセット）はLaravelの標準の機能でカバーできていました。

しかし、開発が一通り終わる頃、「同じユーザーが一つのメールアドレスで複数のアカウントを扱えるようにしたい」という要求が出てきたため:sweat:、仕様を変更する必要に迫られました。

幸い、システム内ではログインした後はユーザー登録時に自動で付加されるDB内の連番のIDを使用してユーザーの識別をしていたため、問題となるのはログインとパスワードリセットだけでした。

今回は少々手こずったパスワードリセットの機能について書き留めておきたいと思います。

「パスワードリセットをメール以外でする」という題名にしましたが、実際には私のシステムではユーザー名が無い（上記の連番のIDはありますがユーザーは認識しない）ので、メールアドレスを入力してもらい、複数のアドレスがあった場合はどちらをリセットするか選択してもらう画面を挟み、最終的にはIDをPOSTすることでリセットの機能を実現しています。
なので、メールアドレスの代わりにusernameなどを入力してもらうだけで良い場合は、ソース内のidを適宜usernameなどに置き換えればOKです。

なお、私が使用しているのはLaravel8ですが、Laravel5系でもいけると思います。

#パスワード再設定画面のビューの変更
メールアドレスを入力してもらった後に複数のメールアドレスがあるかどうかをチェックし、複数あった場合はアカウントを選択する画面を表示したいので、actionを"{{ route('password.email') }}"から以下のように変更します。
メールアドレスの代わりにusernameを使いたいだけの場合はformはそのままにしてemailのフィールドをusernameに変更すれば良いでしょう。

```php:/resources/views/auth/passwords/email.blade.php
<form method="POST" action="/password/multiple_email_check">
```

#ルーティング追加
ルーティングにmultiple_email_checkを追加します。最初のgetの行はパスワードリセットのsubmitをした後にmultiple_email_checkに戻ってくるので、最初のパスワードリセットのページに戻るようにしています。

```php:/routes/web.php
Route::get('/password/multiple_email_check', 'Auth\ForgotPasswordController@showLinkRequestForm'); // Redirected after sending email
Route::post('/password/multiple_email_check', 'Auth\ForgotPasswordController@multiple_email_check');
```

#アカウント選択機能を追加
Usersテーブル内に同じメールアドレスがあるかチェックし、一つだけ見つかれば「このアドレスでリセットしますがよろしいですか？」というメッセージを表示し、複数の場合はアカウントを選択する画面を表示します。

```php:/app/Http/Controllers/Auth/ForgotPasswordController.php
public function multiple_email_check(Request $request)
{
    $users = User::where('email', $request->email)
    ->where('status', 1)
    ->get();
    
    if($users->count() <= 1)
    {
        // アカウントが一つだけの場合
        $user = $users->first();
        return view('auth/passwords/email_confirm', compact('user'));
    }
    else
    {
        // アカウントが複数の場合
        foreach($users as $user)
        {
            $account = User::
            ->where('users.id', $user->id)
            ->first()->toArray();

            $accounts[] = $account;
        }

        return view('auth/passwords/select_account', compact('accounts'));
    }
}
```

#確認画面（アカウントが一つの場合）
IDをPOSTするようにすれば良いのであとは適当に。

```php:/resources/views/auth/passwords/email_confirm.blade.php
<form method="POST" action="{{ route('password.email') }}">
    @csrf
    {!! Form::hidden('id', $user->id) !!}

    <div class="col-md-8 offset-md-2 mb-3">
        {{ $user->email }}にパスワード再設定用のリンクを送信します。よろしいですか？
    </div>

    <div class="form-group row mb-0">
        <div class="col-md-12 text-center">
            <button type="submit" class="btn btn-primary">
                送信
            </button>
        </div>
    </div>
</form>
```

#確認画面（アカウントが複数の場合）
複数のアカウントがあった場合、アカウントを選択してもらいます。かなり適当ですが、以下のような感じでこちらも選択されたIDがPOSTされるようにすればOKです。

```php:/resources/views/auth/passwords/select_account.blade.php
@foreach ($accounts as $account)
<form method="POST" action="{{ route('password.email') }}">
@csrf
<button class="btn btn-primary form_submit">このアカウントをリセット</button>{{ $account['name'] }}
<input type="hidden" name="id" value="{{ $account['id'] }}">
</form>
@endforeach
```

#メソッドのオーバーライド
メールアドレスではなく、IDで動作するようにLaravel標準のメソッドをController内でオーバーライドします。usernameなどで行う場合はidの部分を適宜置き換えてください。

```php:/app/Http/Controllers/Auth/ForgotPasswordController.php
public function sendResetLinkEmail(Request $request)
{
    $this->validate($request, ['id' => 'required'], ['id.required' => 'Please enter your id.']);

     $response = $this->broker()->sendResetLink(
        $request->only('id')
    );

    if ($response === Password::RESET_LINK_SENT) {
        return back()->with('status', trans($response));
    }

    return back()->withErrors(
        ['email' => trans($response)]
    );
}
```

```php:/app/Http/Controllers/Auth/ResetPasswordController.php
public function showResetForm(Request $request, $token = null)
{
    return view('auth.passwords.reset')->with(
      ['token' => $token, 'id' => $request->id]
    );
}

protected function credentials(Request $request)
{
    return $request->only(
        'id', 'password', 'password_confirmation', 'token'
    );
}

protected function sendResetFailedResponse(Request $request, $response)
{
    return redirect()->back()
           ->withInput($request->only('id'))
           ->withErrors(['id' => trans($response)]);
}

protected function rules()
{
    return [
        'token' => 'required',
        'id' => 'required',
        'password' => 'required|confirmed',
    ];
}
```

#メール内容を変更
通常、パスワードリセットを要求した後に送信されるメール内のボタンにはメールアドレスがパラメーターとして付加されるようになっていますが、そのままではメールアドレスが複数あった場合にアカウントの識別ができないので、これをIDにしてパスワードリセット画面を表示した時にアカウントを識別できるようにします。
以下の例ではついでにメール文を日本語化しています。

```php:/app/Providers/AppServiceProvider.php
public function boot(UrlGenerator $url)
{
    ResetPassword::toMailUsing(function ($notifiable, $token) {
        return (new MailMessage)
          ->subject('パスワード再設定')
          ->greeting('パスワード再設定の要求を受け付けました。')
          ->line('下のボタンをクリックしてパスワードを再設定してください。')
          ->action(Lang::get('Reset Password'), url(config('app.url').route('password.reset', ['token' => $token, 'id' => $notifiable->id], false)))
          ->line('もし心当たりがない場合は、本メッセージは破棄してください。');
    });
}
```

ここまでで、パスワードリセットの画面からメールアドレスを入力し、メールが送信されるまでが動作するようになったと思います。
実際にやってみてメールが届くか確認してみてください。

あとはメール内のボタンが押されたらパスワードリセット画面を表示し、リセットするだけです。

#パスワードリセット画面
通常は以下のようにメールアドレスのフィールドがありますが、これを表示しても仕方がないのでその項目を消去すると同時に、ID（またはusername）をhiddenに設定してPOSTされるようにします。
![reset.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/930440/c1366019-42bc-e409-afa4-dd3e069d5537.png)

```php:/resource/views/auth/passwords/reset.blade.php
//追加
<input id="id" type="hidden" name="id" value="{{ $id }}">

// 以下の行は削除
<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 @error('email') is-invalid @enderror" name="email" value="{{ $email ?? old('email') }}" required autocomplete="email" autofocus>

        @error('email')
            <span class="invalid-feedback" role="alert">
                <strong>{{ $message }}</strong>
            </span>
        @enderror
    </div>
</div>
```

これで完成です。

#参考にしたサイト
https://stackoverflow.com/questions/49590205/password-resetting-in-laravel-when-email-address-is-not-unique
https://krishan.blog/articles/2019-12-12/modify-password-reset-notification