🐡
【ハンズオン】PHPでシンプルなパスワードリセットを実装する
初めに
認証周りが分からないというコンプレックスを克服すべく、下記の記事に続き、パスワードリセットを実装してみました。
どなたかの役に立てば幸いです。
※続編はこちら
実装の流れ
- パスワードリセットメールのリクエストフォームでメールアドレスを入力
- メールアドレスがusersテーブルに登録済みなら次へ、未登録なら、(セキュリティ上の観点から)メール送信完了画面を表示して終了
- password_resetsテーブルに同じメールアドレスがないか確認し、
- 重複なければ、下記を設定したレコードをインサート
- メールアドレス
- ユーザー識別トークン
- トークン送信日時(現在時刻)
- 重複している場合は、「ユーザー識別トークン」「トークン送信日時」を更新し4へ
- 重複なければ、下記を設定したレコードをインサート
- パスワードリセット用URLのクエリに上記で作成したトークンを持たせ、メールアドレスに送信し、送信完了画面を表示
- ユーザーがそのURLからアクセス時、トークン送信日時が有効期間内であれば、パスワード変更フォームを表示
- 入力したパスワードをハッシュ化し、トークンと合致する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 | 主キー |
register_token | 仮登録時に使用。ユーザーを一意に識別する |
register_token_sent_at | 仮登録の有効期限管理に使用 |
register_token_verified_at | 本登録時に更新 |
password | |
status | enum型。tentative:仮登録、puvlic:本登録 |
created_at | |
updated_at |
password_resetsテーブル
カラム名 | 備考 |
---|---|
主キー | |
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 : hoge@hoge.com\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 'パスワードの変更が完了しました。';
この後の続きをやるとすれば、以下の対応をすればいいかと思います。
- 変更完了メールを送る
- ログインしてマイページへ遷移する
最後までお読みいただき、ありがとうございました!
Discussion