1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

でも...

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

実装してみました。

ルーティングの設定

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 のマジックリンクの実装を流用しています。

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 が使えないとなると、ここに書く必要はないというか。となるとコントローラに直接書いても良いレベルだけどまぁ一応。

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 & AdminLTE4(Beta) を使っているので、それに準じた書き方となっています。

共通パーツ

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 ?>

送信先メールアドレス入力フォーム

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() ?>

新しいパスワードを入力するフォーム

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() ?>

送信するメール本文

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>

ハマりどころ

パスワード再設定時のバリデーション。本来ならユーザー登録時で使うバリデーションにならって

$passwordRules            = $this->getPasswordRules();
$passwordRules['rules'][] = 'strong_password[]';

としたいところなんですが、この strong_password が使えない(涙

公式ドキュメント 曰く、

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() の後にわざわざ

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());
}

とチェックするハメになりました。

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

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?