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_DSN | mysql: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コードにして認証アプリへ登録し、ログインまで通してみましょう。

0 件のコメント:
コメントを投稿