为 Bluemix Node.js 应用程序配置多因素身份验证

使用风险分析和增强实现更高的安全性

Comments

在本教程中,您将学习如何为 Bluemix® Node.js 应用程序配置双因素身份验证。将一个单独的标记发送到用户的电子邮件地址,这可以使得伪装该用户变得困难得多。潜在的攻击者不仅需要盗窃密码,还需要攻破邮件服务器来获得该标记。

此外,本教程还会介绍一些风险分析技术。通过分析风险,应用程序可以确定登录尝试何时存在风险。仅在存在风险的情况下,该应用程序才会使用第二个身份验证因素。

构建您的应用程序需要做的准备工作

运行应用程序获取代码

在本教程中,您将学习如何使用通过电子邮件传送的随机字符串作为第二个身份验证因素。我还将讨论一些风险分析方法。

为什么对存在风险的登录使用双因素身份验证?

密码有两种主要的失败模式:

  • 非故意泄露:攻击者发现了授权用户的密码。
  • 密码共享:授权用户将密码提供给其他人,这样做通常是为了使这个人能够使用授权用户的一些权限。

这两种失败模式都可以通过以下方式进行修复:要求授权用户证明他们能够访问其电子邮件,以此作为第二个身份验证因素。可以每次都要求提供第二因素,或者仅在某个事务似乎面临危险和需要额外的安全保护时提供。

身份验证

首先,您需要一个使用第二因素身份验证的身份验证工作流。为此,可以发送一些包含长随机字符串的电子邮件。只有合法的用户或其他能够访问该用户的电子邮件的人才能够访问这些值。

创建随机字符串

创建随机字符串的最简单方法是下载和使用 node-uuid 包,该包可以创建 RFC 4122 标识符。每个标识符有 60 个随机位,这对所有实际用途都足够了。这些标识符被编码为文本。

  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");

发出消息

要发出电子邮件消息,可以使用 Bluemix 中的 SendGrid 服务。首先,需要创建并绑定该服务,然后获得一个 API 密钥:

  1. 登录到 Bluemix,并在仪表板中单击您的应用程序。
  2. 单击 ADD A SERVICE OR API 磁贴。
  3. 转到目录的 Web and Applications 部分并单击 SendGrid 服务。
  4. 选择一个包并单击 CREATE
  5. 在获得提示时,单击 RESTAGE
  6. 单击新建服务的时间,然后单击 OPEN SENDGRID DASHBOARD
  7. 单击 SETTINGS > API Keys
  8. 单击 Create API Key > General API Key
  9. 为该密钥命名(应用程序的名称是一个合理的选择),并为它提供访问 Mail Send 的完整权限。它不需要其他任何访问权。该屏幕截图显示了 Add New API Key
    该屏幕截图显示了 Add New API Key
  10. 单击 Save
  11. 将 API 密钥复制到文本编辑器,比如 Notepad。

使用 SendGrid 发送一封电子邮件

  1. 在 SendGrid(的任何级别)上添加一个对 package.json 的依赖关系:
    	"dependencies": {
    		"express": "4.12.x",
    		"cfenv": "1.0.x",
    		"body-parser": "*",
    		"node-uuid": "*",
    		"sendgrid": "*"
    	},
  2. 使用您收到的 API 密钥创建一个 SendGrid 对象,并使用它发送电子邮件。从 app.js 的主代码(而不是处理函数)发送电子邮件,以确认所有功能都正常。
    // 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);

您应该在一两分钟内收到该电子邮件。如果未收到该邮件,一定要查看垃圾邮件文件夹。许多过滤器会将与此类似的电子邮件视为垃圾邮件。

将各部分功能结合起来:身份验证工作流

用户通过填写 index.html 上的不同表单来注册和登录。他们的信息会在一个 POST 请求中发送给服务器。本节的剩余部分将介绍登录流;注册流与此非常类似。

用户尝试登录

首先,该代码检查电子邮件和密码对是否有效。用户的记录存储在一个哈希表中,其中的键是用户的电子邮件地址。如果该用户不存在,或者如果密码错误,那么该应用程序会向用户返回一条错误消息。无论用户不存在还是密码错误,都会返回同一条消息。这避免了意外地表明一个电子邮件地址是否属于一个有效的用户。

  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 ;  	
  }

