目次


IBM Cloud の Node.js アプリケーション用に多要素認証を構成する

リスク分析と機能強化によってセキュリティーを高める

Comments

このチュートリアルでは、IBM Cloud の Node.js アプリケーションの 2 要素認証を構成する方法を説明します。別個のトークンをユーザーの e-メール・アドレスに送信すると、他人がそのユーザーになりすますのが極めて困難になります。潜在的なアタッカーは、パスワードを盗まなければならないだけなく、メール・サーバーに侵入してトークンを取得しなければならなくなるためです。

さらに、このチュートリアルではリスク分析のいくつかの手法についても説明します。リスク分析によって、アプリケーションは、あるログイン試行がリスクを伴うものである場合を判断できるようになります。アプリケーションは、ログイン試行がリスクを伴う場合にのみ、認証に 2 つ目の要素を要求します。

アプリケーションを作成するために必要となるもの

アプリを実行するコードを入手する

このチュートリアルでは、2 つ目の認証要素として、e-メールで送られてくるランダムな文字列を使用する方法を説明します。また、リスク分析のいくつかの方法についても説明します。

リスクを伴うログインに対して 2 要素認証を使用する理由

パスワードの主な欠陥モードには、以下の 2 つがあります。

  • 不注意による露呈: アクセスが許可されているユーザーのパスワードを、アタッカーが探り出すことです。
  • パスワードの共有: アクセスが許可されているユーザーが、(普通は自分のアクセス権を誰か他の人が使えるようにすることを目的として) パスワードを他の人に知らせることです。

どちらの欠陥モードも、修正する方法はあります。それは、2 つ目の認証要素として、許可されたユーザーに、ユーザー本人の e-メールにアクセスできることを証明させるという方法です。この 2 つ目の認証要素は、毎回要求することも、トランザクションが危険であるように見え、さらなるセキュリティーが必要な場合にのみ要求することもできます。

開始手順

以下の手順に従って、IBM Cloud 上で新規 Node.js アプリケーションを作成します。

  1. IBM Cloud コンソールにログオンし、無料アカウントを作成します (まだアカウントを持っていない場合)。
  2. メニュー・アイコンをクリックし、「Cloud Foundry Apps (Cloud Foundryアプリケーション)」を選択します。lこれで IBM Cloud 内の Platform as a Service (PaaS) オファリングにアクセスできます。
  3. 「Create Cloud Foundry app (Cloud Foundry アプリケーションを作成)」をクリックします。
  4. 「SDK for Node.js」をクリックします。
  5. アプリケーション名 (例えば、mfa-app) とホスト名 (私は mfa-app としたので、皆さんは別のホスト名を選択する必要があります) を入力してから、「Create (作成)」をクリックします。
  6. アプリケーションが起動するまで待ちます。

Web IDE を構成する

ローカル・システム上でアプリケーションを開発することもできますが、私は Web ベースの IDE が好みです。

  1. アプリケーションのページが開いたら、左のサイドバーにある「Overview (概要)」をクリックし、「Continuous delivery (継続的デリバリー)」という見出しまでスクロールダウンして「Enable (有効化)」をクリックします。
  2. スクロールダウンして、リポジトリーのタイプとして「Clone (複製)」を指定し、ソース・リポジトリーの URL として https://git.ng.bluemix.net/qbzzt1/mfa-app を指定します。
  3. 「Create (作成)」をクリックします。
  4. ツールチェーンが作成されたら、「Eclipse Orion Web IDE」をクリックしてアプリケーション・ファイルの編集を開始します。
  5. manifest.yml を開き、2 要素認証で使用する名前とホストを、(ステップ 5 で) アプリケーションを作成したときに入力した値に変更します。
  6. 再生ボタン・アイコンをクリックします。これで、更新後のマニフェストを使用してアプリケーションがデプロイされます。

認証

まずは、2 つ目の認証要素を使用する認証ワークフローが必要です。2 つ目の認証要素を使用するには、長くてランダムな文字列を記載した e-メールを送信します。これらの文字列にアクセスできるのは、正規のユーザーか、そのユーザーの e-メールにアクセスできる他のユーザーだけです。

