Next.js, MongoDB, React, Tutorial

Next.js and MongoDB full-fledged app Part 3

Email Verification, Password Reset/Change

An application with authentication must come with security features. Today, we are working on adding: Email Verification, Password Reset, and Password Change.


This is a follow-up to Part 2. Make sure you read it before this post.

Again, Below are the Github repository and a demo for this project to follow along.

Github repo

Demo

About nextjs-mongodb-app project

nextjs-mongodb-app is a Full-fledged serverless app made with Next.JS and MongoDB

Different from many other Next.js tutorials, this:

  • Does not use the enormously big Express.js, supports serverless
  • Minimal, no fancy stuff like Redux or GraphQL for simplicity in learning
  • Using Next.js latest features like API Routes or getServerSideProps

For more information, visit the Github repo.

What we are making

We are working on several features, which all involve email transactions.

  • Email Verification allows you to verify the emails users used to sign up by sending them a verification link.

  • Password Reset allows the users to reset their passwords from a reset link sent to their emails.

  • Password Change allows the users to change their passwords simply by inputting their old and new passwords.

Building Password Change

This is the easiest feature to implement. All we have to do is match the user's old password against the database and save their new one.

Password Change Section

For simplicity, I added the section right below the Profile Settings page.

Password Change Page

import { useCurrentUser } from "@/lib/user";
import { useRouter } from "next/router";
import { useEffect, useCallback } from "react";
import { fetcher } from "@/lib/fetch";
const AboutYou = ({ user, mutate }) => {
/* ... */
};
const Auth = () => {
const oldPasswordRef = useRef();
const newPasswordRef = useRef();
const onSubmit = useCallback(async (e) => {
e.preventDefault();
try {
await fetcher("/api/user/password", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
oldPassword: oldPasswordRef.current.value,
newPassword: newPasswordRef.current.value,
}),
});
} catch (e) {
console.error(e.message);
} finally {
oldPasswordRef.current.value = "";
newPasswordRef.current.value = "";
}
}, []);
return (
<section>
<h4>Password</h4>
<form onSubmit={onSubmit}>
<input
type="password"
autoComplete="current-password"
ref={oldPasswordRef}
placeholder="Old Password"
/>
<input
type="password"
autoComplete="new-password"
ref={newPasswordRef}
placeholder="New Password"
/>
<button type="submit">Save</button>
</form>
</section>
);
};
const SettingsPage = () => {
const { data, error, mutate } = useCurrentUser();
const router = useRouter();
useEffect(() => {
if (!data && !error) return; // useCurrentUser might still be loading
if (!data.user) {
router.replace("/login");
}
}, [router, data, error]);
if (!data?.user) return null;
return (
<>
<AboutYou user={data.user} mutate={mutate} />
<Auth />
</>
);
};
export default SettingsPage;

When the user submits, we make a PUT call to /api/user/password with the old and new password. After the request, we clear the new and old password fields.

We now go ahead and create our API at /api/user/password.

Password Change API

Create /pages/api/user/password/index.js.

import { auths, database, validateBody } from "@/api-lib/middlewares";
import nc from "next-connect";
const handler = nc();
handler.use(database, ...auths);
handler.put(
validateBody({
type: "object",
properties: {
oldPassword: { type: "string", minLength: 8 },
newPassword: { type: "string", minLength: 8 },
},
required: ["oldPassword", "newPassword"],
additionalProperties: false,
}),
async (req, res) => {
if (!req.user) {
res.json(401).end();
return;
}
const { oldPassword, newPassword } = req.body;
// We could not req.user because that object does not have the `password` field
const currentUser = await db
.collection("users")
.findOne({ _id: req.user._id });
const matched = await bcrypt.compare(oldPassword, currentUser.password);
if (!matched) {
res.status(401).json({
error: { message: "The old password you entered is incorrect." },
});
return;
}
const password = await bcrypt.hash(newPassword, 10);
await req.db
.collection("users")
.updateOne({ _id: currentUser._id }, { $set: { password } });
res.status(204).end();
}
);
export default handler;

We first validate the body using our validateBody middleware. Then, we check if the user is logged in by checking req.user. If not, it will send a 401 response.

Then, we fetch the user object, containing hashed password field. (In the previous version of this project, I directly compare using req.user.password. However, the new version uses projection to omit that field when authenticating users for security reasons.)

We then go ahead and retrieve oldPassword and newPassword from the request body. The oldPassword is compared against the hashed current password (bcrypt.compare(oldPassword, currentUser.password)). If it does not match, we reject the request. If it does we hash the new password (bcrypt.hash(newPassword, 10)) and save it in our database.

await req.db
.collection("users")
.updateOne({ _id: req.user._id }, { $set: { password } });

And the feature is ready to roll~

Password Change

(Note: The GIF is from an old, barebone version :))

Password Reset

Now that the user can change their current password to a new one. Yet that is only when they know their current password. Let's implement the password reset feature.

Forget Password API

