なぜパスワードだけでは不十分なのか?
2年前、電子マネーのプロジェクトに関わっていた際、上司にこう聞かれました。「もしハッカーがハッシュ化されたパスワードを含むデータベースを丸ごと持ち出したら、顧客の資金は消えてしまうのか?」と。私は沈黙しました。実際、RTX 4090を搭載したマシンがあれば、MD5やSHA1のハッシュ値をブルートフォースで解析するのは一瞬のことです。その時、2FA(2要素認証)はオプションではなく, 不可欠なものであると悟りました。
TOTP (Time-based One-Time Password) は、現在この分野のゴールドスタンダードです。Google Authenticatorを開いて30秒ごとに変わる6桁の数字を確認することには慣れているでしょう。この仕組みは非常にスマートです。SMS 1通につき0.05ドルかかることもなく、SIMスワップの心配もありません。すべてはサーバーとユーザーのスマートフォンの間で同期されたアルゴリズムに基づいて動作します。
Node.jsでこのシステムを実装するのは、実は非常に簡単です。さっそく始めましょう。
ツールのインストール
現在最も普及している2つのライブラリを使用します:
- speakeasy:シークレットキーの生成とOTPコードの検証というロジック処理の「心臓部」です。
- qrcode:無機質な文字列を、ユーザーがスキャンできるQRコードに変換します。
次のコマンドでプロジェクトを素早く初期化します:
mkdir node-2fa-lab && cd node-2fa-lab
npm init -y
npm install speakeasy qrcode express
実践的な3ステップの実装プロセス
大規模なシステムでは、メンテナンス性と拡張性を高めるために、このプロセスを3つのフェーズに分けるのが一般的です。
ステップ1:シークレットキー(Secret Key)の生成
各アカウントには個別のシークレットキーが必要です。このキーは第2のパスワードのようなもので、サーバー(データベース)とユーザーのアプリの両方に保存されます。
const speakeasy = require('speakeasy');
const secret = speakeasy.generateSecret({
name: "MyApp ([email protected])",
});
console.log("Base32 Key:", secret.base32); // これをDBに保存
console.log("OTP Auth URL:", secret.otpauth_url);
重要な注意点: 常に base32 形式を使用してください。これはGoogle AuthenticatorやAuthyが正しく動作するために要求される標準的な形式です。
ステップ2:スムーズなQRコードスキャン体験の構築
ユーザーに32文字の文字列を手入力させるのは、アプリをアンインストールさせる一番の近道です。代わりにQRコードを使いましょう。以前関わったプロジェクトでは、QRコードを導入したことで「キーの入力ミス」に関するサポートチケットが80%減少しました。
const QRCode = require('qrcode');
app.get('/setup-2fa', async (req, res) => {
const secret = speakeasy.generateSecret({ name: "SecureApp" });
// URLをimgタグに直接埋め込めるようにBase64形式に変換
const dataUrl = await QRCode.toDataURL(secret.otpauth_url);
res.send(`
<h3>セキュリティを有効にするためにコードをスキャンしてください</h3>
<img src="${dataUrl}">
<p>確認のためにアプリの6桁の数字を入力してください</p>
`);
});
ステップ3:トークンの検証(Verify)
ユーザーが6桁の数字を入力すると、サーバーは保存されているシークレットキーと照合します。ここが魔法の起こる場所です。
app.post('/verify-2fa', (req, res) => {
const { userToken, storedSecret } = req.body;
const verified = speakeasy.totp.verify({
secret: storedSecret,
encoding: 'base32',
token: userToken,
window: 1 // 30秒のズレを許容
});
if (verified) {
return res.json({ success: true, message: "認証に成功しました!" });
}
res.status(400).json({ success: false, message: "無効なコードです。" });
});
ヒント: window: 1 パラメータは非常に重要です。スマートフォンとサーバーの時刻は数秒ずれることがよくあります。デフォルト(window: 0)のままだと、新しいコードに切り替わった直後にエラーになり、ユーザーにストレスを与えることになります。
本番環境で避けるべき「落とし穴」
ローカルでコードが動くのは、道のりの半分に過ぎません。以下は、多くのトラブル対応を経て得た「血肉となる」教訓です。
1. リカバリコード(Backup Codes)は必須
ユーザーがスマートフォンを紛失するのは日常茶紛事です。リカバリコードがなければ、アカウントは永久にロックされてしまいます。2FAを有効にする際に、10個のランダムなコードを生成し、パスワードのようにハッシュ化して保存させ、ユーザーには安全な場所に保管するよう促しましょう。
2. シークレットキーをプレーンテキストで保存しない
データベースが流出し、シークレットキーも漏洩してしまえば、2FAは無意味になります。データベースに保存する前に、AES-256などでキーを暗号化してください。検証が必要な時にのみ、ロジック層で復号します。
3. ブルートフォース攻撃を阻止する
OTPコードは100万通り(000000 – 999999)しかありません。単純なスクリプトを使えば数分で突破される可能性があります。試行回数を制限する必要があります。例えば、5回間違えたら15分間検証機能をロックするといった具合です。express-rate-limitを使用するのが最も手っ取り早い実装方法です。
4. サーバー時刻のズレ
TOTPは時間に依存しているため、サーバーの時計が1分ずれるだけでシステムは完全に「機能不全」に陥ります。常にNTP(Network Time Protocol)をインストールして時刻を同期させてください。Linuxでは、timedatectlを実行して、すべてが正確であることを確認しましょう。
これらの共有が、プロジェクトのセキュリティ実装において自信に繋がることを願っています。セキュリティにおいて、「慎重すぎる」ということは決してありません!

