パスワードリセット処理を実装する①メール送信

2024/04/20に公開

はじめに

これまでの章はNext.jsとAuth.jsを使ってメールアドレスとパスワードによるアカウント登録からログイン、ログアウトの実装を行なってきました。最低限の認証機能は実現できましたがWebサービスとして運営していくには様々なケースを想定して機能を実装しておく必要があります。

  • 登録したメールアドレスが正しいか検証(メールアドレスを活用して様々な案内、通知をするため)
  • パスワードのリセット(パスワードを忘れてしまった利用者のため)
  • ワンタイムトークンを発行し二要素認証によるセキュリティ強化

今回はそのうちの「パスワードのリセット(パスワードを忘れてしまった利用者のため)」を2回の記事に渡って実装していきます。

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

パスワードリセットの実装方針

今回は新たにパスワードリセットをしたいメールアドレスを入力する画面を用意します。

メールアドレスを入力するとパスワードを新たにセットできる画面のリンクを載せたメールを送信します。

Image from Gyazo

ユーザーはそのメールを開きリンクをクリックすると、新しいパスワードを設定する画面をユーザーに表示します。
ユーザーは表示された画面で新しいパスワードを入力します。その後、サーバ側ではトークンの検証と、あっていればパスワードを更新します。

Image from Gyazo

ユーザーは新しいパスワードを使ってログインします。

Image from Gyazo

今回のゴール

メールを送信する処理を実装します。メールのリンクをクリックして実際のパスワードリセットする処理についてはこの次の記事にて実装していきます。

Image from Gyazo

実装の流れ

下記の流れで実装を進めていきます。

  • ①トークンをデータベースで保持できるようにする
  • ②トークンの作成処理
  • ③メール送信処理の実装
  • ④パスワード再発行画面の作成
  • ⑤サーバーアクションを作成しユーザーの入力後にメールを送信
  • ⑥動作確認

①トークンをデータベースで保持できるようにする

発行したトークンをデータベースで保持できるよう新たなモデルを作成します。既存のschema.prismaに下記を追加します。

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

トークンに加えて、どのメールアドレスに対するものなのか?有効期限はいつまでか?も合わせて保持しておきます。

コードを書き終わったら、TypeScriptで使用するための型を下記コマンドで生成します。

$ npx prisma generate

データベースにスキーマ定義を反映させることでPasswordResetTokenというテーブルが作成されます。

$ npx prisma db push

データベースにテーブルができたことを確認します。

$ npx prisma studio

Image from Gyazo

②トークンの作成処理

最初にトークンを生成するときに使うuuidをインストールします。

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

ランダムな文字列を生成するのに便利なライブラリとなります。

https://github.com/uuidjs/uuid

lib/tokens.tsファイルに下記のコードを追加していきます。

token.ts
+ export const generatePasswordResetToken = async (email: string) => {
+   const token = uuidv4()
+   const expires = new Date(new Date().getTime() + 60 * 60 * 1000)
+
+   //TDOO: 既にトークンが存在していた場合は削除する
+
+   const passwordResetToken = await db.passwordResetToken.create({
+     data: {
+       email,
+       token,
+       expires,
+     },
+   })
+
+   return passwordResetToken
+ }

有効期限についてはトークン作成した時点から1時間後としています。

60(秒) * 60(分) * 1000(ミリ秒)

次にdata/password-reset-token.tsファイルを作成し下記のコードを書いていきます。

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
  }
}

どちらもデータベースに登録されているトークンを取得しているのですが、引数がemailなのかtokenなのかが異なるため2つ用意しています。トークンがあればトークンを返し、なければnullを返しています。

先ほどのlib/tokens.tsファイルに戻り、トークンが存在していたら削除します。複数回、パスワードリセット用のトークンを発行した場合に最新のトークンのみ有効としておくためとなります。

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
}

③作成したトークンを使ってメール送信処理の実装

まずはSendgridのセットアップをおこなっていきます。
コチラの手順書のAPIキーを管理するでAPIキーを取得し、.envファイルに追加します。

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

.env
SENDGRID_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Sendgridはnode.js向けにライブラリを提供しておりコチラを使うとメール送信処理がスムーズに進みます。

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

npm i @sendgrid/mail

上記のライブラリの使い方はコチラが参考になります。

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

lib/mail.tsファイルに、メール送信処理を実装します。

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: 'パスワード再発行のお知らせ',
+     html: `<p>以下のリンクをクリックして、パスワードのリセットを行ってください。<br><a href="${resetLink}">ここをクリック</a></p>`,
+   })
+ }
export const sendPasswordResetEmail = async (email: string, token: string) => {

メールアドレスとトークンを引数に取ります。

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

ユーザーがメールを開封した際にクリックするリンクのURLを生成します。
このリンクにトークンを含めることでリンクでアクセスがあった際にサーバー側でトークンの検証が可能となります。

  await sendgrid.send({
    from: 'test-taro@shinagawa-web.com',
    to: email,
    subject: 'パスワード再発行のお知らせ',
    html: `<p>以下のリンクをクリックして、パスワードのリセットを行ってください。<br><a href="${resetLink}">ここをクリック</a></p>`,
  })

メールの送信元をfromで定義しています。コチラはメール送信サービスで認証したドメインを使うこととなります。私の場合は会社のドメインを使用しています。

htmlというキーでHTML形式でメール本文を定義します。aタグを用いてリンクを設定しています。

④サーバーアクションを作成しユーザーの入力後にメールを送信

これまで部分的な処理を実装してきました。

  • トークンの作成処理
  • トークンを受け取ってメールを送信する処理

これらの処理を合体してユーザーから実際に入力されたメールアドレスを受け取って一連の処理を組み込むサーバアクションを実装していきます。

まずはZodスキーマを作成します。

schema/index.tsファイルに下記のコードを追加します。

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

ユーザーがメールアドレスを入力するため、その入力内容をチェックするスキーマとなります。

作成したスキーマを使いつつサーバーアクションを実装します。

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: '入力内容を修正してください' }
  }

  const { email } = validatedFields.data

  const existingUser = await getUserByEmail(email)

  if (!existingUser) {
    return { error: 'メールアドレスが存在しません。' }
  }

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

    return {
      success:
        '登録されたメールアドレスに「パスワード再発行のお知らせ」を送信しました。',
    }
  } catch (error) {
    console.error('Error sending password reset email:', error)
    return { error: 'エラーが発生しました。' }
  }
}

