1. 今回の学習ゴールと練習課題(仕様)

自作のブログや掲示板、Webサービスに「ユーザー登録とログイン機能」を追加しようとしたとき、パスワードをそのままデータベースに平文(プレーンテキスト)で保存するのはセキュリティ上論外です。万が一DBが漏洩した瞬間に全ユーザーのパスワードが流出してしまいます。

PHPにはパスワードを安全に扱うための強力な標準関数 password_hash() と password_verify() が用意されています。しかし、「なぜか毎回ハッシュ値が変わる」「ログイン時の照合方法がわからない」と混乱する初学者が後を絶ちません。

そこで今回のドリルでは、データベース接続が手元になくても「PHPの単一スクリプト(CLI)だけで完結して動作確認できる認証処理クラス」を作成します。

満たすべき要件仕様

  1. パスワード強度の検証(バリデーション):
    • 文字数が 8文字以上 であること。
    • 英大文字・英小文字・数字 がそれぞれ最低1文字以上含まれていること。
    • 条件を満たさない場合は、処理を中断して分かりやすいエラーメッセージを返すこと。
  2. 新規ユーザー登録ロジック (register):
    • 入力されたパスワードの強度を検証する。
    • PHP標準の password_hash() を使用し、安全な不可逆ハッシュ値を生成すること(アルゴリズムは PASSWORD_DEFAULT)。
    • ユーザー情報として「メールアドレス」と「生成されたハッシュ文字列」を連想配列で返却すること。
  3. ログイン認証判定ロジック (authenticate):
    • ユーザーがログイン画面で入力した「平文パスワード」と、登録時に保存された「パスワードハッシュ」を受け取る。
    • password_verify() を使用して一致判定を行い、認証成功なら true、失敗なら false を返すこと。
  4. 自動再ハッシュ判定ロジック (checkRehash):
    • 将来的にPHPのデフォルトアルゴリズムや計算コスト(ストレッチング回数)が向上した際、ハッシュの再生成が必要かどうかを password_needs_rehash() で検知すること。

期待される実行結果

スクリプトを実行した際、以下のように「バリデーション拒否」「正常登録」「ログイン成功」「ログイン失敗」が明瞭に出力されることを目指します。

=== 1. パスワード強度バリデーションのテスト ===
[検証] パスワード: 'weak' (短すぎる)
 -> 判定: 失敗 (パスワードは8文字以上で、大文字・小文字・数字を含める必要があります)
[検証] パスワード: 'Password123' (条件クリア)
 -> 判定: 合格

=== 2. ユーザー登録処理のテスト ===
登録成功: test@example.com
生成ハッシュ: $2y$10$e8w.xX994Ld0qHk7pW... (60文字)

=== 3. ログイン認証のテスト ===
[試行①] 正しいパスワード 'Password123' でログイン試行
 -> 結果: 【成功】 ログインが承認されました。
[試行②] 誤ったパスワード 'WrongPassword999' でログイン試行
 -> 結果: 【失敗】 メールアドレスまたはパスワードが違います。

=== 4. 再ハッシュ判定テスト ===
再ハッシュが必要か: 不要 (最新の暗号強度を満たしています)

まずはここで画面をスクロールするのを止め、ご自身のエディタでどのように関数やクラスを設計するか考えてみてください!

2. 初心者がハマるNGコード(Before)と解説

パスワード認証を自作する際、初学者がほぼ100%ハマってしまう典型的なNGコードを見てみましょう。

<?php
// ❌ 初心者がやりがちな致命的NGコード例

// NG①: 古代の教材を見て md5() や sha1() を使ってしまう
function registerUserOld(string $email, string $rawPassword): array {
    $hash = md5($rawPassword); // 危険!
    return ['email' => $email, 'password' => $hash];
}

