Создание безопасных потоков паролей с помощью NodeJS и MySQL

Опубликовано: 2022-03-10
Краткая сводка ↬ Функция сброса пароля — это ставка на стол для любого удобного приложения. Это также может быть кошмаром безопасности. Используя NodeJS и MySQL, Даршан демонстрирует, как успешно создать безопасный поток сброса пароля, чтобы вы могли избежать этих ловушек.

Если вы чем-то похожи на меня, вы не раз забывали свой пароль, особенно на сайтах, которые вы давно не посещали. Вы, вероятно, также видели и / или были огорчены электронными письмами для сброса пароля, которые содержат ваш пароль в виде обычного текста.

К сожалению, рабочему процессу сброса пароля уделяется мало внимания при разработке приложений. Это не только может привести к разочарованию пользователя, но также может оставить ваше приложение с зияющими дырами в безопасности.

Мы собираемся рассказать, как создать безопасный рабочий процесс сброса пароля. Мы будем использовать NodeJS и MySQL в качестве базовых компонентов. Если вы пишете с использованием другого языка, платформы или базы данных, вы все равно можете воспользоваться общими «Советами по безопасности», изложенными в каждом разделе.

Процесс сброса пароля состоит из следующих компонентов:

  • Ссылка для отправки пользователя к началу рабочего процесса.
  • Форма, которая позволяет пользователю отправить свою электронную почту.
  • Поиск, который проверяет электронную почту и отправляет электронную почту на адрес.
  • Электронное письмо, содержащее токен сброса с истечением срока действия, который позволяет пользователю сбросить свой пароль.
  • Форма, позволяющая пользователю создать новый пароль.
  • Сохранение нового пароля и разрешение пользователю снова войти в систему с новым паролем.

Помимо Node, Express и MySQL, мы будем использовать следующие библиотеки:

  • Упорядочить ORM
  • Nodemailer

Sequelize — это ORM базы данных NodeJS, который упрощает выполнение миграции базы данных, а также создание запросов безопасности. Nodemailer — это популярная библиотека электронной почты NodeJS, которую мы будем использовать для отправки электронных писем для сброса пароля.

Совет по безопасности №1

В некоторых статьях предлагается разработать безопасные потоки паролей с использованием веб-токенов JSON (JWT), которые устраняют необходимость в хранилище базы данных (и, следовательно, их проще реализовать). Мы не используем этот подход на нашем сайте, потому что секреты токенов JWT обычно хранятся прямо в коде. Мы хотим избежать «одного секрета», чтобы управлять ими всеми (по той же причине, по которой вы не добавляете пароли с одним и тем же значением), и поэтому нам нужно переместить эту информацию в базу данных.

Еще после прыжка! Продолжить чтение ниже ↓

Установка

Сначала установите Sequelize, Nodemailer и другие связанные библиотеки:

 $ npm install --save sequelize sequelize-cli mysql crypto nodemailer

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

 const nodemailer = require('nodemailer');

И настройте его с вашими учетными данными SMTP электронной почты.

 const transport = nodemailer.createTransport({ host: process.env.EMAIL_HOST, port: process.env.EMAIL_PORT, secure: true, auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS } });

Решение для электронной почты, которое я использую, — это простая служба электронной почты AWS, но вы можете использовать что угодно (Mailgun и т. д.).

Если вы впервые настраиваете службу отправки электронной почты, вам потребуется потратить некоторое время на настройку соответствующих ключей домена и настройку авторизации. Если вы используете Route 53 вместе с SES, это очень просто и выполняется практически автоматически, поэтому я выбрал его. У AWS есть несколько учебных пособий о том, как SES работает с Route53.

Совет по безопасности №2

Чтобы хранить учетные данные отдельно от моего кода, я использую dotenv, который позволяет мне создать локальный файл .env с моими переменными среды. Таким образом, при развертывании в рабочей среде я могу использовать разные производственные ключи, которые не видны в коде, и, следовательно, позволяет мне ограничить разрешения моей конфигурации только определенными членами моей команды.

Настройка базы данных

Поскольку мы собираемся отправлять токены сброса пользователям, нам нужно хранить эти токены в базе данных.

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

Если вы еще не использовали Sequelize в своем приложении, вы можете настроить его, выполнив приведенную ниже команду в корневой папке вашего приложения:

 $ sequelize init

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

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

Давайте воспользуемся инструментом командной строки Sequelize, чтобы сгенерировать для нас таблицу базы данных.

 $ sequelize model:create --name ResetToken --attributes email:string,token:string,expiration:date,used:integer $ sequelize db:migrate

Эта таблица имеет следующие столбцы:

  • адрес электронной почты пользователя,
  • Токен, который был сгенерирован,
  • Срок действия этого токена,
  • Был ли токен использован или нет.

