4fox4:mfa

v0.1.3Published 2 years ago

Multi-Factor Authentication and Passwordless for Meteor, supporting U2F hardware keys as well as OTPs.

What is U2F?

This package was created to add support for U2F aka hardware keys. Hardware keys are widely regarded as the strongest form of multi factor authentication. They require the user plugs a physical key into their device in order to login to a website. This also includes built in anti-phishing security, if a key is used to authenticate on the wrong domain (go0gle.com instead of google.com), the signature it produces will be invalid to the intended domain. This means, barring a compromised device/browser, its very difficult for phishers to attack users who use U2F keys.

This package also supports OTPs (codes delivered via SMS, email, etc).

What is passwordless?

The majority of security experts agree, passwords are the weakest link when it comes to security. Forgotten and phished passwords cost companies millions of dollars and cause countless data breaches.

For this reason, security expers and tech companies like Microsoft, Google, and Apple are encouraging a simple solution. No passwords.

This package supports passwordless using U2F Security Keys.

Known Issues

  • Password Resets that fail MFA authentication will still expire token (e.g. if used has a typo in their code they will require a new token)

Roadmap/To-Do

If you are interested in contributing on any of these tasks please reach out!

  • Passwordless (There is now a release candidate for passwordless, if you'd like to check it out)
  • Add rate limiting
  • Create meteor-react-native companion package
  • Implement Automated Testing
  • Support Recovery Keys (Keys generated at time of setting up MFA which can be used when no other method is available)
  • Add "Login other device" support: generate an OTP from a device capable of using U2F to be entered on a device not capable of U2F
  • Support requiring more then 2 factors to authenticate

Add the package with meteor add ndev:mfa

You can then follow the instructions below for setting up U2F and/or OTP.

Note that U2F is enabled by default, and OTP is disabled by default

First, Set required configuration fields on server:

1import MFA from 'meteor/ndev:mfa';
2
3MFA.setConfig({
4  rp:{
5    id:"thedomain.of.your.app",
6    name:"My App"
7  }
8});

And add the ability for users to enable MFA from the client

1import MFA from 'meteor/ndev:mfa';
2
3MFA.registerU2F().then(r => {
4  alert("MFA is now turned on!");
5}).catch(err => {
6  alert("Something went wrong");
7});

One-Time-Passwords are codes typically sent via SMS or Email. This package takes a very simplistic approach to enrollment in order to let you control how the codes get delivered.

First, enable OTP in the config, and define a function for sending codes:

1MFA.setConfig({
2  enableOTP:true,
3  onSendOTP:(userId, code) => {...}
4})

And simply call this function from the server to enable OTP MFA for a user (note that it will fail if MFA is already enabled):

1MFA.enableOTP(userId);

Since you have full control over how a code is sent, it is up to you to maintain the user's prefered method of delivery.

Time-Based One Time Passwords are generated from an app. The client can register their TOTP device like so:

1MFA.registerTOTP().then(r => {
2  // The secret can be retrieved from r.secret, and should be shown to the user in a QR code
3  // You should save r.registrationId to be used when calling MFA.finishRegisterTOTP later
4  
5  // This should be triggered by your UI after the user adds the secret to their app and enters a token generated by the app
6  let token = prompt("What is the token");
7  MFA.finishRegisterTOTP(token, r.registrationId).then(() => {
8    this.setState({secret:null});
9  }).catch(e => {
10    alert(e.reason);
11  });
12  
13}).catch(e => {
14  console.error(e);
15  alert(e.reason);
16});

The package provides a method called MFA.login. This method will attempt to login the user normally, then if it fails due to mfa being required, runs MFA.loginWithMFA. If you prefer, you can customize your implementation by doing your own check to see if MFA exists then, if it does, directly calling MFA.loginWithMFA.

There are two steps to logging in with MFA:

  1. Perform the first-factor login (password), and retrieve the MFA method
  2. Obtain any necessary information from the user and finish the login

The flow has been designed this way so that the implementation for U2F and OTP (one-time-password/code) is as similar as possible.

First, you call MFA.login with your username/email and password. This will resolve with an object containing the method and a field called finishLoginParams, which can be stored to be passed to finishLoginParams later.

For U2F: you can then either immediately call MFA.finishLogin(finishLoginParams) or make some changes to your UI, then call it.

For OTP and TOTP: you can store finishLoginParams, update your UI to collect the OTP, then once the OTP has been entered call MFA.finishLogin.

Here is a simple example:

1import MFA from 'meteor/ndev:mfa';
2
3MFA.login(username, password).then(({method, finishLoginParams}) => {
4  if(method === "u2f") {
5    // For U2F, you don't really need to make any changes to your UI since the pop-up will appear immediately, but you can do-so here if you wish
6    MFA.finishLogin(finishLoginParams);
7  }
8  else {
9    //
10    // You can save the finishLoginParams, collect the code in your UI, then call MFA.finishLogin
11    let code = prompt("What is the OTP?"); // Note that prompt should NEVER be used as it blocks the JS thread. This is just a simplified example
12    MFA.finishLogin(finishLoginParams, code);
13  }
14}).catch(err => {
15  alert(err.message);
16});

You can see a complete login page example here

To reset a password with MFA authentication, use the MFA.resetPassword method. The usage of this method is very similar to the usage of MFA.login:

1MFA.resetPassword(token, newPassword).then(r => {
2  if(r.method === null) {
3    // The user doesn't have MFA enabled
4  }
5  else {
6    if(method === "u2f") {
7      // For U2F, you don't really need to make any changes to your UI since the pop-up will appear immediately, but you can do-so here if you wish
8      MFA.finishResetPassword(finishLoginParams);
9    }
10    else {
11      // You can save the finishLoginParams, collect the code in your UI, then call MFA.finishLogin
12      let code = prompt("What is the OTP?"); // Note that prompt should NEVER be used as it blocks the JS thread. This is just a simplified example
13      MFA.finishResetPassword(finishLoginParams, code);
14    }    
15  }
16})

You should also set config.requireResetPasswordMFA to true. This will require that users use MFA when resetting their password.

This package exposes the MFA.generateChallenge and MFA.verifyChallenge methods which allow you to integrate U2F authentication into your method. See an example below which requires the user authenticates before they can disable MFA:

1Meteor.methods({
2  "start:disableMFA":function () {
3    let challenge = MFA.generateChallenge(this.userId, "disableMFA", MFA.generateConnectionHash(this.connection));
4    return challenge;
5  },
6  
7  "complete:disableMFA":function (solvedChallenge) {
8    let userId = MFA.verifyChallenge(this.userId, "disableMFA", MFA.generateConnectionHash(this.connection), solvedChallenge);
9    MFA.disableMFA(userId);
10  }  
11});

U2F authentication is the most secure option. However, there are a lot of devices and platforms that are incompatible with it (React Native, for example).

This package exposes the MFA.authorizeAction method on the client to solve this. The MFA.authorizeAction function triggers U2F authentication, then creates a one-time-code which can be used in place of a solved challenge for any other method.

This one-time-code can then be passed to a different device to allow it to do something that requires U2F authentication.

The MFA.authorizeAction method can only be used for accounts with U2F mfa.

Here is how you generate the one-time-code:

1MFA.authorizeAction(type).then(code => { // Type should refer to the action. If the one-time-code is for logging in, it should be "login"
2  // Display the code in the UI
3}).reject(e => {
4  alert(e.reason);
5});

On the other device, you collect the code and wrap it with the MFA.useU2FAuthorizationCode(code) method.

1MFA.loginWithMFA(username, password).then((r) => {
2  if(r.method === "u2f") {
3    let code = prompt("Please enter your authorization code generated from another device"); // As always, you should never use prompt, this is just an example
4    MFA.finishLogin(r.finishLoginParams, MFA.useU2FAuthorizationCode(code)).then(/*...*/)
5  }
6}).catch(/*...*/);

In order to design your login flows better, the package exposes the method MFA.supportsU2FLogin(). This attempts to detect whether the browser will support a u2f login. As a convenience, this value is also passed in the resolution of MFA.login or MFA.loginWithMFA. If the value is false, show the user instructions to generate a code on another device along with an input for them to enter the code.

Here is a complete login example:

1import MFA from 'meteor/ndev:mfa';
2
3MFA.login(username, password).then(({method, finishLoginParams, supportsU2FLogin}) => {
4  if(method === "u2f") {
5    if(supportsU2FLogin) {
6      // For U2F, you don't really need to make any changes to your UI since the pop-up will appear immediately, but you can do-so here if you wish
7      MFA.finishLogin(finishLoginParams);
8    }
9    else {
10      let code = prompt("Enter the code generated from your other device");
11      MFA.finishLogin(finishLoginParams, MFA.useU2FAuthorizationCode(code));
12    }
13  }
14  else {
15    let code = prompt("What is the OTP?"); 
16    MFA.finishLogin(finishLoginParams, code);
17  }
18}).catch(err => {
19  // There was an error in the first stage of login, likely a "user not found" or "incorrect password"
20  alert(err.reason);
21});

As always, prompt is used as an example. After MFA.login resolved, unless the method is u2f and u2f login is supported, you should save finishLoginParams, collect the code in your UI, then continue with MFA.finishLogin.

For situations where you are not logging in (like in the "Authenticating in a Meteor method" section above), you can use MFA.useU2FAuthorizationCode(code) in place of MFA.solveChallenge().

Passwords are widely regarded as the "weakest link" when it comes to security. Passwordless is really straightforward. No passwords. Instead, physical security keys. This concept is being promoted by Microsoft, the FIDO Alliance (a consortium consisting of PayPal, Google, etc), and more giant tech companies.

This package only supports passwordless login using U2F security keys. It does not support passwordless using OTPs or TOTPs.

This package enables passwordless login in a straightforward way. It is also flexible. You can have some users on passwordless, some users on MFA, and some users without either.

1. Enable passwordless in config

Passwordless is disabled by default. On the server, set config.passwordless to true:

1MFA.setConfig({passwordless:true});

2. Register the user's U2F key:

To enable passwordless for a user, call MFA.registerU2F on the client with the following options:

1MFA.registerU2F({passwordless:true, password:"user's current password here"}).then(() => {
2  // All done!
3}).catch(e => {
4  // User cancelled U2F verification, incorrect password, etc
5})

3. Login with passwordless:

On the client, call MFA.loginWithPasswordless. This method will catch only due to an error with U2F. If the user does not have passwordless enabled, it will resolve with passwordRequired as true:

1MFA.loginWithPasswordless("email or username here").then(passwordRequired => {
2  if(passwordRequired) {
3    // User doesn't have passwordless enabled. Show regular login form.
4  }
5  else {
6    // Passwordless login successfull
7  }
8}).catch(e => {
9  // user cancelled U2F verification, etc
10});

There are many ways to design your login flow. Here are some examples:

  • Add a "Login with Security Key" button to your login page, and when clicked, collect their username/email and trigger MFA.loginWithPasswordless
  • On your login form, only ask for email/username initially, then immediately attempt MFA.loginWithPasswordless. If it fails (due to passwordless not being enabled), then collect the password

MFA.login(email/username, password)

Resolves when logged in, catches on error. This function is a wrapper for the MFA.loginWithMFA function. It attempts to login the user. If it receives an mfa-required error, it uses MFA.loginWithMFA. If you prefer to customize this, you can use the MFA.loginWithMFA function

MFA.loginWithMFA(email/username, password)

Requests a login challenge, solves it, then logs in. This function will fail if the user doesn't have MFA enabled.

MFA.loginWithPasswordless(email/username)promise:(passwordNeeded)

Attempts a passwordless login. Resolves with a single boolean passwordNeeded. If true, the user doesn't have passwordless turned on, so you must use the regular login flow. If false, the user is now logged in.

MFA.finishLogin(finishLoginParams)

Completes a login

MFA.registerU2F(params)

Registers the user's U2F device and enables MFA. To just enable MFA, call without any arguments. To enable MFA and passwordless, call with the following params:

1{passwordless:true, password:"..."}

MFA.registerTOTP()

Generates a TOTP secret and a registrationId. Resolves with {secret, registrationId}.

MFA.finishRegisterTOTP(token, registrationId)

Completes registration of a TOTP app.

MFA.solveChallenge(challenge)

Solves a challenge and resolves with a solved challenge. Useful for server-side methods that require MFA authentication (like disabling MFA).

MFA.resetPassword(token, newPassword)

Resets a password using the password reset token. See "Resetting Passwords with MFA" above for usage instructions. This method works even if the user doesn't have MFA enabled.

MFA.resetPasswordWithMFA(token, newPassword)

Like MFA.resetPassword, but will fail if user doesn't have MFA enabled

MFA.authorizeAction(type)

Creates a pre-authenticated code for challenges of a certain type

MFA.useU2FAuthorizationCode(code)

Wraps a pre-authenticated code to be used in place of MFA.solveChallenge()

MFA.supportsU2FLogin()

Returns a boolean of whether the device supports u2f login

MFA.setConfig(options)

See the config options section below

MFA.disableMFA(userId)

Disables MFA for a user. This is an internal method. If you'd like the user to authenticate before you disable, see Authenticating in a Method. Note: if the user has passwordless enabled, this will also disable passwordless.

MFA.disablePasswordless(userId)

Disables passwordless for a user. When this method is called, MFA will remain enabled. To disable passwordless and MFA, call MFA.disableMFA.

MFA.generateChallenge(userId, type, connectionHash)

Generates a challenge. This is then sent to the client and passed into MFA.solveChallenge(). See connectionHash below.

MFA.verifyChallenge(userId, type, connectionHash, solvedChallenge)

Verifies the solvedChallenge (created by MFA.solveChallenge on client). Throws an error on failiure, returns the userId that generated the challenge on success.

The userId argument is used to verify that the user that you generated the challenge for is the same as the user you are verifying the challenge for, so you should typically pass in this.userId, or however you can get the current user id. In situations where you are performing this kind of verification in another way, set userId to null.

MFA.enableDebug()

Enables debugging

MFA.generateConnectionHash(connection)

Creates a connection hash per the config.

connectionHash

The connection hash ensures that the same device that creates the challenge is the one that verifies/uses it. When using MFA.generateChallenge or MFA.verifyChallenge, you can use the MFA.generateConnectionHash method to create one, or if you do not need it you can pass an empty string.

Config Options

mfaDetailsField String (default: "mfa") The field where the mfa status object is stored (this is the field you can publish to tell whether a user has enabled)

challengeExpiry Number (default: 1 minute) How long before a challenge expires (in milliseconds)

getUserDetails Function(userId) (default: {id:user._id, name:user.username}) A function that returns an object with the id and name properties. The function recieves a single argument, the id of the user.

onFailedAssertion Function(info) (default: none) Function that runs when an assertion is failed. The only situation where this can be produced by the user accidentally is if the timeout expires or the wrong key is plugged into the device. Therefore, you should consider notifying the user if this happens. This function receives a single argument, which is the info argument of Accounts.validateLoginAttempt.

enableU2F Boolean (default:true) Enable U2F Authentication

enableTOTP Boolean (default:true) Enable TOTP Authentication.

enableOTP Boolean (default:false) Enable OTP Authentication. If this is enabled, you will need to set config.onSendOTP.

onSendOTP Function (default: none) Function that is called when an OTP needs to be sent. Receives the arguments (userId, code).

requireResetPasswordMFA Boolean (default: false) Require MFA authentication when resetting password. This is set to false by default because you must use the custom resetPassword method should you enable this. See "Resetting Passwords with MFA" above.

enforceMatchingConnectionId Boolean (default: true) Enforce that the connection id that finishes a login challenge is the same as the one that creates it

enforceMatchingClientAddress Boolean (default: true) Enforce that the client address (IP) that finishes a login challenge is the same as the one that creates it

enforceMatchingUserAgent Boolean (default: true) Enforce that the user agent that finishes a login challenge is the same as the one that creates it

allowU2FAuthorization Boolean (default: true) Allow the MFA.authorizeAction method

authorizationDisabledMethods Array (default: []) Block certain challenge types from being validated using authorization codes generated by MFA.authorizeAction

keepChallenges Boolean (default: false) Defines whether challenges should be maintained in the database. When set to false, challenges are deleted after use. When set to true, challenges are marked as invalid, but remain in database.