PHPでTOTP方式のMFAを実装する|Imagick不要のSVG QRコード生成
作成日:2026.08.08
PHP 8.3.15の環境で、認証アプリを使ったTOTP方式のMFAを実装します。spomky-labs/otphpでワンタイムコードを検証し、bacon/bacon-qr-codeでImagickを使わずにSVG形式のQRコードを生成します。MFAやTOTPの基本概念から、登録処理、簡略化したログイン処理、本番環境での注意点まで紹介します。
目次
Webアプリケーションへログイン機能を追加する場合、ユーザー名とパスワードだけで認証する構成にすることがあります。
しかし、パスワードが漏えいすると、それだけでログインされてしまう可能性があります。そこで、パスワードとは別の情報も使って認証するMFA(多要素認証)を追加します。
今回は、PHP 8.3.15の環境で、認証アプリに表示されるワンタイムコードを使ったTOTP方式のMFAを実装してみます。
実装には、TOTPの処理を行なうspomky-labs/otphpと、認証アプリへ登録するQRコードを生成するbacon/bacon-qr-codeを使用します。QRコードはSVG形式で出力するため、Imagickは使用しません。
ログイン処理は説明用に簡略化しています。実際のサービスへ導入する場合に必要な対策についても、最後に整理します。
MFAとTOTPの基本
MFAとは
MFAは、認証に複数種類の情報を組み合わせる仕組みです。認証で使う情報は、例えば以下のように分類できます。
| 種類 | 例 |
|---|---|
| 知識情報 | パスワード、PIN |
| 所持情報 | スマートフォン、ハードウェアトークン |
| 生体情報 | 指紋、顔、虹彩 |
例えば、パスワードは知識情報です。認証アプリをインストールしたスマートフォンは、ここでは所持情報として扱います。
パスワードと認証アプリのコードを組み合わせる場合、異なる2種類の認証要素を使うため、2FA(2要素認証)とも呼ばれます。2FAはMFAの一種です。
OTPとTOTP
OTPは、One-Time Passwordの略で、一度だけ使うパスワードという意味です。
今回使うTOTPは、Time-based One-Time Passwordの略です。サーバーと認証アプリが同じ秘密鍵を持ち、秘密鍵と現在時刻からワンタイムコードを生成します。
同じ秘密鍵と時刻を使えば、サーバーと認証アプリは同じコードを計算できます。一定時間が過ぎるとコードが変わるため、以前のコードを使い回しにくい点が特徴です。
QRコードの役割
認証アプリへ秘密鍵や発行者名などを手入力するのは大変です。そこで、TOTPの登録情報をotpauth://から始まるURIにし、そのURIをQRコードに変換して認証アプリで読み取ります。
QRコードは秘密鍵を含む登録情報です。表示中の画面を第三者に見られたり、生成したQRコードを公開したりすると、同じコードを生成される可能性があります。登録が終わったら、QRコードを不用意に再表示しないようにします。
前提環境
今回の動作確認環境は以下の通りです。
- Windows
- PHP 8.3.15
- Composer
- Laravelプロジェクトを
php artisan serveで起動 - Authy
Laravelの認証機能やデータベース処理を詳しく扱う記事ではないため、以下のコードはPHPライブラリの使い方と処理の流れに絞っています。Laravelへ組み込む場合は、実際のプロジェクトのController、Blade、ユーザー管理へ置き換えてください。
Composerでライブラリを導入する
まず、Laravelプロジェクトのルートディレクトリで、以下のコマンドを実行します。
composer require spomky-labs/otphp bacon/bacon-qr-code
spomky-labs/otphpは、TOTPの秘密鍵やワンタイムコードを扱うライブラリです。bacon/bacon-qr-codeは、QRコードを生成するライブラリです。
パッケージのバージョンや依存関係は変更される可能性があります。記事のコードを試すときは、Composerが生成したcomposer.lockも確認し、必要に応じてcomposer auditを実行してください。
TOTPの登録処理を作成する
まず、ユーザーが認証アプリへTOTPを登録する処理を作成します。
説明を簡単にするため、ここでは生成した秘密鍵をPHPのセッションへ保存します。実際のアプリケーションでは、ログイン対象ユーザーのレコードへ安全に保存してください。
public/setup.phpを作成し、以下のコードを記述します。
<?php
declare(strict_types=1);
session_start();
require dirname(__DIR__) . '/vendor/autoload.php';
use OTPHP\TOTP;
if (empty($_SESSION['totp_secret'])) {
$totp = TOTP::generate();
$_SESSION['totp_secret'] = $totp->getSecret();
$_SESSION['totp_label'] = 'demo@example.test';
$_SESSION['totp_issuer'] = 'abe-tatsuya.com Demo';
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>MFAの登録</title>
</head>
<body>
<h1>MFAの登録</h1>
<p>Authyで以下のQRコードを読み取ってください。</p>
<p>
<img src="qr.php" alt="Authyへ登録するTOTPのQRコード">
</p>
<p>登録後、Authyに表示されたコードを入力してください。</p>
<form action="verify-setup.php" method="post">
<label>
認証コード
<input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" required>
</label>
<button type="submit">登録を確認する</button>
</form>
</body>
</html>
TOTP::generate()で新しい秘密鍵を生成し、セッションへ保存しています。
ここで生成した秘密鍵を、次に作成するQRコード生成処理と、ワンタイムコードの検証処理の両方で使います。
QRコードをSVG形式で出力する
次に、public/qr.phpを作成します。このファイルは、HTMLを返すのではなく、SVG画像を直接返すようにします。
<?php
declare(strict_types=1);
session_start();
require dirname(__DIR__) . '/vendor/autoload.php';
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use OTPHP\TOTP;
$secret = $_SESSION['totp_secret'] ?? null;
$label = $_SESSION['totp_label'] ?? null;
$issuer = $_SESSION['totp_issuer'] ?? null;
if ($secret === null || $label === null || $issuer === null) {
http_response_code(404);
exit('TOTPの登録情報がありません。');
}
$totp = TOTP::createFromSecret($secret)
->withLabel($label)
->withIssuer($issuer);
$renderer = new ImageRenderer(
new RendererStyle(300),
new SvgImageBackEnd()
);
$writer = new Writer($renderer);
$svg = $writer->writeString($totp->getProvisioningUri());
header('Content-Type: image/svg+xml; charset=UTF-8');
echo $svg;
SvgImageBackEndを指定しているため、QRコードはSVGとして生成されます。PNG画像を生成するImagickImageBackEndとは異なり、この例ではImagickのインストールは必要ありません。
QRコードをブラウザへ直接出力するため、setup.phpの<img src="qr.php">から読み込めます。
MFAの登録を確認する
AuthyでQRコードを読み取ったら、表示された6桁のコードを入力します。public/verify-setup.phpを作成します。
<?php
declare(strict_types=1);
session_start();
require dirname(__DIR__) . '/vendor/autoload.php';
use OTPHP\TOTP;
$secret = $_SESSION['totp_secret'] ?? null;
$code = trim((string) ($_POST['code'] ?? ''));
if ($secret === null || !preg_match('/^\d{6}$/', $code)) {
http_response_code(400);
exit('認証コードが正しくありません。');
}
$totp = TOTP::createFromSecret($secret);
if (!$totp->verify($code)) {
http_response_code(401);
exit('認証コードの確認に失敗しました。');
}
$_SESSION['mfa_enabled'] = true;
echo 'MFAの登録が完了しました。';
TOTP::createFromSecret()で、保存しておいた秘密鍵からTOTPオブジェクトを作成します。
verify()へAuthyから入力されたコードを渡し、正しいコードであればMFAを有効化しています。
この例では処理を簡単にするため、認証コードが6桁であることだけを確認しています。実際の仕様や利用するライブラリの設定に合わせて、入力値の扱いを決めてください。
ログイン時にTOTPコードを確認する
次に、パスワード認証の後でTOTPコードを確認する簡略なログイン処理を作成します。
public/login.phpを作成します。
<?php
declare(strict_types=1);
session_start();
require dirname(__DIR__) . '/vendor/autoload.php';
use OTPHP\TOTP;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim((string) ($_POST['email'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
$code = trim((string) ($_POST['code'] ?? ''));
// 実際にはDBからユーザーを取得し、保存済みのハッシュを使います。
$demoUser = [
'email' => 'demo@example.test',
'password_hash' => password_hash('demo-password', PASSWORD_DEFAULT),
];
$passwordIsValid = $email === $demoUser['email']
&& password_verify($password, $demoUser['password_hash']);
if (!$passwordIsValid) {
$error = 'メールアドレスまたはパスワードが正しくありません。';
} elseif (empty($_SESSION['mfa_enabled'])) {
$error = '先にMFAの登録を完了してください。';
} elseif (!preg_match('/^\d{6}$/', $code)) {
$error = '認証コードが正しくありません。';
} else {
$secret = $_SESSION['totp_secret'] ?? null;
$totp = $secret === null ? null : TOTP::createFromSecret($secret);
if ($totp === null || !$totp->verify($code)) {
$error = '認証コードが正しくありません。';
} else {
session_regenerate_id(true);
$_SESSION['logged_in_user'] = $email;
echo 'ログインしました。';
exit;
}
}
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>ログイン</title>
</head>
<body>
<h1>ログイン</h1>
<?php if ($error !== null): ?>
<p><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<form method="post">
<label>
メールアドレス
<input type="email" name="email" required>
</label>
<label>
パスワード
<input type="password" name="password" required>
</label>
<label>
Authyの認証コード
<input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" required>
</label>
<button type="submit">ログイン</button>
</form>
</body>
</html>
この例では、最初にパスワードを確認し、その後でTOTPコードを確認しています。両方の確認に成功した場合だけ、ログイン済みのセッションを作成します。
session_regenerate_id(true)は、ログイン成功時にセッションIDを再生成するために呼び出しています。
なお、サンプルでは処理の流れを分かりやすくするため、毎回password_hash()を呼び出しています。実際にはユーザー登録時にパスワードをハッシュ化してDBへ保存し、ログイン時は保存済みのハッシュをpassword_verify()で検証します。
動作確認
Laravelプロジェクトのルートディレクトリで、以下のコマンドを実行します。
php artisan serve
ブラウザでsetup.phpを開き、表示されたQRコードをAuthyで読み取ります。
Authyに表示されたコードを入力して「登録を確認する」を実行し、MFAの登録が完了することを確認します。
その後、login.phpを開き、パスワードとAuthyのコードを入力します。パスワードとTOTPコードが正しければ、「ログインしました。」と表示されます。
認証コードを間違えた場合はログインできません。Authyに表示されるコードが切り替わるタイミングで入力した場合や、サーバーと端末の時刻がずれている場合も、検証に失敗することがあります。
本番環境へ導入するときの注意点
秘密鍵をセッションへ保存しない
今回のコードでは、複数画面で処理をつなぐために秘密鍵をセッションへ保存しました。セッションへ保存した値は、セッションの有効期間や保存方式に依存します。
実際のアプリケーションでは、ユーザーごとのMFA秘密鍵をDBなどへ保存し、アクセスできる処理を限定します。DBへ保存する場合は、暗号化やバックアップ時の扱いも検討します。
QRコードと秘密鍵を公開しない
QRコードを読み取ると、認証アプリと同じ秘密鍵を登録できます。QRコードやプロビジョニングURIをログ、画面キャプチャ、ソースコードへ残さないようにします。
登録完了後は、QRコードを何度も表示できないようにすることも検討します。
試行回数を制限する
TOTPコードは一定の桁数しかないため、無制限に試行できると総当たり攻撃を受ける可能性があります。
ユーザー単位やIPアドレス単位で試行回数を制限し、必要に応じて一時的なロックや監視を行ないます。認証コードそのものをログへ記録してはいけません。
MFAの解除と再登録を用意する
スマートフォンの紛失や機種変更が起きると、ユーザーは認証アプリを使えなくなることがあります。
リカバリーコード、本人確認を伴う再登録、管理者による解除など、アカウントへ戻るための手順を用意します。ただし、MFA解除の手順が簡単すぎると、攻撃者に回避手段として悪用される可能性があります。
このサンプルの範囲
今回のコードでは、詳細な例外処理、CSRF対策、ユーザー情報のDB管理、レート制限、リカバリーコードなどを省略しています。
そのため、このまま本番環境へ配置するのではなく、MFAの基本的な処理を確認するためのサンプルとして利用してください。
まとめ
今回は、PHP 8.3.15の環境で、TOTP方式のMFAを実装しました。
MFAはパスワードだけでなく、認証アプリなどの別の認証要素も組み合わせる仕組みです。TOTPでは、サーバーと認証アプリが共有する秘密鍵と時刻からワンタイムコードを生成します。
spomky-labs/otphpでTOTPの秘密鍵やコード検証を行ない、bacon/bacon-qr-codeのSVGバックエンドでQRコードを生成すれば、Imagickを使わずに認証アプリへの登録処理を作成できます。
実際のサービスへ導入する際は、秘密鍵の保護、試行回数制限、セッション管理、MFA解除やリカバリーの手順まで含めて設計する必要があります。
詳しい仕様やセキュリティ上の注意点については、以下の資料も参照してください。
奈良市を拠点に、27年以上の経験を持つフリーランスWebエンジニア、阿部辰也です。
これまで、ECサイトのバックエンド開発や業務効率化システム、公共施設の予約システムなど、多彩なプロジェクトを手がけ、企業様や制作会社様のパートナーとして信頼を築いてまいりました。
【制作会社・企業様向けサポート】
Webシステムの開発やサイト改善でお困りの際は、どうぞお気軽にご相談ください。小さな疑問から大規模プロジェクトまで、最適なご提案を心を込めてさせていただきます。
ぜひ、プロフィールやWeb制作会社様向け業務案内、一般企業様向け業務案内もご覧くださいね。
Windows 11にMailpitを導入してLaravelのメール送信を確認する
2026.08.06
Windows 11にMailpitを導入し、Laravel 13からSMTPでテストメールを送信して、MailpitのWeb UIで受信内容を確認する手順を紹介します。設定キャッシュ、ポート競合、キュー送信、メールFakeとの使い分けについても説明します。
GeminiのInteractions APIをPHPで実装:previous_interaction_idで会話を継続する
2026.08.01
Gemini Developer APIのInteractions APIを、PHPのcURLから呼び出す方法を解説します。PHP用の公式SDKがないためREST APIを利用し、APIキーの設定、リクエストとレスポンスの処理、previous_interaction_idを使った会話継続まで、実際のコード例とともに紹介します。
Google OAuth 2.0で実装するPHP SSO入門:Google Cloud 設定と実装コード例
2025.11.24
Google OAuth 2.0 を利用した Google アカウントによる SSO を PHP で実装する手順を解説します。Google Cloud Console での OAuth 同意画面とクライアント作成、必要なスコープ設定から、Composer の導入、GSI(Google Sign‑In)ボタンの設置、ログインスクリプトの実装例までを一貫して扱います。
漏洩パスワードとの照合:Pwned Passwords APIの仕組みと実運用での注意点
2025.11.06
Pwned Passwords API(Have I Been Pwned)が採用する k‑Anonymity の仕組みを分かりやすく解説し、PHPによる実装例を通して漏洩パスワードチェックの具体手順を示します。さらに、ユーザー通知文面の注意点など、実運用で押さえるべきセキュリティと運用上の留意点をまとめています。