Некоторое время у меня была возможность поработать в стартапе.
Мы использовали Meteor.js в качестве серверной части (и внешнего интерфейса).
В какой-то момент мы столкнулись с необходимостью реализовать двухфакторную аутентификацию.
В этой статье я хотел бы рассказать вам, как реализовать эту функцию в Meteor.js. Под катом вы не найдете ни одного скриншота/картинки, но увидите весь код, необходимый для реализации.
Введение
В нашем случае вторым фактором стал код в SMS-сообщении, отправленном через Twilio. Многие из вас воскликнут, что второй фактор в виде СМС-сообщения – это расточительство и глупость.Эта реализация TFA может использовать любой другой фактор.
На мой взгляд, было бы идеально формализовать их (вторые факторы) как стратегии и связывать по мере необходимости, но у меня до этого руки не дошли.
Я сосредоточусь конкретно на реализации функциональности на платформе Meteor.js. Я реализовал классический подход, при котором при успешном входе первого фактора открывается ограниченная по времени сессия, которую можно завершить вводом второго фактора.
В Meteor Accounts нет возможности приостановить аутентификацию, и эта пауза нам нужна для того, чтобы сгенерировать код, отправить его и дать пользователю время для входа.
Поэтому нам придется отказаться от стандартного метода Meteor.loginWithPassword и используйте метод Метеор.
loginWithToken , чего нет в документации.
Этот метод позволяет пользователю аутентифицироваться в системе, используя токен, уже сгенерированный и сохраненный в MongoDB.
Ход действий
Шаг за шагом:- Мы заменяем весь процесс аутентификации нашим методом Meteor, который мы назовем Процедура входа в систему ;
- Валидация первого фактора и всевозможные проверки;
- Генерируем второй фактор — код, и отправляем его с помощью Twilio — этот шаг можно заменить любым методом генерации второго фактора и его доставки;
- Код и другие данные мы сохраняем в отдельной коллекции MongoDB, в которой будут храниться открытые сеансы аутентификации;
- Вернем промежуточный результат, согласно которому клиент потребует от пользователя ввести второй фактор;
- Получение и проверка второго фактора;
- Генерация нового токена, возврат его клиенту;
- Клиент автоматически выполняет вход с токеном с полученным токеном;
Шаги 1–2
Использовать метод Meteor для аутентификации легко, но как запретить пользователям использовать стандартный? вход с паролем ? Существует метод Accounts.validateLoginAttempt , который должен «одобрить» каждую операцию аутентификации.Объект идет туда в качестве аргумента пытаться , в котором нас интересуют атрибуты Имя метода И тип .
Для метода вход с токеном эти атрибуты будут иметь значения авторизоваться И резюме соответственно.
А если мы хотим разрешить аутентификацию после подтверждения учетной записи по электронной почте и после восстановления пароля, то нам также необходимо «одобрить» дополнительные значения.
Имя метода .
В результате получается следующий метод:
Давайте сразу напишем функции для генерации нового токена.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 возвращает новый токен, который необходимо вернуть клиенту для последующего выполнения метода вход с токеном .
Метод Аккаунты.
_hashStampedToken хеширует токен, и именно в хешированной форме мы должны хранить его в MongoDB. Пришло время вернуться к нашему методу Метеора.
А вот код, пояснения после: 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 их хэширует. Именно для этого и используется метод Аккауты.
_checkPassword .
В качестве аргументов ему передается запись пользователя, полученная ранее из MongoDB, и еще один объект, содержащий хеш пароля пользователя и метод хеширования.
Это всегда Ша-256. Само хеширование мы выполним на стороне клиента перед вызовом метода Meteor. Используется стандартный метод - Package.sha.SHA256('PpAarRole') .
Здесь же описан порядок действий при отключенном TFA — просто генерируем новый токен, возвращаем его клиенту, и оттуда будет выполнен вызов.
Метеор.
loginWithToken .
Хочу уточнить количество аргументов метода Meteor — я использую один и тот же метод для открытия и завершения сеанса аутентификации.
Аргумент хэш предназначен для отслеживания уже открытой сессии.
Допустим, пользователь открывает сеанс аутентификации, а затем закрывает браузер/вкладку, но SMS с кодом уже отправлено.
И в течение минуты (время жизни сессии) он снова откроет сессию аутентификации, а затем снова отправит СМС.
Это была бы чистая потеря денег.
Поэтому для открытой сессии (после прохождения первого фактора) создается хеш, который связывается с ней и сохраняется вместе с ней в MongoDB, а затем возвращается клиенту и там сохраняется в localstorage/cookie. И когда клиент снова загрузится, он с помощью временных вычислений проверит, активен ли предыдущий сеанс аутентификации.
Если жив, то он прикрепит этот хэш вместе с первым фактором (имя пользователя, пароль).
Это также позволит вам открывать сеансы 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);
}
}
}
});
Если вызов метода поступает от клиента с хеш-аргументом, мы должны попытаться найти уже существующий открытый сеанс аутентификации.
Даже если такая вещь существует, все равно нужно проверять ее время жизни (клиент непредсказуем, обязательно найдется персонаж, который будет вызывать через консоль самые разные методы с разными аргументами).
Если все в порядке, мы даем клиенту понять, что ему еще нужно пройти второй фактор.
Если аргумент хэш нет, и передается первый фактор, далее генерируем код (второй фактор), хэш, сохраняем все необходимое и доставляем код (второй фактор).
Как видите, мой хеш — это вообще не хеш, а просто временная метка.
Мне этого показалось достаточным для демонстрационных целей, но никто не запретит вам использовать полноценный хеш, в котором можно скрыть данные, например, для привязки открытой сессии к устройству.
Для работы с Twilio я использовал официальный модуль твилио-узел .
Для подключения модулей из Node.js к Meteor вы можете использовать удобный пакет метеорхаки:npm .
Также стоит обратить внимание Метеор.
wrapAsync .
Если вы знакомы с Meteor, то знаете, что все асинхронные задачи на стороне сервера должны быть обернуты таким образом.
В результате клиенту отправляется хеш для дальнейшей идентификации открытой сессии и код, по которому он будет отображать форму для ввода второго фактора.
Все достаточно просто, но, согласен, хаотично.
Шаги 6–7
Теперь пришло время подумать о клиентской стороне.Предположим, есть шаблон для аутентификации – войти .
Он имеет форму для первого фактора и модальное всплывающее окно для второго фактора, которое обозначается #модальный , и все вложенные элементы похожи на #модальный- .
Как вы помните, хеш для идентификации открытой сессии должен храниться в localstorage/cookie, поэтому в следующем коде мы будем использовать объект Хранилище .
Это будет некий абстрактный объект, который будет решать, куда поместить значение (локальное хранилище или 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]);
}
});
}
});
Код полон комментариев, подробно описывающих происходящее, но я все равно объясню.
По событию отправить #signInForm читаем содержимое формы, хэшируем пароль и вызываем метод Meteor, также отправляя хэш , если найден.
Мы ожидаем получить один из 4 вариантов ответа:
- 400 — сессия не прошла валидацию (истёк срок ttl), клиент должен стереть её хеш;
- 200 — первый фактор пройден, но второй не включен, значит пришел токен, с помощью которого можно аутентифицироваться;
- 403 — сгенерирован и отправлен новый код (второй фактор), показывающий модальное всплывающее окно для ввода;
- 401 — старый код (второй фактор) все еще активен, мы показываем всплывающее окно, в котором отображается оставшееся время жизни сессии и необходимость ввести тот же код.
В результате мы ожидаем получить один из следующих двух ответов:
- 400 — сессия уже истекла, показать ошибки, очистить хэш в локальном хранилище/куки;
- 200 — второй фактор успешно пройден, чистим его хэш в localstorage/cookie, чтобы избежать ошибок, и выполните аутентификацию с помощью полученного токена.
Это событие характеризуется наличием всех 4 аргументов при вызове метода Метеор.
И вот код: 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. Далее мы проверяем сессию.
Как минимум необходимо проверить:
- Эта сессия уже закрыта?
- Срок действия истек?
- Проверьте количество попыток закрытия сессии;
- Соответствует ли код (второй фактор);
- Дополнительные проверки, чтобы убедиться, что сеанс соответствует пользователю.
Поэтому мы обновим запись в MongoDB, добавив время закрытия сеанса.
Затем мы генерируем новый токен для пользователя, используя ранее определенные функции, и отправляем его обратно на сторону клиента.
Шаг 8
Код для этого шага содержится в предыдущем шаге.На стороне клиента при получении токена мы сразу вызываем метод Метеор.
loginWithToken и успешно пройти аутентификацию.
Заключение
Многим API Meteor.js может показаться закрытым, ограниченным и не позволяющим делать что-либо сложное и замысловатое.Но, как показано в этой статье, можно заглянуть глубже и реализовать функциональность, которая, похоже, не вписывается в стандартные пакеты.
Самое главное, конечно, то, что нам пришлось использовать скрытые функции, не описанные в официальной документации.
Это будет беспокоить многих, поскольку эти функции могут быть изменены без предварительного уведомления.
Но без них было бы сложно реализовать ТФА в более-менее нормальном виде.
По крайней мере, на момент написания мне не удалось найти ни одной реализации.
Теги: #Meteor.JS #двухфакторная аутентификация #JavaScript #tfa #перевод #JavaScript #Meteor.JS
-
Лечение Синдрома Двойного Щелчка
19 Oct, 24 -
Подкаст Unclesosky – Эпизод №12
19 Oct, 24