How to Implement a User Password Reset Feature: From Token Issuance to Email Sending and Security Measures

  • nextjs
    nextjs
  • prisma
    prisma
  • postgresql
    postgresql
Published on 2024/04/20

Introduction

In the previous chapters, we implemented account registration, login, and logout using email and password with Next.js and Auth.js. While we now have the minimum authentication features, to operate a web service in practice we need to anticipate various cases and implement additional functionality.

  • Verify that the registered email address is correct (so we can use it for various notifications and announcements)
  • Password reset (for users who have forgotten their password)
  • Issue one-time tokens to strengthen security with two-factor authentication

In this article series, we will implement one of these: “Password reset (for users who have forgotten their password)” over two articles.

https://shinagawa-web.com/en/blogs/nextjs-login-email-password

Implementation plan for password reset

This time, we will newly prepare a screen where users can enter the email address for which they want to reset the password.

When the user enters their email address, we send an email containing a link to a screen where they can set a new password.

Image from Gyazo

When the user opens that email and clicks the link, we display a screen where they can set a new password.
On that screen, the user enters a new password. After that, the server verifies the token and, if it is valid, updates the password.

Image from Gyazo

The user then logs in using the new password.

Image from Gyazo

Goal of this article

We will implement the process that sends the email. The process that actually resets the password after clicking the link in the email will be implemented in the next article.

Image from Gyazo

Implementation flow

We will proceed with the implementation in the following steps:

  • ① Enable storing tokens in the database
  • ② Implement token creation
  • ③ Implement email sending
  • ④ Create the password reset request screen
  • ⑤ Create a server action that sends the email after user input
  • ⑥ Verify operation

① Enable storing tokens in the database

Create a new model so that issued tokens can be stored in the database. Add the following to the existing schema.prisma.

schema.prisma
+ model PasswordResetToken {
+   id String @id @default(cuid())
+   email String
+   token String @unique
+   expires DateTime
+   @@unique([email, token])
+ }

In addition to the token, we also store which email address it is for and when it expires.

After writing the code, generate types for use in TypeScript with the following command:

$ npx prisma generate

Reflect the schema definition in the database so that a table named PasswordResetToken is created.

$ npx prisma db push

Confirm that the table has been created in the database.

$ npx prisma studio

Image from Gyazo

② Token creation process

First, install uuid, which we will use when generating tokens.

npm i uuid
npm i --save-dev @types/uuid

This is a convenient library for generating random strings.

https://github.com/uuidjs/uuid

Add the following code to the lib/tokens.ts file.

token.ts
+ export const generatePasswordResetToken = async (email: string) => {
+   const token = uuidv4()
+   const expires = new Date(new Date().getTime() + 60 * 60 * 1000)
+
+   //TODO: Delete if token already exists
+
+   const passwordResetToken = await db.passwordResetToken.create({
+     data: {
+       email,
+       token,
+       expires,
+     },
+   })
+
+   return passwordResetToken
+ }

The expiration time is set to one hour from the time the token is created.

60(seconds) * 60(minutes) * 1000(milliseconds)

Next, create a data/password-reset-token.ts file and add the following code:

password-reset-token.ts
import db from '@/lib/db'

export const getPasswordResetTokenByToken = async (token: string) => {
  try {
    const passwordResetToken = await db.passwordResetToken.findUnique({
      where: { token },
    })

    return passwordResetToken
  } catch {
    return null
  }
}

export const getPasswordResetTokenByEmail = async (email: string) => {
  try {
    const passwordResetToken = await db.passwordResetToken.findFirst({
      where: { email },
    })

    return passwordResetToken
  } catch {
    return null
  }
}

Both functions retrieve tokens registered in the database, but we provide two versions because the argument differs: one takes email and the other token. If a token exists, it is returned; if not, null is returned.

Go back to the lib/tokens.ts file and delete any existing token if it exists. This ensures that when multiple password reset tokens are issued, only the latest token remains valid.

tokens.ts
import { v4 as uuidv4 } from 'uuid'
+ import { getPasswordResetTokenByEmail } from '@/data/password-reset-token'
import db from '@/lib/db'

export const generateVerificationToken = async (email: string) => {
  const token = uuidv4()
  const expires = new Date(new Date().getTime() + 60 * 60 * 1000)

+   const existingToken = await getPasswordResetTokenByEmail(email)
+
+   if (existingToken) {
+     await db.passwordResetToken.delete({
+       where: { id: existingToken.id },
+     })
+   }

  const verificationToken = await db.verificationToken.create({
    data: {
      email,
      token,
      expires,
    },
  })

  return verificationToken
}

