二段階認証の導入方法 第2回:ユーザーテーブルとPHPの連携

2026年9月22日火曜日

プログラミング

t f B! P L

1. ユーザーテーブルに必要な項目を用意する

ユーザー情報に、暗号化した秘密鍵、有効化状態、使用済みの時間枠、失敗回数を追加します。認証状態をDBに持たせ、別セッションからの試行にも対応します。

前回で仕組みは分かった。今回は、ユーザーごとに鍵を保存するところをPHPにしたい。
MySQLのユーザーテーブルを使って作ります。PHP 8.4以上の8系を対象にした、学習用の一式です。Laravelは使いません。
まず、何を保存しておく?
ユーザー名、パスワードのハッシュ、暗号化したTOTP秘密鍵が基本です。加えて、有効化済みか、どの時間枠まで認証に使ったか、失敗回数も記録します。
新規の検証用DBで実行する schema.sql は次の内容です。既存の本番テーブルにそのまま実行するためのSQLではありません。
CREATE TABLE users (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(100) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    totp_secret_enc TEXT NULL,
    totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
    totp_last_step BIGINT NOT NULL DEFAULT -1,
    totp_failures INT NOT NULL DEFAULT 0,
    totp_locked_until BIGINT NOT NULL DEFAULT 0,
    password_failures INT NOT NULL DEFAULT 0,
    password_locked_until BIGINT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
totp_last_step は、何を入れる?
最後に成功した「30秒単位の時間枠の番号」です。同じ時間枠のコードを使い回せないようにします。6桁そのものを永久に使用済みにすると、将来たまたま同じ数字になった場合まで拒否してしまうので、時間枠を記録します。
失敗回数もDBに持たせるんだ。
はい。ブラウザーのセッションだけで数えると、別セッションに変えて制限を回避できるからです。このサンプルでは5回失敗で60秒待つ設定にします。

2. 暗号化鍵とDB接続を設定する

サーバー用の暗号化鍵は一度だけ生成し、DB接続情報とともに環境変数から読み込みます。保存済みの秘密鍵を復号できるよう、同じ暗号化鍵を保管して使います。

暗号化の鍵は、どこに書く?
サーバーの環境変数 TOTP_ENCRYPTION_KEY から読み込みます。コードに直書きしたり、ユーザーテーブルへ一緒に保存したりしません。
最初に一度だけ、次のコマンドでサーバー用の暗号化鍵を生成します。
php -r 'echo base64_encode(random_bytes(32)), PHP_EOL;'
起動するたびに実行すればいい?
いいえ。同じ値を保管して使い続けます。毎回変えると、以前保存した秘密鍵を復号できなくなります。バックアップも、DBとは別に安全に保管します。

環境変数設定例・内容
DB_DSNmysql:host=127.0.0.1;dbname=totp_demo;charset=utf8mb4
DB_USER検証用DBへ接続するユーザー名
DB_PASSWORDそのユーザーのDBパスワード
TOTP_ENCRYPTION_KEY先ほど生成した32バイトの鍵のBase64文字列
以下の関数は、サンプルの src/core.php にまとめてあります。ファイルの先頭でライブラリを読み込みます。
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/vendor/autoload.php';

use OTPHP\TOTP;
環境変数の読み込みとDB接続は次のとおりです。
function setting(string $name): string
{
    $value = getenv($name);
    if ($value === false || $value === '') {
        throw new RuntimeException($name . ' を設定してください。');
    }
    return $value;
}

function db(): PDO
{
    static $pdo;
    return $pdo ??= new PDO(setting('DB_DSN'), setting('DB_USER'), setting('DB_PASSWORD'), [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]);
}

3. 秘密鍵を暗号化・復号する

SodiumでTOTP秘密鍵を暗号化し、照合時に復号します。Base64は保存しやすくするための変換であり、暗号化そのものではありません。

ここまでは普通のPDO接続だね。
はい。次に、秘密鍵の暗号化と復号です。PHPのSodiumを使います。暗号文の改ざんも検出できる方式です。
function encryptionKey(): string
{
    $key = base64_decode(setting('TOTP_ENCRYPTION_KEY'), true);
    if ($key === false || strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
        throw new RuntimeException('暗号化鍵は32バイトをBase64にした値が必要です。');
    }
    return $key;
}

function encryptSecret(string $secret): string
{
    $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
    $ciphertext = sodium_crypto_secretbox($secret, $nonce, encryptionKey());
    return base64_encode($nonce . $ciphertext);
}

function decryptSecret(string $stored): string
{
    $data = base64_decode($stored, true);
    $n = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;
    if ($data === false || strlen($data) < $n + SODIUM_CRYPTO_SECRETBOX_MACBYTES) {
        throw new RuntimeException('保存された秘密鍵の形式が不正です。');
    }
    $secret = sodium_crypto_secretbox_open(substr($data, $n), substr($data, 0, $n), encryptionKey());
    if ($secret === false) {
        throw new RuntimeException('秘密鍵を復号できません。');
    }
    return $secret;
}
nonce というのは? また鍵?
暗号化のたびに新しく作るランダムな値です。これは秘密の暗号化鍵とは別で、暗号文と一緒に保存できます。同じ秘密鍵を再び暗号化しても、同じ暗号文にならないようにします。
最後にBase64にしているけど、それが暗号化?
いいえ。暗号化は sodium_crypto_secretbox() が行います。Base64はバイナリをテキストとして保存しやすくする変換です。

4. ユーザーごとの秘密鍵を生成・保存する

安全な乱数から秘密鍵を作り、暗号化してユーザーに紐づけます。準備中の鍵は再表示時にも使い、同時処理で別の鍵を作らないようにします。

では、ユーザーごとのTOTP秘密鍵を作ろう。
20バイトの安全な乱数を作り、認証アプリが扱うBase32形式に変換します。20バイトは160ビットなので、5ビットずつ区切ると32文字になります。
function generateSecret(): string
{
    // 160ビットの暗号学的乱数を、パディングなしBase32に変換。
    $bits = '';
    foreach (str_split(random_bytes(20)) as $byte) {
        $bits .= str_pad(decbin(ord($byte)), 8, '0', STR_PAD_LEFT);
    }
    $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    $secret = '';
    foreach (str_split($bits, 5) as $chunk) {
        $secret .= $alphabet[bindec($chunk)];
    }
    return $secret;
}
Base64とBase32が出てきたけど、用途が違うのか。
そうです。DBには「暗号化した結果をBase64で表現したもの」を保存し、認証アプリには「元のTOTP秘密鍵をBase32で表現したもの」を渡します。どちらの変換自体も暗号化ではありません。
ユーザーの取得と、未登録ユーザーの鍵を準備する関数はこちらです。
function userById(int $id): array
{
    $stmt = db()->prepare('SELECT * FROM users WHERE id = ?');
    $stmt->execute([$id]);
    $user = $stmt->fetch();
    if (!$user) {
        throw new RuntimeException('ユーザーがありません。');
    }
    return $user;
}

function prepareSecret(int $id): string
{
    $pdo = db();
    $pdo->beginTransaction();
    try {
        $stmt = $pdo->prepare('SELECT * FROM users WHERE id = ? FOR UPDATE');
        $stmt->execute([$id]);
        $user = $stmt->fetch();
        if (!$user || (int)$user['totp_enabled'] === 1) {
            throw new RuntimeException('登録用の鍵を表示できません。');
        }
        if ($user['totp_secret_enc'] === null) {
            $secret = generateSecret();
            $pdo->prepare('UPDATE users SET totp_secret_enc = ? WHERE id = ?')
                ->execute([encryptSecret($secret), $id]);
        } else {
            $secret = decryptSecret($user['totp_secret_enc']);
        }
        $pdo->commit();
        return $secret;
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        throw $e;
    }
}
すでに保存されていれば、新しく作らないんだね。
はい。ページを再表示しただけで鍵が変わると、スマホに登録した鍵と食い違います。totp_enabled = 0 の準備中だけこの関数を使い、有効化済みの鍵は登録画面に表示しません。
FOR UPDATE は何のため?
同じユーザーについて二つの処理が同時に動いたとき、別々の鍵を生成してしまわないためです。トランザクション中、その行をロックして確認と保存をまとめます。

5. 認証コードを照合して使用済みにする

DBの秘密鍵を復号してコードを照合し、成功した時間枠を記録します。行ロックと失敗回数の制限で、同時使用や連続試行に備えます。

では、コードの照合は?
保存された鍵を復号して、OTPHPでコードを作ります。現在と前後の30秒枠を確認し、成功した時間枠をDBに保存します。
function confirmTotp(int $id, string $code, bool $enrolling): bool
{
    $pdo = db();
    $pdo->beginTransaction();
    try {
        // 照合と使用済み時間枠の更新を、ユーザー行のロック中に行う。
        $stmt = $pdo->prepare('SELECT * FROM users WHERE id = ? FOR UPDATE');
        $stmt->execute([$id]);
        $user = $stmt->fetch();
        $now = time();
        if (!$user || $user['totp_secret_enc'] === null ||
            (bool)$user['totp_enabled'] === $enrolling ||
            (int)$user['totp_locked_until'] > $now) {
            $pdo->commit();
            return false;
        }
        $matched = null;
        if (preg_match('/\A[0-9]{6}\z/', $code)) {
            $otp = TOTP::createFromSecret(decryptSecret($user['totp_secret_enc']));
            $currentStep = intdiv($now, 30);
            foreach ([0, -1, 1] as $delta) {
                $step = $currentStep + $delta;
                if ($step > (int)$user['totp_last_step'] &&
                    hash_equals($otp->at($step * 30), $code)) {
                    $matched = $step;
                    break;
                }
            }
        }
        if ($matched === null) {
            $failures = (int)$user['totp_failures'] + 1;
            $lock = $failures >= 5 ? $now + 60 : 0;
            $pdo->prepare('UPDATE users SET totp_failures = ?, totp_locked_until = ? WHERE id = ?')
                ->execute([$lock ? 0 : $failures, $lock, $id]);
        } else {
            $pdo->prepare('UPDATE users SET totp_enabled = 1, totp_last_step = ?,
                totp_failures = 0, totp_locked_until = 0 WHERE id = ?')
                ->execute([$matched, $id]);
        }
        $pdo->commit();
        return $matched !== null;
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        throw $e;
    }
}
自分でHMACを書かなくてもいいのか。
そうです。TOTP::createFromSecret() で保存済みの鍵を渡し、at() に対象時刻を指定して計算します。TOTPの計算はライブラリに任せ、こちらではユーザーの特定やDBの更新を担当しています。OTPHPの使い方も参照できます。
enrolling という引数があるね。
初回登録の確認なら true、登録後のログイン確認なら false です。初回のコード確認が成功すると totp_enabled = 1 になります。QRを表示しただけで有効にはしません。
ここでも行をロックしている。
同じコードが同時に二つ届いても、両方成功させないためです。「未使用か確認する」と「使用済みにする」を一つのトランザクションで行います。

6. パスワード認証からTOTPへ引き継ぐ

照合対象のユーザーIDは、パスワード確認後にサーバー側セッションへ保持します。フォームのIDを信用せず、TOTP成功後に本ログインへ切り替えます。

ユーザーIDは、フォームから送ってもらえばいい?
認証対象のIDは、パスワードを確認したときにサーバー側セッションへ記録します。フォームから来たIDをそのまま信用しません。サンプルの login.php では、次のように仮認証状態を作ります。
// $id は、DBのパスワード確認に成功したユーザーID。
session_regenerate_id(true);
$_SESSION['pending_user_id'] = $id;
$_SESSION['pending_until'] = time() + 300;

// 本ログインの $_SESSION['user_id'] は、ここでは設定しない。
パスワードが合っていても、まだログイン完了にしないわけだ。
はい。次回の画面でQR登録またはコード入力を行い、成功して初めて本ログインに切り替えます。付属サンプルにはパスワード照合とログイン画面も入れてあります。
これで「ユーザーごとの鍵を暗号化保存して、必要なときだけ復号する」ところまでできた。
次回は、その鍵をQRコードにして認証アプリへ登録し、ログインまで通してみましょう。

検索

年月ごとアーカイブ

フレンドブログ

Translate

自己紹介

自分の写真
映画が好き! 音楽好き! 演奏も好き! ミキシングが一番好き!

QooQ