个性化阅读
专注于IT技术分析

使用NodeJS和MySQL创建安全的密码流

如果你和我一样, 便会忘记密码一次以上, 尤其是在你一段时间没有访问过的网站上。你可能还会看到重置密码电子邮件和(或)被这些重置密码电子邮件所破坏, 其中包含以纯文本格式显示的密码。

不幸的是, 在应用程序开发过程中, 重置密码工作流变得简短而注意力有限。这不仅会导致令人沮丧的用户体验, 而且还会使你的应用程序存在巨大的安全漏洞。

我们将介绍如何构建安全的重置密码工作流程。我们将使用NodeJS和MySQL作为基本组件。如果你使用其他语言, 框架或数据库进行编写, 则仍然可以从遵循每个部分中概述的“安全性提示”中受益。

重置密码流包含以下组件:

  • 用于将用户发送到工作流程开始的链接。
  • 允许用户提交电子邮件的表格。
  • 查找, 用于验证电子邮件并将电子邮件发送到该地址。
  • 包含重置令牌的电子邮件, 其过期时间允许用户重置其密码。
  • 允许用户生成新密码的表单。
  • 保存新密码, 然后让用户使用新密码再次登录。

除了Node, Express和MySQL外, 我们还将使用以下库:

  • Sequelize ORM
  • Nodemailer

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数据库服务器。

让我们使用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中创建获取路由:

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();
  expireDate.setDate(expireDate.getDate() + 1/24);
 
  //insert token data into DB
  await ResetToken.create({
    email: req.body.email, expiration: expireDate, token: token, 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'});
});

你会看到上面引用的User变量-这是什么?就本教程而言, 我们假设你有一个User模型, 该模型连接到数据库以检索值。上面的代码基于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(style="display:none;") 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(style='display:none;')
        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);
        }
      });
    });

它应该是这样的:

重置密码表单,以确保安全的重置密码工作流程

你的重置密码表格。 (

大型预览

)

将链接添加到你的登录页面

最后, 不要忘了在你的登录页面上添加指向此流程的链接!完成此操作后, 你应该可以使用重置密码流程。确保在流程的每个阶段都进行彻底的测试, 以确认一切正常, 并且令牌的有效期较短, 并且随着工作流程的进行, 令牌的状态正确。

下一步

希望这可以帮助你编码安全, 用户友好的重置密码功能。

  • 如果你有兴趣了解有关密码安全性的更多信息, 建议你参考Wikipedia的摘要(警告, 它太密集了!)。
  • 如果你想为应用的身份验证增加更多的安全性, 请查看2FA。那里有很多不同的选择。
  • 如果我担心你无法建立自己的重置密码流程, 则可以依靠Google和Facebook等第三方登录系统。 PassportJS是可用于实现这些策略的NodeJS的中间件。
赞(0)
未经允许不得转载:srcmini » 使用NodeJS和MySQL创建安全的密码流

评论 抢沙发

评论前必须登录!