😸

【ハンズオン】PHPでシンプルな新規会員登録を実装する

2022/03/26に公開約8,800字

自己紹介

普段はPHP/Laravelで開発しています。
認証周りの機能はLaravelが用意してくれている&リリース初期にやることなので、
途中からプロジェクトに加わった私はこれまで理解せずに来てしまいました。

そんな自分のコンプレックスを克服するべく、Laravelに頼らず素のPHPで新規会員登録機能を実装してみました。
どなたかの役に立てば幸いです。

※続編はこちら

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

実装の流れ

  1. 仮会員登録ページでメールアドレスを入力
  2. メールアドレスがusersテーブルで重複していないか確認し、
    • 重複なければ、下記を設定したレコードをインサート
      • メールアドレス
      • ユーザー識別トークン
      • トークン送信日時(現在時刻)
      • 仮登録ステータス
    • 既に仮会員登録済みなら、「ユーザー識別トークン」「トークン送信日時」を更新し3へ
    • 既に本登録済みなら(セキュリティ上の観点から)メール送信完了画面を表示
  3. 本登録用URLのクエリに上記で作成したトークンを持たせ、メールアドレスに送信し、送信完了画面を表示
  4. ユーザーがそのURLからアクセス時、トークン送信日時が有効期間内であれば、会員登録フォームを表示
  5. パスワード等、必要な入力がされたらデータベースに保存し、ユーザーを「本会員登録」状態に更新

ファイル構成

.
├─ database.php
├─ show_tmp_register_form.php
├─ show_register_form.php
├─ tmp_register.php
├─ register.php
└─ views
    ├── tmp_register_form.php
    ├── register_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
作成した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
);

1. 仮会員登録ページでメールアドレスを入力

show_tmp_register_form.php
<?php
session_start();

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

// 仮登録フォームを読み込む
require_once './views/tmp_register_form.php';

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

views/tmp_register_form.php
<form action="tmp_register.php" method="POST">
    <p>仮会員登録</p>
    <input type="hidden" name="_csrf_token" value="<?= $_SESSION['_csrf_token']; ?>">
    <label>
        メールアドレスを入力してください
        <input type="email" name="email" value="">
    </label>
    <button type="submit">登録</button>
</form>

2. 入力されたメールアドレスを仮登録ユーザーとしてデータベースに保存

tmp_register.php
<?php
session_start();

// csrf tokenを取得
$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';
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':email', $email, \PDO::PARAM_STR);
$stmt->execute();
$user = $stmt->fetch(\PDO::FETCH_OBJ);

// ユーザーがいる場合、本登録済みユーザーなら新規登録処理はせずにメール送信完了画面を表示
// 「登録済みです」と表示するのは、そのメールアドレスを知っている別人がこのメールアドレスを入力した場合に、
// 「このメールアドレスは登録済みである」という情報を与えてしまうので避けたい
if ($user && $user->status !== 'tentative') {
    require_once './views/email_sent.php';
    exit();
}

if (!$user) {
    // ユーザーがいなければ、仮登録としてテーブルにインサート
    $sql = 'INSERT INTO users(email, register_token, register_token_sent_at) VALUES(:email, :register_token, :register_token_sent_at)';
} else {
    // 既に仮登録済みのユーザーがいる場合、register_tokenの再発行と有効期限のリセットを行う
    // 有効期限切れで再度仮登録する場合はこちらの処理になる
    $sql = 'UPDATE users SET register_token = :register_token, register_token_sent_at = :register_token_sent_at WHERE email = :email';
}

// register token生成
$registerToken = bin2hex(random_bytes(32));

// 仮登録とメール送信は原子性を保ちたいため、トランザクションを設置する
// メール送信に失敗した場合は、仮登録も失敗させる
try {
    $pdo->beginTransaction();

    // ユーザーを仮登録
    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(':email', $email, \PDO::PARAM_STR);
    $stmt->bindValue(':register_token', $registerToken, \PDO::PARAM_STR);
    $stmt->bindValue(':register_token_sent_at', (new \DateTime())->format('Y-m-d H:i:s'), \PDO::PARAM_STR);
    $stmt->execute();
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. 本登録用URLのクエリに、テーブルに保存したトークンを持たせ、メールアドレスに送信

tmp_register.php(※2.のファイルの続き)
    // 日本語が文字化けしないよう、設定。php.iniで設定してあれば不要
    mb_language("Japanese");
    mb_internal_encoding("UTF-8");

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

    $subject =  '仮登録が完了しました';

    $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('メール送信に失敗しました。');

    // メール送信まで成功したら、仮登録を確定
    $pdo->commit();

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

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


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

ここでのポイントは2つです。

  • 既に登録済みのメールアドレスであっても、セキュリティの観点からメール送信完了画面を表示する
  • 仮登録だけ完了してメール送信失敗、ということが起こらないよう、トランザクションを設置する

登録したメールアドレスに、メールが届きます。
分かる人には、MAMPでやっているのがバレますね笑

4. ユーザーがそのURLからアクセス時、有効期限が切れていなければ会員登録フォームを表示

show_register_form.php
<?php
session_start();

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

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

// tokenに合致するユーザーを取得
$sql = 'SELECT * FROM `users` WHERE `register_token` = :register_token AND `status` = :status';

// register_tokenが合致するユーザーを取得
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':register_token', $registerToken, \PDO::PARAM_STR);
$stmt->bindValue(':status', 'tentative', \PDO::PARAM_STR);
$stmt->execute();
$user = $stmt->fetch(\PDO::FETCH_OBJ);

if (!$user) exit('無効なURLです');

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

// 仮登録が24時間以上前の場合、有効期限切れとする
if ($user->register_token_sent_at < $tokenValidPeriod) exit('有効期限切れです');

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

// 本登録フォームを読み込む
require_once './views/register_form.php';

本登録フォーム(form部分を抜粋)

register_form.php
<form action="register.php" method="POST">
    <p>会員登録</p>
    <input type="hidden" name="_csrf_token" value="<?= $_SESSION['_csrf_token']; ?>">
    <input type="hidden" name="register_token" value="<?= $registerToken; ?>">
    <label>パスワード
        <input type="password" name="password">
    </label>
    <br>
    <label>パスワード(確認用)
        <input type="password" name="password_confirmation">
    </label>
    <br>
    <button type="submit">登録</button>
</form>

5. パスワード等、必要な入力がされたらデータベースに保存し、ユーザーを本会員登録状態に更新

register.php
<?php
session_start();

require_once './database.php';
$pdo = getPdo();

$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('不正なリクエストです');
}

// 本来はここでpasswordとregister_tokenのバリデーションをする

$sql = 'UPDATE users SET password = :password, register_token_verified_at = :register_token_verified_at, status = :status  WHERE register_token = :register_token';

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

// 仮登録ユーザーを本登録(パスワードを登録し、ステータスを本登録ステータスにする)
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':password', $hashedPassword, \PDO::PARAM_STR);
$stmt->bindValue(':register_token_verified_at', (new \DateTime())->format('Y-m-d H:i:s'), \PDO::PARAM_STR);
$stmt->bindValue(':status', 'public', \PDO::PARAM_STR);
$stmt->bindValue(':register_token', $request['register_token'], \PDO::PARAM_STR);

$stmt->execute();

echo '本会員登録が完了しました。';

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

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

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

GitHubで編集を提案

Discussion

ログインするとコメントできます