ランダムな文字列を生成する

長くてランダムな文字列を生成する最も簡単な方法は、RFC 4122 準拠の識別子を生成する node-uuid パッケージをダウンロードして、これを利用することです。各識別子は、60 個のランダムなビットで構成されています。これだけ長ければ、あらゆる実用目的で使用することができます。識別子はテキストとしてエンコードされます。UUID オブジェクトを作成する手順は以下のとおりです (これらのステップはソース・コード内でも確認できます)。

  1. node-uuid を使用するには、node-uuid に対する (任意のバージョンの) 依存関係を package.json に追加します。
    "dependencies": {
        "express": "4.12.x",
        "cfenv": "1.0.x",
        "body-parser": "*",
        "node-uuid": "*"
    	},
  2. 次に、uuid オブジェクトを作成します。
    // Use uuid to generate random strings.
    var uuid = require("node-uuid");

メッセージを送信する

e-メール・メッセージを送信するには、IBM Cloud に用意されている SendGrid サービスを利用します。まず、このサービスを作成してアプリケーションにバインドし、API キーを取得する必要があります。

  1. IBM Cloud にログインして、ダッシュボードでサービスをバインドするアプリケーションをクリックします。
  2. 左のサイドバー上にある「Overview (概要)」をクリックします。
  3. 「Connections (接続)」までスクロールダウンして、「Connect new (新規接続)」をクリックします。
  4. カタログの「Application Services (アプリケーション・サービス)」カテゴリーを選択して、「SendGrid」サービスをクリックします。
  5. パッケージを選択して、「CREATE (作成)」をクリックします。
  6. 再ステージングを促されたら、「Restage (再ステージ)」をクリックします。
  7. メニュー・アイコンをクリックし、「Application Services (アプリケーション・サービス)」を選択します。次に、作成した SendGrid サービスをクリックします。
  8. 「Open SendGrid Dashboard (SendGrid ダッシュボードを開く)」をクリックします。
  9. SendGrid サイトで、左のサイドバーにある「Settings (設定)」 > 「API Keys (API キー)」をクリックします。
  10. 「Create API Key (API キーの作成)」をクリックします。
  11. キーの名前を入力し、「Full Access (フル・アクセス権)」を選択してから、「Create and View (作成して表示)」をクリックします。
  12. この API キーをクリップボードにコピーします。

    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

SendGrid を使用して e-メールを送信する

  1. IBM Cloud コンソールに戻り、作成したアプリケーションを表示します。「View toolchain (ツールチェーンを表示)」までスクロールダウンしてクリックします。
  2. SendGrid に対する(任意のバージョンの) 依存関係を package.json に追加します (サンプル・アプリケーションでは、この作業をすでに完了しています)。
    "dependencies": {
        "express": "4.12.x",
        "cfenv": "1.0.x",
        "body-parser": "*",
        "node-uuid": "*",
        "sendgrid": "*"
    	},
  3. 受け取った API キーを使用して SendGrid オブジェクトを作成し、そのオブジェクトを使用して e-メールを送信します。ハンドラーではなく app.js のメイン・コードから一度 e-メールを送信して、問題なく送信されることを確認します。

    注: サンプル・アプリケーションを複製して手順を開始する場合は、app.js 内の行 41 に設定されているAPI キーを変更すればよいだけです。

    // Use SendGrid to send emails as a second token.
    var sendgrid = require("sendgrid")("API_KEY goes here ");
      
    // Send an email
    var email = new sendgrid.Email();
    
    email.addTo("unmonitored@my.app");
    email.setFrom("qbzzt1@gmail.com");
    email.setSubject("");
    email.setHtml("<H2>Big test</H2>");
    
    sendgrid.send(email);

1、2 分で e-メールを受信するはずです。e-メールが届かない場合は、迷惑メール・フォルダーに振り分けられていないかどうか確認してください。このような e-メールメールを迷惑メールとみなすフィルターは、数多くあるからです。