请注意,将用户的记录存储在像这样的哈希表中很简单,因此这是本文中的示例程序的理想选择。但是,在生产环境中重新启动该应用程序时,会删除所有用户,或者,让应用程序的不同实例拥有不同的用户列表不是一个好想法。在生产中,应该使用 MongoDB,这已在 “使用 Bluemix 和 MEAN 堆栈构建自助发表 Facebook 信息的应用程序,第 2 部分” 的第 2-4 步中进行了解释。

如果用户和密码匹配,则检查用户是否仍需要审核。如果需要审核,这也是一个错误条件。您可以添加一条链接来向用户重新发送确认电子邮件。

  // 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 哈希表中。该请求包含请求用户的身份。它还会获得一个时间戳,以便允许您清除被丢弃的旧请求。前面在介绍电子邮件/密码对时已经提到过,在生产应用程序中,pendingReqs 哈希表应是一个数据库。

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

调用 putRequest 的函数需要向用户告知该 ID,是该用户可以验证请求是否合法。因此,putRequest 将该 ID 返回给调用方。

   return id;
};

该应用程序通过电子邮件发送一个标记

在执行 putRequest 后,处理函数会调用一个函数来向用户发送一封电子邮件并响应用户。

  // 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 消息并将该消息发送给用户。消息文本中有两个变量。第一个变量是 appEnv.url,用于访问该应用程序的 URL。该变量是必需的,因为在没有 Web 浏览器上次使用的 URL 的上下文的电子邮件中,相对链接不起作用。第二个变量是要批准的请求的 ID。将它们结合起来,消息中的 URL 为 <appEnv.url>/confirm/<id>。这是您确认电子邮件地址是否正确的 URL。

// 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 个字符)代码供用户键入到一个 Web 表单中。我更喜欢这个方法,因为它更容易,而且允许使用更多的密钥。该方法的缺点是,任何能够访问该电子邮件的人都能够攻破该应用程序。在本教程末尾处的 “防御电子邮件嗅探器” 部分中,我将讨论如何解决这个问题。

用户使用通过电子邮件发来的标记进行登录

电子邮件将用户定向到 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    	
    }

每个请求的对象包含标识该用户的电子邮件地址。这使您能够检索用户信息。

	var userData = users[userRequest.email];

如果用户仍然处于挂起状态,则意味着这是对该帐户的确认。

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

如果用户帐户已确认,那么这是对第二个身份验证因素的确认。

	// 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);
});

在实际的应用程序中,这是您创建会话并在浏览器中放入会话 cookie 的地方。要了解如何在 Node.js 上实现此操作,请参阅 “使用 LDAP 实现您的 Node.js Bluemix 应用程序中的身份验证和授权” 中的第 3 步。

备注:此帐户已被稍微简化。在 SendGrid 收到要发送的电子邮件后,它会将这些链接替换为自己的站点的链接,浏览器会在其中重定向到原始 URL。这使得 SendGrid 能够提供通过电子邮件访问的链接的统计数据。在下面的图示中,您会看到,在星期四,SendGrid 发送了 13 条消息,获得了对 7 个唯一的 URL 的 9 次单击。

该屏幕截图显示了统计数据概述
该屏幕截图显示了统计数据概述

风险分析

可以在每次用户登录时都要求进行双因素身份验证。但是,这被认为对用户不友好。从适用性角度讲,如果应用程序评估登录尝试违法的可能性,并使用该信息来确定要求使用第二个因素是否能够保证安全性,这样做会好得多。

重要的是此决策基于难以伪造的因素。例如,浏览器的类型和版本很容易在 HTTP 标头中进行伪造。但是,IP 地址(因为您需要路由到您的响应)或访问时间很难伪造。

客户端 IP 地址

浏览器不会直接访问 Bluemix,而是使用 IBM WebSphere DataPower Appliances 作为代理来访问它。要获得客户端的 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 address> 来获得完整的信息,或者访问 http://ipinfo.io/<ip address>/<field> 获得某个特定字段,比如国家。

