Двух-факторная аутентификация в Meteor.js

в 7:39, , рубрики: javascript, Meteor.JS, TFA, двух-факторная аутентификация, перевод

В течение какого-то времени мне довелось работать в стартапе. В качестве бэк-энда (и фронт-энда) мы использовали Meteor.js. В какой-то момент мы столкнулись с необходимостью реализации двух-факторной аутентификации. В этой статье я бы хотел рассказать т том, как реализовать эту фичу в Meteor.js.

Под катом вы не найдете ни одного скриншота/картинки, зато увидите весь код, необходимый для реализации.

Вступление

В нашем случае вторым фактором был код в смс-сообщении, отправляемом через Twilio. Многие из вас будут восклицать, что второй фактор в виде смс-сообщения — это расточительно и глупо. Эта реализация TFA может использовать любой второй фактор. На мой взгляд, было бы идеально оформить их (вторые факторы) как стратегии и подключать по необходимости, но до этого я так и не добрался. Сосредоточусь именно на реализации функционала на платформе Meteor.js.

Я реализовал классический подход, в котором при успешном вводе первого фактора открывается ограниченная по времени сессия, которую можно завершить вводом второго фактора.

Пакет Meteor Accounts не имеет способов приостановки аутентификации, а нам нужна эта пауза для того, чтобы сгенерировать код, отправить его и дать пользователю время на ввод. Поэтому нам придется отказаться от стандартного метода Meteor.loginWithPassword и использовать метод Meteor.loginWithToken, которого нет в документации. Этот метод позволяет пользователю аутентифицироваться в системе с использованием уже сгенерированного и сохраненного в MongoDB токена.

Ход действий

По шагам:

  1. Заменяем весь процесс аутентификации нашим Meteor методом, который назовем LoginProcedure;
  2. Валидация первого фактора и всяческие проверки;
  3. Генерируем второй фактор — код, и отправляем его с помощью Twilio — этот шаг можно заменить на любой способ генерации второго фактора и его доставки;
  4. Сохраняем код и другие данные в отдельную коллекцию MongoDB, которая будет хранить открытые сессии аутентификации;
  5. Вернем промежуточный результат, по которому клиент потребует от пользователя ввод второго фактора;
  6. Получение и проверка второго фактора;
  7. Генерация нового токена, возвращаем его клиенту;
  8. Клиент автоматически выполняет loginWithToken с полученным токеном;

Шаги 1-2

Использовать свой Meteor метод для аутентификации просто, но как запретить пользователям использовать стандартный loginWithPassword?
Есть метод Accounts.validateLoginAttempt, который должен «одобрить» каждую операцию аутентификации. Аргументом туда попадает объект attempt, в котором нас интересуют атрибуты methodName и type. Для метода loginWithToken эти атрибуты будут иметь значения login и resume соответственно. И если мы хотим допустить аутентификацию после подтверждения аккаунта по e-mail и после восстановления пароля, то нужно также «одобрить» дополнительные значения methodName. В итоге получится следующий метод:

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

Сразу напишем функции для генерации нового токена. Эти функции также будут использовать пару методов, не попавших в документацию. А вот и код:

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());
};

Метод Accounts._generateStampedLoginToken возвращает новый токен, который должен быть возвращен на клиент для того, чтобы в дальнейшем выполнить метод loginWithToken. Метод Accounts._hashStampedToken хэширует токен, и именно в хэшированной виде мы должны сохранить его в MongoDB.

Самое время вернуться к нашему Meteor методу. А вот и код, пояснения после:

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
            ...
        }
    }
});

Как видите, еще один метод, не описанный в документации.

Так как мы проводим всю аутентификацию вручную, то и пароль проверять тоже нам вручную. А проблема кроется в том, что мы не знаем, каким образом Meteor их хэширует. Как раз для этого и используется метод Accouts._checkPassword. Как аргументы ему передаются запись пользователя, полученная ранее из MongoDB, и еще один объект, содержащий хэш пароля пользователя и метод хэширования. Это всегда sha-256.
Само хэширование будем выполнять на клиентской стороне перед вызовом Meteor метода. Используется при этом стандартный метод — Package.sha.SHA256('ПпАарРоль').

Здесь же описан ход действий при отключенной TFA — просто генерируем новый токен, возвращаем на клиент, а оттуда выполнится вызов Meteor.loginWithToken.

Хочу пояснить количество аргументов Meteor метода — я использую один и тот же метод для открытия и завершения сессии аутентификации.

Аргумент hash предназначен для отслеживания уже открытой сессии. Допустим, пользователь откроет сессию аутентификации, а затем закроет браузер/вкладку, но смс с кодом уже отправлено. А в течение минуты (времени жизни сессии) он снова откроет сессию аутентификации, и тогда снова будет отправлено смс. Это была бы чистая потеря денег. Поэтому для открытой сессии (после прохождения первого фактора) создается хэш, который к ней привязан и сохранен c ней в MongoDB, а затем возвращен на клиент, а там сохранен в localstorage/cookie. И, когда клиент загрузится в очередной раз, он проверит по своим временным подсчетам, жива ли прошлая сессия аутентификации. Если жива, то он прикрепит этот хэш вместе с первым фактором (username, password). Это также позволит открывать сессии TFA с разных устройств. Об этом процессе детальнее в следующих шагах.