1 つにまとめる: 認証ワークフロー

ユーザーは index.html のさまざまなフォームに入力することによって、アカウントを登録したり、ログインしたりします。ユーザーがフォームに入力する情報は、POST リクエストでサーバーに送信されます。このセクションの残りでは、ログイン・フローについて説明します。登録フローは、ログイン・フローにとてもよく似ています。

ユーザーがログインを試行する

まず始めに、以下のコードで、e-メール・アドレスとパスワードのペアが有効であるかどうかをチェックします。こうしたユーザーの情報は、ハッシュ・テーブルに保管され、ユーザーの e-メール・アドレスがキーになります。該当するユーザーが存在しないか、パスワードが誤っている場合、アプリケーションはエラー・メッセージをユーザーに返します。ユーザーが存在しない場合でも、パスワードが誤っている場合でも、同じエラー・メッセージを返します。いずれの場合にも同じエラー・メッセージを返すのは、e-メール・アドレスが有効なユーザーのものであるかどうかが意図せず明らかにされるのを回避するためです。

var user = users[req.body.email];
  
if (!user) {
  	res.send("Bad user name or password.");
  	return ;
}
  
if (user.password !== req.body.passwd) {
  	// Same response, not to disclose user identities
  	res.send("Bad user name or password.");
  	return ;  	
}

このようにユーザーのレコードをハッシュ・テーブルに保管するのは簡単なので、今回のようなサンプル・プログラムには理想的な方法です。しかし本番環境では、アプリケーションを再起動するたびにすべてのユーザーが削除されたり、アプリケーションのインスタンスごとにユーザー・リストが異なったりするのは賢明なことではありません。本番環境ではCloudant DB を使用してください。

ユーザー名とパスワードの組み合わせが一致した場合、ユーザーのステータスがまだログインの保留中であるかどうかをチェックします。保留中であれば、これもエラー条件となります。その場合は、ユーザーに送信するメッセージに、確認用 e-メールを再送するためのリンクを追加することができます。

// User exists, but email not confirmed yet
if (user.status === "pending") {
  	res.send("Account not confirmed yet.");
  	return ;
}

すべてを調べ終えたら、次のステップではユーザーに送信するリクエストを作成します。

// Create request to confirm the logon
var id = putRequest(req.body.email);

putRequest 関数は、前述のランダムな識別子を生成することから始めます。

// Register a pending request for this email
var putRequest = function(email) {
   // Get the random identifier for this request
   var id = uuid.v4();

次に、そのランダムな識別子をインデックスとする pendingReqs ハッシュ・テーブルにリクエストを追加します。このリクエストには、ログインを要求しているユーザーの身元を示す情報を含めます。さらにこの関数は、タイム・スタンプを取得して、放棄された古いリクエストをクリーンアップできるようにします。先ほど e-メール・アドレスとパスワードのペアに関連して説明したように、本番アプリケーションでは pendingReqs ハッシュ・テーブルはデータベースで置き換える必要があります。

   pendingReqs[id] = {
   	email: email,
   	time: new Date()
   };

リクエストが正規のものであることをユーザーが確認できるように、putRequest を呼び出した関数はユーザーにランダムな識別子をユーザーに通知する必要があります。そのため putRequest は、生成したランダムな識別子を呼び出し側に返します。

   return id;
};

アプリケーションが e-メールでトークンを送信する

ハンドラーは putRequest を呼び出した後で、ユーザーに e-メールを送信する関数を呼び出すことで、ユーザーに応答します。

  // E-mail the account confirmation request
  sendLoginRequest(req.body.email, id);
  
  res.send("Thank you for your request. Please click the link you will receive by email to " +
    req.body.email + " shortly.");	
});

