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
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: '...'
}
})
);
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>'
});
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,
});
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) => {...}
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);
});
});
}
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
// ./routes/user.js
const usersController = require('../controllers/users');
router.post('/new-password', usersController.postNewPassword);
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);
});
};
We're done! The flow now works like this:
A user goes to a form to input their email for resetting a password
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
Upon submitting the form, the token value along with the user's new password is sent to the backend
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.
Top comments (0)