📜 ⬆️ ⬇️

Two-factor authentication in Meteor.js

For some time I happened to work in a startup. We used Meteor.js as a back end (and front end). At some point, we are faced with the need to implement two-factor authentication. In this article I would like to tell you how to implement this feature in Meteor.js.

Under the cut you will not find any screenshots / pictures, but you will see all the code necessary for implementation.

Introduction


In our case, the second factor was the code in the SMS message sent via Twilio. Many of you will exclaim that the second factor in the form of SMS messages is wasteful and stupid. This implementation of TFA can use any second factor. In my opinion, it would be ideal to formulate them (second factors) as a strategy and connect as necessary, but I never got to that. I’ll focus on the implementation of functionality on the Meteor.js platform.

I implemented a classic approach in which, with the successful input of the first factor, a time-limited session opens, which can be completed by entering the second factor.
')
Meteor Accounts does not have a way to pause authentication, and we need this pause in order to generate the code, send it and give the user time to enter. Therefore, we will have to abandon the standard Meteor.loginWithPassword method and use the Meteor.loginWithToken method, which is not in the documentation. This method allows the user to authenticate to the system using the token already generated and stored in MongoDB.

Course of action


Steps:

  1. We replace the entire authentication process with our Meteor method, which we call LoginProcedure ;
  2. Validation of the first factor and all sorts of checks;
  3. We generate the second factor - the code, and send it using Twilio - this step can be replaced with any method of generating the second factor and its delivery;
  4. We save the code and other data into a separate collection of MongoDB, which will store open authentication sessions;
  5. Let us return the intermediate result for which the client will require the user to enter the second factor;
  6. Receiving and checking the second factor;
  7. Generate a new token, return it to the client;
  8. The client automatically executes the loginWithToken with the received token;


Steps 1-2


Using your Meteor method for authentication is simple, but how to prevent users from using the standard loginWithPassword ?
There is an Accounts.validateLoginAttempt method that must "approve" each authentication operation. The argument gets the object attempt , in which we are interested in the methodName and type attributes. For the loginWithToken method , these attributes will have the values login and resume, respectively. And if we want to allow authentication after confirming the account by e-mail and after recovering the password, then we also need to “approve” the additional methodName values. The result is the following method:

Accounts.validateLoginAttempt(function(attempt){ var allowed = [ 'login', 'verifyEmail', 'resetPassword' ]; if (_.contains(allowed, attempt.methodName) && attempt.type == 'resume'){ return true; } return false; }); 

