【ハンズオン】PHPでシンプルなパスワードリセットを実装する


初めに

認証周りが分からないというコンプレックスを克服すべく、下記の記事に続き、パスワードリセットを実装してみました。

https://zenn.dev/syamozipc/articles/php_auth_register

どなたかの役に立てば幸いです。

※続編はこちら

https://zenn.dev/syamozipc/articles/php_remember_me

実装の流れ

  1. パスワードリセットメールのリクエストフォームでメールアドレスを入力
  2. メールアドレスがusersテーブルに登録済みなら次へ、未登録なら、(セキュリティ上の観点から)メール送信完了画面を表示して終了
  3. password_resetsテーブルに同じメールアドレスがないか確認し、
    • 重複なければ、下記を設定したレコードをインサート
      • メールアドレス
      • ユーザー識別トークン
      • トークン送信日時(現在時刻)
    • 重複している場合は、「ユーザー識別トークン」「トークン送信日時」を更新し4へ
  4. パスワードリセット用URLのクエリに上記で作成したトークンを持たせ、メールアドレスに送信し、送信完了画面を表示
  5. ユーザーがそのURLからアクセス時、トークン送信日時が有効期間内であれば、パスワード変更フォームを表示
  6. 入力したパスワードをハッシュ化し、トークンと合致するusersテーブルのレコードのpasswordカラムを更新

ファイル構成

.
├─ database.php
├─ show_request_form.php
├─ show_reset_form.php
├─ request.php
├─ reset.php
└─ views
    ├── request_form.php
    ├── reset_form.php
    └── email_sent.php

使用するテーブル

usersテーブル

カラム名 備考
id 主キー
email
register_token 仮登録時に使用。ユーザーを一意に識別する
register_token_sent_at 仮登録の有効期限管理に使用
register_token_verified_at 本登録時に更新
password
status enum型。tentative:仮登録、puvlic:本登録
created_at
updated_at

password_resetsテーブル