コードの解説をしていきます。

  const validatedFields = ResetSchema.safeParse(values)

  if (!validatedFields.success) {
    return { error: '入力内容を修正してください' }
  }

まず最初にユーザーが入力したメールアドレスをチェックします。
メールアドレスの形式が間違っていた場合などはエラーを返します。

  const { email } = validatedFields.data

  const existingUser = await getUserByEmail(email)

  if (!existingUser) {
    return { error: 'メールアドレスが存在しません。' }
  }

入力されたメールアドレスが既に登録済みであることを確認し、もし存在しないメールアドレスだった場合はエラーを返します。
パスワードリセットの対象となるメールアドレスは既にアカウント登録を済ませたメールアドレスが前提となります。

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

    return {
      success:
        '登録されたメールアドレスに「パスワード再発行のお知らせ」を送信しました。',
    }
  } catch (error) {
    console.error('Error sending password reset email:', error)
    return { error: 'エラーが発生しました。' }
  }

先ほど作成したトークン作成用のgeneratePasswordResetToken()を使ってトークンを作成します。
その後、ユーザーが入力したメールアドレスとトークンを使ってメール送信処理を行います。
メール送信処理が正常終了したら、成功のメッセージをクライアントサイドに渡します。

⑤パスワード再発行画面の作成

ユーザーがメールアドレスを入力できるパスワード再発行画面の作成をしていきます。

まずは入力フォームを作成します。
入力フォームはこれまで実装してきた、アカウント登録やログインのフォームと同じような実装となります。

components/auth/reset-form.tsxというファイルを作成し下記のコードを書いていきます。

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="パスワード再発行"
      buttonLabel="ログイン画面に戻る"
      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">
            メールを送信する
          </Button>
        </form>
      </Form>
    </CardWrapper>
  )
}

コードの解説をしていきます。

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

(※一部省略)

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

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

ユーザーがメールアドレスを入力し「メールを送信する」をクリックした後の処理内容です。
先ほど作成したサーバーアクションにvalueここではメールアドレスを渡して実行しています。
処理の結果successerrorどちらかのメッセージを受け取りuseStateで保持します。

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

successerrorどちらかのメッセージを受け取った後、表示している箇所となります。

作成した入力フォームを使って画面を実装します。

/app/auth/reset/page.tsxファイルを作成し下記のコードを書いていきます。

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

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

export default ResetPage

これでパスワード再発行画面の作成は完了となります。

Image from Gyazo

次に、この画面に利用者が辿り着くための導線を確保します。ログイン画面に、パスワード再発行画面へ遷移できるようリンクを表示させます。

components/auth/login-form.tsxに下記のコードを追加します。

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

(※一部省略)

                  </FormControl>
                  <FormMessage />
+                   <Button
+                     size="sm"
+                     variant="link"
+                     asChild
+                     className="px-0 font-normal"
+                   >
+                     <Link href="/auth/reset">パスワードを忘れた方はコチラ</Link>
+                   </Button>
                </FormItem>
              )}
            />

パスワードを入力するフォームのすぐそばにリンクが表示されました。

Image from Gyazo

最後に、ルーティングの設定をします。
現状のミドルウェアの設定では未ログインの状態では新しい画面にアクセスできないため、アクセスできるようパスを追加します。

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

ここでは入力画面だけでなく、この次の記事で作成する「新しいパスワード設定画面」についても追加しています。こちらも未ログイン状態でアクセスする画面のため追加が必要となります。

⑤動作確認

アカウント登録済みのメールアドレスを使って「パスワード再発行画面」にアクセスしメールアドレスを入力します。
メールを送信とするとメールが届いているかと思います。
リンクをクリックすると404のページが表示されるかと思います。
URLを確認して頂き、

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

新しいパスワード設定画面の/auth/new-passwordとなっていればOKです。

Image from Gyazo

さいごに

今回はトークンの生成とメール送信処理の実装を行い画面からパスワードリセットをしたいメールアドレスを入力しメールが実際に届く仕組みを実装しました。
「メール認証機能を実装する①メール送信」の実装と似ている部分が多かったと思います。コードの書き方によっては関数などの共通化もできるかと思いますので、機会がありましたら共通化による工数削減に関する記事も書いてご紹介できたらと思います。

次の記事はこちら

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

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ

技術支援などお仕事に関するお問い合わせ📄

技術支援やお仕事のご依頼に関するお問い合わせは、下記よりお気軽にお問い合わせください。
お問い合わせフォームへ