sendLoginRequest 関数は、HTML メッセージを作成してユーザーに送信します。メッセージ・テキストには 2 つの変数があります。1 つ目の appEnv.url 変数は、アプリケーションにアクセスするために使用する URL です。この変数が必要になるのは、e-メールでは Web ブラウザーで最後にアクセスした URL のコンテキストを持たないことから、相対リンクが機能しないためです。2 つ目の変数は、承認する対象となるリクエストの ID です。このすべてを組み合わせると、メッセージ内の URL は <appEnv.url>/confirm/<id> となります。この URL で、e-メール・アドレスが正しいかどうかの確認を取ることになります。

// Send a link. Standard practice is to send a code, but using a link
// is easier and more secure.
var sendLoginRequest = function(email, id) {
  
  // Send an email
  var msg = new sendgrid.Email();

  msg.addTo(email);
  msg.setFrom("notMonitored@nowhere.at.all");
  msg.setSubject("Application log in");
  msg.setHtml("<H2>Welcome to the application</H2>" +
  	'<a href="' + appEnv.url + '/confirm/' + id + '">' +
  	'Click here to log in</a>.');
  	
  sendgrid.send(msg);

};

この手法は標準的なものではないことに注意してください。標準的な手法では、短い (4 ~ 6 文字の) コードを e-メールに記載して、ユーザーがそのコードを Web フォームに入力するという形を取ります。私がこの標準的でない手法を選んだ理由は、この方が簡単で、より多くのキーを使用できるからです。その一方で、e-メールにアクセスできる誰もがアプリケーションに侵入できてしまうという欠点があります。この問題を解決する方法については、このチュートリアルの終わり近くのセクション「e-メール・スニファーから保護する」で説明します。

ユーザーが e-メールで通知されたトークンを使用してログインする

e-メールによって、ユーザーは confirm/<id> というパスの URL にリダイレクトされます。この呼び出しは、以下のコードで処理します。「:id」という文字列は、ここに来る値はパスを構成する有効な要素であること、そしてその値は req.params.id から入手できることを意味しています。

// A confirmation (of an attempt to register or log in)
app.get("/confirm/:id", function(req, res) {

最初に、確認対象のリクエストを変数に取得しておき、そのリクエストは削除する必要があります。該当するリクエストがない場合は、ユーザーにエラー・メッセージを返します。

	var userRequest = pendingReqs[req.params.id];
	delete pendingReqs[req.params.id];

    // Meaning there is no user request that matches the ID.
    if (!userRequest) {
    	res.send("Request never existed or has already timed out.");
    	return ;   // Nothing to return, but this exits the function    	
    }

リクエストに相当するオブジェクトにはいずれも、ユーザーを識別する e-メール・アドレスが格納されます。この e-メール・アドレスを使用すれば、ユーザー情報を取得することができます。

	var userData = users[userRequest.email];

ユーザーのステータスがログインの保留中になっている場合、それは、アカウントの確認リクエストであることを意味します。

if (userData.status === "pending") {
    userData.status = "confirmed";
        res.send("Thank you " + userRequest.email + " for confirming your account.");    	
		return ;
}

ユーザー・アカウントが確認済みであれば、2 つ目の認証要素の確認リクエストであることを意味します。

	// In a real application, this is where we'd set up the session and redirect
	// the browser to the application's start page.
	res.send("Welcome " + userRequest.email);
});

実際のアプリケーションでは、ここでセッションを作成し、セッション・クッキーをブラウザーに取り込むことになります。この処理を Node.js で行う方法については、「Use LDAP for authentication and authorization in your Node.js IBM Cloud application」のステップ 3 を参照してください。

注: この説明は多少単純化されています。SendGrid は、送信する e-メールを受け取ると、e-メール内のリンクを自身のサイトへのリンクで置き換えます。そしてこのサイトでは、ブラウザーを本来の URL にリダイレクトします。これにより、SendGrid は e-メールを介してアクセスされたリンクの統計を提供できるようになります。以下に示す図の場合、SendGrid が木曜日に 13 件のメッセージを送信し、そのうち 9 件のメッセージが開封されて、7 つの異なる URL へのリンクがクリックされたことがわかります。

統計の概要を示す画面のスクリーンショット
統計の概要を示す画面のスクリーンショット

リスク分析