// NG②: password_hash() 同士を === で直接比較しようとする
function loginUserWrong(string $inputPassword, string $savedHash): bool {
    // password_hash() は実行するたびに毎回異なるハッシュを生成するため、
    // この比較は「絶対に true にならない」!
    $inputHash = password_hash($inputPassword, PASSWORD_DEFAULT);
    
    if ($inputHash === $savedHash) {
        return true;
    }
    return false;
}
?>

なぜこの書き方は現場で絶対にリジェクトされるのか?

3. コピペで即動く模範解答コード(After)

それでは、PHP 8.3/8.4基準のモダンで安全な模範解答コードを作成しましょう。このコードを password_drill.php という名前で保存し、ターミナルで php password_drill.php と実行するだけで即座にテスト結果が動きます。

<?php

declare(strict_types=1);

/**
 * ユーザー認証サービスクラス
 * パスワード検証、ハッシュ生成、ログイン認証を責務に持つ
 */
final class SimpleAuthService
{
    /**
     * パスワードポリシーの検証
     * - 8文字以上
     * - 大文字、小文字、数字を各1文字以上含む
     *
     * @param string $password
     * @return array{isValid: bool, errorMessage: string|null}
     */
    public function validatePasswordPolicy(string $password): array
    {
        // 8文字以上かチェック
        if (mb_strlen($password) < 8) {
            return [
                'isValid'      => false,
                'errorMessage' => 'パスワードは8文字以上である必要があります。',
            ];
        }

        // 大文字、小文字、数字が各1文字以上含まれるかチェック
        $hasUppercase = preg_match('/[A-Z]/', $password) === 1;
        $hasLowercase = preg_match('/[a-z]/', $password) === 1;
        $hasNumber    = preg_match('/[0-9]/', $password) === 1;

        if (!$hasUppercase || !$hasLowercase || !$hasNumber) {
            return [
                'isValid'      => false,
                'errorMessage' => 'パスワードには英大文字・英小文字・数字をそれぞれ最低1文字含める必要があります。',
            ];
        }

        return [
            'isValid'      => true,
            'errorMessage' => null,
        ];
    }

    /**
     * 新規ユーザー登録用データの作成(パスワードのハッシュ化)
     *
     * @param string $email
     * @param string $plainPassword
     * @return array{success: bool, user?: array{email: string, password_hash: string}, error?: string}
     */
    public function register(string $email, string $plainPassword): array
    {
        // メールアドレスの簡易形式チェック
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return [
                'success' => false,
                'error'   => 'メールアドレスの形式が正しくありません。',
            ];
        }

        // パスワード強度の検証
        $policyResult = $this->validatePasswordPolicy($plainPassword);
        if (!$policyResult['isValid']) {
            return [
                'success' => false,
                'error'   => $policyResult['errorMessage'] ?? 'パスワードが無効です。',
            ];
        }

        // 安全なハッシュ化を実行(現行PHP標準では Bcrypt が使われる)
        $passwordHash = password_hash($plainPassword, PASSWORD_DEFAULT);

        return [
            'success' => true,
            'user'    => [
                'email'         => $email,
                'password_hash' => $passwordHash,
            ],
        ];
    }

    /**
     * ログイン認証判定
     *
     * @param string $inputPassword 入力された平文パスワード
     * @param string $storedHash    DB等に保存されているハッシュ値
     * @return bool
     */
    public function authenticate(string $inputPassword, string $storedHash): bool
    {
        // 空文字チェック
        if ($inputPassword === '' || $storedHash === '') {
            return false;
        }

        // 安全な照合(タイミング攻撃耐性あり)
        return password_verify($inputPassword, $storedHash);
    }

    /**
     * ハッシュの更新(コスト変更・アルゴリズム進化への追従)が必要か判定
     *
     * @param string $storedHash
     * @return bool
     */
    public function needsRehash(string $storedHash): bool
    {
        return password_needs_rehash($storedHash, PASSWORD_DEFAULT);
    }
}

// ==========================================
// 実行・動作確認テスト(CLIシミュレーション)
// ==========================================