В фоновом режиме sequenceize-cli выполняет следующий SQL-запрос:

 CREATE TABLE `ResetTokens` ( `id` int(11) NOT NULL AUTO_INCREMENT, `email` varchar(255) DEFAULT NULL, `token` varchar(255) DEFAULT NULL, `expiration` datetime DEFAULT NULL, `createdAt` datetime NOT NULL, `updatedAt` datetime NOT NULL, `used` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

Убедитесь, что это работает правильно, используя клиент SQL или командную строку:

 mysql> describe ResetTokens; +------------+--------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +------------+--------------+------+-----+---------+----------------+ | id | int(11) | NO | PRI | NULL | auto_increment | | email | varchar(255) | YES | | NULL | | | token | varchar(255) | YES | | NULL | | | expiration | datetime | YES | | NULL | | | createdAt | datetime | NO | | NULL | | | updatedAt | datetime | NO | | NULL | | | used | int(11) | NO | | 0 | | +------------+--------------+------+-----+---------+----------------+ 7 rows in set (0.00 sec)

Совет по безопасности №3

Если вы в настоящее время не используете ORM, вам следует подумать об этом. ORM автоматизирует написание и правильное экранирование SQL-запросов, делая ваш код более читабельным и безопасным по умолчанию. Они помогут вам избежать атак SQL-инъекций, правильно избегая ваших SQL-запросов.

Настройка маршрута сброса пароля

Создайте маршрут получения в user.js :

 router.get('/forgot-password', function(req, res, next) { res.render('user/forgot-password', { }); });

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

Советы по безопасности № 4–6

  1. Даже если мы не находим адрес электронной почты, мы возвращаем статус «ok». Мы не хотим, чтобы недобросовестные боты выясняли, какие электронные письма в нашей базе данных настоящие, а какие нет.
  2. Чем больше случайных байтов вы используете в токене, тем меньше вероятность его взлома. Мы используем 64 случайных байта в нашем генераторе токенов (не используйте меньше 8).
  3. Срок действия токена истекает через 1 час. Это ограничивает окно времени, в течение которого работает токен сброса.
 router.post('/forgot-password', async function(req, res, next) { //ensure that you have a user with this email var email = await User.findOne({where: { email: req.body.email }}); if (email == null) { /** * we don't want to tell attackers that an * email doesn't exist, because that will let * them use this form to find ones that do * exist. **/ return res.json({status: 'ok'}); } /** * Expire any tokens that were previously * set for this user. That prevents old tokens * from being used. **/ await ResetToken.update({ used: 1 }, { where: { email: req.body.email } }); //Create a random reset token var fpSalt = crypto.randomBytes(64).toString('base64'); //token expires after one hour var expireDate = new Date(new Date().getTime() + (60 * 60 * 1000)) //insert token data into DB await ResetToken.create({ email: req.body.email, expiration: expireDate, token: fpSalt, used: 0 }); //create email const message = { from: process.env.SENDER_ADDRESS, to: req.body.email, replyTo: process.env.REPLYTO_ADDRESS, subject: process.env.FORGOT_PASS_SUBJECT_LINE, text: 'To reset your password, please click the link below.\n\nhttps://'+process.env.DOMAIN+'/user/reset-password?token='+encodeURIComponent(token)+'&email='+req.body.email }; //send email transport.sendMail(message, function (err, info) { if(err) { console.log(err)} else { console.log(info); } }); return res.json({status: 'ok'}); });

Вы увидите указанную выше пользовательскую переменную — что это? Для целей этого руководства мы предполагаем, что у вас есть модель пользователя, которая подключается к вашей базе данных для получения значений. Приведенный выше код основан на Sequelize, но вы можете изменить его по мере необходимости, если будете запрашивать базу данных напрямую (но я рекомендую Sequelize!).

Теперь нам нужно сгенерировать представление. При использовании Bootstrap CSS, jQuery и фреймворка pug, встроенного в фреймворк Node Express, представление выглядит следующим образом:

 extends ../layout block content div.container div.row div.col h1 Forgot password p Enter your email address below. If we have it on file, we will send you a reset email. div.forgot-message.alert.alert-success() Email address received. If you have an email on file we will send you a reset email. Please wait a few minutes and check your spam folder if you don't see it. form#forgotPasswordForm.form-inline(onsubmit="return false;") div.form-group label.sr-only(for="email") Email address: input.form-control.mr-2#emailFp(type='email', name='email', placeholder="Email address") div.form-group.mt-1.text-center button#fpButton.btn.btn-success.mb-2(type='submit') Send email script. $('#fpButton').on('click', function() { $.post('/user/forgot-password', { email: $('#emailFp').val(), }, function(resp) { $('.forgot-message').show(); $('#forgotPasswordForm').remove(); }); });

Вот форма на странице:

поле сброса пароля для безопасного рабочего процесса сброса пароля
Форма сброса пароля. (Большой превью)

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

Настройте маршрут «Сброс пароля»

Теперь давайте продолжим и настроим остальную часть рабочего процесса.

Добавьте модуль Sequelize.Op к вашему маршруту:

 const Sequelize = require('sequelize'); const Op = Sequelize.Op;

Теперь давайте построим маршрут GET для пользователей, которые щелкнули ссылку для сброса пароля. Как вы увидите ниже, мы хотим убедиться, что правильно проверяем токен сброса.

Совет по безопасности № 7:

Убедитесь, что вы ищете только те токены сброса, срок действия которых не истек и которые не использовались.

В демонстрационных целях я также очищаю здесь все токены с истекшим сроком действия при загрузке, чтобы таблица оставалась небольшой. Если у вас большой веб-сайт, перенесите его на cronjob.

 router.get('/reset-password', async function(req, res, next) { /** * This code clears all expired tokens. You * should move this to a cronjob if you have a * big site. We just include this in here as a * demonstration. **/ await ResetToken.destroy({ where: { expiration: { [Op.lt]: Sequelize.fn('CURDATE')}, } }); //find the token var record = await ResetToken.findOne({ where: { email: req.query.email, expiration: { [Op.gt]: Sequelize.fn('CURDATE')}, token: req.query.token, used: 0 } }); if (record == null) { return res.render('user/reset-password', { message: 'Token has expired. Please try password reset again.', showForm: false }); } res.render('user/reset-password', { showForm: true, record: record }); });

Теперь давайте создадим маршрут POST, который будет активирован, когда пользователь введет свои новые данные пароля.

Совет по безопасности с 8 по 11:

  • Убедитесь, что пароли совпадают и соответствуют вашим минимальным требованиям.
  • Еще раз проверьте токен сброса, чтобы убедиться, что он не использовался и срок его действия не истек. Нам нужно проверить это снова, потому что токен отправляется пользователем через форму.
  • Перед сбросом пароля пометьте токен как использованный. Таким образом, если произойдет что-то непредвиденное (например, сбой сервера), пароль не будет сброшен, пока токен все еще действителен.
  • Используйте криптографически безопасную случайную соль (в данном случае мы используем 64 случайных байта).
 router.post('/reset-password', async function(req, res, next) { //compare passwords if (req.body.password1 !== req.body.password2) { return res.json({status: 'error', message: 'Passwords do not match. Please try again.'}); } /** * Ensure password is valid (isValidPassword * function checks if password is >= 8 chars, alphanumeric, * has special chars, etc) **/ if (!isValidPassword(req.body.password1)) { return res.json({status: 'error', message: 'Password does not meet minimum requirements. Please try again.'}); } var record = await ResetToken.findOne({ where: { email: req.body.email, expiration: { [Op.gt]: Sequelize.fn('CURDATE')}, token: req.body.token, used: 0 } }); if (record == null) { return res.json({status: 'error', message: 'Token not found. Please try the reset password process again.'}); } var upd = await ResetToken.update({ used: 1 }, { where: { email: req.body.email } }); var newSalt = crypto.randomBytes(64).toString('hex'); var newPassword = crypto.pbkdf2Sync(req.body.password1, newSalt, 10000, 64, 'sha512').toString('base64'); await User.update({ password: newPassword, salt: newSalt }, { where: { email: req.body.email } }); return res.json({status: 'ok', message: 'Password reset. Please login with your new password.'}); }); And again, the view: extends ../layout block content div.container div.row div.col h1 Reset password p Enter your new password below. if message div.reset-message.alert.alert-warning #{message} else div.reset-message.alert() if showForm form#resetPasswordForm(onsubmit="return false;") div.form-group label(for="password1") New password: input.form-control#password1(type='password', name='password1') small.form-text.text-muted Password must be 8 characters or more. div.form-group label(for="password2") Confirm new password input.form-control#password2(type='password', name='password2') small.form-text.text-muted Both passwords must match. input#emailRp(type='hidden', name='email', value=record.email) input#tokenRp(type='hidden', name='token', value=record.token) div.form-group button#rpButton.btn.btn-success(type='submit') Reset password script. $('#rpButton').on('click', function() { $.post('/user/reset-password', { password1: $('#password1').val(), password2: $('#password2').val(), email: $('#emailRp').val(), token: $('#tokenRp').val() }, function(resp) { if (resp.status == 'ok') { $('.reset-message').removeClass('alert-danger').addClass('alert-success').show().text(resp.message); $('#resetPasswordForm').remove(); } else { $('.reset-message').removeClass('alert-success').addClass('alert-danger').show().text(resp.message); } }); });

Вот как это должно выглядеть:

форма сброса пароля для безопасного рабочего процесса сброса пароля
Форма сброса пароля. (Большой превью)

Добавьте ссылку на свою страницу входа

Наконец, не забудьте добавить ссылку на этот поток со страницы входа! Как только вы это сделаете, у вас должен быть работающий поток сброса пароля. Обязательно тщательно тестируйте на каждом этапе процесса, чтобы убедиться, что все работает, а ваши токены имеют короткий срок действия и помечаются правильным статусом по мере продвижения рабочего процесса.

Следующие шаги

Надеюсь, это помогло вам создать безопасную и удобную функцию сброса пароля.

  • Если вам интересно узнать больше о криптографической безопасности, я рекомендую краткую информацию из Википедии (осторожно, она слишком объемная!).
  • Если вы хотите повысить безопасность аутентификации вашего приложения, обратите внимание на 2FA. Есть много разных вариантов.
  • Если я отпугнул вас от создания собственного процесса сброса пароля, вы можете положиться на сторонние системы входа, такие как Google и Facebook. PassportJS — это промежуточное ПО, которое вы можете использовать для NodeJS, реализующее эти стратегии.