近年セキュリティリスクの増大に伴い、従来のパスワード認証に加えワンタイムパスワードやSMSなどによる多要素認証(MFA)を必要とするサイトが増えています。
TOTPを使った認証に関して、すでにこちらのブログで基本的な対応方法が記載されておりますが、実際に運用していただくうえで、Tokenの有効期限を考慮して頂く必要があり、それに対応したスクリプトを本ブログにて紹介します。
Wikipediaによれば、TOTPは共有シークレットを用いた時間ベースのワンタイムパスワードとなります。シークレットがユーザ側と認証側で共有され、同じアルゴリズムでワンタイムパスワードを発行することができるようになっています。こちらの前提として、ユーザ側とシステム側で時刻がほぼ同期されていることが前提となっており、1970年1月1日午前0時0分0秒を起点として、30秒ごとにワンタイムパスワードが変わるように設計されています。
そのため、TOTPを利用された方は経験があるかと思いますが、ワンタイムパスワードを表示したタイミングでそのパスワードの残り有効時間が30秒近く残っている場合もあれば、数秒表示された後すぐに次のワンタイムパスワードに切り替わるということがあります。前述のブログの場合、この取得タイミングによるワンタイムパスワードの有効期限切れへの対応が含まれていないため、そのまま適用された場合に稀にログイン認証に失敗するという事象が発生します。
本ブログでは、その課題に対応した実際のコードを記載しています。なお、テスト対象のサイトとして https://authenticationtest.com/totpChallenge/ を利用しており、こちらのページ記載のユーザ名、パスワード、共有シークレットを利用しているため、実際にコードを動かして確認される際は、サイトに表示された値を設定してご確認ください。
ポイントの説明は後述しますが、スクリプト全体は以下のようになります。
var assert = require('assert');
var DefaultTimeout = 30000;
var otpauth = require('otpauth');
// TOTPの設定
let totp = new otpauth.TOTP({
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: $secure.MFA_LOGIN_PRIVATE_KEY
});
/**
* 有効期限に余裕のあるTOTP Tokenを取得する関数
* @param {number} minRemainingSeconds - 最低限必要な残り時間(秒)
* @returns {Promise<string>} Token
*/
async function getSafeToken(minRemainingSeconds = 5) {
// 現在のタイムスタンプ(ミリ秒)から、現在の30秒周期の残り時間を計算
const periodMs = totp.period * 1000;
let remainingMs = periodMs - (Date.now() % periodMs);
let remainingSeconds = remainingMs / 1000;
// 残り時間が指定秒数未満(例: 5秒未満)の場合は、次の周期まで待機
if (remainingSeconds < minRemainingSeconds) {
// 次のトークンに切り替わるまでの時間(ミリ秒)待機する
// 完全に切り替わるよう、少しだけバッファ(+100ms)を足しています
const waitTime = remainingMs + 100;
console.log(`Tokenの残り時間が ${remainingSeconds.toFixed(1)}秒 のため、次のTokenまで ${waitTime / 1000}秒 待機します...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
// 余裕がある状態(または切り替わった直後)でTokenを生成して返す
return totp.generate();
}
console.log('Step 1');
console.log('Navigating to "https://authenticationtest.com/totpChallenge/"');
$webDriver.get("https://authenticationtest.com/totpChallenge/")
.then(function() {
console.log('Step 2');
console.log('ユーザ名入力');
$webDriver.findElement($selenium.By.id("email")).sendKeys($secure.MFA_LOGIN_USER_NAME);
})
.then(function() {
console.log('Step 3');
console.log('パスワード入力');
$webDriver.findElement($selenium.By.id("password")).sendKeys($secure.MFA_LOGIN_PASSWORD);
})
.then(async function() {
console.log('Step 4');
console.log('Tokenの所得及び入力');
let token = await getSafeToken(5);
$webDriver.findElement($selenium.By.id("totpmfa")).sendKeys(token);
})
.then(function() {
console.log('Step 5');
console.log('Submitのクリック');
$webDriver.findElement($selenium.By.css('input[type="submit"]')).click();
})
.then(function() {
console.log('Step 6: ページロードが完了するのを待つ');
// ブラウザの document.readyState が "complete" になるまで最大10秒待機
return $webDriver.wait(function() {
return $webDriver.executeScript('return document.readyState').then(function(readyState) {
return readyState === 'complete';
});
}, 10000);
})
.then(function() {
console.log('Step 7: H1要素が出現するのを待ってテキストを取得');
// ページロードが完了した状態で、h1要素を探してテキストを取得
return $webDriver.findElement($selenium.By.tagName('h1')).getText();
})
.then(function(headerText) {
// 実際のテキストが「Login Success」かを検証
assert.equal(headerText, 'Login Success');
console.log('Verification passed: Login Success!');
})
.then(function() {
// スクリーンショットを取得
$webDriver.takeScreenshot();
});
続いてポイントの説明です。今回のポイントとなるワンタイムパスワードの有効期限対策に関しての実装は19行目から37行目の以下のgetSafeToken()という関数になります。
/**
* 有効期限に余裕のあるTOTP Tokenを取得する関数
* @param {number} minRemainingSeconds - 最低限必要な残り時間(秒)
* @returns {Promise<string>} Token
*/
async function getSafeToken(minRemainingSeconds = 5) {
// 現在のタイムスタンプ(ミリ秒)から、現在の30秒周期の残り時間を計算
const periodMs = totp.period * 1000;
let remainingMs = periodMs - (Date.now() % periodMs);
let remainingSeconds = remainingMs / 1000;
// 残り時間が指定秒数未満(例: 5秒未満)の場合は、次の周期まで待機
if (remainingSeconds < minRemainingSeconds) {
// 次のトークンに切り替わるまでの時間(ミリ秒)待機する
// 完全に切り替わるよう、少しだけバッファ(+100ms)を足しています
const waitTime = remainingMs + 100;
console.log(`Tokenの残り時間が ${remainingSeconds.toFixed(1)}秒 のため、次のTokenまで ${waitTime / 1000}秒 待機します...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
// 余裕がある状態(または切り替わった直後)でTokenを生成して返す
return totp.generate();
}
スクリプト内のコメントに記載されていますが、ワンタイムパスワード(Token)の取得を試みようとしたタイミングで、Tokenの有効期限が5秒(デフォルト値)未満の場合は、次のTokenに切り替わる時間まで待機し、Tokenを取得するようになっています。そのため、スクリプト側でTokenを取得した時間(Synthetic側の時間)から、サーバが受け取るまでの時間(テスト対象のホスト上の時間)の差が5秒以内であれば、正しいワンタイムパスワードを渡していただけるようになっています。
なお、ワンタイムパスワード取得する側のコードは57行目にございますが、その有効期限のため、可能な限り実際に入力する直前にワンタイムパスワードを取得していただくようにしてください。
.then(async function() {
console.log('Step 4');
console.log('Tokenの所得及び入力');
let token = await getSafeToken(5);
$webDriver.findElement($selenium.By.id("totpmfa")).sendKeys(token);
})
本ブログに掲載されている見解は著者に所属するものであり、必ずしも New Relic 株式会社の公式見解であるわけではありません。また、本ブログには、外部サイトにアクセスするリンクが含まれる場合があります。それらリンク先の内容について、New Relic がいかなる保証も提供することはありません。