$auth = new SimpleAuthService();

echo "==================================================\n";
echo "【PHP初心者実践ドリル】パスワード認証の動作検証\n";
echo "==================================================\n\n";

// 1. パスワードポリシー検証テスト
echo "--- 1. パスワード強度バリデーション ---\n";
$testPasswords = [
    'weak'        => '短すぎるパスワード',
    'onlylowercase123' => '大文字なし',
    'ONLYUPPER123'     => '小文字なし',
    'NoNumberPass'     => '数字なし',
    'ValidPass2026'    => '条件を満たすパスワード',
];

foreach ($testPasswords as $pass => $label) {
    $res = $auth->validatePasswordPolicy($pass);
    $status = $res['isValid'] ? '【合格】' : '【拒否】';
    $msg = $res['isValid'] ? 'ポリシー適合' : ($res['errorMessage'] ?? '');
    echo sprintf("%-18s (%s) -> %s %s\n", "'{$pass}'", $label, $status, $msg);
}
echo "\n";

// 2. ユーザー登録のシミュレーション
echo "--- 2. ユーザー登録(ハッシュ生成) ---\n";
$registerResult = $auth->register('developer@example.com', 'SecureCode2026');

if (!$registerResult['success'] || !isset($registerResult['user'])) {
    echo "登録失敗: " . ($registerResult['error'] ?? '不明なエラー') . "\n";
    exit(1);
}

$savedUser = $registerResult['user'];
echo "登録完了: {$savedUser['email']}\n";
echo "生成されたハッシュ値 (DB保存用):\n";
echo "  " . $savedUser['password_hash'] . "\n";
echo "  (文字数: " . strlen($savedUser['password_hash']) . "文字)\n\n";

// 3. ログイン認証のテスト
echo "--- 3. ログイン認証テスト(password_verify) ---\n";
$loginAttempts = [
    'SecureCode2026'  => '正しいパスワード',
    'WrongPassword88' => '誤ったパスワード',
    'securecode2026'  => '大文字小文字違い(失敗すべき)',
    ''                => '空文字入力',
];

foreach ($loginAttempts as $inputPass => $label) {
    $isLoggedIn = $auth->authenticate($inputPass, $savedUser['password_hash']);
    if ($isLoggedIn) {
        echo sprintf("入力: %-18s (%s) => 【ログイン成功】\n", "'{$inputPass}'", $label);
    } else {
        echo sprintf("入力: %-18s (%s) => 【ログイン失敗】 メールアドレスまたはパスワードが違います\n", "'{$inputPass}'", $label);
    }
}
echo "\n";

// 4. 再ハッシュ判定テスト
echo "--- 4. 再ハッシュ(password_needs_rehash)判定 ---\n";
$needsRehash = $auth->needsRehash($savedUser['password_hash']);
echo "現在のハッシュに再計算が必要か: " . ($needsRehash ? "要再ハッシュ" : "最新仕様を満たしています(再ハッシュ不要)") . "\n";
echo "==================================================\n";

4. 現場のプロ(kixking)による徹底コード解説

このコードには、Web開発の現場で「絶対に必要なセキュリティの作法」がいくつも凝縮されています。重要なポイントを噛み砕いて解説します。

① なぜ password_verify() は平文パスワードとハッシュを一致させられるのか?

「ハッシュ値は不可逆(元の文字に戻せない)なのに、なぜ password_verify() は一致するかどうか判別できるの?」と疑問に思うかもしれません。

その秘密は、生成されたハッシュ文字列のフォーマットにあります。たとえば、生成された文字列をよく観察してみてください:

$2y$10$N9qo8uLOickgx2ZMRZoMye.I...(続く)
──┬─ ─┬─ ──────────┬─────────── ───────┬───────
  │   │            │                   └─ 暗号化されたハッシュ本体
  │   │            └─ 22文字のランダムなソルト(Salt)
  │   └─ 計算コスト(ストレッチング回数: 2の10乗 = 1024回)
  └─ アルゴリズム識別子(Bcrypt)

