【Laravel7でユーザー認証_4】パスワード変更フォームを作成する

# はじめに

Laravelでユーザー認証した後、認証パスワードを変更するフォームを作成する手順をまとめます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262236/29efd12f-705f-0060-655d-2f2a088f4e87.png" width="600">

※細かいことはいいからソースが見たい→今回作成した新規ファイルは[まとめのソース](#まとめのソース) にまとめました。

# 環境

XAMPP環境でLaravelが使えるように設定してあります。

- Windows10 Pro 64bit
- PHP 7.3.18
- Laravel 7.12.0
- MariaDB 10.1.32

また、Laravelプロジェクトは以下の手順で、ユーザー認証をユーザー名とパスワードで行えるようにしてあります。

- [【Laravel7でユーザー認証_1】基本のき](https://qiita.com/crosawassant/items/bf3982666b6333bcb8c8)
- [【Laravel7でユーザー認証_2】ユーザー認証を日本語化](https://qiita.com/crosawassant/items/d1f4c5c93f30afa9eb65)
- [【Laravel7でユーザー認証_3】ユーザー認証をメールアドレスからユーザー名に変更する](https://qiita.com/crosawassant/items/2464db8a0fe1c603401a)

# 実装手順
## コントローラの作成

パスワード変更用のコントローラを作成します。
今回は、Authディレクトリの中に「ChangePasswordController.php」というファイルを追加しました。

```
$ php artisan make:controller Auth/ChangePasswordController
```

## ルーティングの設定

以下の仕様で、ルーティングを設定します。

- 通常のアクセス（GET）の場合は、「Auth\ChangePasswordController」コントローラの「showChangePasswordForm」メソッドを実行。
- パスワードを変更の処理（POST）の場合は、「Auth\ChangePasswordController」コントローラの「changePassword」メソッドを実行。

それぞれのルーティングには、「password.form」、「password.change」という名前を付けました。

```diff:/routes/web.php
  Route::get('/home', 'HomeController@index')->name('home');
+ Route::get('/password/change', 'Auth\ChangePasswordController@showChangePasswordForm')->name('password.form');
+ Route::post('/password/change', 'Auth\ChangePasswordController@ChangePassword')->name('password.change');

```

## viewの作成

viewは、`auth/passwords` の中にある、`reset.blade.php` を参考にして `change.blade.php` を作成しました。
formのアクション先は、ルーティングで付けた「password.change」という名前を使います。

```php:resources/views/auth/passwords/change.blade.php
@extends('layouts.app')

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

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

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

                            <div class="col-md-6">
                                <input id="current_password" type="password" class="form-control @error('current_password') is-invalid @enderror" name="current_password" required autocomplete="new-password">

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

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

                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control @error('password') is-invalid @enderror" name="password" required autocomplete="new-password">

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

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

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

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

```

###翻訳ファイルに追加
view内で使った翻訳文字をja.jsonに追加します。

```diff:resource/lang/ja.json
      "Password": "パスワード",
      "Confirm Password": "パスワード (確認用)",
+     "Current Password": "現在のパスワード",
+     "New Password": "新しいパスワード",
+     "Confirm New Password": "新しいパスワード（確認用）",
+     "Change Password": "パスワード変更",
```

## コントローラにメソッド追加

`ChangePasswordController` に、ルーティングで指定したメソッドを追加します。

`showChangePasswordForm` メソッド内では、作成したview（auth/passwords/change.blade.php）が表示されるように指定します。

`changePassword` メソッドは、パスワードを変更する処理を入れたいのですが、ひとまず空にしておきます。

```diff:app/Http/Controllers/Auth/ChangePasswordController.php
  <?php
  
  namespace App\Http\Controllers\Auth;
  
  use App\Http\Controllers\Controller;
  use Illuminate\Http\Request;
  
  class ChangePasswordController extends Controller
  {
-     //
+     public function showChangePasswordForm()
+      {
+          return view('auth\passwords\change');
+      }
+      
+     public function changePassword()
+     {
+          /* ===ここにパスワード変更の処理=== */
+     }
    }
```

## 一旦確認

http://127.0.0.1:8000/password/change にアクセスして、表示ができるかどうか確認します。
エラーが出てしまった場合、`php artisan route:list` で `password/change` のルーティングが正しくできているか、ルーティングで指定したコントローラ名やメソッド名が正しいかどうか、viewファイル内で指定したaction先が合っているかなどをチェックします。

## ログインしていない場合はログイン画面を表示させる

現状だと、ログインをしていなくてもパスワード変更フォームが表示されてしまいます。
ログインをしていない場合は、ログイン画面を表示するようにコントローラを変更します。

```diff:app/Http/Controllers/Auth/ChangePasswordController.php
  class ChangePasswordController extends Controller
  {
+     public function __construct()
+     {
+         $this->middleware('auth');
+     }
```

##パスワード変更の処理

### バリデーションチェック

#### FormRequest の設定
パスワード変更のフォームに入力があったときに、以下の内容で入力が正しいかどうかのチェックをするよう、FormRequestを設定します。

- 現在のパスワード、新しいパスワードを必須項目になっているか
- 現在のパスワード、新しいパスワードは8桁以上になっているか
- 新しいパスワードは確認用と同じ値が入っているか
- 現在のパスワードに入力された値が、登録されているパスワードと同じかどうか

`ChangePasswordRequest` という `FormRequest` を作成します。

```
$ php artisan make:request ChangePasswordRequest
```

作成された `app/Http/Request/ChangePasswordRequest.php` について、バリデーションルールを追加します。

```diff:app/Http/Request/ChangePasswordRequest.php
  <?php
  
  namespace App\Http\Requests;
  
  use Illuminate\Foundation\Http\FormRequest;
+ use Illuminate\Contracts\Validation\Validator;
+ use Illuminate\Http\Exceptions\HttpResponseException;
+ use Illuminate\Support\Facades\Auth;
+ use Illuminate\Support\Facades\Hash;

  class ChangePasswordRequest extends FormRequest
  {
      /**
       * Determine if the user is authorized to make this request.
       *
       * @return bool
       */
      public function authorize()
      {
-         return false;
+         return true;
      }
  
      /**
       * Get the validation rules that apply to the request.
       *
       * @return array
       */
      public function rules()
      {
          return [
-              //
+             'current_password' => ['required', 'string', 'min:8'],
+             'password' => ['required', 'string', 'min:8', 'confirmed']
          ];
      }
+
+     public function withValidator(Validator $validator) {
+          $validator->after(function ($validator) {
+              $auth = Auth::user();
+              
+              //現在のパスワードと新しいパスワードが合わなければエラー
+              if (!(Hash::check($this->input('current_password'), $auth->password))) {
+                  $validator->errors()->add('current_password', __('The current password is incorrect.'));
+              }
+          });
+      }
  }
```

#### コントローラに読み込み

コントローラ側で、作成した `ChangePasswordRequest` クラスを使うように`changePassword` メソッドを変更します。

```diff:app/Http/Controllers/Auth/ChangePasswordController.php
  <?php

  namespace App\Http\Controllers\Auth;

  use App\Http\Controllers\Controller;
- use Illuminate\Http\Request;
+ use App\Http\Requests\ChangePasswordRequest;

  class ChangePasswordController extends Controller
  {
  ===（略）===
-     public function changePassword(ChangePasswordRequest $request)
+     public function changePassword(ChangePasswordRequest $request)
      {
+          //ValidationはChangePasswordRequestで処理
           /* ===ここにパスワード変更の処理=== */
+
+          // パスワード変更処理後、homeにリダイレクト
+          return redirect()->route('home')->with('status', __('Your password has been changed.'));
      }
```

####翻訳ファイルに追加

```diff:resources/lang/ja.json
+     "The current password is incorrect.": "現在のパスワードが違います。",
+     "Your password has been changed.": "パスワードが変更されました。"
  }
```

### パスワード変更の処理

パスワード変更のボタンを押した後の処理を、コントローラに追加します。
`changePassword`メソッドについて、以下のように修正します。

```diff:app/Http/Controllers/Auth/ChangePasswordController.php
  <?php

  namespace App\Http\Controllers\Auth;

  use App\Http\Controllers\Controller;
  use Illuminate\Http\Request;
+ use Illuminate\Support\Facades\Auth;

  ===（略）===

      public function changePassword(ChangePasswordRequest $request)
      {
           //ValidationはChangePasswordRequestで処理
-          /* ===ここにパスワード変更の処理=== */
+          //パスワード変更処理
+          $user = Auth::user();
+          $user->password = bcrypt($request->get('password'));
+          $user->save();
 
           // パスワード変更処理後、homeにリダイレクト
           return redirect()->route('home')->with('status', __('Your password has been changed.'));
      }
```

## ヘッダからリンクする

ログインした後、右上に表示されるメニューの中に、パスワード変更のリンクを張っておきます。

```diff:resource/views/layouts/app.blade.php
                                <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">
                                    <a class="dropdown-item" href="{{ route('logout') }}"
                                       onclick="event.preventDefault();
                                                     document.getElementById('logout-form').submit();">
                                        {{ __('Logout') }}
                                    </a>

                                    <form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
                                        @csrf
                                    </form>
+
+                                   <a class="dropdown-item" href="{{ route('password.form') }}">
+                                       {{ __('Change Password') }}
+                                   </a>
                                </div>
```

## 最終確認
パスワード変更画面にアクセスして、

- 現在のパスワードの欄に適当なパスワードを入れたときに「現在のパスワードが違います。」と表示されるか
- パスワードが8桁未満の場合「パスワードは、8文字以上で指定してください。」と表示されるか
- 正しく入力すると、「パスワードが変更されました」と表示されてhomeにリダイレクトされるか
- 変更後のパスワードでログインできるか

など、希望の動作になるか確認します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262236/72d6186a-9f27-37dc-9515-fa45241b31c7.png" width="600">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262236/9889d4a5-bba6-2a54-8ea9-dd02145c6b07.png" width="600">

# 他のログイン済みのデバイスをすべてログアウトさせたい場合

パスワード漏洩対策として、パスワードを変更したら他のデバイスからのログインをすべてログアウトさせたい、という場合、`app/Http/Kernel.php` で `AuthenticateSession` を有効にします。

```diff:app/Http/Kernel.php
    protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
-           // \Illuminate\Session\Middleware\AuthenticateSession::class,
+           \Illuminate\Session\Middleware\AuthenticateSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \App\Http\Middleware\VerifyCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
```


# まとめのソース
<details><summary>View（resources/views/auth/passwords/change.blade.php）</summary><div>

```php:resources/views/auth/passwords/change.blade.php
@extends('layouts.app')

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

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

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

                            <div class="col-md-6">
                                <input id="current_password" type="password" class="form-control @error('current_password') is-invalid @enderror" name="current_password" required autocomplete="new-password">

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

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

                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control @error('password') is-invalid @enderror" name="password" required autocomplete="new-password">

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

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

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

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

<details><summary>Controller（app/Http/Controllers/Auth/ChangePassswordController.php）</summary><div>

```php:app/Http/Controllers/Auth/ChangePassswordController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\ChangePasswordRequest;
use Illuminate\Support\Facades\Auth;

class ChangePasswordController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth');
    }

    public function showChangePasswordForm()
    {
        return view('auth\passwords\change');
    }

    public function changePassword(ChangePasswordRequest $request)
    {
        //ValidationはChangePasswordRequestで処理
        //パスワード変更処理
        $user = Auth::user();
        $user->password = bcrypt($request->get('password'));
        $user->save();

        //homeにリダイレクト
        return redirect()->route('home')->with('status', __('Your password has been changed.'));
  }
}
```
</div></details>

<details><summary>FormRequest（app/Requests/ChangePasswordRequest.php）</summary><div>

```php:app/Requests/ChangePasswordRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;

class ChangePasswordRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'current_password' => ['required', 'string', 'min:8'],
            'password' => ['required', 'string', 'min:8', 'confirmed']
        ];
    }

    public function withValidator(Validator $validator) {
         $validator->after(function ($validator) {
             $auth = Auth::user();
             
             //現在のパスワードと新しいパスワードが合わなければエラー
             if (!(Hash::check($this->input('current_password'), $auth->password))) {
                 $validator->errors()->add('current_password', __('The current password is incorrect.'));
             }
         });
     }
}
```
</div></details>


# 参考サイト
- [[Laravel 5.7]パスワード変更フォームの作り方 &#8211; ryotaの技術メモ](http://ryota01.com/archives/22)
- [[PHP] Laravel でユーザー認証されている時のユーザー情報の値のとり方や、判定する関数など ｜ memocarilog](https://memocarilog.info/php-mysql/8749)
- [Laravelを使ったAPI開発でController内のバリデーションをFormRequestに抽出して幸せになろう - NAKKA-Kの技術ブログ](https://nakka-k.hatenablog.com/entry/2019/06/04/191906)
- [パスワード変更時に他のデバイスでログインしているアカウントをログアウトさせる | Xzxzyzyz](https://xzxzyzyz.com/2018/02/laravel-authenticate-session/)