ユーザーがログインするたびに 2 要素認証を要求することは可能ですが、ユーザーの使い勝手に反していると見なされます。ユーザビリティーの観点でそれよりも遥かに望ましいのは、アプリケーションでログイン試行が不正なものである可能性を評価し、その情報を基に 2 つ目の認証要素を要求するのが妥当かどうかを判断することです。

重要な点として、その判断は、偽造するのが困難な要素に基づいて行わなければなりません。例えば、HTTP ヘッダー内でブラウザーのタイプとバージョンを偽造するのはとても簡単ですが、IP アドレスやアクセス時刻を偽造するのは遥かに困難です (IP アドレスを偽造するには、レスポンスを自分のところにルーティングする必要が出てきます)。

クライアントの IP アドレス

ブラウザーは IBM Cloud に直接アクセスするのではなく、プロキシーの役割を果たす IBM WebSphere DataPower アプライアンスを介して IBM Cloud にアクセスします。アプリケーションが (プロキシーの IP アドレスではなく) クライアントの IP アドレスを取得するには、プロキシーを信頼しなければなりません。この設定をするには、以下のように app.set を使用します。

// Necessary to know the IP of the browser
app.set("trust proxy", true);

リクエストの送信元 IP アドレスは、req.ip から入手できます。以下に、その使用例を示します。

// Show the user's IP address
app.get("/ip.html", function(req, res) {
	res.send("<H2>Your IP address is</H2>" + req.ip);
});

結果を表示するには、http://two-factor-auth.mybluemix.net/ip.html を参照します。

IP アドレスを解釈する

IP アドレスを使用するには、それを解釈する必要があります。IP アドレスのデータベースとして簡単に利用できるのが http://ipinfo.io です。http://ipinfo.io/<IP アドレス> にアクセスすれば、完全な情報を入手することができ、http://ipinfo.io/<IP アドレス>/<フィールド> にアクセスすれば、特定のフィールド (国など) の情報を得ることができます。

アプリケーションから HTTP リクエストを送信してレスポンスを受け取る方法については、「IBM Cloud と MEAN スタックを使用して自動投稿 Facebook アプリケーションを作成する: パート 3 サーバーにユーザーの代理を務めさせる」のステップ 3 を参照してください。このアプリケーションでは、以下のコードを使用しています。

// The library to issue HTTP requests
var http = require("http");

Node.js はシングル・スレッドで動作し、リクエストが ipinfo.io に到達してレスポンスが返されてからでないと結果を利用できないので、結果が利用可能になった時点で呼び出される next() 関数を使用します。

// Interpret an IP address and then call the next function with the data
var interpretIP = function(ip, next) {

http.get 関数は URL とコールバック関数を引数として受け取った後、その URL のコンテンツをサーバーから取得します。

	http.get("http://ipinfo.io/" + ip,

HTTP ヘッダーを取得すると同時に、以下に示すコールバック関数が呼び出されます。ただし、必要となるデータはレスポンスの HTTP 本体に含まれているため、HTTP 本体のデータを受信するまで待たなければなりません。

		function(res) {

以下のコードが、データ・イベントのハンドラーを登録します。レスポンスは非常に短いものなので、1 つのチャンクで返ってくると想定することができます。複数のチャンクで返されるとしたら、終了イベントを受け取るまでチャンクを連結していくことになります。

			res.on('data', function(body) {

ブラウザーからアクセスできない場合に役立つように、ipinfo.io は解析しやすい JSON オブジェクトにデータを格納して提供します。

				var data = JSON.parse(body);
				next(data);
			});
		}
	);

};

// Show the user's IP address
app.get("/ip.html", function(req, res) {
	interpretIP(req.ip, function(ipData) {
		var resHtml = "";
		resHtml += "<html><head><title>IP interpretation</title></head>";
		resHtml += "<body><H2>Intepretation of " + req.ip + "</H2>";

結果を表示するには、すべてのデータ・フィールドをテーブルに取り込みます。

		resHtml += "<table><tr><th>Field</th><th>Data</th></tr>";
		for (var attr in ipData) {
			resHtml += "<tr><td>" + attr + "</td><td>" + ipData[attr] + "</td></tr>";
		}
		resHtml += "</table></body></html>";
		res.send(resHtml);
	});
});