We will have two routes for this API.

  • POST /pages/api/user/password/reset: Handle requests to create a password reset token and send email
  • PUT /pages/api/user/password/reset: Reset password using a token.

Password reset request

Create /pages/api/user/password/reset/index.js:

import { sendMail } from "@/api-lib/mail";
import { database, validateBody } from "@/api-lib/middlewares";
import nc from "next-connect";
import normalizeEmail from "validator/lib/normalizeEmail";
import { nanoid } from "nanoid";
const handler = nc();
handler.use(database);
handler.post(
validateBody({
type: "object",
properties: {
email: { type: "string", minLength: 1 },
},
required: ["email"],
additionalProperties: false,
}),
async (req, res) => {
const email = normalizeEmail(req.body.email);
const user = await req.db.collection("users").findOne({ email });
if (!user) {
res.status(400).json({
error: { message: "We couldn’t find that email. Please try again." },
});
return;
}
const securedTokenId = nanoid(32); // create a secure reset password token
await db.collection("tokens").insertOne({
_id: securedTokenId,
creatorId: user._id,
type: "passwordReset",
expireAt: new Date(Date.now() + 20 * 60 * 1000), // let's make it expire after 20 min
});
await sendMail({
to: user.email,
from: "no-reply@nextjs-mongodb.vercel.app",
subject: "[nextjs-mongodb-app] Reset your password.",
html: `
<div>
<p>Hello, ${user.name}</p>
<p>Please follow <a href="${process.env.WEB_URI}/forget-password/${securedTokenId}">this link</a> to reset your password.</p>
</div>
`,
});
res.status(204).end();
}
);
export default handler;

We only need our database middleware in this API because we don't need any authentication info.

We first verify if there is a user with such email in the database req.db.collection('users').findOne({ email: req.body.email }).

If the user exists, we create a secure passwordReset token. We insert a document to our tokens collection with the created token along with the intended user's _id in creatorId. The secure token is set as the document _id (since _id is indexed by MongoDB, it allows faster lookup)

To set the token to expire after the expireAt property for security reasons, we can create an index on that collection like below (usually called when the server starts):

db.collection("tokens").createIndex("expireAt", { expireAfterSeconds: 0 });

We then send an email to the user with a password reset link (website_url/forget-password/{token}).

Note: You have to implement the sendEmail function.

Forget Password page

Password Reset Page

We now create a forget password page at /pages/forget-password/index.jsx

import { fetcher } from "@/lib/fetch";
import { useCallback, useRef } from "react";
const ForgetPasswordPage = () => {
const emailRef = useRef();
const onSubmit = useCallback(async (e) => {
e.preventDefault();
try {
await fetcher("/api/user/password/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: emailRef.current.value,
}),
});
} catch (e) {
console.error(e.message);
}
}, []);
return (
<>
<Head>
<title>Forget password</title>
</Head>
<h1>Forget password</h1>
<p>
Enter the email address associated with your account, and we&apos;ll
send you a link to reset your password.
</p>
<form onSubmit={onSubmit}>
<input
ref={emailRef}
type="email"
autoComplete="email"
placeholder="Email"
/>
<button type="submit">Submit</button>
</form>
</>
);
};
export default ForgetPasswordPage;

We simply ask the user for their email, which we then send to our just-created API above at '/api/user/password/reset'.

Reset Password API

We need an API to resolve the reset token.

Let's add a PUT request handler to /pages/api/user/password/reset.js:

import nc from "next-connect";
import bcrypt from "bcryptjs";
import database from "@/api-lib/middlewares";
const handler = nc();
handler.use(database);
handler.post(/* ... */);
handler.put(
validateBody({
type: "object",
properties: {
password: { type: "string", minLength: 8 },
token: { type: "string", minLength: 0 },
},
required: ["password", "token"],
additionalProperties: false,
}),
async (req, res) => {
const deletedToken = await db
.collection("tokens")
.findOneAndDelete({ _id: id, type });
if (!deletedToken) {
res.status(403).end();
return;
}
const password = await bcrypt.hash(newPassword, 10);
await db
.collection("users")
.updateOne({ _id: deletedToken.creatorId }, { $set: { password } });
res.status(204).end();
}
);
export default handler;