要了解如何发送 HTTP 请求并从应用程序收到响应,请参阅 “使用 Bluemix 和 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 主体中提供的。因此,您需要等待收到数据。

		function(res) {

此代码为一个数据事件注册了一个处理函数。因为响应非常短,所以可以假设它包含在单个数据块中。如果有多个数据块,可以将它们串联起来,直到得到最终的事件。

			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

一周的时间和日期

获得一周的时间和日期非常简单。只需创建一个新的 Date 对象。一周中的日期从 0(星期日)开始,到 6 结束(表示星期六);小时为 0-23。但是,时区为 UTC,也就是伦敦时区(没有夏令时时间)。举例而言,这意味着对于美国的 CST,您需要减去 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 地址。

风险页面的屏幕截图
风险页面的屏幕截图

风险分析策略

通过使用两个参数(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.");	
  }

增强

可以通过多种增强来改进此程序,让它变得更安全、更稳定。

防御电子邮件嗅探器

前面已经提到,有一个安全问题,因为任何可以获得用户的电子邮件的攻击者都可以使用该确认链接攻破该应用程序。一个解决方案使用了浏览器 cookie。首先,将 cookie-parser 添加到 package.json 中并在 app.js 中使用它:

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

然后,将登录处理函数修改为:

  1. 生成第二个随机 ID。
  2. 将该随机 ID 放在浏览器 cookie 中。
  3. 将同一个随机 ID 和用户的电子邮件地址一起放在挂起的请求结构中。
  // 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

另外,修改确认链接处理函数,以便检索在登录处理函数中创建的 cookie 的值,并将该值与挂起的请求中的值进行比较。如果这些值不同,则登录失败。

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

要验证此方法是否有效,可以从某个设备进行登录,然后从另一个设备或从同一个设备上的另一个浏览器单击确认电子邮件。该操作应该会失败。

清理

现在,如果用户出于某种原因而没有单击该链接,挂起请求仍会保持有效,这会占用内存并增加查找有效请求所花的时间。

要解决这个问题,可以使用 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 的值可能很有用。此调用使得您可从浏览器使用该值。(备注:请记得在应用程序部署到生产中之前删除此函数。它公开了两个可用于攻破该应用程序的值。)

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

上面的 /* @callback */ 注释没有更改该函数。它的目的是告诉编辑器,即使未在任何地方使用 req,也需要提供它,因为它是回调函数,而且您不确定它获得了哪些参数。这样做会消除该警告,使得将精力放在潜在问题上变得更容易。

该屏幕截图显示了表明参数未使用的消息
该屏幕截图显示了表明参数未使用的消息

需要使用 HTTPS

允许用户以明文形式提供密码并使用 cookie 作为响应,这不是一个好想法。添加此调用来将 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.<HTTP method> 函数的任何部分,而不只是 app.all)是在此回调未处理该请求时要调用的函数。如果请求已是 HTTPS,则无需进行重定向,因此可以恢复正常的处理。

		next();
});

SMS 代替电子邮件

互联网的建立没有考虑安全性。而电话网络考虑了完全性。因此,通过 SMS 代替电子邮件来发送标记会更加安全。为此:

  1. 向注册表单添加一个手机号码字段。
  2. 不要创建长标记,创建一个人们可以输入的短标记。例如:uuid.v4().substring(0,5)
  3. 使用 Bluemix 中的 Twilio 服务通过 SMS 发送标记。
  4. 无需告诉用户单击确认链接,可以将它们重定向到一个表单,可以在该表单中键入此标记。

用户个人资料

无需同等地对待所有用户,可以存储一些个人资料信息,比如用户的职位或通常所待的位置,并将该信息包含在风险分析中。例如,John Doe 通常从美国登录。当他从中国登录时,登录行为可能和可疑,并且需要使用第二个因素。但是当中国员工 Chang Xiu 从中国登录时,此行为则不可疑。反之亦然,当 Joe 和 Chang 都从美国登录时,或者当 Chang 登录时,如果时间为中央时间的中午,那么这个时间对他而言可能是凌晨 2 点。

结束语

您现在应能够在您的 Bluemix Node.js 应用程序中实现双因素身份验证。还应能够使用风险分析来识别存在风险的情况,在这些情况下部署双因素身份验证更有意义。


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Open source, Cloud computing
ArticleID=1027956
ArticleTitle=为 Bluemix Node.js 应用程序配置多因素身份验证
publish-date=03022016