如何在 ExpressJS 中处理密码重置

2025-06-07

如何在 ExpressJS 中处理密码重置

如果没有密码重置功能,任何身份验证系统都是不完整的。我个人绝不会发布不包含此功能的产品。有必要为用户提供一种在丢失或忘记密码的情况下恢复其帐户/数据访问权限的方法。在本文中,我将演示如何在 ExpressJS 中处理密码重置。

在前两篇文章中,我写了如何将 ExpressJS 应用程序连接到 MongoDB 数据库以及构建用户注册和身份验证系统

这两篇文章都与今天的文章相关。我们将使用 mongoose 和我们保存的用户数据来启用密码重置。

如果您已经阅读过这些文章,或者已经拥有自己的身份验证系统,请继续阅读。即使您使用的是其他技术栈,您仍然可以从这种方法中获得一些有价值的想法。

与往常一样,该项目托管在Github上。欢迎随意克隆该项目以获取我在本文中使用的源代码。

密码重置流程

在深入研究代码之前,让我们首先从用户的角度确定密码重置流程是什么样的,然后设计该流程的实现。

用户视角

从用户的角度来看,该过程应该如下:

  1. 点击登录页面中的“忘记密码”链接。
  2. 重定向到需要电子邮件地址的页面。
  3. 在电子邮件中接收密码重置链接。
  4. 链接重定向到需要输入新密码和密码确认的页面。
  5. 提交后,重定向到登录页面并显示成功消息。

重置系统特性

我们还需要了解良好密码重置系统的一些特点:

  1. 应该为用户生成唯一的密码重置链接,以便用户访问该链接时能够立即被识别。这意味着链接中需要包含一个唯一的令牌。
  2. 密码重置链接应该有一个有效期(例如 2 小时),超过该时间后它将不再有效并且不能用于重置密码。
  3. 重置链接应在密码重置后过期,以防止使用同一链接多次重置密码。
  4. 如果用户多次请求更改密码,且未完成整个流程,则每次生成的链接都应使前一个链接失效。这可以防止出现多个可重置密码的有效链接。
  5. 如果用户选择忽略发送到其电子邮件的密码重置链接,则其当前凭据应保持不变且对将来的身份验证有效。

实施步骤

现在,我们已经从用户的角度清晰地了解了重置流程,并了解了密码重置系统的特点。以下是我们在实施该系统时将采取的步骤:

  1. 创建一个名为“PasswordReset”的 Mongoose 模型来管理活动的密码重置请求/令牌。此处设置的记录应在指定时间段后过期。
  2. 在登录表单中包含“忘记密码”链接,该链接指向包含电子邮件表单的路线。
  3. 一旦电子邮件提交到邮寄路线,请检查是否存在具有所提供电子邮件地址的用户。
  4. 如果用户不存在,则重定向回电子邮件输入表单并通知用户未找到提供电子邮件的用户。
  5. 如果用户存在,则生成一个密码重置令牌,并将其保存到引用该用户的文档的 PasswordReset 集合中。如果此集合中已存在与该用户关联的文档,则更新/替换当前文档(每个用户只能有一个)。
  6. 生成一个包含密码重置令牌的链接,并通过电子邮件将该链接发送给用户。
  7. 重定向到登录页面并显示成功消息,提示用户检查其电子邮件地址以获取重置链接。
  8. 一旦用户点击链接,它就会引导至 GET 路由,该路由将令牌作为路由参数之一。
  9. 在此路由中,提取令牌并查询 PasswordReset 集合以获取此令牌。如果未找到文档,则提醒用户该链接无效/已过期。
  10. 如果找到该文档,请加载表单以重置密码。该表单应包含两个字段(新密码字段和确认密码字段)。
  11. 当表单提交时,其发布路由将把用户的密码更新为新密码。
  12. 删除 PasswordReset 集合中与此用户关联的密码重置文档。
  13. 将用户重定向到登录页面并显示成功消息。

执行

设置

首先,我们需要设置项目。安装uuid包用于生成唯一 token,以及nodemailer包用于发送电子邮件。

npm install uuid nodemailer

将完整域名添加到环境变量。我们需要它来生成一个链接,用于发送给用户的电子邮件。

DOMAIN=http://localhost:8000

对应用程序入口文件进行以下几方面的修改:

  1. 在 mongoose 连接选项中将“useCreateIndex”设置为“true”。这将使 mongoose 的默认索引构建使用 createIndex 而不是 EnsureIndex,并避免出现 MongoDB 弃用警告。
  2. 导入一个名为“password-reset”的新路由文件,其中包含所有重置路由。我们稍后会创建这些路由。
