CodeIgniter4 の認証ライブラリ Shield で、パスワードリセット機能を実装してみた

CodeIgniter4 の認証ライブラリ Shield において、パスワードを忘れた場合のログイン手段として、マジックリンクという機能があります。メールアドレスを指定して、自動的にログインできるリンクを送ってくれる機能です。

でも...

普通のサイトみたいに、パスワードリセット機能が欲しい。

実装してみました。

## ルーティングの設定

```diff_php:app/Config/Routes.php
+ $routes->get('forgot-password', 'ResetPasswordController::forgotPassword', ['as' => 'forgot_password']);
+ $routes->post('forgot-password', 'ResetPasswordController::sendResetLink', ['as' => 'send_reset_link']);
+ $routes->get('reset-password', 'ResetPasswordController::resetPassword', ['as' => 'reset_password']);
+ $routes->post('reset-password', 'ResetPasswordController::updatePassword', ['as' => 'update_password']);
```

## コントローラの作成

大部分は Shield のマジックリンクの実装を流用しています。

```php:app/Controllers/ResetPasswordController.php
<?php

namespace App\Controllers;

use App\Controllers\BaseController;
use App\Validation\ValidationRules;
use CodeIgniter\Events\Events;
use CodeIgniter\I18n\Time;
use CodeIgniter\Shield\Models\UserModel;
use CodeIgniter\Shield\Models\LoginModel;
use CodeIgniter\Shield\Models\UserIdentityModel;

class ResetPasswordController extends BaseController
{
    protected $userModel;

    public function __construct()
    {
        $this->userModel = auth()->getProvider();
    }

    public function forgotPassword()
    {
        return view('forgot_password');
    }

    public function sendResetLink()
    {
        // Check if the user exists
        $email = $this->request->getPost('email');
        $user = $this->userModel->findByCredentials(['email' => $email]);

        if ($user === null) {
            return redirect()->back()->with('error', 'そのメールアドレスのアカウントは存在しません');
        }

        /** @var UserIdentityModel $identityModel */
        $identityModel = model(UserIdentityModel::class);

        // Delete any previous reset_password identities
        $identityModel->deleteIdentitiesByType($user, 'reset_password');

        helper('text');
        $token = random_string('crypto', 20);

        $identityModel->insert([
            'user_id' => $user->id,
            'type'    => 'reset_password',
            'secret'  => $token,
            'expires' => Time::now()->addSeconds(setting('Auth.magicLinkLifetime')),
        ]);

        $ipAddress = $this->request->getIPAddress();
        $userAgent = (string) $this->request->getUserAgent();
        $date      = Time::now()->toDateTimeString();

        // Send the user an email with the code
        helper('email');
        $email = emailer(['mailType' => 'html'])
            ->setFrom(setting('Email.fromEmail'), setting('Email.fromName') ?? '');
        $email->setTo($user->email);
        $email->setSubject('パスワードリセット');
        $email->setMessage(view(
            'Email/email_reset_password',
            ['token' => $token],
            ['debug' => false]
        ));

        if ($email->send(false) === false) {
            log_message('error', $email->printDebugger(['headers']));

            return redirect()->back()->with('error', lang('Auth.unableSendEmailToUser', [$user->email]));
        }

        // Clear the email
        $email->clear();

        return redirect()->back()->with('message', 'パスワードリセットのメールを送信しました');
    }

    public function resetPassword()
    {
        $token = $this->request->getGet('token');

        /** @var UserIdentityModel $identityModel */
        $identityModel = model(UserIdentityModel::class);

        $identity = $identityModel->getIdentityBySecret('reset_password', $token);

        $identifier = $token ?? '';

        // No token found?
        if ($identity === null) {
            $this->recordLoginAttempt($identifier, false);

            $credentials = ['resetPasswordToken' => $token];
            Events::trigger('failedLogin', $credentials);

            return redirect()->route('forgot_password')->with('error', lang('Auth.magicTokenNotFound'));
        }

        // Token expired?
        if (Time::now()->isAfter($identity->expires)) {
            $this->recordLoginAttempt($identifier, false);

            $credentials = ['resetPasswordToken' => $token];
            Events::trigger('failedLogin', $credentials);

            return redirect()->route('forgot_password')->with('error', lang('Auth.magicLinkExpired'));
        }

        return view('reset_password', ['token' => $token]);
    }

    public function updatePassword()
    {
        $token = $this->request->getPost('token');

        /** @var UserIdentityModel $identityModel */
        $identityModel = model(UserIdentityModel::class);

        $identity = $identityModel->getIdentityBySecret('reset_password', $token);

        $identifier = $token ?? '';

        // No token found?
        if ($identity === null) {
            $this->recordLoginAttempt($identifier, false);

            $credentials = ['resetPasswordToken' => $token];
            Events::trigger('failedLogin', $credentials);

            return redirect()->route('forgot_password')->with('error', lang('Auth.magicTokenNotFound'));
        }

        // Token expired?
        if (Time::now()->isAfter($identity->expires)) {
            $this->recordLoginAttempt($identifier, false);

            $credentials = ['resetPasswordToken' => $token];
            Events::trigger('failedLogin', $credentials);

            return redirect()->route('forgot_password')->with('error', lang('Auth.magicLinkExpired'));
        }

        $rules = new ValidationRules();
        $rules = $rules->getResetPasswordRules();

        if (! $this->validateData($this->request->getPost(), $rules, [], config('Auth')->DBGroup)) {
            return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
        }

        // strong_passwordを自前で確認
        /** @var Passwords $checker */
        $checker = service('passwords');
        $user = $this->userModel->findById($identity->user_id);

        $result = $checker->check($this->request->getPost('password'), $user);

        if (! $result->isOk()) {
            return redirect()->back()->withInput()->with('error', $result->reason());
        }

        $user->fill([
            'id' => $user->id,
            'password' => $this->request->getPost('password'),
        ]);

        try {
            $this->userModel->save($user);
        } catch (ValidationException $e) {
            return redirect()->back()->withInput()->with('errors', $this->userModel->errors());
        }

        // // Delete the db entry so it cannot be used again.
        $identityModel->delete($identity->id);

        return redirect()->to(route_to('login'))
            ->with('message', 'パスワードを変更しました');
    }

    /**
     * @param int|string|null $userId
     */
    private function recordLoginAttempt(
        string $identifier,
        bool $success,
        $userId = null
    ): void {
        /** @var LoginModel $loginModel */
        $loginModel = model(LoginModel::class);

        $loginModel->recordLoginAttempt(
            'reset_password',
            $identifier,
            $success,
            $this->request->getIPAddress(),
            (string) $this->request->getUserAgent(),
            $userId
        );
    }
}
```

