1. 二段階認証を導入するメリット
パスワードに認証アプリの確認を加えることで、パスワード漏えいに備えられます。SMS送信やGoogleへの認証API呼び出しも不要です。
二段階認証って、自分で作ったサービスにも付けられるの? 個人で作っているものでも。
付けられます。今回は、Google 認証システムに表示される「一定時間で変わる6桁の数字」を使う方式です。TOTPという共通規格なので、対応する認証アプリと自分のPHPを組み合わせられます。
それなら、かなり便利じゃない?
そうですね。特に、パスワードだけでは心配なログインに、もう一つ確認を加えられるのがメリットです。
| メリット | どう役立つか |
|---|---|
| パスワード漏えいへの備え | パスワードを知られただけでは、ログインを完了できなくなる |
| SMS送信が不要 | 認証コードを送るたびのSMS料金がかからない |
| Googleへの認証API呼び出しが不要 | 自分のサーバーでコードを照合できる |
| 共通規格を使える | Google 認証システム以外のTOTP対応アプリも選べる |
| コード生成はオフラインで可能 | スマホが通信できないときでも、登録済みのコードは計算できる |
Googleと契約する必要もない?
この構成では、Googleの有料認証APIを契約して呼び出すことはありません。自分のプログラムがTOTPを計算します。もちろん、開発やサーバーの費用は別です。
2. ユーザーごとの秘密鍵と暗号化保存
TOTP秘密鍵はユーザーごとにランダム生成し、DBには暗号化して保存します。認証用の秘密鍵と、保存時の暗号化鍵は役割を分けて管理します。
じゃあ、まず仕組みを知りたいわ。何を比べているの?
スマホとサーバーが「同じ秘密鍵」と「現在時刻」を基に、それぞれ計算したコードです。TOTPの計算方式はRFC 6238に記載されています。
時刻は分かるけど、秘密鍵はどこから出てくる?
最初の登録時に、サーバーがユーザーごとに安全な乱数で生成します。ユーザーIDや誕生日から作るのではありません。今回のPHPでは
random_bytes(20) を使い、20バイト、つまり160ビットの乱数を作ります。
その鍵をユーザーテーブルに入れておけばいいんだね。当然、そのまま保存するのではなく、暗号化して。
はい。既存のユーザー情報に、二段階認証用の項目を追加するイメージです。
| id | username | password_hash | totp_secret_enc | totp_enabled |
|---|---|---|---|---|
| 1 | yamada | パスワードのハッシュ | 山田さん用秘密鍵の暗号文 | 1 |
| 2 | tanaka | パスワードのハッシュ | 田中さん用秘密鍵の暗号文 | 1 |
| 3 | suzuki | パスワードのハッシュ | 未作成ならNULL | 0 |
パスワードの保存と同じ?
そこは違います。パスワードはハッシュ化します。一方、TOTPの秘密鍵は、サーバーが元の鍵を取り出して計算する必要があるので、復号できる暗号化で保存します。
ということは、その暗号化にも鍵がいるよね?
そうです。この話には、役割の違う二つの鍵が登場します。
| 鍵 | 役割 | 保管場所 |
|---|---|---|
| ユーザーごとのTOTP秘密鍵 | スマホとサーバーで同じ認証コードを作る | スマホの認証アプリ/DBには暗号化して保存 |
| サーバー側の暗号化鍵 | DBに保存するTOTP秘密鍵を暗号化・復号する | サーバーの秘密設定。DBや公開ソースに一緒に入れない |
なるほど。DBが漏れても、暗号化鍵まで一緒に渡さないようにするわけだ。
はい。ただ、実行中のサーバー自体を乗っ取られれば、復号される可能性があります。暗号化保存で守れる範囲と、サーバーの管理は分けて考えます。
3. QRコードで秘密鍵を共有する
初回だけQRコードで秘密鍵を認証アプリへ渡します。アプリの6桁が一致することを確認してから、二段階認証を有効にします。
スマホには、どうやって同じ鍵を渡すの?
初回登録用のQRコードです。DBから復号したTOTP秘密鍵を登録用データに入れ、認証アプリで読み取ってもらいます。サーバー側の暗号化鍵はQRに入れません。
つまり、QRは「秘密鍵の受け渡し」なんだ。
その理解で合っています。登録はこの順番です。
- 本人のパスワードを確認する。
- そのユーザーのTOTP秘密鍵をランダムに生成する。
- 秘密鍵を暗号化してユーザーテーブルへ保存する。この時点では未有効。
- 登録用QRを表示し、スマホで読み取ってもらう。
- スマホの6桁を入力してもらい、サーバーで一致を確認する。
- 一致したら
totp_enabled = 1にして登録完了。
QRを出した時点では、まだ登録完了にしないんだね。
はい。読み取りに失敗したまま有効にすると、本人がログインできなくなるからです。
4. 6桁のコードを作るアルゴリズム
スマホとサーバーは、同じ秘密鍵と30秒単位の時刻からコードを計算します。同じ条件で計算するため、通信しなくても同じ6桁が得られます。
で、肝心の6桁はどう計算する?
まず、現在のUnix時刻を30で割り、小数部分を切り捨てます。それが「今は何番目の30秒区間か」という番号です。
時間枠の番号 = floor(Unix時刻 ÷ 30)
その番号と秘密鍵を組み合わせる?
はい。今回の設定では、時間枠の番号を8バイトのデータにし、秘密鍵を使ってHMAC-SHA1を計算します。結果から規定の方法で31ビットの整数を取り出し、100万で割った余りを6桁に整えます。
時間枠の番号
→ 秘密鍵を使ってHMAC-SHA1を計算
→ 計算結果から31ビットの整数を取り出す
→ 1,000,000で割った余り
→ 先頭の0を含めて6桁にする
「暗号化して比較する」という理解とは、少し違うのか。
そうですね。DB保存時には暗号化しますが、6桁を作る処理はHMACによる計算です。認証コードを復号して秘密鍵を取り出す仕組みではありません。
スマホとサーバーは同じものを持っているから、同じ数字になる。
そのとおりです。下は説明用の例で、実際の計算値ではありません。
| 計算する側 | 入力 | 結果の例 |
|---|---|---|
| スマホ | ユーザーAの秘密鍵+時間枠100 | 482915 |
| サーバー | ユーザーAの秘密鍵+時間枠100 | 482915 |
| 別ユーザーのスマホ | ユーザーBの秘密鍵+時間枠100 | 705138 |
同じ時刻でも、ユーザーごとに鍵が違うからコードも変わるわけか。
はい。ただし6桁なので、別ユーザーと偶然同じ数字になることはあります。コードだけでユーザーを探すのではなく、パスワード認証を通過したユーザーの鍵で照合します。
5. ログインを完了させるタイミング
パスワードが合った段階では仮認証にとどめます。認証コードの確認にも成功してから、会員ページへ進めるようにします。
あとは、パスワードの後にこれを確認すれば二段階認証になるね。
はい。パスワードが合った段階は「仮認証」にとどめ、6桁の確認に成功してからログインを完了させます。先に会員ページへ入れてしまうと、二段階目を飛ばせてしまいます。
6. 必要な環境とライブラリ
PHPとMySQLを使い、TOTP計算はOTPHP、QR生成はendroid/qr-codeに任せます。秘密鍵の暗号化にはPHPのSodium拡張を使います。
必要なライブラリは? PHPだけでできる?
PHPでできます。この連載では計算とQR生成にライブラリを使い、DB保存とのつなぎ方に集中します。
| 用途 | 使用するもの |
|---|---|
| 実行環境 | PHP 8.4以上の8系、MySQL 8系、Composer |
| TOTP計算 | spomky-labs/otphp 11系 |
| QR画像生成 | endroid/qr-code 6系とPHPのXML拡張。SVG形式で生成 |
| 秘密鍵の暗号化 | PHPのSodium拡張 |
| DB接続 | PHPのPDO・pdo_mysql拡張 |
| 乱数・パスワード | PHPの random_bytes()、password_hash()、password_verify() |
サンプル一式の
sample フォルダーで composer install を実行すれば、付属の composer.json に指定したライブラリが入ります。空のプロジェクトに導入する場合の指定は次のとおりです。
composer require 'spomky-labs/otphp:^11.0' 'endroid/qr-code:^6.0'
仕組みが分かると、思ったより身近だね。
次回は、この流れをユーザーテーブルとPHPにつなげます。秘密鍵を生成して暗号化保存し、必要なときだけ復号してコードを確認するところを作りましょう。

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