③ Implement email sending using the created token

First, set up Sendgrid.
Follow the “Managing API Keys” section in this guide to obtain an API key and add it to your .env file.

https://sendgrid.kke.co.jp/docs/Tutorials/index.html

.env
SENDGRID_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Sendgrid provides a library for node.js, and using it makes the email sending process smoother.

https://github.com/sendgrid/sendgrid-nodejs

npm i @sendgrid/mail

The usage of the above library is explained here:

https://github.com/sendgrid/sendgrid-nodejs/tree/main/packages/mail

Implement the email sending process in the lib/mail.ts file.

mail.ts
+ export const sendPasswordResetEmail = async (email: string, token: string) => {
+   const resetLink = `${domain}/auth/new-password?token=${token}`
+
+   await sendgrid.send({
+     from: 'test-taro@shinagawa-web.com',
+     to: email,
+     subject: 'Password Reset Notification',
+     html: `<p>Please click the link below to reset your password.<br><a href="${resetLink}">Click here</a></p>`,
+   })
+ }
export const sendPasswordResetEmail = async (email: string, token: string) => {

It takes the email address and token as arguments.

  const resetLink = `${domain}/auth/new-password?token=${token}`

This generates the URL for the link that the user will click when they open the email.
By including the token in this link, the server can verify the token when the link is accessed.

  await sendgrid.send({
    from: 'test-taro@shinagawa-web.com',
    to: email,
    subject: 'Password Reset Notification',
    html: `<p>Please click the link below to reset your password.<br><a href="${resetLink}">Click here</a></p>`,
  })

The sender of the email is defined with from. This must be a domain that has been verified with your email sending service. In my case, I use my company’s domain.

The html key defines the email body in HTML format. We use an anchor tag to set the link.

④ Create a server action that sends the email after user input

So far, we have implemented partial processes:

  • Token creation
  • Email sending that takes a token

Now we will combine these and implement a server action that receives the email address actually entered by the user and runs the entire flow.

First, create a Zod schema.

Add the following code to the schema/index.ts file.

index.ts
+ export const ResetSchema = z.object({
+   email: z.string().email({
+     message: 'Email is required',
+   }),
+ })

This schema validates the user’s email address input.

Next, implement the server action using the schema you just created.

Add the following code to actions/reset.ts.

reset.ts
'use server'

import * as z from 'zod'
import { getUserByEmail } from '@/data/user'
import { sendPasswordResetEmail } from '@/lib/mail'
import { generatePasswordResetToken } from '@/lib/tokens'
import { ResetSchema } from '@/schema'

export const reset = async (values: z.infer<typeof ResetSchema>) => {
  const validatedFields = ResetSchema.safeParse(values)

  if (!validatedFields.success) {
    return { error: 'Please correct the input' }
  }

  const { email } = validatedFields.data

  const existingUser = await getUserByEmail(email)

  if (!existingUser) {
    return { error: 'Email address does not exist.' }
  }

  try {
    const passwordResetToken = await generatePasswordResetToken(email)
    await sendPasswordResetEmail(
      passwordResetToken.email,
      passwordResetToken.token,
    )

    return {
      success:
        'Sent 'Password Reset Notification' to the registered email address.',
    }
  } catch (error) {
    console.error('Error sending password reset email:', error)
    return { error: 'An error occurred.' }
  }
}

Here is an explanation of the code:

  const validatedFields = ResetSchema.safeParse(values)

  if (!validatedFields.success) {
    return { error: 'Please correct the input' }
  }

First, we validate the email address entered by the user.
If the email format is incorrect, we return an error.

  const { email } = validatedFields.data

  const existingUser = await getUserByEmail(email)

  if (!existingUser) {
    return { error: 'Email address does not exist.' }
  }

We check that the entered email address is already registered, and if it is not, we return an error.
The target of password reset must be an email address that has already been used to register an account.

  try {
    const passwordResetToken = await generatePasswordResetToken(email)
    await sendPasswordResetEmail(
      passwordResetToken.email,
      passwordResetToken.token,
    )

    return {
      success:
        'Sent 'Password Reset Notification' to the registered email address.',
    }
  } catch (error) {
    console.error('Error sending password reset email:', error)
    return { error: 'An error occurred.' }
  }

We use the generatePasswordResetToken() function we created earlier to generate a token.
Then we send the email using the user’s email address and the token.
If the email sending process completes successfully, we return a success message to the client side.

⑤ Create the password reset request screen

Now we will create the password reset request screen where the user can enter their email address.

First, create the input form.
The input form implementation is similar to the account registration and login forms we have already implemented.

Create a file components/auth/reset-form.tsx and add the following code:

reset-form.tsx
'use client'

import { zodResolver } from '@hookform/resolvers/zod'
import { useState, useTransition } from 'react'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { reset } from '@/actions/reset'
import { CardWrapper } from '@/components/auth/card-wrapper'
import { FormError } from '@/components/form-error'
import { FormSuccess } from '@/components/form-success'
import { Button } from '@/components/ui/button'
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { ResetSchema } from '@/schema'

export const ResetForm = () => {
  const [error, setError] = useState<string | undefined>('')
  const [success, setSuccess] = useState<string | undefined>('')
  const [isPending, startTransition] = useTransition()

  const form = useForm<z.infer<typeof ResetSchema>>({
    resolver: zodResolver(ResetSchema),
    defaultValues: {
      email: '',
    },
  })

  const onSubmit = (values: z.infer<typeof ResetSchema>) => {
    setError('')
    setSuccess('')

    startTransition(() => {
      reset(values).then((data) => {
        setError(data?.error)
        setSuccess(data?.success)
      })
    })
  }

  return (
    <CardWrapper
      headerLabel="Password Reset"
      buttonLabel="Return to Login Screen"
      buttonHref="/auth/login"
    >
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
          <div className="space-y-4">
            <FormField
              control={form.control}
              name="email"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Email</FormLabel>
                  <FormControl>
                    <Input
                      {...field}
                      disabled={isPending}
                      placeholder="nextjs@example.com"
                      type="email"
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </div>
          <FormError message={error} />
          <FormSuccess message={success} />
          <Button disabled={isPending} type="submit" className="w-full">
            Send Email
          </Button>
        </form>
      </Form>
    </CardWrapper>
  )
}

Here is an explanation of the code:

import { reset } from '@/actions/reset'

((※ Partially omitted))

  const onSubmit = (values: z.infer<typeof ResetSchema>) => {
    setError('')
    setSuccess('')

    startTransition(() => {
      reset(values).then((data) => {
        setError(data?.error)
        setSuccess(data?.success)
      })
    })
  }

This is the process that runs after the user enters their email address and clicks “Send email”.
We pass values (the email address here) to the server action we created earlier and execute it.
We then receive either a success or error message as the result and store it with useState.

          <FormError message={error} />
          <FormSuccess message={success} />

This is where we display whichever message (success or error) we received.

Next, we implement the screen using the form we just created.

Create the /app/auth/reset/page.tsx file and add the following code:

page.tsx
import { ResetForm } from '@/components/auth/reset-form'

const ResetPage = () => {
  return <ResetForm />
}

export default ResetPage

This completes the creation of the password reset request screen.

Image from Gyazo

Next, we will provide a path for users to reach this screen. On the login screen, we will display a link that navigates to the password reset request screen.

Add the following code to components/auth/login-form.tsx.

login-form.tsx
+ import Link from 'next/link'

((※ Partially omitted))

                  </FormControl>
                  <FormMessage />
+                   <Button
+                     size="sm"
+                     variant="link"
+                     asChild
+                     className="px-0 font-normal"
+                   >
+                     <Link href="/auth/reset">Forgot your password? Click here</Link>
+                   </Button>
                </FormItem>
              )}
            />

