Credit: IBM
Use risk analysis and enhancements for more security
In this tutorial, you learn how to configure two-factor authentication for
your IBM Cloud Node.js application. Sending a separate token to the
user’s email address makes masquerading as that user a lot harder. A
potential attacker would not only have to steal the password, but would
also need to hack into the mail server to get the token.
In addition, this tutorial teaches you some techniques for risk analysis.
By analyzing risk, an application can determine when an attempt to log in
is risky. It will require a second factor for authentication only in such
cases.
What
you’ll need to build your application
Run the appGet the code
“ In this tutorial, you learn how to use a random string
delivered by email as a second authentication factor. I also discuss
several methods for risk analysis.”
Why
use two-factor authentication for risky logins?
Passwords have two main failure modes:
- Inadvertent disclosure: An attacker discovers the password of an
authorized user. - Password sharing: An authorized user gives the password to somebody
else, usually to enable that person to use some of the authorized
user’s permissions.
Both of these failure modes can be fixed by requiring authorized users to
prove that they have access to their email as a second authentication
factor. The second factor can be required each time, or only when a
transaction appears dangerous and requires extra security.
Get started
Follow these steps to create a new Node.js application on IBM Cloud:
- Log on to IBM Cloud. Create a free account if you do not
have one. - Click on the menu icon and select Cloud Foundry Apps.
this is how you get to the platform as a service (PaaS) offerings in
IBM Cloud. - Click Create Cloud Foundry app.
- Click SDK for Node.js.
- Type an app name (for example,
mfa-app
) and a hostname (I
chosemfa-app
, so you will have to choose something
else). Then click Create. - Wait for the application to start.
Configure the web IDE
We could develop the application on our own system, but I prefer the
web-based IDE.
- When the application page opens, click Overview on
the left sidebar, scroll down to the Continuous delivery heading and
click Enable. - Scroll down and specify the repository type Clone and
the source repository URL
https://git.ng.bluemix.net/qbzzt1/mfa-app
, - Click Create.
- Once the tool chain is created, click Eclipse Orion Web
IDE to edit the application files. - Open
manifest.yml
and change the name and host from
two-factor authentication to the values you entered when you created
the application (step 5 under ). - Click the play button icon to deploy the application with the new
manifest.
Authentication
First, you need an authentication workflow that uses the second factor
authentication. To do this, you send emails with long random strings. Only
the legitimate user, or others with access to that user’s email, can
access these values.
Create random strings
The easiest way to create long random strings is to download and use the node-uuid package,
which creates RFC 4122 identifiers. Each of those identifiers has 60
random bits, which are enough for all practical purposes. The identifiers
are encoded as text. These are the steps to create a UUID object (you can
also see them in the source code).
- To use node-uuid, add a dependency on node-uuid (at any version) to
package.json:"dependencies": { "express": "4.12.x", "cfenv": "1.0.x", "body-parser": "*", "node-uuid": "*" },
- Then, create a
uuid
object:// Use uuid to generate random strings. var uuid = require("node-uuid");
Send out messages
To send out email messages, use the SendGrid service in IBM Cloud. First, you
need to create and bind the service and obtain an API key:
- Log in to IBM Cloud and click on your application in the dashboard.
- Click Overview on the left sidebar.
- Scroll down to the Connections and click Connect
new. - Select the Application Services category of the
catalog and click the SendGrid service. - Select a package and click Create.
- If you are prompted, click Restage.
- Click the menu icon and select Application Services.
Then click on the SendGrid service that you created. - Click Open SendGrid Dashboard.
- From the SendGrid site, click Settings > API Keys
on the left sidebar. - Click Create API Key.
- Name the key, select Full Access, then click
Create and View. - Copy this API key to the clipboard:
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
Use SendGrid to send an email
- Return to the IBM Cloud console. Go to the application that you created,
scroll down, and click View toolchain. - Add a dependency on SendGrid (at any version) to package.json (which
is already done in the sample application):"dependencies": { "express": "4.12.x", "cfenv": "1.0.x", "body-parser": "*", "node-uuid": "*", "sendgrid": "*" },
- Create a SendGrid object by using the API key that you received and
use it to send email. Do it once from the main code of app.js, rather
than a handler, to verify that everything works.Note:
When you start by cloning the application, you just need
to change the API Key in app.js, on line 41.// 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);
You should receive the email in a minute or two. If not, make sure to look
in your spam folder. Many filters consider email like this to be spam.
Putting it all together: The
authentication workflow
Users register and log in by filling out different forms on index.html.
Their information is sent in a POST request to the server. The rest of
this section explains the login flow; the registration flow is very
similar.
The user attempts to log in
First, the code checks if the email and password pair is even valid. The
users are stored in a hash table, where the key is the user’s email
address. If the user does not exist, or if the password is wrong, the
application returns to the user an error message. It is the same message
whether the user is nonexistent or the password is wrong. This avoids
unintentionally revealing whether an email address belongs to a valid
user.
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 ; }
Note that storing users’ records in a hash table like this is simple, and
is therefore ideal for sample programs such as this one. But it is not a
good idea to delete all the users whenever you restart the application in
production, or to have different instances of the application have
different user lists. In production, you should use the Cloudant DB.
If the user and password match, check if the user is still pending. If so,
this is also an error condition. You can add a link to resend the
confirmation email to the message sent to the user.
// User exists, but email not confirmed yet if (user.status === "pending") { res.send("Account not confirmed yet."); return ; }
Assuming that everything checks out, the next step is to create a
request.
// Create request to confirm the logon var id = putRequest(req.body.email);
The putRequest
function starts by creating a random identifier
as explained earlier.
// Register a pending request for this email var putRequest = function(email) { // Get the random identifier for this request var id = uuid.v4();
Next, it adds the request to the pendingReqs
hash table with
that identifier. The request includes the identity of the requesting user.
It also gets a time stamp, to allow you to clean up old requests that are
abandoned. As noted above in regard to the email/password pair, in a
production application, the pendingReqs
hash table should be
a database instead.
pendingReqs[id] = { email: email, time: new Date() };
The function that called putRequest
needs to inform the user
of the ID so the user can verify that the request is legitimate.
Therefore, putRequest
returns the ID to the caller.
return id; };
The application sends a token by
email
After putRequest
, the handler calls a function to send the
user an email and responds to the user.
// 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."); });
The sendLoginRequest
function composes an HTML message and
sends it to the user. There are two variables in the message text. The
first, appEnv.url
, is the URL used to access the application.
This is necessary because a relative link won’t work in an email that
doesn’t have the context of the web browser’s last URL. The second is the
ID of the request to be approved. Taken all together, the URL in the
message is <appEnv.url>/confirm/<id>. This
is the URL where you will get the confirmation if the email address is
correct.
// 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); };
Note that this is different from the standard practice, which is to provide
a short (4-6 characters) code in the email for the user to type into a web
form. I prefer this method because it is easier and allows for more
possible keys. The disadvantage is that anybody who can access the email
can break into the application. In the “http://www.ibm.com/Keep safe from
email sniffers” section near the end of this tutorial, I discuss
how to solve that problem.
The user logs in with the
emailed token
The email directs the user to a URL at the path
confirm/<id>
. This call is processed by the
code below. The :id
string means it can be any valid path
component, and the value will be available in
req.params.id
.
// A confirmation (of an attempt to register or log in) app.get("/confirm/:id", function(req, res) {
The first thing to do is to retrieve the request that is being confirmed
and delete it. If there is no such request, report the error to the
user.
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 }
The object for every request includes the email address that identifies the
user. This lets you retrieve the user information.
var userData = users[userRequest.email];
If the user is pending, it means that this is a confirmation of the
account.
if (userData.status === "pending") { userData.status = "confirmed"; res.send("Thank you " + userRequest.email + " for confirming your account."); return ; }
If the user account is already confirmed, then this is a confirmation of
the second factor for authentication.
// 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); });
In a real application, this is where you would create a session and put a
session cookie in the browser. To learn how to do that on Node.js, refer to the tutorial “http://www.ibm.com/Use LDAP and Active Directory to authenticate Node.js users.”
Note: This account is somewhat simplified. When SendGrid receives
an email to send, it replaces the links with links to its own site, where
the browser is redirected to the original URL. This allows SendGrid to
provide statistics for links accessed through email. In the case of the
following illustration, you see that on Thursday, SendGrid sent 13
messages, and got nine clicks, to seven unique URLs.
Risk analysis
It is possible to require two-factor authentication every time a user logs
in. However, that is considered user hostile. It is much better from a
usability perspective if the application evaluates the chance that a login
attempt is illicit and use that information to decide whether requiring a
second factor is warranted.
It is important that this decision be based on factors that are difficult
to forge. For example, the type and version of the browser is very easy to
fake in an HTTP header. It is much harder, though, to fake IP addresses
(because you need the response routed to you) or the time of access.
Client IP address
Browsers do not access IBM Cloud directly, but through IBM WebSphere DataPower Appliances acting as proxies. To obtain
the client IP address, rather than that of the proxy, the application has
to trust the proxy. You set this using app.set
:
// Necessary to know the IP of the browser app.set("trust proxy", true);
The IP address from which a request arrives is available in
req.ip
. Here it is in use:
// Show the user's IP address app.get("/ip.html", function(req, res) { res.send("<H2>Your IP address is</H2>" + req.ip); });
To see the result, browse to http://two-factor-auth.mybluemix.net/ip.html.
Interpreting the IP
address
To use the IP address, you need to interpret it. One easy-to-use database
of IP addresses is http://ipinfo.io. You
can go to http://ipinfo.io/<ip address> to get complete
information, or http://ipinfo.io/<ip
address>/<field> to get a specific field, such
as the country.
To learn how to send an HTTP request and receive a response from the
application, see Step 3 in “http://www.ibm.com/Build a self-posting Facebook application with IBM Cloud and the MEAN
stack, Part 3.” Here is the code used in this application:
// The library to issue HTTP requests var http = require("http");
Because Node.js is single threaded, and the result will be available only
after the request gets to ipinfo.io and the response comes back, use a
next()
function that is called when the result is
available.
// Interpret an IP address and then call the next function with the data var interpretIP = function(ip, next) {
The http.get
function receives a URL and a callback function.
It then gets the URL from its server.
http.get("http://ipinfo.io/" + ip,
This callback function is called as soon as you get the HTTP headers. But
the data you need is provided in the HTTP body of the response. Therefore,
you need to wait until you receive data.
function(res) {
This code registers a handler for a data event. Because the response is so
short, it can be assumed to come in a single chunk. If there were multiple
chunks, you would concatenate them together until you got an end
event.
res.on('data', function(body) {
When you don’t access it from a browser, ipinfo.io helpfully provides the
data in a JSON object, which is easy to parse.
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>";
To show the result, put all the data fields in a table.
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); }); });
To see the result for your own IP address, browse to https://two-factor-auth.mybluemix.net/ip.html.
Time and day of the week
Getting the time and day of the week is very simple. Just create a new Date object. The days of the week start with 0 as Sunday and 6 as
Saturday; the hour is 0-23. However, the time zone is UTC, the London time
zone (without daylight savings time). This means, for example, that for
CST in the US you need to deduct 6 hours.
Usually, the risk depends on whether the time can be classified as business
hours, evening, or weekend. This is the code that handles that:
// 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); });
To see the current result for CST click here.
Show an example of risk
analysis
The problem with using risk analysis in a sample application is that it can
be an annoyance to check the parameters. You would want to see the results
for multiple countries and multiple times, without traveling or waiting.
Therefore, the risk page lets you manually specify the time classification and
the IP address.
Risk analysis policy
Using two parameters—IP address and time classification—you can set up a
policy to decide what to do. For example, you might decide that logins
from the US are expected only during business hours, logins from China are
expected at any time except the weekend (because their working hours are
very different), and you never expect users to log in from anywhere
else.
It is trivial to implement such a policy in code:
// 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="http://www.ibm.com/https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/' + 'css/bootstrap-theme.min.css">'; resHtml += '<script ' + 'src="http://www.ibm.com/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); }); });
To apply this policy, simply calculate the value of safe
in
the login handler, and add an if
statement for the next
step.
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."); }
Enhancements
There are several enhancements that can improve this program, making it
safer and more stable.
Keep safe from email
sniffers
There is a security problem, mentioned above, because any attacker who can
get the user’s email can break into the application by using the
confirmation link. One solution uses browser cookies. First, add
cookie-parser
to package.json and use it in app.js:
// Use cookie-parser to read the cookies var cookieParser = require("cookie-parser"); app.use(cookieParser());
Then, modify the login handler to:
- Generate a second random ID.
- Place that random ID in a browser cookie.
- Place the same random ID in the pending requests structure along with
the user’s email address.
// 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
Also, modify the confirmation link handler to retrieve the value of the
cookie that is created in the login handler and compare that value to the
value in the pending request. If the values are not the same, the login
fails.
// For preventing somebody who gets the email from logging on: if (req.cookies["secValue"] !== userRequest.cookie) { res.send("Wrong browser"); return ; }
To verify that this works, log in from one device and then click the
confirmation email from another device, or from another browser on the
same device. This should fail.
Cleanup
Right now, if users do not click the link for some reason, the pending
request just stays active, taking up memory and increasing the time that
it takes to look up active requests.
To solve this, use the setInterval
function to delete old
requests. JavaScript measures time in milliseconds, so to get 5 minutes it
is necessary to multiply 5 by 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 }
Because the cleanup function runs every 5 minutes, pending requests are
deleted between 5 and 10 minutes after they are created.
}, maxAge);
Debugging
To debug the cleanup function, it is useful to know the value of
pendingReqs
. This call makes it available from a browser.
(Note: Remember to delete this function before the
application is deployed in production. It discloses the two values that
can be used to break into the application.)
app.get("/pend", /* @callback */ function(req, res) { res.send(JSON.stringify(pendingReqs)); });
The /* @callback */
comment above does not change the
function. Its purpose is to tell the editor that even though
req
is not used anywhere, it is required because it is a
callback function and you do not determine which parameters it gets. This
removes the warning and makes it easier to focus on potential
problems.
Require HTTPS
It is a bad idea to allow users to submit passwords and respond with
cookies in clear text. Add this call to redirect HTTP users to HTTPS. Put
it before any other handler declaration for the app.
//Handle all (any method) and any path (slash followed by any string) app.all('/*', function(req, res, next) {
The application always gets HTTP, because the SSL tunnel is terminated by
IBM WebSphere® DataPower. However, the original protocol is available
in the header as 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
The third parameter of the callback (for any part of the app.<HTTP
method> functions, not just app.all), is the function to call
if this callback does not handle the request. If the request is already
HTTPS, you do not need to redirect and therefore you let normal processing
resume.
next(); });
SMS instead of email
The Internet was not built with security in mind. The telephone network, on
the other hand, was. It is therefore safer to send tokens with SMS instead
of email. To do so:
- Add a mobile phone number field to the registration.
- Instead of a long token, create a short one that people can type. For
example:uuid.v4().substring(0,5)
- Use the Twilio service in IBM Cloud to send the tokens by SMS.
- Instead of telling users to click the confirmation link, redirect them
to a form where they can type the token.
User profiles
Instead of treating all users as identical, it is possible to store some
profile information, such as the user’s role or normal location, and
include that information in the risk analysis. For example, John Doe
typically logs in from the US. When he logs in from China, that might be
suspicious and require a second factor. But when Chang Xiu, an employee in
China, does it, it is not suspicious. The converse is true when Joe and
Chang both log in from the US, or when Chang logs in when it is noon
Central time, which would be 2 AM for him.
Conclusion
You should now be able to implement two-factor authentication in your
IBM Cloud Node.js applications. You should also be able to use risk analysis
to identify risky cases where it makes more sense to deploy two-factor
authentication.
Downloadable resources
Related topics
Credit: IBM