The handler will try to find and delete the token we inserted earlier. If deleteToken is not null, we also know that the token is deleted from the database (we don't want the same token to be used twice).

If the token is null, we simply reject the request.

We then hash the password using bcrypt and update the user whose _id can be found at token.creatorId.

Reset Password page

This page represents the link that is sent to the user's email (website_url/forget-password/{token}). Create /pages/forget-password/[token].jsx:

import { database } from "@/api-lib/middlewares";
import nc from "next-connect";
import Head from "next/head";
const ResetPasswordTokenPage = ({ valid, token }) => {
const passwordRef = useRef();
const onSubmit = useCallback(
async (event) => {
event.preventDefault();
setStatus("loading");
try {
await fetcher("/api/user/password/reset", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token,
password: passwordRef.current.value,
}),
});
} catch (e) {
console.error(e.message);
}
},
[token]
);
if (!valid)
return (
<>
<h1>Invalid Link</h1>
<p>
It looks like you may have clicked on an invalid link. Please close
this window and try again.
</p>
</>
);
return (
<>
<Head>
<title>Forget password</title>
</Head>
<h1>Forget password</h1>
<p>Enter a new password for your account</p>
<form onSubmit={onSubmit}>
<input
ref={passwordRef}
type="password"
autoComplete="new-password"
placeholder="New Password"
/>
<button type="submit">Reset password</button>
</form>
</>
);
};
export async function getServerSideProps(context) {
await nc().use(database).run(context.req, context.res);
const tokenDoc = await db.collection("tokens").findOne({
_id: context.params.token,
type: "passwordReset",
});
return { props: { token: context.params.token, valid: !!tokenDoc } };
}
export default ResetPasswordTokenPage;

For this page, we use getServerSideProps to check the token validity (This function is run server-side only). The token can be found in context.params since our page is a dynamic route (with dynamic token parameter: /pages/forget-password/[token].jsx).

Our database middleware is used to load the database into req.db. We use it to check if the token can be found in the database by querying its _id (we set the secure token as the document _id earlier).

The page component will know if the token is valid based on the valid prop, which is true if the token document can be found and false otherwise.

As we can see, if valid is false, we show a "Invalid Link" message.

Invalid link message

Otherwise, we render an input for a new password.

Reset password valid page

After the user submit the new password, we simply make a request to the just created API along with the token received from props.

await fetcher("/api/user/password/reset", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token,
password: passwordRef.current.value,
}),
});

Building Email Verification

Let's finish up with email verification. This feature is similar to password reset as we similarly send a link with the token to the user.

Email Verification API

Get started by creating /pages/api/user/email/verify.js. This will handle users' requests to receive a confirmation email.

import { sendMail } from "@/api-lib/mail";
import { auths, database } from "@/api-lib/middlewares";
import nc from "next-connect";
const handler = nc();
handler.post(async (req, res) => {
if (!req.user) {
res.json(401).end();
return;
}
const securedTokenId = nanoid(32);
const token = await {
_id: securedTokenId,
creatorId: req.user._id,
type: "emailVerify",
expireAt: new Date(Date.now() + 1000 * 60 * 60 * 24), // expires in 24h
};
await sendMail({
to: req.user.email,
from: "no-reply@nextjs-mongodb.vercel.app",
subject: `Verification Email for ${process.env.WEB_URI}`,
html: `
<div>
<p>Hello, ${req.user.name}</p>
<p>Please follow <a href="${process.env.WEB_URI}/verify-email/${token._id}">this link</a> to confirm your email.</p>
</div>
`,
});
res.status(204).end();
});
export default handler;

In this handler, we are checking if the user is logged in and create a token that associates the user ID req.user._id. This token will expire in 24 hours.

The token creation and email sending are similar to those of Reset Password.

Email Verification page

Similar to the password reset page, we create a dynamic route page at /pages/verify-email/[token].jsx.

import { database } from "@/api-lib/middlewares";
import nc from "next-connect";
import Head from "next/head";
export default function EmailVerifyPage({ valid }) {
return (
<>
<Head>
<title>Email verification</title>
</Head>
<p>
{valid
? "Thank you for verifying your email address. You may close this page."
: "It looks like you may have clicked on an invalid link. Please close this window and try again."}
</p>
</>
);
}
export async function getServerSideProps(context) {
const handler = nc(ncOpts);
handler.use(database);
await handler.run(context.req, context.res);
const { token } = context.params;
const deletedToken = await db
.collection("tokens")
.findOneAndDelete({ _id: token, type: "emailVerify" });
if (!deletedToken) return { props: { valid: false } };
await db.collection("users").updateOne(
{ _id: deletedToken.creatorId },
{
emailVerified: true,
}
);
return { props: { valid: true } };
}

For this page, we do the email verification process inside getServerSideProps. Similar to the reset password feature, we find and delete the token from the database. If the token is found (and thus deleted), we update the user whose _id found in the token creatorId to have emailVerify = true.

The returned prop valid inform the UI to show the correct message.

Verify email page

Present the email verification status

The returned value of our /api/user actually contains a property call emailVerified, as seen above.

Therefore, we can use our useCurrentUser SWR hook to show a message asking the user to verify his or her email.

export default () => {
const {
data: { user },
} = useCurrentUser();
if (!user.emailVerified)
return (
<p>
<strong>Note:</strong> <span>Your email</span> (
<span className="link">{user.email}</span>) is unverified.
</p>
);
return null;
};

Email verify note

Conclusion

Tadah! We have managed to have the password change, password reset and email verification features in our app.

I hope this has been helpful in working with your Next.js app. Again, check out the repository nextjs-mongodb-app. If you find this helpful, consider giving it a star to motivate me with further development and more content.

Good luck on your next Next.js + MongoDB project!