## バリデーションの作成

コメントアウト部分を見ればわかると思いますが、`strong_password` が使えないとなると、ここに書く必要はないというか。となるとコントローラに直接書いても良いレベルだけどまぁ一応。
```php:app/Validation/ValidationRules.php
<?php

declare(strict_types=1);

namespace App\Validation;

use CodeIgniter\Shield\Validation\ValidationRules as ShieldValidationRules;

class ValidationRules extends ShieldValidationRules
{
    protected function initialize(): void
    {
        parent::initialize();
    }
    
    public function getResetPasswordRules(): array
    {
        $passwordRules            = $this->getPasswordRules();

        // strong_password はログイン状態でしか使えないので、スマートな書き方模索中...
        // $passwordRules['rules'][] = 'strong_password';

        return [
            'password'         => $passwordRules,
        ];
    }
}
```

## ビューの作成

パスワードリセットのためのビューを作成します。

ちなみに現在 [Bootstrap5](https://getbootstrap.jp/docs/5.3/) & [AdminLTE4(Beta)](https://github.com/ColorlibHQ/AdminLTE/releases/tag/v4.0.0-beta1) を使っているので、それに準じた書き方となっています。

### 共通パーツ
```php:app/Views/message.php
<?php if (session()->has('error')) : ?>
    <div class="alert alert-danger" role="alert"><?= session('error') ?></div>
<?php elseif (session()->has('errors')) : ?>
    <div class="alert alert-danger" role="alert">
        <?php if (is_array(session('errors'))) : ?>
            <?php foreach (session('errors') as $error) : ?>
                <?= $error ?>
                <br>
            <?php endforeach ?>
        <?php else : ?>
            <?= session('errors') ?>
        <?php endif ?>
    </div>
<?php endif ?>
<?php if (session()->has('message')) : ?>
    <div class="alert alert-success" role="alert"><?= session('message') ?></div>
<?php endif ?>
```

### 送信先メールアドレス入力フォーム
```php:app/Views/forgot_password.php
<?= $this->extend(config('Auth')->views['layout']) ?>
<?= $this->section('title') ?>パスワードリセット<?= $this->endSection() ?>
<?= $this->section('main') ?>
    <div class="card">
        <div class="card-body login-card-body">
            <p class="login-box-msg">パスワードリセット</p>
            <?= $this->include('message') ?>
            <form action="<?= url_to('forgot_password') ?>" class="mb-3" method="post">
                <?= csrf_field() ?>
                <div class="input-group mb-3">
                    <input type="email" class="form-control" id="floatingEmailInput" name="email" inputmode="email" autocomplete="email" placeholder="<?= lang('Auth.email') ?>" value="<?= old('email', auth()->user()->email ?? null) ?>" required>
                    <div class="input-group-text">
                        <span class="bi bi-envelope"></span>
                    </div>
                </div>
                <button type="submit" class="btn btn-primary btn-block"><?= lang('Auth.send') ?></button>
            </form>
            <p class="mb-0"><a href="<?= url_to('login') ?>"><?= lang('Auth.backToLogin') ?></a></p>
        </div>
    </div>
<?= $this->endSection() ?>
```

### 新しいパスワードを入力するフォーム
```php:app/Views/reset_password.php
<?= $this->extend(config('Auth')->views['layout']) ?>
<?= $this->section('title') ?>パスワードリセット<?= $this->endSection() ?>
<?= $this->section('main') ?>
    <div class="card">
        <div class="card-body login-card-body">
            <p class="login-box-msg">パスワードリセット</p>
            <?= $this->include('message') ?>
            <form action="<?= url_to('reset_password') ?>" class="mb-3" method="post">
                <?= csrf_field() ?>
                <input type="hidden" name="token" value="<?= $token ?>">
                <div class="input-group mb-2">
                    <input type="password" class="form-control" id="floatingPasswordInput" name="password" inputmode="text" autocomplete="new-password" placeholder="<?= lang('Auth.password') ?>" required>
                    <div class="input-group-text">
                        <span class="bi bi-lock-fill"></span>
                    </div>
                </div>
                <button type="submit" class="btn btn-primary btn-block">リセット</button>
            </form>
        </div>
    </div>
<?= $this->endSection() ?>
```

### 送信するメール本文
```php:member/app/Views/Email/email_reset_password.php
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<head>
    <meta name="x-apple-disable-message-reformatting">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>パスワードリセット</title>
</head>
<body>
    <a href="<?= url_to('reset_password') ?>?token=<?= $token ?>">パスワードリセット</a>
</body>
</html>
```

## ハマりどころ

パスワード再設定時のバリデーション。本来ならユーザー登録時で使うバリデーションにならって
```php
$passwordRules            = $this->getPasswordRules();
$passwordRules['rules'][] = 'strong_password[]';
```
としたいところなんですが、この `strong_password` が使えない（涙

[公式ドキュメント](https://shield.codeigniter.com/getting_started/concepts/?h=strong_password#password-validators) 曰く、

> The `strong_password` rule only supports use cases to check the user's own password. It fetches the authenticated user's data for **NothingPersonalValidator** if the visitor is authenticated. If you want to have use cases that set and check another user's password, you can't use `strong_password`. You need to use `service('passwords')` directly to check the password. But remember, it is not good practice to set passwords for other users. This is because the password should be known only by that user.

以下はDeepLによる訳

> `strong_password` ルールは、ユーザー自身のパスワードをチェックするユースケースのみをサポートしています。訪問者が認証されている場合、**NothingPersonalValidator** のために認証済みユーザのデータをフェッチします。他のユーザのパスワードを設定したりチェックしたりするユースケースを持ちたい場合は、`strong_password` を使用することはできません。パスワードをチェックするには、`service('passwords')` を直接使用する必要があります。しかし、他のユーザーのパスワードを設定するのは良い習慣ではありません。なぜなら、パスワードはそのユーザーしか知らないはずだからです。

なのだそうです。つまり、ログイン状態の場合しかサポートされず、未ログイン時のパスワードのみを更新する場合には使えない、と。

そのせいで、コントローラーにて、`validateData()` の後にわざわざ
```php:app/Controllers/ResetPasswordController.php
// strong_passwordを自前で確認
/** @var Passwords $checker */
$checker = service('passwords');
$user = $this->userModel->findById($identity->user_id);

$result = $checker->check($this->request->getPost('password'), $user);

if (! $result->isOk()) {
    return redirect()->back()->withInput()->with('error', $result->reason());
}
```
とチェックするハメになりました。

ここは絶対にもっとスマートな書き方があるはずなので、どなたかコメントいただけると嬉しいなぁ

## 参考
- [CodeIgniter Shield 公式ドキュメント](https://shield.codeigniter.com/)