About a week ago I had written about using HTTPS with Node.js and hinted at hardware based two-factor authentication as my reason for needing it. In case you’re unfamiliar with 2FA, there are numerous approaches ranging from HMAC-based one-time passwords (HOTP) and time-based one-time passwords (TOTP) which are software based, to the hardware based universal two-factor (U2F) standard.
If you’ve been keeping up with the blog, you’ll remember I had written a tutorial titled, Implement 2FA with Time-Based One-Time Passwords in a Node.js API, which focused on the software side of things. I recently picked up some YubiKey dongles and thought I’d try my luck with the hardware side of things.
In this tutorial, we’re going to see how to implement U2F functionality in our Node.js powered RESTful API and interact with the API and our hardware dongles using jQuery in the web browser.
Before getting too involved with this tutorial, there are a few things to note:
If you’re looking to buy a YubiKey, Amazon has them at a reasonable price. When it comes to the HTTPS side of things, I encourage you to look at my previous tutorial if you’re not already familiar with using self-signed certificates with Node.js.
Before we start playing around with the U2F hardware key, we need to start building our backend that will support the key. Let’s create a new project by executing the following from our command line:
npm init -y
With the project’s package.json file created, we’re going to want to download our project dependencies. From the command line, execute the following:
npm install u2f --save
npm install express --save
npm install body-parser --save
npm install cors --save
While we don’t need Express Framework to build an API, it is probably the easiest and most stable solution out there. We’re going to need to accept payloads in the requests so the body-parser package will be necessary. Likewise, our client facing application will be operating on a different port or domain, hence cross-origin resource sharing (CORS) must be configured. Finally, the u2f package will allow us to process requests with the hardware key.
Let’s create a file to hold our application source code:
touch app.js
If you don’t have the touch
command on your operating system, go ahead and create an app.js file manually. You’ll also need a set of self-signed certificates, but the assumption is that you’ve already reviewed my previous tutorial.
Open the project’s app.js so we can start adding our boilerplate project code:
const U2F = require("u2f");
const Express = require("express");
const BodyParser = require("body-parser");
const Cors = require("cors");
const HTTPS = require("https");
const FS = require("fs");
const APP_ID = "https://localhost:2015";
var app = Express();
app.use(BodyParser.json());
app.use(BodyParser.urlencoded({ extended: true }));
app.use(Cors());
var user;
app.get("/register", (request, response, next) => {});
app.post("/register", (request, response, next) => {});
app.get("/login", (request, response, next) => {});
app.post("/login", (request, response, next) => {});
HTTPS.createServer({
key: FS.readFileSync("server.key"),
cert: FS.readFileSync("server.cert")
}, app).listen(443, () => {
console.log("Listening at :443...");
});
The first thing we do in the above code is import our project dependencies. You’ll notice that we’re also importing https
and fs
for working with our certificates, but they did not need to be downloaded since they ship with Node.js. As per the documentation for the U2F package, we’ll need to strictly define the client facing application. In my example, it is being served on port 2015 using HTTPS.
When we serve the application, we’ll be using the standard 443 port as well as the self-signed certificates that you should have already created.
This is where things start to get a little interesting. We have four endpoints for various U2F operations. Don’t get confused by my naming conventions because these are not typical registrations and sign-ins. These endpoints represent the necessary secondary interactions with our hardware keys, not with the username and passwords that might exist as a first step. You’ll also notice a user
variable. Because we’re not using a database in this example, we’re going to store the registration in memory.
As part of our process, the first step is our request against the register
endpoint:
app.get("/register", (request, response, next) => {
var session = U2F.request(APP_ID);
app.set("session", JSON.stringify(session));
response.send(session);
});
Typically you’d want to do a better job than I’ve done when it comes to sessions, but what we’re doing is we’re creating a U2F request which includes things like a challenge and storing it in memory. This session is also being returned to the client facing application that requested it. We can’t trust the user to maintain the only copy because they might try to manipulate it and gain unauthorized accesses. When the user receives this request information, at this point they should be asked to activate their hardware key. When the hardware key becomes active, the user will send a request to the following endpoint:
app.post("/register", (request, response, next) => {
var registration = U2F.checkRegistration(JSON.parse(app.get("session")), request.body.registerResponse);
if(!registration.successful) {
return response.status(500).send({ message: "error" });
}
user = registration;
response.send(registration);
});
What we’re doing is we are taking the session that was created along with the U2F payload that was sent from the client facing application and are validating it. If it is valid, as in the payload was modeled around the information found in our session, we can store it in memory, or in reality, a database. While in our example we are returning the registration information, you probably shouldn’t. We are doing it so you get an idea of what it would look like in your database.
With U2F registration information in our database, we can focus on the login. Technically we could start with a clean slate, but since our user information is stored in memory, we shouldn’t. The session information could, of course, be reset though.
The login follows the same process as the registration. First we get our request with challenge information:
app.get("/login", (request, response, next) => {
var session = U2F.request(APP_ID, user.keyHandle);
app.set("session", JSON.stringify(session));
response.send(session);
});
The difference between the above endpoint and the registration is that now we are obtaining the key information about our previously registered user, which again would probably come from a database in production. After getting the request, the client should ask the user to initiate their hardware key, at which point another request with a payload should be made:
app.post("/login", (request, response, next) => {
var success = U2F.checkSignature(JSON.parse(app.get("session")), request.body.loginResponse, user.publicKey);
response.send(success);
});
In the above endpoint, we take the session that was previously created, as well as the U2F payload and the public key that resides in our database and validate the information. In this example we return the result of the validation back to the user, which is fine, but realistically you’d probably want to return a JSON web token (JWT) or something to be passed around with future requests. More information about JWT can be found in my previous tutorial titled, JWT Authentication in a Node.js Powered API.
At this point in time our U2F backend is complete. Just to reiterate, we didn’t develop the first phase for registration and login with username and password. We only focused on the second phase. We also didn’t use a database to store our registered U2F hardware key, when in production we should. Now we can focus on the client interface.
As previously mentioned, U2F with hardware keys is only officially supported in Google Chrome. While Firefox has some support, it is more or less use at your own risk. If you’re implementing U2F, make sure you offer other alternatives such as HOTP or TOTP.
Somewhere on your computer create an index.html file with the following boilerplate code:
<html>
<head></head>
<body>
<h1>U2F with Node.js</h1>
<button id="register">Start Registration</button>
<button id="login">Start Login</button>
<script>
// Logic here...
</script>
</body>
</html>
You’ll notice that we have two buttons, one to start our registration process and register our hardware key and the other to validate. To do this, we’ll need two JavaScript libraries. First, you’ll want to download Google’s u2f-api library. Next you’ll want to download jQuery, which is optional, but will make our lives easier for this example.
With the libraries downloaded, we can make the following update to our index.html file:
<html>
<head></head>
<body>
<h1>U2F with Node.js</h1>
<button id="register">Start Registration</button>
<button id="login">Start Login</button>
<script src="jquery-3.3.1.min.js"></script>
<script src="u2f-api.js"></script>
<script>
// Logic here...
</script>
</body>
</html>
We know that there will be two different interactions with our RESTful API, so let’s first look at the registration:
$("#register").click(() => {
if(window.u2f && window.u2f.register) {
$.get("https://localhost/register", result => {
console.log(result);
window.u2f.register(result.appId, [result], [], response => {
$.post("https://localhost/register", { registerResponse: response }, result => {
console.log(result);
});
console.log(response);
});
});
} else {
document.write("<p>U2F is not supported</p>");
}
});
In the above code, when the registration button is clicked, we first check to make sure that U2F is available for the current web browser. It is only available if we are using the u2f-api library and are using Google Chrome. If U2F works, we first request our challenge information from the GET endpoint. You’ll notice that I’m printing the result so you can get an idea of what it looks like.
With the challenge information, we can attempt to register our YubiKey. The browser will wait until the key is initiated, at which point the response data from the key as well as the challenge information is sent to the second part of the registration. We’ll print the results as appropriate.
Assuming that we have a registered key, we can attempt to login:
$("#login").click(() => {
if(window.u2f && window.u2f.sign) {
$.get("https://localhost/login", result => {
console.log(result);
window.u2f.sign(result.appId, result.challenge, [result], response => {
$.post("https://localhost/login", { loginResponse: response }, result => {
console.log(result);
});
console.log(response);
});
});
} else {
document.write("<p>U2F is not supported</p>");
}
});
Again we check to make sure that U2F is supported and if it is, we get our challenge information based on our user. We aren’t passing any user information, but in production you’d want to figure out your user in the first factor which would be username and password authentication. That, in theory, would get the user information, including the hardware registration. Once we have the information we need, the browser will wait until we initiate the key and then send the response as part of the payload. Assuming the key information matches what is stored in the database, we would be logged in.
If you recall, our backend assumes that our application is operating at https://localhost:2015. It is a requirement that you are using HTTPS on your frontend and a requirement that it matches what your backend expects. If you’re having trouble, I might suggest using Caddy Server for testing. You can pretty easily get where you need with the following Caddyfile setup:
localhost:2015 {
tls server.cert server.key
}
In the above example, server.cert and server.key are the same that the Node.js application is using. Again, you don’t need to use Caddy, you just need to be serving on HTTPS for both your backend and frontend. How you get there is up to you.
You just saw how to use a U2F hardware key such as the YubiKey as a second factor of authentication in your Node.js powered web application. It is advisable that all modern web applications allow for two-factor authentication (2FA) whether that be in the form of HOTP, TOTP, U2F, or something else.
When learning how to use U2F, the most difficult part for me was configuring HTTPS. It wasn’t obvious that I needed it for testing locally or how to properly configure it. Once getting beyond that hurdle, interacting with the hardware keys is actually quite easy.
If you’re interested in learning more about developing APIs with Node.js, I encourage you to check out my eBook and course titled, Web Services for the JavaScript Developer.