Создание безопасных потоков паролей с помощью NodeJS и MySQL
Опубликовано: 2022-03-10Если вы чем-то похожи на меня, вы не раз забывали свой пароль, особенно на сайтах, которые вы давно не посещали. Вы, вероятно, также видели и / или были огорчены электронными письмами для сброса пароля, которые содержат ваш пароль в виде обычного текста.
К сожалению, рабочему процессу сброса пароля уделяется мало внимания при разработке приложений. Это не только может привести к разочарованию пользователя, но также может оставить ваше приложение с зияющими дырами в безопасности.
Мы собираемся рассказать, как создать безопасный рабочий процесс сброса пароля. Мы будем использовать 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
- Даже если мы не находим адрес электронной почты, мы возвращаем статус «ok». Мы не хотим, чтобы недобросовестные боты выясняли, какие электронные письма в нашей базе данных настоящие, а какие нет.
- Чем больше случайных байтов вы используете в токене, тем меньше вероятность его взлома. Мы используем 64 случайных байта в нашем генераторе токенов (не используйте меньше 8).
- Срок действия токена истекает через 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, реализующее эти стратегии.