自身の IP アドレスに対する結果を表示するには、https://two-factor-auth.mybluemix.net/ip.html を参照します。

時刻と曜日

時刻と曜日を取得するのは非常に簡単で、新しい Data オブジェクトを作成するだけで取得することができます。曜日は日曜日を表す 0 から始まり、土曜日を表す 6 で終わります。時刻は 0 から 23 になります。ただし、タイム・ゾーンはロンドンのタイム・ゾーンである UTC (Coordinated Universal Time: 協定世界時) に設定されます (サマータイムは使用しません)。従って、例えば米国の CST (Central Standard Time: 中部標準時) の場合は 6 時間差し引く必要があります。

一般に、ログイン時刻がビジネス・アワー、平日夜、週末のどれに分類されるかで、リスクの程度は変わってきます。以下に、時刻と曜日で分類する処理を行うコードを示します。

// Classify time as "day", "after hours", or "weekend". The time zone
// is the difference in hours between your time and GMT.
var classifyTime = function(timeZone) {
	var now = new Date();
	
	// Hour of the week, zero at a minute after midnight, on Sunday
	var hour = now.getDay()*24 + now.getHours() + timeZone;

	// If the hour is out of bounds because of the time zone, return it
	// to the 0 - (7*24-1) range.
	if (hour < 0)
		hour += 7*24;
	
	if (hour >= 7*24)
		hour -= 7*24;
		
	// The weekend lasts until 8am on Monday (day 1) and starts at 5pm on
	// Friday (day 5)
	if (hour < 24+8 || hour >= 5*24+17)
		return "weekend";
		
	// Work hours are 8am to 5pm
	if (hour % 24 >= 8 && hour % 24 < 17)
		return "day";
	
	// If we get here, it is after hours during the work week
	return "after hours";
};



// Show the current time and day of the week
app.get("/now.html", /* @callback */ function(req, res) {
	var now = new Date();
	
	var resHtml = "";
	resHtml += "<html><head><title>Present Time</title></head>";
	resHtml += "<body><H2>Present Time</H2>";
	resHtml += "Day of the week (UTC): " + now.getDay() + "<br />";
	resHtml += "Hour (UTC): " + now.getHours() + "<br />";
	resHtml += "Time classification CST:" + classifyTime(-6) + "<br />";
	resHtml += "</body></html>";
	
	res.send(resHtml);
});

CST の場合の現在の結果を確認するには、このリンクをクリックしてください。

リスク分析の例を表示する

サンプル・アプリケーションでリスク分析を使用する際に問題となるのは、パラメーターを確認するのが煩わしい作業になりがちなことです。あちこちページを移動したり、結果を待ったりすることなく、複数の国の複数の時刻の結果を確認できるのが理想です。このことから、リスク・ページでは、時刻の分類と IP アドレスを自分の手で指定するようになっています。

リスク・ページのスクリーンショット
リスク・ページのスクリーンショット

リスク分析のポリシー

2 つのパラメーター (IP アドレスと、時刻の分類) を使用して、取るべきアクションを決定するポリシーを設定することができます。例えば、「米国からのログインはビジネス・アワーに限られるものと想定し、中国からのログインは週末を除く任意の時刻に行われるものと想定し (中国のビジネス・アワーは、米国とはかなり異なるため)、米国と中国以外のどの場所からもユーザーがログインすることはないと想定する」と決めることができます。

このようなポリシーをコードで実装するのは簡単です。