Шаги 3-5

Данные шаги включают сам второй фактор.
Создадим специальную коллекцию в MongoDB, которая будет содержать открытые сессии аутентификации. Предположим, она будет называться TwoFactorSessions. Она должна быть определена только на серверной стороне Meteor.

А вот и код:

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);
            }
        }
    }
});

В случае, если с клиента приходит вызов метода с аргументом hash, мы должны попытаться найти уже существующую открытую сессию аутентификации. Даже если такая существует, все равно надо проверить её время жизни (клиент непредсказуем, обязательно найдется персонаж, который будет через консоль вызывать самые разные методы с самыми разными аргументами). Если же все в порядке, даем клиенту понять, что еще нужно пройти второй фактор.

Если аргумента hash нет, и первый фактор пройден, то генерируем код (второй фактор), хэш, сохраняем все необходимое и доставляем код (второй фактор). Как видите, мой хэш вовсе не хэш, а просто timestamp. Мне это показалось достаточным для демонстрационных целей, но вам никто не запретит использовать полноценный хэш, в который можно спрятать данные для привязки открытой сессии к устройству, например.

Для работы с Twilio я использовал официальный модуль twilio-node. Для подключения модулей от Node.js к Meteor можно использовать удобный пакет meteorhacks:npm.

Также стоит обратить внимание на Meteor.wrapAsync. Если вы знакомы с Meteor, то знаете, что все асинхронные задачи на серверной стороне надо оборачивать таким образом.

В результате, на клиент отправляется хэш для дальнейшей идентификации открытой сессии и код, по которому он отобразит форму для ввода второго фактора.

Все достаточно просто, но, согласен, сумбурно.

Шаги 6-7

Теперь пора задуматься о клиентской стороне.

Предположим, есть шаблон для аутентификации — signIn. В нем есть форма для первого фактора и модальный поп-ап для второго фактора, который идентифицируется по #modal, а все вложенные элементы как #modal-<название и роль элемента>. Как вы помните, хэш для идентификации открытой сессии должен храниться в localstorage/cookie, поэтому в последующем коде будем использовать объект Storage. Это будет некий абстрактный объект, который сам решит куда класть значение (localstorage или cookie, по доступности). А вот и код:

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]);
            }
        });
    }
});

В коде полно комментариев, тщательно описывающих происходящее, но все же поясню.

По событию submit #signInForm считываем содержимое формы, хэшируем пароль и вызываем Meteor method, также отправляя hash, если он найден. Ожидаем получить один из 4х вариантов ответа:

  1. 400 — сессия не прошла валидацию (ttl истек), клиент должен стереть свой hash;
  2. 200 — первый фактор пройден, а второй не включен, значит пришел токен, с которым можно аутентифицироваться;
  3. 403 — новый код (второй фактор) сгенерирован и отправлен, показываем модальный поп-ап для ввода;
  4. 401 — старый код (второй фактор) еще активен, показываем поп-ап, в котором отображается оставшееся время жизни сессии и необходимость ввести тот же код.

Из модального окна по событию click #modal-code-submit вызываем тот же Meteor method, но передаем еще и код (второй фактор). В результате ожидаем получить один из следующих двух ответов:

  1. 400 — сессия уже истекла, показываем ошибки, чистим hash в localstorage/cookie;
  2. 200 — второй фактор успешно пройден, чистим hash в localstorage/cookie во избежание ошибок, и аутентифицируемся с полученным токеном.

Теперь необходимо реализовать проверку второго фактора на серверной стороне. Это событие характеризуется присутствием всех 4х аргументов при вызове Meteor метода. А вот и код:

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 аргумента при вызове Meteor метода означают попытку завершить открытую сессию TFA. Первым делом проверим, существует ли такая сессия. Простой запрос в MongoDB.

Далее валидируем сессию. Как минимум, нужно проверить:

  • Не закрыта ли уже эта сессия;
  • Не истек ли ttl;
  • Проверить количество попыток закрыть сессию;
  • Совпадает ли код (второй фактор);
  • Дополнительные проверки на соответствие сессии пользователю.

После того, как валидация пройдена успешно, сессия должна считаться закрытой. Поэтому обновим запись в MongoDB, добавив туда время закрытия сессии.
Затем, генерируем новый токен для пользователя ранее определенными функциями и отправляем назад на клиентскую сторону.

Шаг 8

Код этого шага содержиться в прошлом шаге. На клиентской стороне, при получении токена, сразу вызываем метод Meteor.loginWithToken и успешно аутентифицируемся.

Заключение

Для многих API Meteor.js может показаться замкнутым, ограниченным, без возможности сделать что-то сложное и замысловатое. Но, как было продемонстрировано в данной статье, можно заглянуть глубже и реализовать функционал, который вроде и не укладывается в стандартные пакеты.

Самое важное, конечно, заключается в том, что пришлось использовать скрытые функции, не описанные в официальной документации. Многих это будет настораживать потому, что эти функции могут меняться без предупреждения. Но без них было бы сложно реализовать TFA в более менее нормальном виде. По-крайней мере, на момент написания статьи, я не смог найти ни одной реализации.

Автор: RavisMsk

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js