DEV Community

Cover image for How to Handle Password Reset in ExpressJS
Kelvin Mwinuka
Kelvin Mwinuka

Posted on • Originally published at kelvinmwinuka.com

How to Handle Password Reset in ExpressJS

No authentication system is complete without a password reset feature. I would personally never ship a product that did not have this feature included. It is necessary to provide a way for users to recover access to their accounts/data in case of a lost or forgotten password. In this article, I will be demonstrating how to handle password resets in ExpressJS.

In the last 2 articles, I wrote about how to connect ExpressJS application to MongoDB database and building a user registration and authentication system.

Both of these articles tie into today's article. We're going to use mongoose and our saved user data to enable password resets.

If you've read those articles, or already have your own authentication system, read on. Even if you're using a different tech stack, you may still gain some valuable ideas from this approach.

As always, this project is hosted on Github. Feel free to clone the project to get access to the source code I use in this article.

The password reset flow

Before we dive into the code, let's first establish what the password reset flow will look like from the user's perspective and then design the implementation of this flow.

User's perspective

From the user's perspective, the process should go as follows:

  1. Click on the 'Forgot password' link in the login page.
  2. Redirected to a page which requires an email address.
  3. Receive password reset link in an email.
  4. Link redirects to a page that requires a new password and password confirmation.
  5. After submission, redirected to the login page with a success message.

Reset system characteristics

We also need to understand some characteristics of a good password reset system:

  1. Unique password reset link should be generated for the user such that when the user visits the link, they are instantly identified. This means including a unique token in the link.
  2. Password reset link should have an expiry time (e.g. 2 hours) after which it is no longer valid and cannot be used to reset the password.
  3. The reset link should expire once the password has been reset to prevent the same link from being used to reset the password several times.
  4. If the user requests to change password multiple times without following through on the whole process, each generated link should invalidate the previous one. This prevents having multiple active links from which the password can be reset.
  5. If the user chooses to ignore the password reset link sent to their email, their current credentials should be left intact and valid for future authentication.

Implementation steps

We now have a clear picture of the reset flow from the user's perspective and the characteristics of a password reset system. Here are the steps we will take in the implementation of this system:

  1. Create a mongoose model called 'PasswordReset' to manage active password reset requests/tokens. The records set here should expire after a specified time period.
  2. Include the 'Forgot password' link in the login form that leads to a route that contains an email form.
  3. Once the email is submitted to a post route, check whether a user with the provided email address exists.
  4. If the user does not exist, redirect back to the email input form and notify the user that no user with provided email was found.
  5. If the user exists, generate a password reset token and save it to PasswordReset collection in a document that references the user. If there already is a document in this collection associated with this user, update/replace the current document (there can only be one per user).
  6. Generate a link that includes the password reset token within it, email the link to the user.
  7. Redirect to the login page with success message prompting the user to check their email address for the reset link.
  8. Once the user clicks the link, it should lead to a GET route that expects the token as one of the route params.
  9. Within this route, extract the token and query the PasswordReset collection for this token. If the document is not found, alert the user that the link is invalid/expired.
  10. If the document is found, load a form to reset the password. The form should have 2 fields (new password & confirm password fields).
  11. When the form is submitted, its post route will update the user's password to the new password.
  12. Delete the password reset document associated with this user in the PasswordReset collection.
  13. Redirect the user to the login page with a success message.

Implementation

The setup

Firstly, we'll have to set up the project. Install the uuid package for generating a unique token, and the nodemailer package for sending emails.

npm install uuid nodemailer

Add the full domain to the environment variables. We'll need this to generate a link to email to the user.

DOMAIN=http://localhost:8000

Make some changes to the app entry file in the following areas:

  1. Set 'useCreateIndex' to 'true' in the mongoose connection options. This makes mongoose's default index build use createIndex instead of ensureIndex and prevents MongoDB deprecation warnings.
  2. Import a new route file that will contain all the reset routes called 'password-reset'. We will create these routes later.
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

We need to have a dedicated model to handle the password reset records. In the models folder, create a model called 'PasswordReset' with the following code:

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

We have two properties in this model, the user that's requested the password reset, and the unique token assigned to the particular request.

Make sure to set the timestamps option to true in order to include 'createdAt' and 'updatedAt' fields in the document.

After defining the schema, create an index on the updatedAt field with an expiry time of 300 seconds (5 minutes). I've set it this low for testing purposes. In production, you can increase this to something more practical like 2 hours.

In the User model we created in this article (or the user model you currently have), update the pre save hook to the following:

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

Do this to make sure the password field is hashed whether the document is new or the password field has been changed in an existing document.

Routes

Create a new file in the route's folder called 'password-reset.js'. This is the file we import in the app entry file.

In this file, import the User and PasswordReset models. Import the v4 function from the uuid package for token generation.

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

Create the first 2 routes. These routes are associated with the form which accepts the user's email address.

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

The first is a GET route to '/reset'. In this route, render the 'reset.html' template. We will create this template later.

