Laravel9でログイン時にメールアドレスではなくユーザ名で認証をする

## はじめに

デフォルトでLaravelはメールアドレス`email`を認証に利用する。過去にLaravel5.8で、ログイン時のユーザ認証にメールアドレスではなくユーザ名`username`を利用するようにカスタマイズしたが、その時は、以下のページの"Username Customization"の項を参照にした。

https://laravel.com/docs/5.8/authentication#included-authenticating

Laravel9で、同じようにユーザ名で認証を行うようにカスタマイズしようとしたが、Laravel9のドキュメントでは正にこの目的のためのガイドが見つけられず、ドキュメントとコードを調べながら試行錯誤して実現したので、その結果をメモする。

## 前提

カスタマイズをする以前に、ユーザ認証機能そのものの導入手段が、Laravel5.8と9では異なっており、Laravel9では、以下のページをみて、Laravel Breezaeをインストールすることで導入した。

https://laravel.com/docs/9.x/starter-kits#laravel-breeze

この時の、各バージョンは次のとおり。

```
"name": "laravel/framework", "version": "v9.0.2",
"name": "laravel/breeze",    "version": "v1.8.1",
```

本稿は、Laravel9とLaravel Breezaeが導入済みであり、メールアドレスでの認証はできている状態を前提とする。

## やったこと

### まずは見た目から...　フォームでの入力を「名前」にする

「ログイン」フォームの、1番目のテキストボックスのキャプションが`Email`となっているので、これを`Name`に修正する。
1番目のテキストボックスに入力した値(value)が、このフォームから`POST　/login`で送信するデータ(リクエスト)に含まれるが、その時のkeyが、`email`ではなく`name`になるように修正する。

```diff
--- a/resources/views/auth/login.blade.php
+++ b/resources/views/auth/login.blade.php
@@ -15,11 +15,11 @@
         <form method="POST" action="{{ route('login') }}">
             @csrf
 
-            <!-- Email Address -->
+            <!-- Name -->
             <div>
-                <x-label for="email" :value="__('Email')" />
+                <x-label for="name" :value="__('Name')" />
 
-                <x-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus />
+                <x-input id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus />
             </div>
 
             <!-- Password -->
```

### リクエストの検証ルールを変更する

`POST　/login`にアクセスすると、`AuthenticatedSessionController`クラスの`store`メソッドを実行するが、`store`メソッドには`LoginRequest`型のデータが必要になる。

```shell
$ php artisan route:list
...
  GET|HEAD   login ........ login › Auth\AuthenticatedSessionController@create
  POST       login ................ Auth\AuthenticatedSessionController@store
  POST       logout ...... logout › Auth\AuthenticatedSessionController@destroy
```

```php
class AuthenticatedSessionController extends Controller
{
...
    public function store(LoginRequest $request)
    {
        $request->authenticate();
...
```

「ログイン」フォームから送信するデータから、`LoginRequest`が生成され`store`メソッドに渡されるが、この`LoginRequest`を生成するときの検証ルールが定義されており、デフォルトでは"`email`が必要"となっているため"`name`が必要"に修正する。

前項の修正によって、「ログイン」フォームから送信するデータには`email`をkeyにするデータは含まれなくなっているため、この検証ルールの変更をしないと、検証に失敗し、`store`メソッドを実行する前にエラーとなってしまう。

```diff
--- a/app/Http/Requests/Auth/LoginRequest.php
+++ b/app/Http/Requests/Auth/LoginRequest.php
@@ -29,7 +29,7 @@ public function authorize()
     public function rules()
     {
         return [
-            'email' => ['required', 'string', 'email'],
+            'name' => ['required', 'string'],
             'password' => ['required', 'string'],
         ];
     }
```

### 認証試行の引数にユーザ名を渡す

これが、今回の修正のメイン。

`store`メソッドが実行されると、認証のために`LoginRequest`クラスの`authenticate`メソッドを実行する。`authenticate`メソッドでは、Laravelの認証サービスを利用して実際の認証を行うため、`Auth`ファサードの`attempt`メソッドを実行する。

`attempt`メソッドは第一引数に指定された連想配列(key/valueのペア)を使って、データベースのusersテーブルからユーザを探し、ユーザが見つかったら配列の`password`の値をハッシュしたものと、データベースにある既にハッシュ化されたパスワードを比較する。

`attempt`メソッドに指定する連想配列のkeyがユーザを探すときのusersテーブルのカラムになる。デフォルトでは`email`カラムの値が、`email`をkeyとする値と一致するユーザを探すが、指定する連想配列を修正し、`name`カラムが、`name`をkeyとする値(=ログインフォームで入力された値)と一致するユーザを探すようにする。

```diff
--- a/app/Http/Requests/Auth/LoginRequest.php
+++ b/app/Http/Requests/Auth/LoginRequest.php
@@ -45,11 +45,11 @@ public function authenticate()
     {
         $this->ensureIsNotRateLimited();
 
-        if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
+        if (! Auth::attempt($this->only('name', 'password'), $this->boolean('remember'))) {
             RateLimiter::hit($this->throttleKey());
 
             throw ValidationException::withMessages([
                 'email' => trans('auth.failed'),
             ]);
         }
```

attemptメソッドの使い方については、以下のページを参照した。

https://laravel.com/docs/9.x/authentication#authenticating-users

### ユーザ登録時にユーザ名が重複していないかチェックする

認証するためにユーザ名を使用するので、同じ名前のユーザが複数いるとユーザ名でユーザを特定できなくなってしまう。メールアドレスで認証をするときは、メールアドレスが重複しなければよかったが、ユーザ名で認証することにしたため、ユーザ名も重複しないように修正する。
ユーザ登録におけるリクエストの検証ルールに、ユーザ名がユニークであることを追加する。またデータベースの定義でも`name`カラムをユニークするための属性をつける。

