1. QRコードに入れる登録情報
QRコードには、TOTP秘密鍵とサービス名・ユーザー名・計算条件を入れます。登録用の秘密情報なので、パスワード確認後の未登録ユーザーにだけ表示します。
前回でユーザーテーブルとPHPがつながった。あとはQRコードを表示したい。
今回で、パスワード確認からQR登録、認証コードの照合までつなげます。QR画像はPHPサーバー内で生成します。
QRの中身は、秘密鍵だけ?
サービス名、ユーザー名、コードの桁数や更新間隔なども入れます。認証アプリ用の
otpauth:// 形式です。
otpauth://totp/サービス名:ユーザー名?secret=秘密鍵&issuer=サービス名&algorithm=SHA1&digits=6&period=30
これは形式の説明です。実際にはサービス名やユーザー名をURLエンコードして組み立てます。Google Authenticatorの登録URI仕様に対応する形式です。
サーバーに保存した暗号文を、そのまま
secret に入れるんじゃないんだね。
入れるのは、復号した元のTOTP秘密鍵のBase32表現です。DB保存用の暗号文やサーバー側の暗号化鍵を渡しても、認証アプリは同じコードを計算できません。
じゃあ、QR自体も大事な情報なんだ。
はい。登録用QRは、秘密鍵そのものを渡す手段です。今回はパスワード確認直後の未登録ユーザーにだけ表示します。登録が完了した後のログイン画面には、コード入力欄だけを出します。
2. PHPでQR画像を生成する
endroid/qr-codeで登録用文字列をSVG画像にします。画像は自分のサーバー内で生成し、秘密鍵を外部のQR作成サービスへ送りません。
QR画像を作る部分だけ見ると?
endroid/qr-code を使うと、次のようになります。$uri には、先ほどの登録用文字列が入っています。
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\SvgWriter;
$qr = new QrCode(data: $uri, size: 280, margin: 20);
$image = (new SvgWriter())->write($qr)->getDataUri();
SVG画像をデータURIとしてHTMLに埋め込むので、PNG生成用のGD拡張や、画像ファイルを保存する場所は不要です。ライブラリのQR生成と出力方法を使っています。
外部のQR作成サイトを呼ぶ必要もない?
ありません。秘密鍵を含む文字列を外部サービスへ送らず、自分のサーバーで画像にします。Composerでライブラリを導入した後は、QR生成のたびの外部通信もありません。
3. 初回登録と認証コード入力の画面を作る
未登録ならQRと入力欄を、登録済みなら入力欄だけを表示します。初回のコード照合に成功した時点で有効化し、ログインを完了させます。
では、画面ごと見たい。
第2回の
src/core.php と、セッション・画面共通処理をまとめた src/web.php を使います。完成した public/totp.php はこちらです。共通ファイルもダウンロード一式に含めています。
<?php
require dirname(__DIR__) . '/src/web.php';
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\SvgWriter;
$id = pendingUserId();
$user = userById($id);
$enrolling = !(bool)$user['totp_enabled'];
$message = '';
// 未登録のときだけ秘密鍵を用意する。表示のたびには作り直さない。
$secret = $enrolling ? prepareSecret($id) : null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrfCheck();
if (confirmTotp($id, trim(post('code')), $enrolling)) {
session_regenerate_id(true);
unset($_SESSION['pending_user_id'], $_SESSION['pending_until']);
$_SESSION['user_id'] = $id;
$_SESSION['csrf'] = bin2hex(random_bytes(32));
redirect('home.php');
}
$message = '認証できません。コードの間違い・期限切れ・再使用・回数制限を確認してください。';
}
pageStart($enrolling ? '認証アプリを登録' : '認証コードを入力');
echo '<p>' . h($message) . '</p>';
if ($enrolling) {
$issuer = 'PHP Blog Demo';
$uri = 'otpauth://totp/' . rawurlencode($issuer . ':' . $user['username']) . '?' .
http_build_query([
'secret' => $secret, 'issuer' => $issuer,
'algorithm' => 'SHA1', 'digits' => 6, 'period' => 30,
], '', '&', PHP_QUERY_RFC3986);
// サーバー内でSVG生成。外部QRサービスへ秘密鍵を送らない。
$qr = new QrCode(data: $uri, size: 280, margin: 20);
$image = (new SvgWriter())->write($qr)->getDataUri();
echo '<p>Google 認証システムでQRコードを読み取ってください。</p>';
echo '<img src="' . h($image) . '" alt="認証アプリ登録用QRコード">';
echo '<details><summary>手入力で登録する場合</summary><p>鍵:<code>' . h($secret) . '</code>';
echo '<br>種類:時間ベース</p></details>';
echo '<p>アプリに表示されたコードが一致したら、登録が完了します。</p>';
}
?>
<form method="post">
<?php csrfField(); ?>
<label>6桁のコード
<input name="code" inputmode="numeric" autocomplete="one-time-code"
pattern="[0-9]{6}" maxlength="6" required placeholder="123456"></label>
<button>確認する</button>
</form>
<p>パスワード確認から5分以内に操作してください。</p>
</html>
最初と、2回目以降で画面が変わるんだ。
totp_enabled を見ています。
| 状態 | 表示 | コード確認後 |
|---|---|---|
| 未登録 | QRコード、手入力用秘密鍵、コード入力欄 | 有効化してログイン完了 |
| 登録済み | コード入力欄 | ログイン完了 |
| パスワード未確認・仮認証期限切れ | ログイン画面へ戻す | パスワードからやり直し |
初回も、アプリのコードを一度入力しないと完了しない。
はい。スマホに正しい鍵が登録されたことを確認してから有効化します。その登録確認に使った時間枠も使用済みとして記録します。
4. ログイン画面とつなぐ
パスワード確認後は仮認証のユーザーIDを保持し、TOTP確認後に本ログインへ切り替えます。会員ページでも本ログイン済みかを確認します。
ログイン画面とのつながりも見たい。
public/login.php は次のとおりです。checkPassword() はDBのハッシュと入力パスワードを照合する付属関数です。
<?php
require dirname(__DIR__) . '/src/web.php';
$message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrfCheck();
unset($_SESSION['user_id'], $_SESSION['pending_user_id'], $_SESSION['pending_until']);
$id = checkPassword(trim(post('username')), post('password'));
if ($id !== null) {
session_regenerate_id(true);
// パスワードが合っても、本ログインのuser_idはまだセットしない。
$_SESSION['pending_user_id'] = $id;
$_SESSION['pending_until'] = time() + 300;
redirect('totp.php');
}
$message = 'ログインできません。入力内容を確認し、続けて失敗した場合は少し待ってください。';
}
pageStart('ログイン');
?>
<p><?= h($message) ?></p>
<form method="post">
<?php csrfField(); ?>
<label>ユーザー名<input name="username" autocomplete="username" required maxlength="100"></label>
<label>パスワード<input name="password" type="password" autocomplete="current-password" required></label>
<button>次へ</button>
</form>
</html>
パスワード確認後は
pending_user_id。TOTP確認後に user_id になるんだね。
はい。会員ページは本ログインの
user_id があるかを確認します。コード入力画面を飛ばしてURLを直接開かれても入れないようにします。付属の home.php にも、その確認を入れています。
5. サンプルを動かして確認する
環境設定とユーザー作成を済ませ、初回登録から再ログインまで試します。コードが合わない場合は、スマホとサーバーの時刻も確認します。
サンプルを動かすには?
初期設定の順番は次のとおりです。詳しい設定例は付属のREADMEにも載せています。
- PHP 8.4以上の8系、MySQL、Composer、PDO MySQL・Sodium・XML拡張を用意する。
sampleフォルダーでcomposer installを実行する。- 検証用DBに
schema.sqlを実行する。 - 第2回のDB接続設定と、固定の暗号化鍵を環境変数に設定する。
php create-user.php demoを実行して検証ユーザーを作る。表示された初期パスワードを控える。php -S 127.0.0.1:8000 -t publicでローカル確認用サーバーを起動する。- ブラウザーで
http://127.0.0.1:8000/login.phpを開く。
demo でログインしたらQRが出るわけだ。
はい。Google 認証システムで読み取り、6桁を入力します。ログイン成功後にログアウトして、もう一度試してください。今度はQRを出さずにコード入力へ進みます。
ブログの記事欄にPHPを貼っても動く?
PHP対応サーバーが必要です。記事にはコードと画面写真を掲載し、動作体験はPHPを実行できる場所で行います。組み込みサーバーはローカル確認用なので、公開サーバーとして使いません。
スマホの時刻がずれたら、合わなくなる?
そうです。サンプルでは前後1枠、つまり前後30秒分も確認しています。ただし、許容範囲を広げ続けるのではなく、サーバーとスマホの時計を正しく合わせておくのが基本です。
6. 紛失・機種変更・鍵交換に備える
運用では、認証アプリが使えない場合の復旧方法や、鍵を切り替える手順も必要です。復旧コードはTOTPとは別の、一度だけ使えるランダム値として扱います。
ここまであれば、実際の運用もいけそうだね。
登録とログインの流れは試せます。本番へ組み込むときは、特に「使えなくなったとき」と「設定を変えるとき」を決めておきます。
| 場面 | 運用で用意すること |
|---|---|
| スマホを紛失した | 一度限りの復旧コード、または十分な本人確認を伴う復旧手続き |
| 機種変更した | 旧端末を使える間に移行を確認。必要なら本人確認後に新しい鍵へ切り替える |
| 二段階認証を解除する | ログイン中というだけで解除せず、パスワードと追加認証などで再確認 |
| 秘密鍵を再発行する | 新しい鍵のコードを確認してから切り替え、古い鍵・復旧コード・必要なセッションを無効化 |
| サーバーの暗号化鍵を変更する | 旧鍵で復号、新鍵で再暗号化する移行手順を用意する |
| 連続して失敗する | アカウント単位の制限に加え、IP単位の制限や監視を検討する |
復旧コードは、普段の6桁と同じ?
別です。時間では変化せず、一度使ったら無効にする十分に長いランダム値です。たとえば生成とハッシュ化だけを示すと、こう書けます。
// 説明用の独立した例。この連載のログイン画面には未接続。
$recoveryCode = bin2hex(random_bytes(16));
$recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT);
// 利用者には $recoveryCode を一度だけ表示して保管してもらう。
// DBには $recoveryHash を保存する。
// 入力時は password_verify($input, $recoveryHash) で確認する。
復旧コードは、TOTP秘密鍵と違ってハッシュ保存でいいんだ。
元の文字列を取り出して計算する必要がなく、入力が正しいかだけを確認できればいいからです。実装時にはユーザーと紐づけ、照合と使用済み更新を同じトランザクションで行い、同時使用も防ぎます。
このサンプルに、復旧画面までは入っている?
入っていません。今回の一式は、暗号化保存、初回登録、パスワード+TOTPでのログイン、ログアウトを試す範囲です。復旧・解除・鍵交換や既存セッションの一括無効化は、実際のサービスの本人確認方法に合わせて追加します。
7. 公開時と記事掲載時の注意点
TOTPだけでフィッシングまで防げるわけではないため、HTTPSや秘密情報の管理も必要です。ブログの画面写真には、使い捨ての検証用アカウントを使います。
セキュリティは、これで完璧というわけではないね。
TOTPはパスワード漏えい対策として役立ちますが、偽サイトへパスワードとコードの両方を入力させる攻撃まで防げるわけではありません。また、公開時はHTTPS、ログやバックアップの管理、エラー詳細を利用者に見せない設定も必要です。
ブログにQRのスクリーンショットを載せるときも、気を付けないと。
実際に使うアカウントのQRや秘密鍵は載せません。記事用の使い捨てユーザーで撮影し、撮影後はその鍵を使わないようにします。
最初はGoogleのサービスに認証を頼むのかと思ったけど、同じルールで計算しているのね。
そうです。ユーザーごとに秘密鍵を作り、暗号化して保存し、初回だけQRで共有する。あとはパスワード確認後に、スマホとサーバーが同じコードを作れるかを確かめる。このつながりが分かれば、PHPの各処理が何をしているかも追いやすくなります。

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