A link is now displayed right next to the password input form.

Image from Gyazo

Finally, configure routing.
With the current middleware settings, the new screen cannot be accessed while logged out, so we add paths to allow access.

route.ts
export const authRoutes: string[] = [
  '/auth/login',
  '/auth/register',
  '/auth/new-verification',
+   '/auth/new-password',
+   '/auth/reset',
]

Here we add not only the input screen but also the “New Password Setting Screen” that we will create in the next article. This screen also needs to be accessible while logged out, so it must be added.

⑤ Operation check

Using an email address that has already been registered, access the “Password Reset Request Screen” and enter the email address.
When you send the email, you should receive an email.
If you click the link, you will probably see a 404 page.
Check the URL and confirm that it is:

http://localhost:3000/auth/new-password?token=xxxxxxxxxxxxxxxxxxxxxxxxx

If the path is /auth/new-password, which is the new password setting screen, then it is OK.

Image from Gyazo

Conclusion

In this article, we implemented token generation and email sending so that users can enter the email address for which they want to reset the password and actually receive an email.
There were many similarities with the implementation in “Implementing Email Verification ① Sending Emails”. Depending on how you write the code, you could also generalize some functions. If I get the chance, I would like to write an article about reducing effort through such generalization and share it.

Next article

https://shinagawa-web.com/en/blogs/nextjs-password-reset-token-validation

Xでシェア
Facebookでシェア
LinkedInでシェア

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form