// Decide the risk level
app.post("/risk", function(req, res) {
	interpretIP(req.body.ip, function(ipData) {
		var country = ipData.country;		
		var time = req.body.time;
		var resHtml = "";
		var safe = false;
		
		resHtml += "<html><head>";
		resHtml += '<link rel="stylesheet"  ' +
			'href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">';
   		resHtml += '<link rel="stylesheet" ' +
			'href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/' +
			'css/bootstrap-theme.min.css">';
   		resHtml += '<script ' +  	
			'src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js">' +
			'</script>';
   		resHtml += "</head><body>";
		
		resHtml += "<H2>Risk Level:</H2>";
		resHtml += "Country: " + country + "<br />";
		resHtml += "Time classification: " + time + "<br />";
		
		// Only expect log in during work hours from the US
		if (country === "US" && time === "day")
			safe = true;
			
		// Log ons from China are expected at any time except weekends
		if (country === "CN" && time !== "weekend")
			safe = true;		
		
		if (safe)
			resHtml += '<span class="label label-pill label-success">' +
				'User name and password</span>';
		else
			resHtml += '<span class="label label-pill label-danger">' +
				'Two factor authentication</span>';
		
		resHtml += "</body></html>"
		
		res.send(resHtml);
	});	
});

このポリシーを適用するには、ログイン・ハンドラーで safe の値を計算し、次のステップに関する if 文を追加するだけで済みます。

  if (safe) {
	createSession(user, res);
  } else {
 
	// Create request to confirm the logon
	var id = putRequest(req.body.email);
  
	// E-mail the account confirmation request
	sendLoginRequest(req.body.email, id);
  
	res.send("Thank you for your request. Please click the link you will receive by email to " +
	req.body.email + " shortly.");	
  }

機能強化

以下で説明するいくつかの機能強化によって、このプログラムを改善し、より安全で安定したものにすることができます。

e-メール・スニファーから保護する

ユーザーの e-メールを入手可能なアタッカーは、確認用リンクを使ってアプリケーションに侵入することができるため、前述したセキュリティー上の問題が生じます。この問題を解決する 1 つの方法は、ブラウザーのクッキーを使用することです。それにはまず、cookie-parser を package.json に追加し、これを app.js で使用します。

// Use cookie-parser to read the cookies
var cookieParser = require("cookie-parser");
app.use(cookieParser());

次に、ログイン・ハンドラーを以下のように変更します。

  1. 2 つ目のランダムな識別子を生成する
  2. 生成したランダムな識別子をブラウザーのクッキーに含める
  3. このランダムな識別子を、ユーザーの e-メール・アドレスと併せて保留中のリクエストの構造に含める
  // For preventing somebody who gets the email from logging on:
  var id2 = uuid.v4();    // 1  
  pendingReqs[id].cookie = id2;   // 2
  res.setHeader("Set-Cookie", ['secValue=' + id2]);  // 3

さらに、確認用リンク・ハンドラーを変更し、ログイン・ハンドラーで作成されたクッキーの値を取得して、その値を保留中のリクエストに含まれる値と比較するようにします。2 つの値が同じでない場合、ログインは失敗します。

    // For preventing somebody who gets the email from logging on:
	if (req.cookies["secValue"] !== userRequest.cookie) {
		res.send("Wrong browser");
		return ;
	}

この仕組みが機能することを確認するには、ある端末からログインし、同じ端末の別のブラウザーか、別の端末から確認用 e-メールに記載されているリンクをクリックします。これによるログインは失敗するはずです。

クリーンアップする

現時点では、ユーザーが何らかの理由でリンクをクリックしなければ、保留中のリクエストはアクティブなままとなり、メモリーが無駄に使われて、アクティブなリクエストを調べるのに要する時間が長くなるだけです。

この問題を解決するには、setInterval 関数を使用して古いリクエストを削除します。JavaScript は時間をミリ秒で計測するため、時間を 5 分に設定するには 5 × 60,000 の乗算を行う必要があります。

// Delete old pending requests
var maxAge = 5*60*1000; // Delete requests older than five minutes

// Run this function every maxAge
setInterval(function() {
	var now = new Date();
	for (var id in pendingReqs) {   // For every pending request
		if (now - pendingReqs[id].time > maxAge)   // If it is old
			delete pendingReqs[id];   // Delete it
	}

クリーンアップ関数は 5 分間隔で実行されるため、保留中のリクエストは作成されてから 5 分から 10 分の間に削除されます。

}, maxAge);
デバッグする

