在本教程中,您将学习如何为IBM Cloud Node.js应用程序配置双重身份验证。 向用户的电子邮件地址发送单独的令牌会使伪装成该用户变得更加困难。 潜在的攻击者不仅必须窃取密码,而且还需要侵入邮件服务器以获取令牌。
此外,本教程还教您一些风险分析技术。 通过分析风险,应用程序可以确定登录尝试何时具有风险。 仅在这种情况下,才需要进行身份验证的第二个因素。
构建应用程序所需的条件
- IBM Cloud帐户 。 ( 免费试用IBM Cloud 。)
- 一个SendGrid帐户。
- 了解HTML和JavaScript。
- 了解MEAN应用程序堆栈(至少Node.js和Express)。
“在本教程中,您将学习如何使用电子邮件发送的随机字符串作为第二个验证因素。 我还将讨论几种风险分析方法。 ”
为什么要对危险的登录使用两因素身份验证?
密码有两种主要的失败模式:
- 意外泄露:攻击者发现授权用户的密码。
- 密码共享:授权用户将密码提供给其他人,通常是为了使该人能够使用某些授权用户的权限。
可以通过要求授权用户证明他们可以访问其电子邮件作为第二个身份验证因素来修复这两种故障模式。 每次都可能需要第二个因素,或者仅在交易看起来很危险并且需要额外的安全性时才需要第二个因素。
开始吧
请按照以下步骤在IBM Cloud上创建一个新的Node.js应用程序:
- 登录到IBM Cloud。 如果您没有,请创建一个免费帐户。
- 单击菜单图标,然后选择Cloud Foundry Apps。 这就是您使用IBM Cloud中的平台即服务(PaaS)产品的方式。
- 单击创建Cloud Foundry应用程序。
- 单击SDK forNode.js。
- 输入一个应用程序名称(例如,
mfa-app
)和一个主机名(我选择了mfa-app
,因此您将不得不选择其他名称)。 然后点击创建 。 - 等待应用程序启动。
配置Web IDE
我们可以在自己的系统上开发应用程序,但是我更喜欢基于Web的IDE。
- 当应用程序页面打开时,单击左侧栏上的“ 概述 ”,向下滚动到“持续交付”标题,然后单击“ 启用”。
- 向下滚动并指定存储库类型Clone和源存储库URL
https://git.ng.bluemix.net/qbzzt1/mfa-app
, - 单击创建。
- 创建工具链后,单击Eclipse Orion Web IDE编辑应用程序文件。
- 打开
manifest.yml
并将名称和主机从两因素身份验证更改为在创建应用程序时输入的值(下方的第5步)。 - 单击播放按钮图标,以使用新清单部署应用程序。
认证方式
首先,您需要使用第二因素身份验证的身份验证工作流。 为此,您发送带有长随机字符串的电子邮件。 只有合法用户或有权访问该用户电子邮件的其他用户才能访问这些值。
创建随机字符串
创建长随机字符串的最简单方法是下载并使用node-uuid包 ,该包会创建RFC 4122标识符。 这些标识符中的每一个都有60个随机位,足以满足所有实际目的。 标识符被编码为文本。 这些是创建UUID对象的步骤(您也可以在源代码中看到它们)。
- 要使用node-uuid,请将对node-uuid的依赖关系(任何版本)添加到package.json:
"dependencies": { "express": "4.12.x", "cfenv": "1.0.x", "body-parser": "*", "node-uuid": "*" },
- 然后,创建一个
uuid
对象:// Use uuid to generate random strings. var uuid = require("node-uuid");
发送信息
要发送电子邮件,请使用IBM Cloud中的SendGrid服务。 首先,您需要创建和绑定服务并获取API密钥:
- 登录到IBM Cloud,然后在仪表板上单击您的应用程序。
- 单击左侧边栏的概述 。
- 向下滚动到“连接”,然后单击“ 连接新的”。
- 选择目录的“ 应用程序服务”类别,然后单击“ SendGrid”服务。
- 选择一个程序包,然后单击创建。
- 如果系统提示您,请单击“ 重新登台”。
- 单击菜单图标,然后选择“ 应用程序服务” 。 然后单击您创建的SendGrid服务。
- 单击“ 打开SendGrid仪表板”。
- 在SendGrid站点上,单击左侧栏中的设置> API密钥 。
- 单击创建API密钥。
- 命名密钥,选择“ 完全访问” ,然后单击“ 创建并查看”。
- 将此API密钥复制到剪贴板:
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
使用SendGrid发送电子邮件
- 返回到IBM Cloud控制台。 转到您创建的应用程序,向下滚动,然后单击查看工具链。
- 将对SendGrid的依赖关系(任何版本)添加到package.json(在示例应用程序中已完成):
"dependencies": { "express": "4.12.x", "cfenv": "1.0.x", "body-parser": "*", "node-uuid": "*", "sendgrid": "*" },
- 通过使用收到的API密钥创建一个SendGrid对象,并将其用于发送电子邮件。 从app.js的主代码(而不是处理程序)执行一次,以验证一切正常。
注意:从克隆应用程序开始时,只需在第41行更改app.js中的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);
您应该在一两分钟内收到电子邮件。 如果没有,请确保查看您的垃圾邮件文件夹。 许多过滤器都将此类电子邮件视为垃圾邮件。
放在一起:身份验证工作流程
用户通过在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 ;
}
请注意,将用户记录存储在这样的哈希表中很简单,因此非常适合诸如此类的示例程序。 但是,每当在生产环境中重新启动应用程序时删除所有用户,或者使应用程序的不同实例具有不同的用户列表,这不是一个好主意。 在生产中,您应该使用Cloudant DB。
如果用户名和密码匹配,请检查用户是否仍在等待处理中。 如果是这样,这也是一个错误情况。 您可以添加链接以将确认电子邮件重新发送到发送给用户的消息。
// 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表单。 我更喜欢这种方法,因为它更容易使用,并允许更多可能的键。 缺点是任何可以访问该电子邮件的人都可以闯入该应用程序。 在本教程结尾处的“ 保持对电子邮件嗅探器的安全 ”部分中,我讨论了如何解决该问题。
用户使用电子邮件令牌登录
电子邮件将用户定向到URL的路径confirm/ <id>
。 此调用由以下代码处理。 :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和Active Directory认证Node.js用户” 。
注意:此帐户有些简化。 当SendGrid收到要发送的电子邮件时,它将链接替换为指向其自己站点的链接,该站点会将浏览器重定向到原始URL。 这使SendGrid可以为通过电子邮件访问的链接提供统计信息。 在下图的情况下,您看到在周四,SendGrid向七个唯一的URL发送了13条消息,并获得了9次单击。
风险分析
每次用户登录时,都可能需要两步验证。但是,这被认为是对用户不利的。 从可用性的角度来看,如果应用程序评估登录尝试是非法的机会,并使用该信息来决定是否需要第二个因素,那会更好。
重要的是,此决定应基于难以伪造的因素。 例如,在HTTP标头中很容易伪造浏览器的类型和版本。 但是,假冒IP地址(因为您需要将响应路由到您)或访问时间要困难得多。
客户端IP地址
浏览器不会直接访问IBM Cloud,而是通过充当代理的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地址>获取完整的信息,或访问http://ipinfo.io/ <ip地址> / <field>获取特定的字段,例如国家/地区。
要了解如何发送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正文中提供。 因此,您需要等待直到接收到数据。
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());
然后,将登录处理程序修改为:
- 生成第二个随机ID。
- 将该随机ID放在浏览器Cookie中。
- 将相同的随机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
的值pendingReqs
。 可以通过浏览器调用该调用。 ( 注意:请记住在应用程序部署到生产环境之前删除此功能。它公开了可用于闯入应用程序的两个值。)
app.get("/pend", /* @callback */ function(req, res) {
res.send(JSON.stringify(pendingReqs));
});
上面的/* @callback */
注释不会更改功能。 其目的是告诉编辑器,即使在任何地方都没有使用req
,但是由于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隧道由IBMWebSphere®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
回调的第三个参数(对于应用程序的任何部分。 <HTTP方法>函数,而不仅仅是app.all)是如果此回调函数不处理请求就调用的函数。 如果请求已经是HTTPS,则无需重定向,因此可以恢复正常处理。
next();
});
短信代替电子邮件
互联网的建立没有考虑安全性。 另一方面,电话网络是。 因此,通过SMS而非电子邮件发送令牌更加安全。 为此:
- 在注册中添加手机号码字段。
- 创建一个简短的供人们输入的令牌,而不是一个长的令牌。 例如:
uuid.v4().substring(0,5)
- 使用IBM Cloud中的Twilio服务通过SMS发送令牌。
- 不要告诉用户单击确认链接,而是将他们重定向到可以键入令牌的表单。
用户资料
代替将所有用户都视为相同用户,可以存储一些个人资料信息(例如用户的角色或正常位置),并将该信息包括在风险分析中。 例如,John Doe通常从美国登录。 当他从中国登录时,这可能令人怀疑,并且需要第二个因素。 但是,当中国员工常秀这样做时,这并不令人怀疑。 相反,当Joe和Chang都从美国登录时,或者当Chang在中部时间正午(即他的凌晨2点)登录时,情况就是这样。
结论
现在,您应该能够在IBM Cloud Node.js应用程序中实现两步验证。 您还应该能够使用风险分析来识别风险案例,在这种情况下,部署两因素身份验证更有意义。