使用 NodeJS 和 MySQL 创建安全密码流
已发表: 2022-03-10如果您像我一样,您已经不止一次忘记了密码,尤其是在您有一段时间没有访问过的网站上。 您可能还看到过,和/或被包含纯文本密码的重置密码电子邮件所困扰。
不幸的是,在应用程序开发过程中,重置密码工作流程受到冷落和有限的关注。 这不仅会导致令人沮丧的用户体验,还会给您的应用程序留下巨大的安全漏洞。
我们将介绍如何构建安全的重置密码工作流程。 我们将使用 NodeJS 和 MySQL 作为我们的基础组件。 如果您使用不同的语言、框架或数据库进行编写,您仍然可以从遵循每个部分中概述的一般“安全提示”中受益。
重置密码流程由以下组件组成:
- 将用户引导至工作流开始的链接。
- 允许用户提交电子邮件的表单。
- 验证电子邮件并向地址发送电子邮件的查找。
- 一封包含重置令牌的电子邮件,其有效期允许用户重置其密码。
- 让用户生成新密码的表单。
- 保存新密码并让用户使用新密码再次登录。
除了 Node、Express 和 MySQL,我们将使用以下库:
- 续集 ORM
- 节点邮件程序
Sequelize 是一个 NodeJS 数据库 ORM,它可以更轻松地运行数据库迁移以及安全创建查询。 Nodemailer 是一个流行的 NodeJS 电子邮件库,我们将使用它来发送密码重置电子邮件。
安全提示 #1
一些文章建议可以使用 JSON Web 令牌 (JWT) 设计安全密码流,从而消除对数据库存储的需求(因此更容易实现)。 我们不会在我们的网站上使用这种方法,因为 JWT 令牌秘密通常存储在代码中。 我们希望避免使用“一个秘密”来统治所有这些(出于同样的原因,您不会使用相同的值对密码进行加盐),因此需要将此信息移动到数据库中。
安装
首先,安装 Sequelize、Nodemailer 和其他相关库:
$ npm install --save sequelize sequelize-cli mysql crypto nodemailer
在要包含重置工作流的路径中,添加所需的模块。 如果您需要复习 Express 和路线,请查看他们的指南。
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 CLI。
如果您还没有在您的应用程序中使用 Sequelize,您可以通过在应用程序的根文件夹中运行以下命令来设置它:
$ sequelize init
这将在您的设置中创建许多新文件夹,包括迁移和模型。
这也将创建一个配置文件。 在您的配置文件中,使用本地 mysql 数据库服务器的凭据更新development
块。
让我们使用 Sequelize 的 CLI 工具为我们生成数据库表。
$ sequelize model:create --name ResetToken --attributes email:string,token:string,expiration:date,used:integer $ sequelize db:migrate
此表具有以下列:
- 用户的电子邮件地址,
- 已生成的令牌,
- 该令牌到期,
- 令牌是否已被使用。
在后台,sequelize-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中创建 get 路由:
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 和 Node Express 框架中内置的 pug 框架,视图如下所示:
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 的中间件。