カラム名 備考
email 主キー
token ユーザーを一意に識別する
token_sent_at パスワードリセットのフローの有効期限管理に使用
作成したDDLはこちら(MySQLを想定)
CREATE TABLE `users` (
    `id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    `email` VARCHAR(50) UNIQUE NOT NULL,
    `register_token` VARCHAR(80),
    `register_token_sent_at` DATETIME,
    `register_token_verified_at` DATETIME,
    `password` VARCHAR(80),
    `status` ENUM('tentative', 'public') NOT NULL DEFAULT 'tentative',
    `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP  ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE `password_resets` (
    `email` varchar(50) PRIMARY KEY,
    `token` varchar(80) NOT NULL,
    `token_sent_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP  ON UPDATE CURRENT_TIMESTAMP
);
流し込み用SQL
INSERT INTO `users` (`id`, `email`, `register_token`, `register_token_sent_at`, `register_token_verified_at`, `password`, `status`, `created_at`, `updated_at`)
VALUES
	(1,'あなたのメールアドレス','45085a3ce32c6623c4159dc3e202007c6ef26d0a8201ab9802426a35a0bc2474','2022-03-27 12:21:41','2022-03-27 12:21:58','$2y$10$6HKFJAEmZNfgHK2KQi4pEOoS/xursM31YSiZL/JpPqPEWkqDIyLty','public','2022-03-27 12:21:41','2022-03-27 16:38:58');

1. パスワードリセットメールのリクエストフォームでメールアドレスを入力

show_request_form.php
<?php
session_start();

// formに埋め込むcsrf tokenの生成
if (empty($_SESSION['_csrf_token'])) {
    $_SESSION['_csrf_token'] = bin2hex(random_bytes(32));
}

// リクエストフォームを読み込む
require_once './views/request_form.php';

フォーム部分(formタグ内を抜粋)

views/request_form.php
<form action="request.php" method="POST">
    <p>パスワードリセット</p>
    <input type="hidden" name="_csrf_token" value="<?= $_SESSION['_csrf_token']; ?>">
    <label>
        メールアドレスを入力してください。リセット用URLをお送りします。
        <input type="email" name="email" value="">
    </label>
    <button type="submit">登録</button>
</form>

2. メールアドレスがusersテーブルに登録済みなら3へ、未登録ならメール送信完了画面を表示して終了

request.php
<?php
session_start();

$csrfToken = filter_input(INPUT_POST, '_csrf_token');

// csrf tokenを検証
if (
    empty($csrfToken)
    || empty($_SESSION['_csrf_token'])
    || $csrfToken !== $_SESSION['_csrf_token']
) {
    exit('不正なリクエストです');
}

// 本来はここでemailのバリデーションもかける
$email = filter_input(INPUT_POST, 'email');

// pdoオブジェクトを取得
require_once './database.php';
$pdo = getPdo();

// emailがusersテーブルに登録済みか確認
$sql = 'SELECT * FROM users WHERE `email` = :email AND `status` = :status';
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':email', $email, \PDO::PARAM_STR);
$stmt->bindValue(':status', 'public', \PDO::PARAM_STR);
$stmt->execute();
$user = $stmt->fetch(\PDO::FETCH_OBJ);

// 未登録のメールアドレスであっても、送信完了画面を表示
// 「未登録です」と表示すると、万が一そのメールアドレスを知っている別人が入力していた場合、「このメールアドレスは未登録である」と情報を与えてしまう
if (!$user) {
    require_once './views/email_sent.php';
    exit();
}

コード内に出てくるdatabase.phpの中身はこちらです。

database.php
database.php
<?php

function getPdo()
{
    $dsn = 'mysql:host=localhost;dbname=zenn;charset=utf8mb4';
    $options = [
        \PDO::ATTR_PERSISTENT => true,
        \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
    ];

    try {
        return new \PDO($dsn, 'root', 'root', $options);
    } catch (\PDOException $e) {
        exit($e->getMessage());
    }
}

3. password_resetsテーブルにレコードをインサートまたはアップデート

request.php(※2.のファイルの続き)
// 既にパスワードリセットのフロー中(もしくは有効期限切れ)かどうかを確認
// $passwordResetUserが取れればフロー中、取れなければ新規のリクエストということ
$sql = 'SELECT * FROM `password_resets` WHERE `email` = :email';
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':email', $email, \PDO::PARAM_STR);
$stmt->execute();
$passwordResetUser = $stmt->fetch(\PDO::FETCH_OBJ);

if (!$passwordResetUser) {
    // $passwordResetUserがいなければ、仮登録としてテーブルにインサート
    $sql = 'INSERT INTO `password_resets`(`email`, `token`, `token_sent_at`) VALUES(:email, :token, :token_sent_at)';
} else {
    // 既にフロー中の$passwordResetUserがいる場合、tokenの再発行と有効期限のリセットを行う
    $sql = 'UPDATE `password_resets` SET `token` = :token, `token_sent_at` = :token_sent_at WHERE `email` = :email';
}

// password reset token生成
$passwordResetToken = bin2hex(random_bytes(32));

// password_resetsテーブルへの変更とメール送信は原子性を保ちたいため、トランザクションを設置する
// メール送信に失敗した場合は、パスワードリセット処理自体も失敗させる
try {
    $pdo->beginTransaction();

    // ユーザーを仮登録
    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(':email', $email, \PDO::PARAM_STR);
    $stmt->bindValue(':token', $passwordResetToken, \PDO::PARAM_STR);
    $stmt->bindValue(':token_sent_at', (new \DateTime())->format('Y-m-d H:i:s'), \PDO::PARAM_STR);
    $stmt->execute();

4. パスワードリセット用URLのクエリに上記で作成したトークンを持たせ、メールアドレスに送信し、送信完了画面を表示

request.php(※3.のファイルの続き)
<?php

    // 以下、mail関数でパスワードリセット用メールを送信
    mb_language("Japanese");
    mb_internal_encoding("UTF-8");

    // URLはご自身の環境に合わせてください
    $url = "http://hoge.com/show_reset_form.php?token={$passwordResetToken}";

    $subject =  'パスワードリセット用URLをお送りします';

    $body = <<<EOD
        24時間以内に下記URLへアクセスし、パスワードの変更を完了してください。
        {$url}
        EOD;

    // Fromはご自身の環境に合わせてください
    $headers = "From : [email protected]\n";
    // text/htmlを指定し、html形式で送ることも可能
    $headers .= "Content-Type : text/plain";

    // mb_send_mailは成功したらtrue、失敗したらfalseを返す
    $isSent = mb_send_mail($email, $subject, $body, $headers);

    if (!$isSent) throw new \Exception('メール送信に失敗しました。');

    // メール送信まで成功したら、password_resetsテーブルへの変更を確定
    $pdo->commit();

} catch (\Exception $e) {
    $pdo->rollBack();

    exit($e->getMessage());
}


// 送信済み画面を表示
require_once './views/email_sent.php';

登録したメールアドレスに、メールが届きます。
今回はMAMP環境でやっているため、localhost:8888になっています

5. ユーザーがそのURLからアクセス時、トークン送信日時が有効期間内であれば、会員登録フォームを表示

show_reset_form.php
<?php
session_start();

// pdoオブジェクトを取得
require_once './database.php';
$pdo = getPdo();

// クエリからtokenを取得
$passwordResetToken = filter_input(INPUT_GET, 'token');

// tokenに合致するユーザーを取得
$sql = 'SELECT * FROM `password_resets` WHERE `token` = :token';
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':token', $passwordResetToken, \PDO::PARAM_STR);
$stmt->execute();
$passwordResetuser = $stmt->fetch(\PDO::FETCH_OBJ);

// 合致するユーザーがいなければ無効なトークンなので、処理を中断
if (!$passwordResetuser) exit('無効なURLです');

// 今回はtokenの有効期間を24時間とする
$tokenValidPeriod = (new \DateTime())->modify("-24 hour")->format('Y-m-d H:i:s');

// パスワードの変更リクエストが24時間以上前の場合、有効期限切れとする
if ($passwordResetuser->token_sent_at < $tokenValidPeriod) {
    exit('有効期限切れです');
}

// formに埋め込むcsrf tokenの生成
if (empty($_SESSION['_csrf_token'])) {
    $_SESSION['_csrf_token'] = bin2hex(random_bytes(32));
}

// パスワードリセットフォームを読み込む
require_once './views/reset_form.php';

パスワードリセットフォームフォーム(form部分を抜粋)

views/reset_form.php
<p>パスワードリセット</p>
<form action="reset.php" method="POST">
    <input type="hidden" name="_csrf_token" value="<?= $_SESSION['_csrf_token']; ?>">
    <input type="hidden" name="password_reset_token" value="<?= $passwordResetToken ?>">

    <label>
        新しいパスワード
        <input type="password" name="password">
    </label>
    <br>
    <label>
        パスワード(確認用)
        <input type="password" name="password_confirmation">
    </label>
    <br>
    
    <button type="submit">送信する</button>
</form>

6. 入力したパスワードをハッシュ化し、トークンと合致するusersテーブルのレコードのpasswordカラムを更新

reset.php
<?php
session_start();

$request = filter_input_array(INPUT_POST);

// csrf tokenが正しければOK
if (
    empty($request['_csrf_token'])
    || empty($_SESSION['_csrf_token'])
    || $request['_csrf_token'] !== $_SESSION['_csrf_token']
) {
    exit('不正なリクエストです');
}

// 本来はここでパスワードのバリデーションをする

// pdoオブジェクトを取得
require_once './database.php';
$pdo = getPdo();

// tokenに合致するユーザーを取得
$sql = 'SELECT * FROM `password_resets` WHERE `token` = :token';
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':token', $request['password_reset_token'], \PDO::PARAM_STR);
$stmt->execute();
$passwordResetuser = $stmt->fetch(\PDO::FETCH_OBJ);

// どのレコードにも合致しない無効なtokenであれば、処理を中断
if (!$passwordResetuser) exit('無効なURLです');

// テーブルに保存するパスワードをハッシュ化
$hashedPassword = password_hash($request['password'], PASSWORD_BCRYPT);

// usersテーブルとpassword_resetsテーブルの原子性を原始性を保証するため、トランザクションを設置
try {
    $pdo->beginTransaction();

    // 該当ユーザーのパスワードを更新
    $sql = 'UPDATE `users` SET `password` = :password WHERE `email` = :email';
    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(':password', $hashedPassword, \PDO::PARAM_STR);
    $stmt->bindValue(':email', $passwordResetuser->email, \PDO::PARAM_STR);
    $stmt->execute();

    // 用が済んだので、パスワードリセットテーブルから削除
    $sql = 'DELETE FROM `password_resets` WHERE `email` = :email';
    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(':email', $passwordResetuser->email, \PDO::PARAM_STR);
    $stmt->execute();

    $pdo->commit();

} catch (\Exception $e) {
    $pdo->rollBack();

    exit($e->getMessage());
}

echo 'パスワードの変更が完了しました。';

この後の続きをやるとすれば、以下の対応をすればいいかと思います。

  • 変更完了メールを送る
  • ログインしてマイページへ遷移する

最後までお読みいただき、ありがとうございました!