Let’s take a look at how we can quickly hash out a correct two-factor authentication (2FA) solution for our web applications. First off, let’s work out the required flow.
2FA relies on unique shared secrets we’ll give our users. Users can then take those shared secrets to generate time-based six-digit tokens on their phone or any other OTP device to log onto our site. This is more secure than plain password-based authentication: in addition to obtaining access to the user’s password, a malicious actor would also need the shared secret in order to compromise their account.
At this point, let’s assume users can already create accounts on our application. To get a minimum viable 2FA out, we’ll need:
- An enrollment flow where users can set up 2FA for the first time
- A way to generate the shared secret
- Somewhere to store each user’s secret
- A way to display a QR code with the shared secret for a more convenient user experience
- A challenge flow where we prompt users for their 2FA tokens after they log in but before granting them access to their account
- A place where we’ll store whether the user is only partially authenticated but not multi-factor authenticated
- A way for the user to turn off 2FA
While most tutorials like this one encourage it, I’m going to assume we don’t want to force 2FA on every user, which is more practical and seems to be more popular in the wild. For the enrollment flow, the first step is to set up some views. Here’s the new /account/security
view on Pony Foo.
When clicking on the button, we’ll be taken to /account/security/multifactor
.
Whoa. Slow down a minute. What’s that garbled text thing? How did we generate that QR code?
Generating the Shared Secret
Well, the “garbled text thing” is the shared secret we discussed a bit earlier. To get a shared secret that’s unique to our user, we’ll use the speakeasy
package. In the bit of code below, we call speakeasy.generateSecret
, which produces an object containing – among other things – a property called base32
, which is the base32-encoded shared secret; and a property called otpauth_url
, which is the endpoint we’ll want to encode as a QR code.
const speakeasy = require('speakeasy')
function setupSecret(user, done) {
const options = {
issuer: `Pony Foo`,
name: `Pony Foo (${ user.email })`,
length: 64
}
const { base32, otpauth_url } = speakeasy.generateSecret(options)
// …
}
Note that the name
option is what the user will see on their OTP device upon scanning the QR code, and it’s generally recommended that we indicate $SITE ($USERNAME)
as the name for our shared secret. The name
will be encoded in the otpauth://
resource locator generated by speakeasy
along with the shared secret.
We’ll need to store this stuff somewhere so that we can later validate the user’s tokens against our shared secret. The catch-22 here is that we don’t want to treat this shared secret as active until we can verify the user is able to generate valid tokens. This is because the user might fail to scan the QR code for some reason, abandon the enrollment flow before writing down the shared secret, or otherwise end up effectively locked out of their own account because of their inability to generate tokens. For that reason, it’s a best practice to ask the user to give us a valid token before we consider their account to have 2FA enabled.
One possible solution is to store the shared secret on their user account anyways, but flag it as not being enrolled yet, as shown next.
function setupSecret(user, done) {
const options = {
issuer: `Pony Foo`,
name: `Pony Foo (${ user.email })`,
length: 64
}
const { base32, otpauth_url } = speakeasy.generateSecret(options)
const mfa = {
created: new Date(),
enrolled: null,
secret: base32,
otp: otpauth_url
}
user.mfa = mfa
user.save(done)
}
Now that we’ve sorted out the algorithm we’ll need to create a shared secret for our users, we can use it when they visit /account/security/multifactor
. To that end, we might set them up with a new secret whenever they visit this page, or we could store the secret until they click on cancel, we’ll leave that up to you to decide. In the bit of code below, we’ll set up a new secret on every visit, generate a base64-encoded data URI for the QR code to the otpauth://
url, and respond with that. Note that it’s assumed req.user
exposes the currently authenticated user document from an ORM like mongoose
.
const QRCode = require(`qrcode`)
function controller(req, res, next) {
setupSecret(req.user, hasSecret)
function hasSecret(err) {
if (err) {
next(err)
return
}
QRCode.toDataURL(req.user.mfa.otp, gotQrCode)
}
function gotQrCode(err, qr) {
if (err) {
next(err)
return
}
// render view with QR code and plain text shared secret …
}
}
The next step is to plug in the verification form. I’ll leave the details of actually connecting the client to the server to you, and meanwhile I’ll focus on the token verification parts.
Verifying TOTP Tokens
In the following chunk of code we’ll take a mongoose
user document and the user-provided token that we want to validate against the shared secret. If the user doesn’t even have an mfa
field, we’ll call that a sanity check that’s enough for us to know that they can’t verify any tokens at all, since they never even visited /account/security/multifactor
.
function verifyToken({ user, token }, done) {
if (!user.mfa) {
done(new Error(`User doesn't have MFA enabled.`))
return
}
// …
}
Next, we use speakeasy.totp.verify
comparing the base32-encoded shared secret we stored on the user
document with the user-provided token. Note that I’ve set a mystifying window
parameter to 1
. This means that we won’t just accept the token for the current 30 second OTP time-step window, but that we’ll accept a valid token that matches the last time-step window, too. It’s pretty common for anxious users – okay, for myself, it’s pretty common for myself – to start entering tokens into a website when the time-step window is about to close, and by the time the form is submitted a new time-step window kicks in and their perfectly valid token “Does Not Meet Expectations”. Setting the window
parameter to 1
will make those kinds of users – me – a bit happier about the site’s UX.
…
const success = speakeasy.totp.verify({
secret: user.mfa.secret,
encoding: `base32`,
window: 1, // let user enter previous totp token because ux
token
})
…
Now, success
is a boolean value indicating whether the provided token is indeed valid. If the token is invalid, we’ll call done
and bail. When it’s valid, there’s two branches. If the user is already enrolled in 2FA, we’ll consider the verification successful and be done with it. When the user is not yet enrolled, we’ll first mark them as enrolled as of this moment, and then call done
with a successful verification.
…
const { enrolled } = user.mfa
if (!success) {
finished(null)
return
}
if (enrolled) {
finished(null)
return
}
user.mfa.enrolled = new Date()
user.markModified(`mfa`)
user.save(finished)
function finished(err) {
done(err, { success, enrolled })
}
}
After you hook up the verification function to the form, we can consider the enrollment flow complete. Users can now visit a page that displays a QR code with a link that Google Authenticator and friends understand, and from there they can enter the code, submit the form, and complete their enrollment in our 2FA program.
The challenge flow is not all that different from the enrollment flow, except we want to lock the users out of their account after they log in, or when they want to perform sensitive actions. Onto the least fun part.
Challenge Flow
How you implement this side of the 2FA flow is up to you. One way is to avoid provisioning user details on the request object until they’ve successfully provided proof of second factor authentication (a valid token).
The first step towards that goal is to add a middleware like ensureMultifactor
right after the user has authenticated on a login form or through an OAuth provider, before they’re redirected to where they were going.
function ensureMultifactor(req, res, next) {
if (!req.user.mfa || !req.user.mfa.enrolled) {
next()
return
}
req.session.mfaLock = true
res.redirect(`/account/login/multifactor`)
}
For illustration purposes, this is what the middleware chain looks like when logging onto Pony Foo:
app.post(`/account/login/local`, ensureAnonymous, rememberReturnUrl, loginController, ensureMultifactor, redirect)
This means that when the user posts their credentials to /account/login/local
:
- We ignore them if they’re already authenticated
- We store the
redirectTo
query parameter in the user’s session - We authenticate the user with their username and password
- We redirect the user to the 2FA page if they are enrolled in the program
- If they’re not enrolled in 2FA, we redirect them to
redirectTo
The form on the 2FA authentication page should post to something that runs verifyToken
like we did for the enrollment flow, except it should also remove the lock from the user’s session.
delete req.session.mfaLock
Okay, let’s take a step back. The user logs in, we set an mfaLock
flag on their session and redirect them to the 2FA page. They are free to navigate away, but they won’t get rid of the lock unless they pass the challenge, which is when we remove the lock. You might’ve noticed that the user is already logged in, even if they are “MFA locked”, they have this authentication credential that grants them access to all the wrong places. How do we prevent that?
I happen to have this route sitting atop Pony Foo’s routing layer. I use passport
to serialize and deserialize the user’s id
onto their session (stored on Redis). Anyway, once passport
deserializes the id
from Redis, this route will look for a few of the user’s details such as their name, email, roles, and avatar on MongoDB, and store them under req.userObject
.
app.all(`/*`, hydrateUserObject)
Now, this route relies on req.user
being the user’s id
. If we were to check whether the session has an MFA lock and act as if there was no authenticated user, the rest of the application wouldn’t know any different. Almost too easy!
function hydrateUserObject(req, res, next) {
if (req.session.mfaLock && shouldVerifyMultifactor()) {
req.user = null // act as if user isn't authenticated
}
…
}
Virtually every request should have this check, because otherwise something might believe the user to be fully authenticated even though they didn’t pass the challenge flow. There is a single exception, which is that requests to verify the challenge should be treated as authenticated. This is why we have shouldVerifyMultifactor
: when we get a POST /api/account/security/multifactor/verify
we don’t want to treat the user as unauthenticated, because we’ll need to look up their shared secret!
function shouldVerifyMultifactor() {
return (
req.method !== `POST` &&
req.path !== `/api/account/security/multifactor/verify`
)
}
Finally, you’ll want to let users turn off MFA.
With the set up I’ve presented in this article, all you’ll have to do is the following:
user.mfa = null
user.save(next)
That is all there is to a simple MFA setup. Here are some things you can do to take this further if you’re planning on implementing 2FA on a serious business and not on a glorified blogging website:
- Generate backup codes and email them to your user or provide them as a download option
- Consider a flow where you send codes via SMS, but then avoid it because it’s horribly insecure
- You can learn about some more nuanced parts of 2FA by reading the excellent
speakeasy
documentation
Comments