Immediately write the function to generate a new token. These functions will also use a couple of methods that are not included in the documentation. And here is the code:

 var generateLoginToken = function(){ var stampedToken = Accounts._generateStampedLoginToken(); return [ stampedToken, Accounts._hashStampedToken(stampedToken) ]; }; var saveLoginToken = function(userId){ return Meteor.wrapAsync(function(userId, tokens, cb){ // In tokens array first is stamped, second is hashed // Save hashed to Mongo Meteor.users.update(userId, { $push: { 'services.resume.loginTokens': tokens[1] } }, function(error){ if (error){ cb(new Meteor.Error(500, 'Couldnt save login token into user profile')); }else{ // Return stamped to user cb && cb(null, [200,tokens[0].token]); } }); })(userId, generateLoginToken()); }; 

The Accounts._generateStampedLoginToken method returns a new token that must be returned to the client in order to later execute the loginWithToken method. The Accounts._hashStampedToken method hashes the token, and it is in the hashed form that we must save it to MongoDB.

It's time to go back to our Meteor method. And here is the code, an explanation after:

 Meteor.methods({ 'LoginProcedure': function(username, pswdDigest, code, hash){ //Here perform some checks //I'll leave it up to you //Something to prevent NoSQL-Injections etc. ... //Now check if user already exists var user = Meteor.users.findOne({ '$or': [ { 'username': username }, { 'emails.address': username } ] }); if (!user) throw new Meteor.Error(404, 'fail'); //Now password checks //Explanations about this are right after the code var password = {digest: pswdDigest, algorithm: 'sha-256'}; var pswdCheck = Accounts._checkPassword(user, password); if (pswdCheck.error) throw new Meteor.Error(403,'fail'); //Next check if two-factor is enabled //If it's not, just generate token and return it //Else start the procedure... if (!user.twoFactorEnabled){ //Use function defined above return saveLoginToken(user._id); }else{ //Step 3-7 ... } } }); 

As you can see, one more method not described in the documentation.

Since we carry out all authentication manually, we also need to check the password manually. And the problem lies in the fact that we do not know how Meteor hashes them. For this purpose, the Accouts._checkPassword method is used . As arguments, the user’s record received earlier from MongoDB and another object containing the user’s password hash and hashing method are passed to it. It is always sha-256.
The hashing itself will be performed on the client side before calling the Meteor method. The standard method used is Package.sha.SHA256 ('PPAarRol') .

It also describes the course of actions when TFA is disabled — we simply generate a new token, return it to the client, and from there, the Meteor.loginWithToken call will be executed .

I want to clarify the number of arguments to the Meteor method - I use the same method to open and end the authentication session.

The hash argument is intended to track an already open session. Suppose the user opens the authentication session and then closes the browser / tab, but the SMS with the code has already been sent. And within a minute (the session lifetime), he will again open the authentication session, and then SMS will be sent again. It would be a total loss of money. Therefore, for an open session (after passing the first factor), a hash is created, which is tied to it and saved with it in MongoDB, and then returned to the client, and there it is stored in localstorage / cookie. And, when the client boots up once again, it will check by its temporary calculations whether the last authentication session is alive. If alive, he will attach this hash along with the first factor (username, password). It will also allow you to open TFA sessions from different devices. About this process in more detail in the following steps.

Steps 3-5


These steps include the second factor itself.
Let's create a special collection in MongoDB that will contain open authentication sessions. Suppose it will be called TwoFactorSessions . It should be defined only on the server side of Meteor.

And here is the code:

 Meteor.methods({ 'LoginProcedure': function(username, pswdDigest, code, hash){ //Steps 1-2 ... if (!user.twoFactorEnabled){ //Steps 1-2 ... }else{ if (code && hash){ //Step 6-7 ... }else(hash){ //That part is for continuing previous session //New code will not be sent, but client-side app //will receive special response code and open the pop-up var session = TwoFactorSessions.findOne({ hash: hash, username: username }); if (session){ //Lets use some imaginary validation function //that you will define by your own in your project validateSession(session, user); return [401, hash]; }else{ // Couldnt find, return error throw new Meteor.Error(404, 'No session'); } }else{ //Generated code, i'll leave it up to you var newCode = <code here>; //The now date can be used as hash, just timestamp var now = new Date(); var hash = +now; //Save it to special collection for suspended sign-in processes TwoFactorSessions.insert({ hash: hash, code: newCode, username: username, sent: now }); // Wrap async task return Meteor.wrapAsync(function(user, hash, code, startTime, cb){ // Send code using Twilio to the phone number of user Twilio.messages.create({ to: user.phone, from: '+000000000000', body: 'Hi! Code - '+code }, function(error, message){ if (error){ // Return error with Twilio cb && cb(new Meteor.Error(500, 'Twilio error')); }else{ // Return 403, saying that SMS has been sent // hash, which user will send to us with code to identify his TF session cb && cb(null, [403, hash]); } }); })(user, hash, newCode, now); } } } }); 

In case a client receives a method call with the hash argument, we should try to find an already existing open authentication session. Even if it exists, you still need to check its lifetime (the client is unpredictable, there will definitely be a character who will call a variety of methods with a variety of arguments through the console). If everything is in order, let the client understand that you still need to pass the second factor.

If the hash argument is not present, and the first factor is passed, then we generate a code (the second factor), a hash, save everything we need and deliver the code (the second factor). As you can see, my hash is not a hash, but just a timestamp. It seemed to me sufficient for demonstration purposes, but no one will forbid you to use a full-fledged hash, in which you can hide data to bind an open session to a device, for example.

To work with Twilio, I used the official twilio-node module. To connect modules from Node.js to Meteor you can use the convenient meteorhacks package : npm .

Also pay attention to Meteor.wrapAsync . If you are familiar with Meteor, you know that all asynchronous tasks on the server side need to be wrapped in this way.

As a result, a hash is sent to the client to further identify the open session and the code by which it displays the form for entering the second factor.

Everything is quite simple, but, I agree, messy.

Steps 6-7


Now it's time to think about the client side.

Suppose there is a template for authentication - signIn . It has a form for the first factor and a modal pop-up for the second factor, which is identified by #modal , and all nested elements as # modal- <element name and role> . As you remember, the hash for identifying an open session should be stored in the localstorage / cookie, so in the following code we will use the Storage object. It will be some abstract object that decides for itself where to put the value (localstorage or cookie, according to availability). And here is the code:

 Template.signIn.events({ ... 'submit #signInForm': function(e) { e.preventDefault(); //Here go your methods for retreiving //username/email and password var username = ...; var password = ...; var pswdDigest = Package.sha.SHA256(password); // Check if there is previous Two-Factor session var sessionHash = Storage.get('two-factor-auth-hash'); if (sessionHash){ //Validate it maybe? //We have additional value here, code expiration time var valid = validateItHereAsYouWant(); if (!valid) sessionHash = null; } //Now actual login procedure start Meteor.call('LoginProcedure', username, pswdDigest, null, sessionHash, function(error, response){ if (error){ if (error.error === 400){ // That code would mean that session is invalid Storage.remove('two-factor-auth-hash'); // Show some alerts here } }else if (response[0]===200){ // That response code would mean that // two-factor authentication is turned off // and client received new login token immediately // right after passing simple username/password check Meteor.loginWithToken(response[1], function(err){ if(err){ alert('Problem!'); }else{ Router.go('Account'); } }); }else if (response[0]===403){ // That response code would mean that second factor code is sent // Open modal window with code input field $('#modal').modal(); // Save hash into storage for continuation Storage.set('two-factor-auth-hash', response[1]); // Show alert saying the code was sent }else if (response[0]===401){ // Open modal window with code input field $('#modal').modal(); // Show alert that there is previous code that awaits input } } ... 'click #modal-code-submit': function(e){ e.preventDefault(); // Read the code, get the id hash var code = $('#modal-code-input').val(); var hash = Storage.get('two-factor-auth-hash'); // Again get the values inside fields // i mean username and password ... // Throught the net, only the digest should go var pswdDigest = Package.sha.SHA256(pswd); // Perform login again, but with code and id hash Meteor.call('LoginProcedure', username, pswdDigest, code, hash, function(error, response){ if (error){ if (error.error === 400){ // That error code would mean that session is invalid Storage.remove('two-factor-auth-hash'); Storage.remove('two-factor-auth-ttl'); $('#modal').modal('toggle'); // Show some error alerts } }else if (response[0]===200){ // Seems like ok, login token received Storage.remove('two-factor-auth-hash'); // Login Meteor.loginWithToken(response[1]); } }); } }); 

The code is full of comments, carefully describing what is happening, but still explain.

On the event #signInForm, we read the contents of the form, hash the password and call the Meteor method, also sending the hash , if it is found. We expect to receive one of 4 answer options:

  1. 400 - the session did not pass validation (ttl expired), the client must erase its hash;
  2. 200 - the first factor is passed, and the second is not included, then a token with which you can authenticate has arrived;
  3. 403 - a new code (the second factor) is generated and sent, we show a modal pop-up for input;
  4. 401 - the old code (the second factor) is still active, we show a pop-up, which displays the remaining lifetime of the session and the need to enter the same code.

From the modal window on the click # modal-code-submit event, we call the same Meteor method, but also pass the code (the second factor). As a result, we expect to receive one of the following two answers:
  1. 400 - the session has already expired, we show errors, we clean the hash in localstorage / cookie;
  2. 200 - the second factor has been successfully passed, we clean the hash in localstorage / cookie in order to avoid errors, and authenticate with the received token.

Now you need to implement a second factor check on the server side. This event is characterized by the presence of all 4 arguments when calling the Meteor method. And here is the code:

 Meteor.methods({ 'LoginProcedure': function(username, pswdDigest, code, hash){ //Steps 1-2 ... if (!user.twoFactorEnabled){ //Steps 1-2 ... }else{ if (code && hash){ //All 4 arguments present here //First factor has already been passed since we're here //Process second factor var session = TwoFactorSessions.findOne({ hash: hash, username: username }); if (session){ //Lets use some imaginary validation function //that you will define by your own in your project validateSession(session, user, code); // Passed all checks // Update two-factor session with submitted date TwoFactorSessions.update({ hash: hash }, { $set: { submitted: new Date() } }); // Generate and save login token using // previously defined function (look for it in steps 1-2) return saveLoginToken(user._id); }else{ // Couldnt find, return error throw new Meteor.Error(404, 'twoFactor.invalidHash'); } }else(hash){ //Step 3-5 ... }else{ //Step 3-5 ... } } } }); 

4 arguments when calling the Meteor method mean trying to end an open TFA session. First check to see if there is such a session. Simple request in MongoDB.

Next, we validate the session. At a minimum, you need to check:


After validation is successful, the session should be considered closed. Therefore, we will update the entry in MongoDB by adding the session closing time there.
Then, we generate a new token for the user with previously defined functions and send it back to the client side.

Step 8


The code for this step is contained in the last step. On the client side, when we receive a token, we immediately call the Meteor.loginWithToken method and successfully authenticate ourselves .

Conclusion


For many, the API Meteor.js may seem closed, limited, without the ability to do something complex and intricate. But, as has been demonstrated in this article, you can take a deeper look and implement functionality that does not seem to fit into standard packages.

The most important thing, of course, is that I had to use hidden functions that are not described in the official documentation. This will be alarming for many because these functions may change without warning. But without them, it would be difficult to implement TFA in a less normal form. At least at the time of this writing, I could not find a single implementation.

Source: https://habr.com/ru/post/252593/


All Articles