このように、「暗号化に使ったアルゴリズム」「計算コスト」「自動生成されたランダムソルト」がすべてハッシュ文字列の中に記録されているのです。

そのため、password_verify($inputPassword, $storedHash) を実行した際、内部では以下の手順が自動で行われます:

  1. 保存されているハッシュ文字列から「ソルト」と「コスト」を取り出す。
  2. 入力されたパスワード $inputPassword に対して、まったく同じソルトとコストを使ってその場でハッシュを再計算する。
  3. 再計算した結果と保存されているハッシュ本体が一致するかを判定する。

これによって、ソルトを別カラムで管理する手間もなく、安全かつ確実に照合が行えるのです。

② タイミング攻撃(Timing Attack)への対策

通常の文字列比較演算子 $a === $b は、文字列の先頭から1文字ずつ比較し、「1文字でも違った瞬間に判定を中断して false を返す」という最適化を行います。

しかし、パスワードの照合においてこれを行うと、攻撃者がマイクロ秒単位のレスポンス時間を計測することで、「何文字目まで合っていたか」を推測する手がかりになってしまいます(タイミング攻撃)。password_verify() は内部で固定時間比較(Constant Time Comparison)を行うため、この脆弱性を自動的に防いでくれます。

③ password_needs_rehash() の真価

セキュリティの世界では、ハードウェアの性能向上に伴って「ハッシュの強度」を定期的に引き上げる必要があります(例: コスト値を10から12へ上げる、新しいアルゴリズムへ移行するなど)。

しかし、不可逆暗号である以上、管理者が全ユーザーのパスワードを一括で再ハッシュすることは不可能です(元のパスワードを知らないため)。そこで実務では以下のようなフローを組みます:

実務での自動再ハッシュフロー:
1. ユーザーがログインに成功する。
2. 直後に password_needs_rehash($storedHash, PASSWORD_DEFAULT) を呼ぶ。
3. もし true(古い設定)なら、その場で入力された平文パスワードから新しい高強度ハッシュを生成し、DBのハッシュ値を上書き保存する!

こうすることで、ユーザーにパスワードの再設定を強いることなく、ログインした人から順次安全な最新ハッシュへと自動移行させることができるのです。

5. もう一歩進める!発展チャレンジ課題

模範解答が手元で動いたら、ぜひ以下の追加要件に自力でチャレンジしてみてください。実務で求められる「例外処理」や「特殊な制約」への理解が一段と深まります。

チャレンジ1:カスタム例外(Exception)による詳細なエラー通知

模範コードでは連想配列でエラー結果を返していますが、オブジェクト指向的な設計として「パスワードポリシー違反時に WeakPasswordException をスローする」形にリファクタリングしてみましょう。どのルール(文字数、英数大文字など)に引っかかったのかを呼び出し元で細かくハンドリングできるようにしてみてください。

チャレンジ2:Bcryptの「72文字(72バイト)制限」を突破する

実は、PHPのデフォルトで採用されている Bcrypt アルゴリズムには「入力が72バイトを超えると、73バイト以降が無視される」という歴史的仕様があります。つまり、72文字目までが一致していれば、73文字目以降がどんな文字列でもログインできてしまうという盲点が存在します。
この対策として、password_hash() に渡す前に hash('sha256', $password, true) で事前ハッシュ(32バイトのバイナリ)を生成してから渡す設計(または Argon2id アルゴリズムの利用)が現場で使われます。この事前ハッシュ処理を組み込んでみましょう。

チャレンジ3:ログイン失敗回数の記録とアカウントロック

連続して5回認証に失敗したユーザーを15分間ロックするような「ブルートフォース(総当たり)防御ロジック」を想定してみましょう。メモリ上の配列やセッションを模擬して、失敗回数と最終試行時刻を記録する仕組みを設計してみてください。