こんにちは!KIYO Learningでスタディングの開発をしている @gawa32 です!
PHPでTOTPベースの2要素認証(Two-Factor Authentication, 2FA)を導入したい場面は、管理画面やログイン機能の強化などで意外と多くあります。
TOTPの仕組み自体はシンプルで、既存のライブラリを使えば比較的簡単に導入できますが、今回は次のような観点も意識して実装方法を整理してみました。
- ライブラリ変更に備えて処理を抽象化
- シークレットキーを暗号化して安全に保存
あわせて、TOTPの基本的な導入ステップについても触れていきます。
これから2要素認証を導入しようとしている方の参考になれば幸いです。
TOTP(Time-based One-Time Password)は、時間ベースで一定間隔ごとに変化するワンタイムパスワードを使った認証方式です。
スマートフォンの認証アプリ(Google Authenticator や Authy など)を使ってコードを生成し、ログイン時にそれを入力することで「パスワード(知識)」に加えて「スマートフォン(所持)」という2つの要素で本人確認を行います。
このような2要素認証(2FA)は、セキュリティが求められる管理画面や管理者アカウントなどによく使われており、不正ログイン対策としても有効です。
2要素認証と2段階認証の違いは?
2要素認証(2FA)は「異なる種類の認証要素(例:パスワードとスマホ)」を組み合わせる方式です。
一方で2段階認証は、2回の認証ステップを踏むものの、必ずしも要素の種類が異なるとは限りません。
TOTPによる2要素認証の導入は、以下のようなステップで構成されます。
登録時の流れ(初回セットアップ)
- サーバー側でTOTPのシークレットキーを生成
- 生成したシークレットキーをユーザーにQRコードで提示
- ユーザーがスマホアプリ(Google Authenticator など)で読み取り
- 認証アプリに表示されたコードを使って動作確認(検証)し、セットアップ完了
このとき、シークレットキーは後述のように暗号化して安全に保存しておく必要があります。
ログイン時の流れ(2FAステップ)
- ユーザーがIDとパスワードでログイン
- 続いて、認証アプリに表示されているTOTPコードを入力
- サーバー側で、保存されているシークレットキーを用いてコードを検証
- 一致すればログイン成功、失敗すればエラーを返す
このステップは、通常のログイン処理のあとに追加のステップとして実装します。
実際のコードに入る前に、次の章ではTOTPの処理をどのように設計したか、ライブラリの扱いをどう抽象化したかを紹介していきます。
全体の流れ
ここまで説明してきた登録〜認証の2ステップを、図にまとめてみました。
3. 設計の工夫:ライブラリに依存しない抽象化
TOTPを使った2要素認証では、phpgangsta/googleauthenticator
や RobThree/TwoFactorAuth
のようなライブラリがよく使われます(いずれも Google Authenticator などの認証アプリと互換性があります)。
これらのライブラリは導入も簡単で、TOTPの生成・検証・QRコードの生成まで一通りサポートされています。
ただ、アプリケーションの中で直接これらのライブラリを使い回してしまうと、後から別のライブラリに切り替えたくなったときに、影響範囲が広くなってしまいます。
そこで今回は、TOTP関連の処理をインターフェースで抽象化して、アプリケーション側からはライブラリを意識しなくても済むような構成にしました。
抽象化の目的
- ライブラリ変更の影響を局所化する
- モック化しやすくしてテスト性を高める
インターフェースの定義
interface OtpServiceInterface {
public function generateSecret(): string;
public function getQrCodeUrl(string $user, string $secret): string;
public function verifyCode(string $secret, string $code): bool;
}
このインターフェースをどう使うか
このインターフェースを導入することで、アプリケーションの中では具体的なライブラリのクラスを直接呼び出さずに、OtpServiceInterface
を通してOTPの処理を扱うことができるようになります。
たとえば、ログイン後にTOTPコードを検証する処理は以下のようになります。
class TwoFactorAuthController
{
public function __construct(
private OtpServiceInterface $otpService,
private UserRepository $userRepository
) {}
public function verify(Request $request)
{
$user = $this->userRepository->find($request->userId);
$secret = $user->getOtpSecret();
if ($this->otpService->verifyCode($secret, $request->input('code'))) {
// 成功
} else {
// 失敗
}
}
}
このように OtpServiceInterface
を介して呼び出す構成にしておけば、後から別のライブラリに乗り換えたくなった場合も、実装クラスだけを差し替えれば済むようになります。
ライブラリを変更したくなった場合の例
たとえば、最初は phpgangsta/googleauthenticator
を使っていたけれど、より積極的にメンテナンスされている RobThree/TwoFactorAuth
に移行したくなったとします。
その場合は、新たに OtpServiceInterface
を実装したクラスを用意するだけで、アプリケーション側の呼び出しコードはそのままで、DIのバインド先を変更するだけで切り替えができます。
use RobThree\Auth\TwoFactorAuth;
class RobThreeOtpService implements OtpServiceInterface
{
private TwoFactorAuth $tfa;
public function __construct()
{
$this->tfa = new TwoFactorAuth('YourAppName');
}
public function generateSecret(): string
{
return $this->tfa->createSecret();
}
public function getQrCodeUrl(string $user, string $secret): string
{
return $this->tfa->getQRCodeImageAsDataUri($user, $secret);
}
public function verifyCode(string $secret, string $code): bool
{
return $this->tfa->verifyCode($secret, $code);
}
}
ここでは、前章で紹介した OtpServiceInterface
を使って、TOTPによる2要素認証を実際にどのように構成しているかを、登録〜認証の処理単位で紹介していきます。
※この実装では、DIコンテナなどで OtpServiceInterface
に対してGoogleAuthenticatorService
をバインドしておく必要があります。
シークレット生成とQRコード表示(登録処理)
まずは、ユーザーにTOTPを設定してもらうための処理です。
class TwoFactorSetupService
{
public function __construct(
private OtpServiceInterface $otpService,
private UserRepository $userRepository
) {}
public function setupForUser(int $userId): array
{
$user = $this->userRepository->find($userId);
// シークレットを生成して保存(暗号化は次章で)
$secret = $this->otpService->generateSecret();
$user->setOtpSecret($secret);
$this->userRepository->save($user);
// QRコードURLを生成して返す
$qrUrl = $this->otpService->getQrCodeUrl($user->email, $secret);
return [
'secret' => $secret,
'qr_url' => $qrUrl,
];
}
}
このように、OtpServiceInterface を通じてシークレットの生成とQRコードの生成を行います。
ログイン時のコード検証処理
ログイン後の2段階目として、ユーザーが入力したコードを検証する処理です。
class TwoFactorAuthController
{
public function __construct(
private OtpServiceInterface $otpService,
private UserRepository $userRepository
) {}
public function verify(Request $request)
{
$user = $this->userRepository->find($request->userId);
$secret = $user->getOtpSecret();
if ($this->otpService->verifyCode($secret, $request->input('code'))) {
// 認証成功
} else {
// 認証失敗
}
}
}
ここでも、具体的なライブラリには一切依存せずにOTPコードの検証を行えています。
実装クラス(phpgangsta/googleauthenticator を使った例)
use PHPGangsta_GoogleAuthenticator;
class GoogleAuthenticatorService implements OtpServiceInterface
{
private PHPGangsta_GoogleAuthenticator $auth;
public function __construct()
{
$this->auth = new PHPGangsta_GoogleAuthenticator();
}
public function generateSecret(): string
{
return $this->auth->createSecret();
}
public function getQrCodeUrl(string $user, string $secret): string
{
return $this->auth->getQRCodeGoogleUrl($user, $secret, 'MyApp');
}
public function verifyCode(string $secret, string $code): bool
{
return $this->auth->verifyCode($secret, $code, 2);
}
}
このように、実装の具体は切り出されており、アプリケーション全体はインターフェース経由でOTP機能を利用しています。
このように、インターフェースを挟むことで、アプリケーション側の処理はすっきりとまとまり、実装の差し替えやテストの柔軟性も担保できます。
ただし、シークレットキーをそのまま保存してしまうのはセキュリティ上のリスクがあります。
次の章では、シークレットを安全に保存するための暗号化処理について紹介します。
TOTPのシークレットキーは、そのままデータベースに保存してしまうと、 万が一データベースが漏洩したときに不正利用されるリスクがあります。
そのため、シークレットキーは暗号化して保存し、利用時に復号するのが基本方針です。
ここでは、PHPの openssl
関数を使って AES-256-CBC で暗号化・復号する例を紹介します。
暗号化クラスの実装例
class OtpSecretEncryptor
{
private string $key;
public function __construct(string $encryptionKey)
{
$this->key = $encryptionKey;
}
public function encrypt(string $plainText): string
{
$iv = random_bytes(openssl_cipher_iv_length('aes-256-cbc'));
$cipherText = openssl_encrypt($plainText, 'aes-256-cbc', $this->key, 0, $iv);
return base64_encode($iv . $cipherText);
}
public function decrypt(string $encrypted): string
{
$data = base64_decode($encrypted);
$ivLength = openssl_cipher_iv_length('aes-256-cbc');
$iv = substr($data, 0, $ivLength);
$cipherText = substr($data, $ivLength);
return openssl_decrypt($cipherText, 'aes-256-cbc', $this->key, 0, $iv);
}
}
鍵の管理はどうする?
暗号化キーは .env ファイルなどの環境変数で管理するのが一般的です。
例えば以下のように定義します。
TOTP_ENCRYPTION_KEY=your-very-secret-key
実装側では、環境変数から読み込んで以下のように渡して使います。
$encryptor = new OtpSecretEncryptor($_ENV['TOTP_ENCRYPTION_KEY']);
このクラスの使い方
TOTPのシークレットキーを登録時に保存する場合は、次のように暗号化してから保存します
$secret = $otpService->generateSecret();
$encryptedSecret = $encryptor->encrypt($secret);
$user->setOtpSecret($encryptedSecret);
$userRepository->save($user);
ログイン後の認証時には、保存されている値を復号してから検証します
$encrypted = $user->getOtpSecret();
$plainSecret = $encryptor->decrypt($encrypted);
if ($otpService->verifyCode($plainSecret, $inputCode)) {
// 認証成功
}
このようにしておくことで、暗号化されたままDBに保存し、必要なときだけ復号して使うという安全な構成が実現できます。
TOTPによる2要素認証は、コードが動くだけでは不十分です。
実際の運用に乗せる際のユーザー体験やトラブル対策も重要です。
ここでは、運用面で考慮すべきポイントをいくつか紹介します。
再設定の導線を用意する
TOTPでよくあるのは「スマホを機種変更したらコードが使えなくなった!」というトラブルです。
以下のような対策をあらかじめ準備しておくと、運用面で安心できます。
- バックアップコードの発行機能を用意する
- サポートチームに再設定依頼できる導線を作る
- パスワード+本人確認を経て設定を初期化できるようにする
よくあるトラブルと対策
問題 | 対策 |
---|---|
認証コードが合わない(時刻ズレ) | スロット数(許容ウィンドウ)を広げて対応する(例:±2) |
QRコードが表示されない | QR生成ライブラリの設定やフォント、CDN制限を確認する |
コードを何度も間違える | 一定回数失敗でロックアウト or クールダウン処理を入れる |
シークレットの再生成・切り替えタイミング
TOTPのシークレットキーをユーザーが再生成したいケース(例:漏洩が疑われたときなど)も考えられます。
- 再生成時に旧コードの無効化を即時に行うか、猶予期間を設けるか
- シークレット変更後、再ログインやメール通知をトリガーするか
といった運用方針を、UXとセキュリティのバランスを考えて設計しておくことが重要です。
ログ記録も忘れずに
2要素認証の設定や解除、認証失敗の履歴など、 セキュリティに関わる操作はログに残すようにしましょう。
- いつ誰が設定したか/解除したか
- 認証失敗が連続したかどうか
といった情報を残しておくことで、後からの調査や異常検知にも役立ちます。
このように、TOTPは「実装したら終わり」ではなく、その先の運用・リカバリ・安全性をどう担保するかまで含めて、設計することが求められます。
ここまで、PHPでTOTPベースの2要素認証を導入するにあたっての考え方や実装方法を紹介してきました。
TOTP自体はシンプルな仕組みですが、ライブラリにただ依存するのではなく、
- インターフェースで処理を抽象化し、変更やテストに強い設計にする
- シークレットキーは安全に暗号化して保存する
- 実装だけでなく、運用や再設定の導線まで見据えておく
といった工夫を取り入れることで、より安全で現実的な2要素認証の仕組みが構築できます。
「TOTPを取り入れたいけど、どう設計するべきか迷っている」
「セキュリティは気になるけど、実装コストが気がかり」
そんな方の参考になれば幸いです。
KIYOラーニング株式会社について
当社のビジョンは『世界一「学びやすく、分かりやすく、続けやすい」学習手段を提供する』ことです。革新的な教育サービスを作り成長させていく事で、オンライン教育分野でナンバーワンの存在となり、世界に展開していくことを目指しています。
プロダクト
- スタディング:「学びやすく・わかりやすく・続けやすい」オンライン資格対策講座
- スタディングキャリア:資格取得者の仕事探しやキャリア形成を支援する転職サービス
- AirCourse:受け放題の動画研修がついたeラーニングシステム(LMS)
KIYOラーニング株式会社では一緒に働く仲間を募集しています
Views: 2