クリーンアップ関数をデバッグするには、pendingReqs の値を知っていると役に立ちます。以下の呼び出しは、ブラウザーから実行することができます (注: アプリケーションを本番環境にデプロイする前に、必ずこの関数を削除してください。この関数によって明らかになる 2 つの値は、アプリケーションに侵入するために悪用される可能性があります)。

app.get("/pend", /* @callback */ function(req, res) {
	res.send(JSON.stringify(pendingReqs));
});

上記の /* @callback */ コメントが関数を変えることはありません。このコメントを挿入した目的は、エディターに対し、「req がどこにも使用されていないとしても、これはコールバック関数なので必要であり、この関数が受け取る引数は開発者が決めるものではない」ということを知らせるためにあります。こうすることによって、警告メッセージが取り除かれて、潜在する問題にフォーカスしやすくなります。

引数が使用されないことを通知するメッセージのスクリーンショット
引数が使用されないことを通知するメッセージのスクリーンショット

HTTPS を要件とする

ユーザーが平文でパスワードを送信できたり、クッキーを使って平文で応答できたりするのは、望ましい状況ではありません。以下の呼び出しを追加すると、HTTP ユーザーが HTTPS にリダイレクトされます。この呼び出しは、アプリケーションの他のあらゆるハンドラー宣言の前に追加してください。

//Handle all (any method) and any path (slash followed by any string)
app.all('/*', function(req, res, next) {

アプリケーションでは常に HTTP が使われます。それは、SSL トンネルが IBM WebSphere DataPower で終端されるためです。ただし、元のプロトコルはヘッダー内の x-forwarded-proto でわかります。

	// If the forwarded protocol isn't HTTPS, send a redirection
	if (req.headers["x-forwarded-proto"] !== "https")
		res.redirect("https://" + req.headers.host + req.path);
	else

(app.all だけではなく、app.<HTTP メソッド> 関数の任意の部分に対する) コールバックの 3 番目の引数は、このコールバックがリクエストを処理しない場合に呼び出す関数です。リクエストが既に HTTPS である場合、リダイレクトする必要はないので、通常の処理を再開させます。

		next();
});

e-メールの代わりに SMS を使用する

インターネットが構築されたとき、セキュリティーは考慮されていませんでした。その一方、電話網では当初からセキュリティーが考慮されていました。従って、e-メールではなく SMS でトークンを送信した方がより安全です。そのようにするには、以下の手順に従います。

  1. 登録フォームに携帯電話番号を入力するフィールドを追加します。
  2. 長いトークンではなく、入力しやすい短いトークンを作成します。例: uuid.v4().substring(0,5)
  3. IBM Cloud の Twilio サービスを利用して、SMS でトークンを送信します。
  4. ユーザーに確認用リンクをクリックするよう指示するのではなく、トークンを入力できるフォームにユーザーをリダイレクトします。

ユーザー・プロファイル

すべてのユーザーを同一に扱うのではなく、プロファイル情報 (ユーザーのロールや、ユーザーが普段、アプリケーションを使用する場所など) を保管して、その情報をリスク分析に含めることが可能です。例えば、John Doe は普段、米国からログインします。彼が中国からログインした場合、そのログインは疑わしいかもしれないため、2 つ目の認証要素を要求します。一方、中国の従業員である Chang Xiu が中国からログインしても、そのログインは疑われません。逆に、Joe と Chang がどちらも米国からログインした場合、または Chang が米国中部標準時の正午 (中国では午前 2 時) にログインした場合には、不正が疑われます。

まとめ

これで皆さんは、2 要素認証を IBM Cloud Node.js アプリケーションに実装できるはずです。また、リスク分析を使用して、2 要素認証を採用した方が妥当な、リスクの高いケースを識別することもできるはずです。


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=セキュリティ, Cloud computing
ArticleID=1031116
ArticleTitle=IBM Cloud の Node.js アプリケーション用に多要素認証を構成する
publish-date=05102018