const connection = mongoose.connect(process.env.MONGO_URI, {
  useNewUrlParser: true,
  useUnifiedTopology: true,
  useCreateIndex: true
})

...

app.use('/', require('./routes/password-reset'))
Enter fullscreen mode Exit fullscreen mode

模型

我们需要一个专门的模型来处理密码重置记录。在 models 文件夹中,创建一个名为 “PasswordReset” 的模型,代码如下:

const { Schema, model } = require('mongoose')

const schema = new Schema({
  user: {
    type: Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  token: {
    type: Schema.Types.String,
    required: true
  }
}, {
  timestamps: true
})

schema.index({ 'updatedAt': 1 }, { expireAfterSeconds: 300 })

const PasswordReset = model('PasswordReset', schema)

module.exports = PasswordReset
Enter fullscreen mode Exit fullscreen mode

这个模型有两个属性:请求重置密码的用户和分配给特定请求的唯一令牌。

确保将时间戳选项设置为 true,以便在文档中包含“createdAt”和“updatedAt”字段。

定义好 schema 后,在 updatedAt 字段上创建一个索引,并将有效期设置为 300 秒(5 分钟)。为了测试目的,我设置了这个较低的值。在生产环境中,你可以将其增加到更实际的时长,例如 2 小时。

在我们在本文中创建的用户模型(或您当前拥有的用户模型)中,将预保存挂钩更新为以下内容:

userSchema.pre('save', async function(next){
  if (this.isNew || this.isModified('password')) this.password = await bcrypt.hash(this.password, saltRounds)
  next()
})
Enter fullscreen mode Exit fullscreen mode

这样做是为了确保无论文档是新的还是现有文档中的密码字段已被更改,密码字段都是经过散列处理的。

路线

在路由文件夹中创建一个名为“password-reset.js”的新文件。这是我们在应用程序入口文件中导入的文件。

在此文件中,导入 User 和 PasswordReset 模型。从 uuid 包中导入 v4 函数用于生成令牌。

const router  = require('express').Router()
const { User, PasswordReset } = require('../models')
const { v4 } = require('uuid')

/* Create routes here */

module.exports = router
Enter fullscreen mode Exit fullscreen mode

创建前两个路由。这些路由与接受用户电子邮件地址的表单相关联。

router.get('/reset', (req, res) => res.render('reset.html'))

router.post('/reset', async (req, res) => {
  /* Flash email address for pre-population in case we redirect back to reset page. */
  req.flash('email', req.body.email)

  /* Check if user with provided email exists. */
  const user = await User.findOne({ email: req.body.email })
  if (!user) {
    req.flash('error', 'User not found')
    return res.redirect('/reset')
  }

  /* Create a password reset token and save in collection along with the user. 
     If there already is a record with current user, replace it. */
  const token = v4().toString().replace(/-/g, '')
  PasswordReset.updateOne({ 
    user: user._id 
  }, {
    user: user._id,
    token: token
  }, {
    upsert: true
  })
  .then( updateResponse => {
    /* Send email to user containing password reset link. */
    const resetLink = `${process.env.DOMAIN}/reset-confirm/${token}`
    console.log(resetLink)

    req.flash('success', 'Check your email address for the password reset link!')
    return res.redirect('/login')
  })
  .catch( error => {
    req.flash('error', 'Failed to generate reset link, please try again')
    return res.redirect('/reset')
  })
})
Enter fullscreen mode Exit fullscreen mode

第一个是指向 '/reset' 的 GET 路由。在这个路由中,渲染 'reset.html' 模板。我们稍后会创建这个模板。

第二个路由是用于“/reset”的 POST 路由。该路由要求在请求正文中输入用户的电子邮件地址。在这个路由中:

  1. 当我们重定向回电子邮件表单时,闪回电子邮件进行预填充。
  2. 检查所提供邮箱地址的用户是否存在。如果不存在,则闪现错误并重定向回“/reset”。
  3. 使用 v4 创建令牌。
  4. 更新与当前用户关联的 PasswordReset 文档。如果尚无文档,请在选项中将 upsert 设置为 true 以创建新文档。
  5. 如果更新成功,则将链接发送给用户,闪现成功消息并重定向到登录页面。
  6. 如果更新不成功,则会闪现错误消息并重定向回电子邮件页面。

目前,我们只将链接打印到控制台。稍后我们将实现电子邮件逻辑。

创建当用户访问上面生成的链接时发挥作用的 2 条路线。

router.get('/reset-confirm/:token', async (req, res) => {
  const token = req.params.token
  const passwordReset = await PasswordReset.findOne({ token })
  res.render('reset-confirm.html', { 
    token: token,
    valid: passwordReset ? true : false
  })
})

router.post('/reset-confirm/:token', async (req, res) => {
  const token = req.params.token
  const passwordReset = await PasswordReset.findOne({ token })

  /* Update user */
  let user = await User.findOne({ _id: passwordReset.user })
  user.password = req.body.password

  user.save().then( async savedUser =>  {
    /* Delete password reset document in collection */
    await PasswordReset.deleteOne({ _id: passwordReset._id })
    /* Redirect to login page with success message */
    req.flash('success', 'Password reset successful')
    res.redirect('/login')
  }).catch( error => {
    /* Redirect back to reset-confirm page */
    req.flash('error', 'Failed to reset password please try again')
    return res.redirect(`/reset-confirm/${token}`)
  })
})
Enter fullscreen mode Exit fullscreen mode

第一个路由是 get 路由,它需要 URL 中的 token。token 会被提取并验证。通过在 PasswordReset 集合中搜索包含 token 的文档来验证 token。

如果找到文档,则将“valid”模板变量设置为 true,否则,将其设置为 false。务必将令牌本身传递给模板。我们将在密码重置表单中使用它。

通过令牌搜索PasswordReset集合来检查令牌的有效性。

第二个路由是 POST 路由,用于接受密码重置表单的提交。从 URL 中提取 token,然后检索与其关联的密码重置文档。

更新与此特定密码重置文档关联的用户。设置新密码并保存更新后的用户。

一旦用户更新,请删除密码重置文档,以防止其被重新用于重置密码。

闪现成功消息并将用户重定向到登录页面,用户可以使用新密码登录。

如果更新不成功,则闪现错误消息并重定向回同一表单。

模板

创建路线后,我们需要创建模板

在views文件夹中,创建一个“reset.html”模板文件,其内容如下:

{% extends 'base.html' %}

{% set title = 'Reset' %}

{% block styles %}
{% endblock %}

{% block content %}
  <form action='/reset' method="POST">
    {% if messages.error %}
      <div class="alert alert-danger" role="alert">{{ messages.error }}</div>
    {% endif %}
    <div class="mb-3">
      <label for="name" class="form-label">Enter your email address</label>
      <input 
        type="text" 
        class="form-control {% if messages.error %}is-invalid{% endif %}" 
        id="email" 
        name="email"
        value="{{ messages.email or '' }}"
        required>
    </div>
    <div>
      <button type="submit" class="btn btn-primary">Send reset link</button>
    </div>
  </form>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

这里我们有一个电子邮件字段,如果在上一个请求中闪现过,则该字段会预先填充电子邮件值。

如果上一个请求中闪现了错误信息,则包含一个显示错误消息的警报。

在同一文件夹中创建另一个名为“reset-confirm.html”的模板,其内容如下:

{% extends 'base.html' %}

{% set title = 'Confirm Reset' %}

{% block content %}
  {% if not valid %}
    <h1>Oops, looks like this link is expired, try to <a href="/reset">generate another reset link</a></h1>
  {% else %}
    <form action='/reset-confirm/{{ token }}' method="POST">
      {% if messages.error %}
        <div class="alert alert-danger" role="alert">{{ messages.error }}</div>
      {% endif %}
      <div class="mb-3">
        <label for="name" class="form-label">Password</label>
        <input 
          type="password" 
          class="form-control {% if messages.password_error %}is-invalid{% endif %}" 
          id="password" 
          name="password">
        <div class="invalid-feedback">{{ messages.password_error }}</div>
      </div>
      <div class="mb-3">
        <label for="name" class="form-label">Confirm password</label>
        <input 
          type="password" 
          class="form-control {% if messages.confirm_error %}is-invalid{% endif %}" 
          id="confirmPassword" 
          name="confirmPassword">
        <div class="invalid-feedback">{{ messages.confirm_error }}</div>
      </div>
      <div>
        <button type="submit" class="btn btn-primary">Confirm reset</button>
      </div>
    </form>
  {% endif %}
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

在此表单中,检查我们在 GET 路由中设置的 'valid' 变量的值,如果为 false,则渲染令牌过期消息。否则,渲染密码重置表单。

如果在之前的请求中出现过错误,则包含一个显示错误消息的警报。

转到我们在注册和身份验证文章中创建的登录表单,并将以下代码添加到表单顶部:

{% if messages.success %}
    <div class="alert alert-success" role="alert">{{ messages.success }}</div>
{% endif %}
Enter fullscreen mode Exit fullscreen mode

这将呈现我们在创建/发送重置链接时以及在重定向到登录页面之前更新用户密码时闪现的成功消息。

邮件

在上一节的路由中,我们在控制台中记录了重置链接。理想情况下,当用户请求密码重置链接时,我们应该向用户发送一封电子邮件。

在此示例中,我使用ethereal.email生成了一个用于开发目的的测试邮箱账户。您可以直接前往该邮箱创建一个(只需单击一下即可)。

创建测试帐户后,将以下变量添加到环境变量中:

EMAIL_HOST=smtp.ethereal.email
EMAIL_NAME=Leanne Zulauf
EMAIL_ADDRESS=leanne.zulauf@ethereal.email
EMAIL_PASSWORD=aDhwfMry1h3bbbR9Av
EMAIL_PORT=587
EMAIL_SECURITY=STARTTLS
Enter fullscreen mode Exit fullscreen mode

这些是我在写作时的价值观,请在此处插入您自己的价值观。

在项目根目录中创建一个“helpers.js”文件。该文件包含一系列可能在整个项目中重复使用的实用函数。

在这里定义这些函数,以便我们可以在需要时导入它们,而不是在整个应用程序中重复类似的逻辑。

const nodemailer = require('nodemailer')

module.exports = {
  sendEmail: async ({ to, subject, text }) => {
    /* Create nodemailer transporter using environment variables. */
    const transporter = nodemailer.createTransport({
      host: process.env.EMAIL_HOST,
      port: Number(process.env.EMAIL_PORT),
      auth: {
        user: process.env.EMAIL_ADDRESS,
        pass: process.env.EMAIL_PASSWORD
      }
    })
    /* Send the email */
    let info = await transporter.sendMail({
      from: `"${process.env.EMAIL_NAME}" <${process.env.EMAIL_ADDRESS}>`,
      to,
      subject,
      text
    })
    /* Preview only available when sending through an Ethereal account */
    console.log(`Message preview URL: ${nodemailer.getTestMessageUrl(info)}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

导出一个具有各种功能的对象。第一个是“sendEmail”函数。

此函数接收收件人的地址、邮件主题和邮件正文。使用先前在选项中定义的环境变量创建 NodeMailer 传输器。使用传递给函数的参数发送电子邮件。

该函数的最后一行将消息 URL 记录到控制台中,以便您可以在 Ethereal 邮件中查看该消息。测试帐户实际上并不发送电子邮件。

返回“password-reset.js”路由并添加电子邮件功能。首先,导入函数:

const { sendEmail } = require('../helpers')
Enter fullscreen mode Exit fullscreen mode

在“/reset”POST 路由中,不要在控制台上记录重置链接,而是添加以下代码:

sendEmail({
      to: user.email, 
      subject: 'Password Reset',
      text: `Hi ${user.name}, here's your password reset link: ${resetLink}. 
      If you did not request this link, ignore it.`
    })
Enter fullscreen mode Exit fullscreen mode

一旦用户成功更新,就通过“/reset-confirm”POST 路由发送一封额外的电子邮件,通知用户密码更改成功:

user.save().then( async savedUser =>  {
    /* Delete password reset document in collection */
    await PasswordReset.deleteOne({ _id: passwordReset._id })
    /* Send successful password reset email */
    sendEmail({
      to: user.email, 
      subject: 'Password Reset Successful',
      text: `Congratulations ${user.name}! Your password reset was successful.`
    })
    /* Redirect to login page with success message */
    req.flash('success', 'Password reset successful')
    res.redirect('/login')
  }).catch( error => {
    /* Redirect back to reset-confirm page */
    req.flash('error', 'Failed to reset password please try again')
    return res.redirect(`/reset-confirm/${token}`)
  })
Enter fullscreen mode Exit fullscreen mode

结论

在本文中,我演示了如何使用 NodeMailer 在 ExpressJS 中实现密码重置功能。

下一篇文章,我将介绍如何在 Express 应用程序中实现用户电子邮件验证系统。我将使用与本文类似的方法,并选择 NodeMailer 作为电子邮件包。

如何在 ExpressJS 中处理密码重置一文首先出现在Kelvin Mwinuka上。

如果您喜欢这篇文章,不妨关注我的网站,以便在内容发布之前抢先体验(别担心,它仍然免费,而且没有烦人的弹出广告!)。也欢迎您随时对这篇文章发表评论。我很想听听您的想法!

文章来源:https://dev.to/kelvinvmwinuka/how-to-handle-password-reset-in-expressjs-ipb
PREV
弹性项目不是网格列 场景:一行中三个不同大小的文本项目 老式酷炫的弹性框网格 差异 结论 尾注:表格
NEXT
2020 年如何编写 React 测试(第二部分)