```diff
--- a/book-keeping/app/Http/Controllers/Auth/RegisteredUserController.php
+++ b/book-keeping/app/Http/Controllers/Auth/RegisteredUserController.php
@@ -34,7 +34,7 @@ public function create()
     public function store(Request $request)
     {
         $request->validate([
-            'name' => ['required', 'string', 'max:255'],
+            'name' => ['required', 'string', 'max:255', 'unique:users'],
             'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
             'password' => ['required', 'confirmed', Rules\Password::defaults()],
         ]);
```

```diff
--- a/book-keeping/database/migrations/2014_10_12_000000_create_users_table.php
+++ b/book-keeping/database/migrations/2014_10_12_000000_create_users_table.php
@@ -15,7 +15,7 @@ public function up()
     {
         Schema::create('users', function (Blueprint $table) {
             $table->id();
-            $table->string('name');
+            $table->string('name')->unique();
             $table->string('email')->unique();
             $table->timestamp('email_verified_at')->nullable();
             $table->string('password');
```

https://qiita.com/shizen-shin/items/579ca6fd96a961b2f5f2

### エラーの項目を「名前」にする

認証に失敗したときや、何回も続けて失敗した時には、例外を発生させ、エラーメッセージを「ログイン」フォームに表示する。
`ValidationException::withMessages()`に引数で指定する配列が、エラーメッセージ(`$errors`)の内容になる。配列のkeyがエラーの項目(何に関するエラーか)になり、値がメッセージそのものになる。

```php
@props(['errors'])

@if ($errors->any())
    <div {{ $attributes }}>
        <div class="font-medium text-red-600">
            {{ __('Whoops! Something went wrong.') }}
        </div>

        <ul class="mt-3 list-disc list-inside text-sm text-red-600">
            @foreach ($errors->all() as $error)
                <li>{{ $error }}</li>
            @endforeach
        </ul>
    </div>
@endif
```

ユーザ名で認証をするのに伴って、エラーメッセージも`email`ではなく`name`に関するものになるので、`ValidationException::withMessages()`に指定する配列のkeyを修正する。
現状、「ログイン」フォームで表示するエラーメッセージは、エラーの項目を気にせず`$errors->all()`で文字列を出力しているため、直接的な効果がないが、意味(何に関するエラーか)が変わるのに合わせて修正する。

```diff
--- a/book-keeping/app/Http/Requests/Auth/LoginRequest.php
+++ b/book-keeping/app/Http/Requests/Auth/LoginRequest.php
@@ -45,11 +45,11 @@ public function authenticate()
     {
         $this->ensureIsNotRateLimited();
 
         if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
             RateLimiter::hit($this->throttleKey());
 
             throw ValidationException::withMessages([
-                'email' => trans('auth.failed'),
+                'name' => trans('auth.failed'),
             ]);
         }
 
@@ -74,7 +74,7 @@ public function ensureIsNotRateLimited()
         $seconds = RateLimiter::availableIn($this->throttleKey());
 
         throw ValidationException::withMessages([
-            'email' => trans('auth.throttle', [
+            'name' => trans('auth.throttle', [
                 'seconds' => $seconds,
                 'minutes' => ceil($seconds / 60),
             ]),
```

エラーメッセージや、`@errors`については以下のページを参照した。

https://qiita.com/gone0021/items/68f0563ac2852ad96b14

### 失敗の回数を数えるときのキーをユーザ名との組み合わせで生成する

同じリクエストが、短期間に連続して認証に失敗する場合、しばらくの間、ログインを禁止する(強制的に認証を失敗にする)。"同じリクエスト"と見做すためのキーは`throttleKey()`で生成される。デフォルトではリクエストに含まれるメールアドレスとIPアドレスの組み合わせでキーを生成していたが、これをユーザ名とIPアドレスの組み合わせで生成するように修正する。
ユーザ名で認証することにしたため、「ログイン」フォームからのリクエストにはメールアドレスは含まれず、この修正をしないと、IPアドレスだけでキーを生成することになる。その結果、あるユーザが規定の回数認証に失敗すると、同じIPアドレスからリクエストを送っている、別のユーザもログイン禁止の条件に該当したと誤って判定してしまう。

```diff
--- a/book-keeping/app/Http/Requests/Auth/LoginRequest.php
+++ b/book-keeping/app/Http/Requests/Auth/LoginRequest.php
@@ -88,6 +88,6 @@ public function ensureIsNotRateLimited()
      */
     public function throttleKey()
     {
-        return Str::lower($this->input('email')).'|'.$this->ip();
+        return Str::lower($this->input('name')).'|'.$this->ip();
     }
 }
```


### おまけ: テストケースの修正

Laravel Breezaeをインストールすると、認証機能に関するテストケースが追加される。`POST　/login`された時の振る舞いのテストでは、デフォルトの「ログイン」フォームに合わせて、メールアドレスとパスワードをインプットしているが、今回の修正に合わせて、ユーザ名とパスワードをインプットするように修正する。

```diff
--- a/book-keeping/tests/Feature/Auth/AuthenticationTest.php
+++ b/book-keeping/tests/Feature/Auth/AuthenticationTest.php
@@ -23,7 +23,7 @@ public function test_users_can_authenticate_using_the_login_screen()
         $user = User::factory()->create();
 
         $response = $this->post('/login', [
-            'email' => $user->email,
+            'name' => $user->name,
             'password' => 'password',
         ]);
 
@@ -36,7 +36,7 @@ public function test_users_can_not_authenticate_with_invalid_password()
         $user = User::factory()->create();
 
         $this->post('/login', [
-            'email' => $user->email,
+            'name' => $user->name,
             'password' => 'wrong-password',
         ]);
```