The second route is a POST route for '/reset'. This route expects the user's email in the request body. In this route:

  1. Flash email back for pre-population in case we redirect back to the email form.
  2. Check if the user with the email provided exists. If not, flash an error and redirect back to '/reset'.
  3. Create a token using v4.
  4. Update PasswordReset document associated with the current user. Set upsert to true in options to create a new document if there isn't one already.
  5. If update is successful, mail the link to the user, flash a success message and redirect to the login page.
  6. If update is unsuccessful, flash an error message and redirect back to the email page.

At the moment, we're only logging the link to the console. We will implement the email logic later.

Create the 2 routes that come into play when the user visits the link generated link above.

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

The first route is a get route that expects the token in the url. The token is extracted and then validated. Validate the token by searching the PasswordReset collection for a document with the provided token.

If the document is found, set the 'valid' template variable to true, otherwise, set it to false. Be sure to pass the token itself to the template. We will use this in the password reset form.

Check the validity of the token by searching the PasswordReset collection by token.

The second route is a POST route that accepts the password reset form submission. Extract the token from the url and then retrieve the password reset document associated with it.

Update the user associated with this particular password reset document. Set the new password and save the updated user.

Once the user is updated, delete the password reset document to prevent it from being reused to reset the password.

Flash a success message and redirect the user to the login page where they can log in with their new password.

If the update is unsuccessful, flash an error message and redirect back to the same form.

Templates

Once we've created the routes, we need to create the templates

In the views folder, create a 'reset.html' template file with the following content:

{% 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

Here we have one email field that is pre-populated with an email value if one was flashed in the previous request.

Include an alert that displays an error message if one has been flashed from the previous request.

Create another template in the same folder named 'reset-confirm.html' with the following content:

{% 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

In this form, check for the value of the 'valid' variable that we set in the GET route, if false, render the expired token message. Otherwise, render the password reset form.

Include an alert that displays an error message if one was flashed in the previous request.

Go to the login form that we created in the registration & authentication article and add the following code to the top of the form:

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

This renders the success messages that we flash when we create/send the reset link and when we update the user's password before redirecting to the login page.

Mail

In the previous routes section, we logged the reset link in the console. Ideally, we should send an email to the user when they've requested a password reset link.

For this example, I've used ethereal.email to generate a test email account for development purposes. Head over there and create one (it's a one-click process).

Once you've created the test account, add the following variables to your environment variables:

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

These are my values at the time of writing, plug in your own values here.

Create a 'helpers.js' file in the root of the project. This file will have a bunch of useful functions that are likely to be reused across the entire project.

Define these functions here so that we can import them when they're needed rather than repeating similar logic all over our application.

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

Export an object with various functions. The first being the 'sendEmail' function.

This function takes the recipient's address, email subject and email text. Create the NodeMailer transporter, using the environment variables defined previously in the options. Send the email using the arguments passed to the function.

The last line of the function logs the message url in the console so you can view the message on Ethereal mail. The test account does not actually send the email.

Go back to the 'password-reset.js' routes and add the email functionality. First, import the function:

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

In the '/reset' POST route, instead of logging the reset link on the console, add the following code:

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

Send an additional email to notify the user of a successful password change in the '/reset-confirm' POST route once the user is successfully updated:

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

Conclusion

In this article, I demonstrated how to implement a password reset feature in ExpressJS using NodeMailer.

In the next article, I will write about implementing a user email verification system in your Express application. I will use a similar approach to the one used in this article, with NodeMailer being the email package of choice.

The post How to Handle Password Reset in ExpressJS appeared first on Kelvin Mwinuka.

If you enjoyed this article, consider following my website for early access to my content before it gets published here (don’t worry, it’s still free with no annoying pop-up ads!). Also, feel free to comment on this post. I’d love to hear your thoughts!

Top comments (7)

Collapse
 
mtrcn profile image
Mete Ercan Pakdil

Hi Kelvin, great article, thanks. I just spotted that you forgot to check whether reset token is valid or not in '/reset-confirm/:token' Post method.

const passwordReset = await PasswordReset.findOne({ token })
Enter fullscreen mode Exit fullscreen mode

passwordReset is not being checked after this line.

Collapse
 
kelvinvmwinuka profile image
Kelvin Mwinuka

Hi Mete,

Good eye. If we don't check it here, the user update will throw an error. To avoid this we can add a guard clause to check the password reset object:

if (!passwordReset) {
    return res.status(404).send()
}

// Continue with the reset
Enter fullscreen mode Exit fullscreen mode
Collapse
 
vdelitz profile image
vdelitz

Hey, really love your AWS Cognito articles. That helped me a lot when I recently worked on a project that uses AWS Cognito as core user management and authentication system. In the project, I added passkeys / WebAuthn to AWS Cognito - have you ever implemented something similar? If yes what were your experiences?

Collapse
 
aalphaindia profile image
Pawan Pawar

Good content!

Collapse
 
kelvinvmwinuka profile image
Kelvin Mwinuka

Thank you!

Collapse
 
wahidn profile image
WahidN

Thank you! Needed this!

Collapse
 
kelvinvmwinuka profile image
Kelvin Mwinuka

You're welcome!