Laravel API パスワードリセット機能を実装する

## パスワードリセットとは？

パスワード忘れた人がメールアドレスを入力して、メールのリンクを踏んで、パスワードを変更する一連のアレをAPIで実装します。パスワードリマインダーとも言う。

Laravel Breeze等の[Laravelアプリケーションスターターキット](https://readouble.com/laravel/9.x/ja/starter-kits.html)にパスワードリセット機能を含め、認証系のテンプレートは用意されてます。

今回はAPI向けに実装します。
認証機能の実装が終わったらついでに作ると良さそうです。
ちなみに認証機能の記事はこれです。

https://qiita.com/ucan-lab/items/3e7045e49658763a9566

## 参考資料

https://readouble.com/laravel/9.x/ja/passwords.html

公式ドキュメントにパスワードリセットを参考にします。

## 環境

- PHP: 8.1.5
- Laravel: 9.9.0

## 実装するAPI

- パスワードリセットメールを送信するAPI
  - パスワードリセットメールの日本語化
- パスワードを変更するAPI

画面を作らなくていい分楽ですね。

## 実装手順

### パスワード再設定メールを送信するAPI

```shell-session
$ php artisan make:request Api/Auth/ForgotPasswordRequest
$ php artisan make:controller -i Api/Auth/ForgotPasswordController
```

<details><summary>app/Http/Requests/Api/Auth/ForgotPasswordRequest.php</summary><div>

```php:app/Http/Requests/Api/Auth/ForgotPasswordRequest.php
<?php declare(strict_types=1);

namespace App\Http\Requests\Api\Auth;

use Illuminate\Foundation\Http\FormRequest;

final class ForgotPasswordRequest extends FormRequest
{
    /**
     * @return bool
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array
     */
    public function rules(): array
    {
        return [
            'email' => ['required', 'email'],
        ];
    }
}
```

</div></details>
<details><summary>app/Http/Controllers/Api/Auth/ForgotPasswordController.php</summary><div>

```php:app/Http/Controllers/Api/Auth/ForgotPasswordController.php
<?php declare(strict_types=1);

namespace App\Http\Controllers\Api\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\ForgotPasswordRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;

final class ForgotPasswordController extends Controller
{
    /**
     * @param ForgotPasswordRequest $request
     * @return JsonResponse
     * @throws ValidationException
     */
    public function __invoke(ForgotPasswordRequest $request): JsonResponse
    {
        $status = Password::sendResetLink($request->only('email'));

        if ($status !== Password::RESET_LINK_SENT) {
            throw ValidationException::withMessages([
                'email' => trans($status),
            ]);
        }

        return new JsonResponse([
            'message' => trans($status),
        ]);
    }
}
```

</div></details>

ルーティングに下記のコードを追記する。
`POST /api/forgot-password` ルーティングを追加します。

```php:routes/api.php
use App\Http\Controllers\Api\Auth\ForgotPasswordController;

Route::post('/forgot-password', ForgotPasswordController::class)->name('password.forgot');
```

ユーザーのデータを1つ作ります。

```shell-session
$ php artisan tinker --execute "App\Models\User::factory()->create(['email' => 'demo@example.com']);"
```

APIをPostmanで叩いてみます。

![ScreenShot 2022-05-03 0.36.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/9ab2c329-72a6-27f3-85ac-efe6b1c5b53f.png)

パスワードリセットメールが届いていればOKです。

### パスワードリセットメールのカスタマイズ

パスワードリセットリンクを任意のURL(フロント側のURL)に変更します。

また、パスワードリセットメールのデザインはそのままでメール本文を日本語化する手順をご紹介します。
大体はプロジェクトによってメールテンプレートが異なると思うので、パスワードリセット用のBladeファイルを作った方がより自由にカスタマイズできるので良いです。

```shell-session
$ php artisan make:notification Api/Auth/ResetPasswordNotification
```

<details><summary>app/Notifications/Api/Auth/ResetPasswordNotification.php</summary><div>

```php:app/Notifications/Api/Auth/ResetPasswordNotification.php
<?php declare(strict_types=1);

namespace App\Notifications\Api\Auth;

use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Lang;

final class ResetPasswordNotification extends ResetPassword
{
    private const PASSWORD_RESET_ENDPOINT = 'https://example.com/reset-password';

    /**
     * @param string $url
     * @return MailMessage
     */
    protected function buildMailMessage($url): MailMessage
    {
        return parent::buildMailMessage($url)
            ->greeting(Lang::get('Greeting'))
            ->salutation(config('app.name'));
    }

    /**
     * @param mixed $notifiable
     * @return string
     */
    protected function resetUrl($notifiable): string
    {
        return self::PASSWORD_RESET_ENDPOINT . '?' . http_build_query([
                'token' => $this->token,
                'email' => $notifiable->getEmailForPasswordReset(),
            ]);
    }
}
```

</div></details>

ResetPasswordNotificationクラスを設定するためにUserのEloquentクラスを修正します。

```php:app/Models/User.php
use App\Notifications\Api\Auth\ResetPasswordNotification;

// ...

    /**
     * @param  string  $token
     * @return void
     */
    public function sendPasswordResetNotification($token): void
    {
        $this->notify(new ResetPasswordNotification($token));
    }
```

```json:lang/ja.json
{
    "Reset Password Notification": "パスワードリセット通知",
    "Greeting": "こんにちは。",
    "You are receiving this email because we received a password reset request for your account.": "アカウントのパスワードリセットリクエストを受け付けました。",
    "Reset Password": "パスワードリセット",
    "This password reset link will expire in :count minutes.": "このパスワードリセットリンクは、:count 分で期限切れになります。",
    "If you did not request a password reset, no further action is required.": "このメールに身に覚えがない場合は無視してください。",
    "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "\":actionText\"ボタンをクリックできない場合は、以下のURLをコピーしてブラウザに貼り付けてください。"
}
```

```php:config/app.php
    'locale' => 'ja',
```

![ScreenShot 2022-05-03 0.29.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/e61bcef6-0330-6030-ed32-b131c6e002ba.png)

#### 補足

公式ドキュメントにリセットメールカスタマイズがあります。
https://readouble.com/laravel/9.x/ja/passwords.html#reset-email-customization

しかしモデルに通知クラスを設定する以上のことは書かれてないです。

- https://github.com/laravel/framework/blob/9.x/src/Illuminate/Auth/Notifications/ResetPassword.php
  - このクラスを継承して通知クラスを作っています。
- https://github.com/laravel/framework/blob/9.x/src/Illuminate/Notifications/resources/views/email.blade.php

メール本文を表示している処理はここです。
これに合わせて翻訳ファイルを作成しました。

### パスワードを変更するAPI

```shell-session
$ php artisan make:request Api/Auth/ResetPasswordRequest
$ php artisan make:controller -i Api/Auth/ResetPasswordController
```

<details><summary>app/Http/Requests/Api/Auth/ResetPasswordRequest.php</summary><div>

```php:app/Http/Requests/Api/Auth/ResetPasswordRequest.php
<?php declare(strict_types=1);

namespace App\Http\Requests\Api\Auth;

use Illuminate\Foundation\Http\FormRequest;

final class ResetPasswordRequest extends FormRequest
{
    /**
     * @return bool
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array
     */
    public function rules(): array
    {
        return [
            'email' => ['required', 'email'],
            'password' => ['required', 'string', 'min:6'],
            'token' => ['required', 'string'],
        ];
    }
}
```

</div></details>
<details><summary>app/Http/Controllers/Api/Auth/ResetPasswordController.php</summary><div>

```php:app/Http/Controllers/Api/Auth/ResetPasswordController.php
<?php declare(strict_types=1);

namespace App\Http\Controllers\Api\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\ResetPasswordRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;

final class ResetPasswordController extends Controller
{
    /**
     * @param ResetPasswordRequest $request
     * @return JsonResponse
     * @throws ValidationException
     */
    public function __invoke(ResetPasswordRequest $request): JsonResponse
    {
        $credentials = request()->only(['email', 'token', 'password']);

        $status = Password::reset($credentials, function (User $user, string $password) {
            $user->password = bcrypt($password);
            $user->save();
        });

        if ($status !== Password::PASSWORD_RESET) {
            throw ValidationException::withMessages([
                'email' => trans($status),
            ]);
        }

        return new JsonResponse([
            'message' => trans($status),
        ]);
    }
}
```

</div></details>

ルーティングに下記のコードを追記する。
`POST /api/reset-password` ルーティングを追加します。

```php:routes/api.php
use App\Http\Controllers\Api\Auth\ResetPasswordController;

Route::post('/reset-password', ResetPasswordController::class)->name('password.reset');
```

動作確認はパスワードリセットメールのリンクからemail, tokenを取得してもいいですし、tinkerでテストデータを生成しても良いです。

```shell-session
$ php artisan tinker

$user = App\Models\User::factory()->create(['email' => 'dummy@example.com']);
=> App\Models\User {#3552
     name: "Glenda Lehner",
     email: "dummy@example.com",
     email_verified_at: "2022-05-02 15:58:39",
     #password: "$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi",
     #remember_token: "43oPT0BnDl",
     updated_at: "2022-05-02 15:58:39",
     created_at: "2022-05-02 15:58:39",
     id: 11,
   }

$token = Illuminate\Support\Facades\Password::createToken($user);
=> "4c5f177ef87f0d3f2da656a97ea328acd74a8f68f08076b2102f4d879d4cef85"
```

PostmanでAPIを叩きます。

![ScreenShot 2022-05-03 0.59.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/1d3c90f0-c015-71e0-f6e7-2f6be903d980.png)

### パスワードリセットのテスト実装

```shell-session
$ php artisan make:test Http/Controllers/Api/Auth/ForgotPasswordControllerTest
$ php artisan make:test Http/Controllers/Api/Auth/ResetPasswordControllerTest
```

<details><summary>tests/Feature/Http/Controllers/Api/Auth/ForgotPasswordControllerTest.php</summary><div>

```php:tests/Feature/Http/Controllers/Api/Auth/ForgotPasswordControllerTest.php
<?php declare(strict_types=1);

namespace Tests\Feature\Http\Controllers\Api\Auth;

use App\Models\User;
use App\Notifications\Auth\ResetPasswordNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

final class ForgotPasswordControllerTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @return void
     */
    public function testSuccess(): void
    {
        Notification::fake();

        $user = User::factory()->create(['email' => 'test@example.com']);

        $params = [
            'email' => 'test@example.com',
        ];

        $this->postJson('/api/forgot-password', $params)
            ->assertStatus(200)
            ->assertJson([
                'message' => 'We have emailed your password reset link!',
            ]);

        Notification::assertSentTo(
            [$user], ResetPasswordNotification::class
        );
    }

    /**
     * @return void
     */
    public function testNotExistsEmail(): void
    {
        Notification::fake();

        $params = [
            'email' => 'test@example.com',
        ];

        $this->postJson('/api/forgot-password', $params)
            ->assertStatus(422)
            ->assertJson([
                'message' => 'We can\'t find a user with that email address.',
                'errors' => [
                    'email' => ['We can\'t find a user with that email address.'],
                ],
            ]);

        Notification::assertNothingSent();
    }

    /**
     * @return void
     */
    public function testThrottle55s(): void
    {
        User::factory()->create(['email' => 'test@example.com']);

        $params = [
            'email' => 'test@example.com',
        ];

        $this->postJson('/api/forgot-password', $params)
            ->assertStatus(200)
            ->assertJson([
                'message' => 'We have emailed your password reset link!',
            ]);

        $this->travel(55)->seconds();

        $this->postJson('/api/forgot-password', $params)
            ->assertStatus(422)
            ->assertJson([
                'message' => 'Please wait before retrying.',
                'errors' => [
                    'email' => ['Please wait before retrying.'],
                ],
            ]);
    }

    /**
     * @return void
     */
    public function testThrottle60s(): void
    {
        User::factory()->create(['email' => 'test@example.com']);

        $params = [
            'email' => 'test@example.com',
        ];

        $this->postJson('/api/forgot-password', $params)
            ->assertStatus(200)
            ->assertJson([
                'message' => 'We have emailed your password reset link!',
            ]);

        $this->travel(60)->seconds();

        $this->postJson('/api/forgot-password', $params)
            ->assertStatus(200)
            ->assertJson([
                'message' => 'We have emailed your password reset link!',
            ]);
    }
}
```

</div></details>
<details><summary>tests/Feature/Http/Controllers/Api/Auth/ResetPasswordControllerTest.php</summary><div>

```php:tests/Feature/Http/Controllers/Api/Auth/ResetPasswordControllerTest.php
<?php declare(strict_types=1);

namespace Tests\Feature\Http\Controllers\Api\Auth;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Password;
use Tests\TestCase;

final class ResetPasswordControllerTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @return void
     */
    public function testSuccess(): void
    {
        $user = User::factory()->create(['email' => 'test@example.com']);
        $token = Password::createToken($user);

        $params = [
            'email' => 'test@example.com',
            'password' => 'secret',
            'token' => $token,
        ];

        $this->postJson('/api/reset-password', $params)
            ->assertStatus(200)
            ->assertJson([
                'message' => 'Your password has been reset!',
            ]);
    }

    /**
     * @return void
     */
    public function testNotExistsEmail(): void
    {
        $params = [
            'email' => 'test@example.com',
            'password' => 'secret',
            'token' => '123456',
        ];

        $this->postJson('/api/reset-password', $params)
            ->assertStatus(422)
            ->assertJson([
                'message' => 'We can\'t find a user with that email address.',
                'errors' => [
                    'email' => ['We can\'t find a user with that email address.'],
                ],
            ]);
    }

    /**
     * @return void
     */
    public function testMismatchToken(): void
    {
        $user = User::factory()->create(['email' => 'test@example.com']);
        Password::createToken($user);

        $params = [
            'email' => 'test@example.com',
            'password' => 'secret',
            'token' => '123456',
        ];

        $this->postJson('/api/reset-password', $params)
            ->assertStatus(422)
            ->assertJson([
                'message' => 'This password reset token is invalid.',
                'errors' => [
                    'email' => ['This password reset token is invalid.'],
                ],
            ]);
    }

    /**
     * @return void
     */
    public function testExpiredToken(): void
    {
        $user = User::factory()->create(['email' => 'test@example.com']);
        $token = Password::createToken($user);

        $params = [
            'email' => 'test@example.com',
            'password' => 'secret',
            'token' => $token,
        ];

        $this->travel(60)->hours();

        $this->postJson('/api/reset-password', $params)
            ->assertStatus(422)
            ->assertJson([
                'message' => 'This password reset token is invalid.',
                'errors' => [
                    'email' => ['This password reset token is invalid.'],
                ],
            ]);
    }
}
```

</div></details>

#### 補足

テストでパスワードリセットトークンの有効期限とパスワードリセットメールを再試行可能になる時間のテストコードを書いています。
この設定は `config/auth.php` で変更できます。

```php:config/auth.php
    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_resets',
            'expire' => 60, // 分単位, パスワードリセットトークンの有効期限
            'throttle' => 60, // 秒単位, パスワードリセットメールを再試行可能になる時間
        ],
    ],
```