Not too long ago I wrote about authenticating within a Node.js API using Json Web Tokens (JWT). The basis of the example is around authenticating via a username and password and receiving a JWT for every future request against the API. While that example is incredibly useful and follows best practice, it doesn’t cover the scenario where you’d like to have a two-factor authentication (2FA) option for your users. In case you’re unfamiliar, 2FA is a second layer of protection for accounts made possible by a time-based token generated by a shared secret key.
We’re going to see how to add a two-factor authentication option to our Node.js API while continuing to use Json Web Tokens.
Before we get invested into the code, we should probably come up with a plan. We’re going to recycle a lot of code found in the previous tutorial on JWT. The user will first authenticate using a username and password. If the account has 2FA enabled, we’ll receive a JWT with a property marked as not authenticated. If we try to access a protected endpoint while not authenticated, we will get an error. If we authenticate with our second layer of protection then we’ll get a new JWT that is marked as authenticated which allows us to access protected endpoints.
Adding two-factor authentication to our web application is not as difficult as it sounds. Because we’re using JWT, the 2FA process can happen from any client front-end including web and mobile.
So let’s get down to business.
Like mentioned, we’re going to be using the API that we had created in a previous tutorial. While I recommend you read and understand the other tutorial, you can see the source code here:
var Express = require("express");
var BodyParser = require("body-parser");
var JsonWebToken = require("jsonwebtoken");
var Bcrypt = require("bcryptjs");
var app = Express();
app.use(BodyParser.json());
app.set("jwt-secret", "bNEPp6H70vPo01yGe5lptraU4N9v005y");
var validateToken = function(request, response, next) {
var authHeader = request.headers["authorization"];
if(authHeader) {
bearerToken = authHeader.split(" ");
if(bearerToken.length == 2) {
JsonWebToken.verify(bearerToken[1], app.get("jwt-secret"), function(error, decodedToken) {
if(error) {
return response.status(401).send({ "success": false, "error": "Invalid authorization token" });
}
request.decodedToken = decodedToken;
next();
});
}
} else {
response.status(401).send({ "success": false, "error": "An authorization header is required" });
}
};
app.post("/authenticate", function(request, response) {
var user = {
username: "nraboy",
password: "$2a$10$LiMweWit2woRvc2IGpSfcuOM23EeRYu5X9f09Fxsw3hUsdLZBoj/q"
};
if(!request.body.username) {
return response.status(401).send({ "success": false, "message": "A `username` is required"});
} else if(!request.body.password) {
return response.status(401).send({ "success": false, "message": "A `password` is required"});
}
Bcrypt.compare(request.body.password, user.password, function(error, result) {
console.log(result);
if(error || !result) {
return response.status(401).send({ "success": false, "message": "Invalid username and password" });
}
var token = JsonWebToken.sign(user, app.get("jwt-secret"), {});
response.send({"token": token});
});
});
app.get("/protected", validateToken, function(request, response) {
response.send({ "message": "Welcome to the protected page" });
});
var server = app.listen("3000", function() {
console.log("Listening on port 3000...");
});
The above code should exist in an app.js file. Within the same directory as the app.js file, execute the following commands from your Terminal or Command Prompt:
npm init -y
npm install express body-parser jsonwebtoken bcryptjs --save
The code and above commands will get us to where we had left off in the previous tutorial. We will be using no database in this example, but instead mock data.
Now we need to make a few modifications for the two-factor authentication components.
To keep things simple we’ll be using a 2FA library for generating and validating one-time passwords rather than coming up with our own algorithm.
Within the same project, execute the following command:
npm install node-2fa --save
The above command will install the 2FA library we’ll be using called node-2fa, created by Jeremy Scalpello.
Before we start doing some heavy lifting, we need to clean up our code a bit. We have some middleware called validateToken
, but we’ll be validating JWT elsewhere in a different fashion as well. However, they will both exist as HTTP headers. For this reason, we should do the following:
var getBearerToken = function(header, callback) {
if(header) {
token = header.split(" ");
if(token.length == 2) {
return callback(null, token[1]);
} else {
return callback("Malformed bearer token", null);
}
} else {
return callback("Missing authorization header", null);
}
}
var validateToken = function(request, response, next) {
getBearerToken(request.headers["authorization"], function(error, token) {
if(error) {
return response.status(401).send({ "success": false, "message": error });
}
JsonWebToken.verify(token, app.get("jwt-secret"), function(error, decodedToken) {
if(error) {
return response.status(401).send({ "success": false, "error": "Invalid authorization token" });
}
if(decodedToken.authorized) {
request.decodedToken = decodedToken;
next();
} else {
return response.status(401).send({ "success": false, "error": "2FA is required" });
}
});
});
};
Notice in the above we have created a separate getBearerToken
method that we then use within our middleware. Again, we’re doing this because we plan to use getBearerToken
in numerous locations.
We are also altering the logic of our middleware a bit. We are now checking if authorized
exists on the decodedToken
object and is true. This value will be true if 2FA is disabled or if 2FA happened successfully.
This brings us back to our authentication endpoint:
app.post("/authenticate", function(request, response) {
var user = {
"username": "nraboy",
"password": "$2a$04$6ovemVqYBt2/j6lbrQ7MQuN1TW2nCrRbRnMjMrYUAP1pcJAG9zFHW",
"2fa": true
};
if(!request.body.username) {
return response.status(401).send({ "success": false, "message": "A `username` is required"});
} else if(!request.body.password) {
return response.status(401).send({ "success": false, "message": "A `password` is required"});
}
Bcrypt.compare(request.body.password, user.password, function(error, result) {
if(error || !result) {
return response.status(401).send({ "success": false, "message": "Invalid username and password" });
}
var token = JsonWebToken.sign({ "username": user.username, "authorized": !user["2fa"] }, app.get("jwt-secret"), {});
response.send({ "token": token, "2fa": user["2fa"] });
});
});
In the above endpoint function we are assuming that our database data has a data element called 2fa
that is a boolean. This indicates whether or not 2FA is enabled or not for this particular user. If the username and password are valid then we can create a JWT with the authorization status. The status will be the opposite of whatever the 2fa
value is. If 2FA is true or otherwise enabled, then we are not yet authorized.
Since we know the token is not authenticated, we need to authenticate it:
app.post("/verify-totp", function(request, response) {
var user = {
"username": "nraboy",
"password": "$2a$04$6ovemVqYBt2/j6lbrQ7MQuN1TW2nCrRbRnMjMrYUAP1pcJAG9zFHW",
"totpsecret": "2MXGP5X3FVUEK6W4UB2PPODSP2GKYWUT"
};
getBearerToken(request.headers["authorization"], function(error, token) {
if(error) {
return response.status(401).send({ "success": false, "message": error });
}
if(!request.body.otp) {
return response.status(401).send({ "success": false, "message": "An `otp` is required"});
}
JsonWebToken.verify(token, app.get("jwt-secret"), function(error, decodedToken) {
if(TwoFactor.verifyToken(user.totpsecret, request.body.otp)) {
decodedToken.authorized = true;
var token = JsonWebToken.sign(decodedToken, app.get("jwt-secret"), {});
return response.send({ "token": token });
} else {
return response.status(401).send({ "success": false, "message": "Invalid one-time password" });
}
});
});
});
In the above endpoint, we again assume a mock user and this mock data has a time-based one-time password (TOTP) secret. Assuming the token generated from the authentication endpoint is valid, we check to see if the passed one-time password is valid using the 2FA library we had downloaded. If the password is valid, we update the authorized
property and return a new token.
Going forward, this new JWT token will get us into protected areas.
While not necessary, if we wanted to generate 2FA secrets or numeric time-based passwords, we could use the following:
app.get("/generate-secret", function(request, response) {
response.send({ "secret": TwoFactor.generateSecret() });
});
app.post("/generate-otp", function(request, response) {
response.send({ "otp": TwoFactor.generateToken(request.body.secret) });
});
The above methods were taken out of the library documentation.
You just saw how to add an extra layer of protection for users of your API. With two-factor authentication your users will have to authenticate with username and password, followed by a time-based one-time password. This password is generated using a shared secret which can be maintained in applications such as Google Authenticator or Authy.
If you’re interested in creating your own 2FA client to be used with this API, you might check out a NativeScript tutorial I wrote called, Build a Time-Based One-Time Password Manager with NativeScript.
The full source code to this project can be downloaded here.