Reset User Passwords

konstantinstanmeyer

Konstantin Stanmeyer

Posted on December 27, 2022

Reset User Passwords

Forgetting a password can always happen, and we must ensure a user can recover their information in a simple, secure way. Here, I will go through the basic steps to send OTPs(One-Time-Password) via email, which will be used to allow users to reset passwords:

Setup

To start, we can install the necessary packages:

npm i express nodemailer nodemailer-sendgrid-transport bcryptjs
Enter fullscreen mode Exit fullscreen mode

nodemailer will be used to perform easier construction of emails and nodemailer-sendgrid-transport will allow us to utilize SendGrid's service to send them. bcrypt will come in at the end to encrypt a user's new password.

This will use Mongoose/MongoDB to interact with the user models/documents.

Sending an Email

I won't include the steps for setting up a SendGrid account, but use their website to create a free api key after setting a single sender(a real email).

To utilize SendGrid in our project, we must go into our controller and import the necessary packages, as well as implement our api key with the .createTransport() method:

// ./controllers/users.js

const nodemailer = require('nodemailer');
const sendgridTransport = require('nodemailer-sendgrid-transport');

const transporter = nodemailer.createTransport(
  sendgridTransport({
    auth: {
      api_key: '...'
    }
  })
);
Enter fullscreen mode Exit fullscreen mode

Formatting the email is extremely simple. Fields like the subject and recipient's address must be provided, as well as the HTML you would like to have in the email. The basic code to send an email with our transporter is like so:

// ./controllers/users.js

transporter.sendMail({
  to: user.email,
  from: 'singlesender@email.com',
  subject: 'Test Email',
  html: '<h1>Hello World!</h1>'
});
Enter fullscreen mode Exit fullscreen mode

You must use the email account you associated with your SendGrid single-sender to perform these actions or an error will arise. (must be from the existing email on record)

With the ability to send emails we can start adding the reset token(OTP) code. The general workflow will be like so:

A user will provide their existing email in the password form, which will then send a randomly generated token to the user's document in the database. This will also send them an email with a link that provides the token within the frontend url's params. The logic involves comparing the database value with the params value, as well as checking if the token has expired. Here is the basic user model to visualize:

// ./models/user.js

const mongoose = require('mongoose');

const Schema = mongoose.Schema;

const userSchema = new Schema({
  email: {
    type: String,
    required: true
  },
  password: {
    type: String,
    required: true
  },
  resetToken: String, // always null until token is created
  resetTokenExpiration: Date,
});
Enter fullscreen mode Exit fullscreen mode

Creating Reset Token

To get a long, randomized value for our token, we can use the built-in crypto library for Node.js to produce a string:

const crypto = require('crypto');

crypto.randomBytes(32, (err, buffer) => {...}
Enter fullscreen mode Exit fullscreen mode

The "buffer" will be the randomized string of 32 values returned if successful, and we will convert it to a hexadecimal value for easy use. Then, pass the desired values into the user instance to save the associated token and expiration to the database. Here is the final postReset function:

// ./controllers/users.js

const nodemailer = require('nodemailer');
const sendgridTransport = require('nodemailer-sendgrid-transport');

const transporter = nodemailer.createTransport(
  sendgridTransport({
    auth: {
      api_key: '...'
    }
  })
);

const User = require('../models/user');

exports.postReset = (req,res) => {
  crypto.randomBytes(32, (err, buffer) => {
    if (err) {
      console.log(err);
      return;
    } 
    const token = buffer.toString('hex');
    User.findOne({ email: '...' })
    .then(user => {
      user.resetToken = token;
      user.resetTokenExpiration = Date.now() + 3600000;
      return user.save();
    })
    .then(result => {
      transporter.sendMail({
        to: user.email,
        from: 'singlesender@email.com',
        subject: 'Test Email',
        html: `
          <p>Follow link to reset password</p>
          <a href="http://.../reset/${token}">Click here</a>
        `
    })
    .catch(err => {
      console.log(err);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

After clicking the provided link, the user should be taken to a route with the token appended onto the url. This approach is extremely secure due to no user information ever being shown. The absolute only way to access this extremely hashed url/token is to have received the email directly.

Posting New Password

When a user loads onto their password reset page, the frontend url should now end with their token, the same url from the email received which should look somewhat like so:

http://example.com/reset/9052919d5d8805f58fb15581549d8aaa
Enter fullscreen mode Exit fullscreen mode
// ./routes/user.js

const usersController = require('../controllers/users');

router.post('/new-password', usersController.postNewPassword);
Enter fullscreen mode Exit fullscreen mode

When a user submits a password reset form, what information do we need? We will obviously need the new password, as the token from the frontend's routing params. Finally, we need an easy way to query the user's document in MongoDB, which will be done with their user_id value. With those we can use update their existing password, and delete the token along with its expiration date at the same time. My function to do so is this:

// ./controllers/users.js

const bcrypt = require('bcryptjs');

exports.postResetPassword = (req, res) => {
  const newPassword = req.body.password;
  const userId = req.body.userId; // grabbed from fetching user info on form-load
  const token = req.body.token;
  let resetUser;

  User.findOne({
    resetToken: token,
    resetTokenExpiration: { $gt: Date.now() },
    _id: userId
  }) // $gt adds a greater-than constraint
    .then(user => {
      resetUser = user;
      return bcrypt.hash(newPassword, 12);
    })
    .then(results => {
      resetUser.password = result;
      resetUser.resetToken = undefined;
      resetUser.resetTokenExpiration = undefined;
      return resetUser.save();
    })
    .then(result => {
      res.redirect('/login');
    })
    .catch(err => {
      console.log(err);
    });
};
Enter fullscreen mode Exit fullscreen mode

We're done! The flow now works like this:

  1. A user goes to a form to input their email for resetting a password

  2. With that form data, if an associated account exists, we can find their account on the backend, and send them an email with a link to their own, secure new-password form which has their token in the url

  3. Upon submitting the form, the token value along with the user's new password is sent to the backend

  4. If the token is not expired and the password is valid, the user's token/expiration date will be deleted from the database, and their password will be updated.

💖 💪 🙅 🚩
konstantinstanmeyer
Konstantin Stanmeyer

Posted on December 27, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related