コンテンツにスキップ

TOTP二要素ログインをテストする

ユースケース: 自分のアプリ(あるいはテストの前段でログインが必要なアプリ)のアカウントが認証アプリ — Google Authenticator形式の6桁コード — で保護されている。テストはこの画面を通過しなければならない。

コードは30秒ごとに変わり、スマホの中にあります。しかしTOTPは公開されたアルゴリズム(RFC 6238)です。スマホが知っているのは共有シークレットだけ — テストも同じシークレットを知っていれば、同じコードを計算できます。

Terminal window
npm install -D otplib

登録時にシークレットを取得する

Section titled “登録時にシークレットを取得する”

2FAセットアップ時に表示されるQRコードには、シークレットを含む otpauth:// URIがエンコードされています。ほぼすべてのアプリが「スキャンできない場合は手動で入力」用の文字列も表示するので、それを取得します:

import { test, expect } from '@playwright/test';
import { authenticator } from 'otplib';
test('enroll TOTP and log in with it', async ({ page }) => {
await page.goto('/account/security');
await page.getByRole('button', { name: 'Enable 2FA' }).click();
// QRコードの横にある手動入力用シークレット。
const secret = (await page.getByTestId('totp-secret').innerText()).replace(/\s/g, '');
// 認証アプリと同じ要領で登録を確定する。
await page.getByLabel('Verification code').fill(authenticator.generate(secret));
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page.getByText('Two-factor enabled')).toBeVisible();
// 以後のログインはコードを再計算するだけ。
await page.goto('/logout');
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('hunter2');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByLabel('Verification code').fill(authenticator.generate(secret));
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page).toHaveURL('/dashboard');
});

事前にプロビジョニング済みのテストアカウントなら、シークレットをパスワードと一緒にシークレットマネージャーに保存しておけば、登録画面のスクレイピングは不要です。

2FAはUIログインを高コストにします。セットアッププロジェクトで一度だけログインし、認証状態(storageState)を使い回しましょう:

playwright.config.ts
// auth.setup.ts — 一度だけ実行される
await page.context().storageState({ path: 'playwright/.auth/user.json' });
use: { storageState: 'playwright/.auth/user.json' },

2FAのUI全体を通しで検証するテストをちょうど1本だけ残し、それ以外はログイン済みの状態から始めます。

ウィンドウの29秒目に生成されたコードは、送信中に期限切れになることがあります。ウィンドウの早い段階で生成することでこのフレークを回避します:

const msLeft = authenticator.timeRemaining() * 1000;
if (msLeft < 3000) await page.waitForTimeout(msLeft); // 次のウィンドウまで待つ
await page.getByLabel('Verification code').fill(authenticator.generate(secret));
  • 登録画面がQR画像しか表示しない(手動入力用文字列がない)場合は、要素をスクリーンショットしてQRライブラリ(例: jsqr)でデコードし、otpauth:// URIをパースしてください。
  • サーバーの時計のずれはTOTPを双方向に壊します。多くのサーバーは±1ウィンドウを許容します。CIでコードが「ランダムに」失敗する場合は、まずランナーのNTP同期を確認してください。
  • SMSベースの2FAは別のレシピです(TwilioのようなテストSMS受信箱を使います)。テストアカウントには無料でオフラインでも動くTOTPを推奨します。