Not too long ago I had written a tutorial titled, U2F Authentication with a YubiKey Using Node.js and jQuery, which demonstrated how to use hardware keys as a means of universal two-factor (U2F) authentication. However, I had left some things to be desired in that previous post. For example, the previous tutorial did not use proper session management with Express.js and it used jQuery, which is neat, but by no means is as popular as some of the other web frameworks that currently exist.
In this tutorial, we’re going to expand upon what we had done previously, but implement proper session management with Express.js and use Vue.js, which is a modern web framework.
There are some things to note before proceeding too deep into this particular tutorial:
The focus of this tutorial is around hardware keys. I am personally using a YubiKey from Yubico, but you can use whatever you’d like as long as it follows the same specification. The YubiKey is affordable and can be purchased on Amazon if you’re interested. As of right now, the only browser to officially support hardware keys is Google Chrome. Both Firefox and Safari are working on support, but you should have a backup in case your users are not using Google Chrome. Finally, U2F requires HTTPS with no exceptions. If you are testing locally, you need HTTPS. If you are testing remotely, you need HTTPS. Luckily, if you’re confused at how to configure certificates and serve with HTTPS, I wrote a tutorial titled, Create a Self-Signed Certificate for Node.js on MacOS. I don’t use Windows and Linux, but I imagine things might be similar on those two operating systems.
We will need to create a server-side application for registering hardware devices and validating them in order to be successful with universal two-factor authentication (U2F). As I previously mentioned, I did already create a U2F tutorial, but we’re going to be doing something a little different. However, the previous is still worth looking at as it is inspiration for this particular tutorial.
On your computer, execute the following commands:
npm init -y
npm install express --save
npm install express-session --save
npm install u2f --save
npm install cors --save
npm install body-parser --save
The above commands will create a new project with all of the dependencies. They could have easily be combined into a single command, but I’ve split them up to make them a little easier to follow.
The express, cors, body-parser, and express-session packages are all part of Express.js. We’ll need to serve API endpoints, store session data round the registration and login of our hardware devices, and handle cross-origin resource sharing (CORS) as well as JSON request bodies. The u2f package will handle our hardware devices.
Within the project, create an app.js file with the following boilerplate 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 session = require("express-session");
const APP_ID = "https://localhost:2015";
var app = Express();
app.use(session({ secret: "thepolyglotdeveloper", cookie: { secure: true, maxAge: 60000 }, saveUninitialized: true, resave: true }));
app.use(BodyParser.json());
app.use(BodyParser.urlencoded({ extended: true }));
app.use(Cors({ origin: [APP_ID], credentials: true }));
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...");
});
In the above code we are importing each of our dependencies and configuring Express Framework. As seen in my previous tutorial titled, Manage Sessions Over HTTPS with Node.js and Vue.js, we are configuring our session to use HTTPS and configuring CORS to whitelist our client facing application. Knowing the client facing application origin is also very important when it comes to our hardware keys.
You’ll notice there is a user
variable within our code. Because we don’t plan to use a database for this example, it is going to act as our in-memory store for hardware key registration data. In other words, we are only going to store one hardware key and we are going to do it in memory. This hardware key must be registered and then validated, each of which is a two step process.
The first part of the registration process looks like the following:
app.get("/register", (request, response, next) => {
request.session.u2f = U2F.request(APP_ID);
response.send(request.session.u2f);
});
The client facing application will create a new challenge hash based on the client facing application origin and return it to the client. We won’t see it yet, but the client will take that challenge and use it in combination of reading the actual hardware key. The client information will then be sent to the second part of the registration process:
app.post("/register", (request, response, next) => {
var registration = U2F.checkRegistration(request.session.u2f, request.body.registerResponse);
if(!registration.successful) {
return response.status(500).send({ message: "error" });
}
user = registration;
response.send({ message: "The hardware key has been registered" });
});
In the second step, the challenge information which was stored in a session is compared against the response that the client gave after interacting with the hardware key. If the validation succeeds, we can store the registration information in our user
variable, which would realistically be a database.
When the user has a hardware key registered, they can use it as the second part of their authentication process. The steps are similar:
app.get("/login", (request, response, next) => {
request.session.u2f = U2F.request(APP_ID, user.keyHandle);
response.send(request.session.u2f);
});
Using the client origin and the key information that we’ve stored for the user, we can generate a new challenge specifically for that user. It is sent to the client and is pending a second step after interaction with the hardware key. After interaction, the following endpoint is requested:
app.post("/login", (request, response, next) => {
var success = U2F.checkSignature(request.session.u2f, request.body.loginResponse, user.publicKey);
response.send(success);
});
Using the challenge information, the response from the hardware key, and the stored public key for the user, we can validate the user. If successful, we would probably return an API token or something useful to the user. For this example we’re just telling them that they were successful.
The entire Node.js application might look something like this:
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 session = require("express-session");
const APP_ID = "https://localhost:2015";
var app = Express();
app.use(session({ secret: "thepolyglotdeveloper", cookie: { secure: true, maxAge: 60000 }, saveUninitialized: true, resave: true }));
app.use(BodyParser.json());
app.use(BodyParser.urlencoded({ extended: true }));
app.use(Cors({ origin: [APP_ID], credentials: true }));
var user;
app.get("/register", (request, response, next) => {
request.session.u2f = U2F.request(APP_ID);
response.send(request.session.u2f);
});
app.post("/register", (request, response, next) => {
var registration = U2F.checkRegistration(request.session.u2f, request.body.registerResponse);
if(!registration.successful) {
return response.status(500).send({ message: "error" });
}
user = registration;
response.send({ message: "The hardware key has been registered" });
});
app.get("/login", (request, response, next) => {
request.session.u2f = U2F.request(APP_ID, user.keyHandle);
response.send(request.session.u2f);
});
app.post("/login", (request, response, next) => {
var success = U2F.checkSignature(request.session.u2f, request.body.loginResponse, user.publicKey);
response.send(success);
});
HTTPS.createServer({
key: FS.readFileSync("server.key"),
cert: FS.readFileSync("server.cert")
}, app).listen(443, () => {
console.log("Listening at :443...");
});
Remember, you’re going to need HTTPS properly configured to be successful. The simplest way to do this is with self-signed certificates. With a functional Node.js application, we can focus on the client experience.
Much of the logic that we’re about to see for our client facing application can be reproduced in another framework. I’ve already shown jQuery, and this will be Vue.js, but you could easily use Angular or React as well. The first step is to create a new Vue.js project with the Vue CLI:
vue create u2f-vue
The CLI will likely ask a few questions, but you can just choose the defaults for this particular project. Because we will be making HTTP requests to our API, we will need to configure Vue.js for the task. For this tutorial we’ll be using the axios package which can be installed like the following:
npm install axios --save
If you’re interested in getting more information on axios or the alternatives for HTTP requests with Vue.js, check out my previous tutorial titled, Consume Remote API Data via HTTP in a Vue.js Web Application.
We’re going to spend most of our time in the project’s src/components/HelloWorld.vue file which I’ve gone and renamed to src/components/U2FComponent.vue. Feel free to change it to whatever makes the most sense for you. Before we start writing code, we need to get a client facing U2F library. We can actually use the u2f-api library that Google maintains for this job. Download it to your project’s public directory.
We need to include the JavaScript library, so open your project’s public/index.html file and include the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>u2f-vue</title>
</head>
<body>
<noscript>
<strong>We're sorry but u2f-vue doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<script src="/u2f-api.js"></script>
<!-- built files will be auto injected -->
</body>
</html>
Notice I’ve used an absolute path. Make sure you use a path that makes the most sense for however you plan to serve this application.
Now open the project’s src/components/U2FComponent.vue file and include the following:
<template>
<div class="hello">
<button v-on:click="register()">Start Registration</button>
<button v-on:click="login()">Start Login</button>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "U2FComponent",
methods: {
register() { },
login() { }
}
}
</script>
<style scoped></style>
In the above code we have two buttons that are bound to methods within the script. As you can probably guess, each will trigger a different part of the U2F API flow. Let’s start with the register
method:
register() {
if(window.u2f && window.u2f.register) {
axios({ method: "GET", "url": "https://localhost/register", withCredentials: true }).then(result => {
window.u2f.register(result.data.appId, [result.data], [], response => {
axios({ method: "POST", "url": "https://localhost/register", "data": { registerResponse: response }, "headers": { "content-type": "application/json" }, withCredentials: true }).then(result => {
console.log(result.data);
}, error => {
console.error(error);
});
});
}, error => {
console.error(error);
});
} else {
console.error("U2F is not supported");
}
}
Before we try to work with our hardware key, we need to make sure our browser supports U2F. If it supports U2F we can make our first request for the challenge information. Notice that we are using the withCredentials
property because we are using sessions tied to a particular user.
After receiving our challenge information, we can prompt the user to interact with the hardware key. After the user interacts with the hardware key, we can do the second request, this time sending the response information that was generated based on the challenge information and the hardware key itself. If the server says everything looks good, we’ll be notified of that in the end.
Now that our hardware key is being stored in memory on the server, we can try to login:
login() {
if(window.u2f && window.u2f.sign) {
axios({ method: "GET", "url": "https://localhost/login", withCredentials: true }).then(result => {
window.u2f.sign(result.data.appId, result.data.challenge, [result.data], response => {
axios({ method: "POST", "url": "https://localhost/login", "data": { loginResponse: response }, "headers": { "content-type": "application/json" }, withCredentials: true }).then(result => {
console.log(result.data);
});
});
});
} else {
console.error("U2F is not supported");
}
}
Again, we are checking for compatibility from the start. After we are compatible, we can request the challenge information based on our stored user. Remember, this is a second phase of authentication so you should be able to do a user lookup based on the user information from the first phase.
After we get the challenge information, we can interact with the hardware key and send the response back to the server. If everything checks out, we can set a local cookie or do whatever is necessary to push the user into the protected or secured area.
Because we are using HTTPS, the built in server for Vue.js cannot be used. There are many ways to serve a Vue.js application with HTTPS, but for this example we’ll use Caddy Server. At the root of our project, we can create a Caddyfile file with the following contents:
localhost:2015 {
root dist
tls server.cert server.key
}
The assumption is that you have your certificates in the same location. You can build and serve your Vue.js application with the following commands:
npm run build
caddy
Again, the above commands assumes you have Caddy Server available. If you would rather use NGINX or something else, that is fine too, but the configuration will be different. Remember, all we want to do is serve with HTTPS. How you accomplish this is up to you. If you’d like to learn more about Caddy Server, check out my article titled, Serve Your Web Applications with Minimal Effort Using Caddy.
You just saw how to use a U2F hardware key with Vue.js and Node.js. Having two-factor authentication available to your users is a great thing. Just remember that U2F with hardware devices is only available on Google Chrome, so make sure you have an alternative solution available. For example, if you want to use time-based one-time passwords (TOTP), check out my tutorial titled, Implement 2FA with Time-Based One-Time Passwords in a Node.js API.
If you’d like to learn more about API development in general, check out my eBook and course titled, Web Services for the JavaScript Developer.
A video version of this tutorial can be found below.