Next.jsでメール認証機能を実装する その1:メール送信機能

2024/09/10に公開

はじめに

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

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

今回はそのうちの「登録したメールアドレスが正しいか検証」(メール認証)を2回の記事に渡って実装していきます。

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

メール認証の設計

これまでのユーザーは未ログイン、ログイン済みでアクセスできる画面を制御していました。未ログイン、ログイン済みの判定をAuth.jsの機能を使い、画面のアクセス制御、リダイレクトは主にNext.jsのミドルウェアの機能を使ってきました。
今回はメールアドレスに対してメール認証済み、未認証の判断をデータベースから取得したユーザー情報で行い、その結果でメールを送信したり然るべき画面に遷移させたりといった処理を実装します。

  • アカウント登録処理

アカウント登録の際に、登録されたメールアドレス宛にメールを送信します。アカウント登録したユーザーはメールアドレスにあるリンクをクリックし新しく作成する画面「トークン検証画面」に遷移します。
有効期限内のトークンであればメール認証が完了します。

Image from Gyazo

  • ログイン処理

ログインの際に、メール認証が完了しているか否かをチェックします。メール認証済みのユーザーであれば、これまで通り設定画面に遷移させます。まだメール認証をしていないユーザーであれば再度、メールを送信しまずはメール認証をして頂くよう促します。

Image from Gyazo

今回のゴール

アカウント登録時、もしくはメール認証が済んでいないユーザーがログインする際に認証用のメールを送信する処理を実装します。
メールを使って実際の認証処理をする「トークン検証画面」についてはこの次の記事にて実装していきます。

Image from Gyazo

メール送信サービス

メールの件名や宛先、文面はNext.jsのサーバーアクションの中で定義しますが実際のメールを送信する部分についてはメール送信サービスを活用することになります。

  • Amazon Simple Email Service
  • Sendgrid
  • Resend
  • Mailgun

など、メール配信を行なってくれるサービスは色々あります。簡単ではありますがメール配信サービスの選定基準をご紹介します。

    1. 配信成功率(Deliverability)
      メールが受信者のインボックスに届くかどうかが最も重要です。特にトランザクションメールや重要な通知を送信する場合は、配信成功率の高いサービスを選びたいですね。Amazon SESなどは高い配信率が特徴です。
    1. スケーラビリティ
      サービスが将来の拡張に対応できるかどうか。小規模なプロジェクトでも、成長とともに大量のメール送信が必要になることがあります。Amazon SESやSendGridのようなサービスは、数百万通のメールを安定して送信できるため、スケーラビリティを考慮する際に有力な選択肢です。
    1. 価格
      送信するメールの量に応じてコストが変わるため、価格も重要な要素です。特に大量にメールを送る場合は、1通あたりの単価が大きな影響を与えます。無料枠や安価なプランがあるサービスであれば、コストパフォーマンスが良いです。
    1. APIの機能と使いやすさ
      開発者にとっては、APIの使いやすさ、ドキュメントの充実度、統合のしやすさも重要です。
    1. サポートとドキュメンテーション
      トラブルが発生したときのサポートの質や、APIや設定に関するドキュメントの充実度も考慮すべきです。ドキュメントが充実しており、サポートも良い評価を得ているサービスを検討する必要もあります。

今回はSendgridを使って実装を進めていきます。

メール送信サービスにおけるドメイン認証

迷惑メールの問題により送信元のチェックを強化する流れが進んでいます。送信元のドメインの認証が大抵のメール送信サービスで必須となっています。既にメール送信のためのドメインを取得している人であればドメインのDNS設定にいくつか登録すれば問題ありませんが、ドメインをお持ちでない方はAWS Route53やお名前ドットコムなどのサービスを使ってドメインをご用意いただく必要があります。
.comでも、安いドメインを探せば1000〜2000円/年程度で取得できます。

実装の流れ

メール送信サービスに関する概要が終わりましたので実際の実装に入っていきます。下記の流れで実装を進めていきます。

  • ①トークンをデータベースで保持できるようにする
  • ②トークンの作成処理
  • ③作成したトークンを使ってメール送信処理の実装
  • ④アカウント登録処理とログイン処理に組み込む
  • ⑤動作確認

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

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

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

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

次にUserモデルにメール認証が完了しているかを表すキーを追加します。emailVerifiedを追加して、日付型とします。これはいつメール認証をしたかがわかるようにする為、日付型としています。

schema.prisma
model User {
  id       String @id @default(cuid())
  name     String
  email    String @unique
+   emailVerified DateTime?
  password String
}

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

$ npx prisma generate

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

$ 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ファイルを作成し下記のコードを書いていきます。

tokens.ts
import { v4 as uuidv4 } from 'uuid'
import db from '@/lib/db'

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

  //TDOO: 既にトークンが存在していた場合は削除する

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

  return verificationToken
}

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

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

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

verification-token.ts
import db from '@/lib/db'

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

    return verificationToken
  } catch {
    return null
  }
}

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

    return verificationToken
  } catch {
    return null
  }
}

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

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

tokens.ts
import { v4 as uuidv4 } from 'uuid'
+ import { getVerificationTokenByEmail } from '@/data/verification-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 getVerificationTokenByEmail(email)

+   if (existingToken) {
+     await db.verificationToken.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
import sendgrid from '@sendgrid/mail'

sendgrid.setApiKey(process.env.SENDGRID_API_KEY as string)

const domain = process.env.NEXT_PUBLIC_APP_URL

export const sendVerificationEmail = async (email: string, token: string) => {
  const confirmLink = `${domain}/auth/new-verification?token=${token}`

  try {
    await sendgrid.send({
      from: 'test-taro@shinagawa-web.com',
      to: email,
      subject: 'メールアドレスの確認',
      html: `<p>以下のリンクをクリックして、メールアドレスの確認を行ってください。<br><a href="${confirmLink}">ここをクリック</a></p>`,
    })

    return { success: true }
  } catch (err) {
    return { success: false }
  }
}

コードの解説をします。

const domain = process.env.NEXT_PUBLIC_APP_URL

(※一部省略)

export const sendVerificationEmail = async (email: string, token: string) => {

メールアドレスとトークンを引数に取ります。
このリンクにトークンを含めることでリンクでアクセスがあった際にサーバー側でトークンの検証が可能となります。

  const confirmLink = `${domain}/auth/new-verification?token=${token}`

ユーザーがメールを開封した際にクリックするリンクのURLを生成します。
現在はローカルで開発をおこなっているため、.envファイルに下記を追加します。

.env
NEXT_PUBLIC_APP_URL="http://localhost:3000"

後々、VercelやAWSにデプロイし本番利用を想定してドメインを環境変数で定義しています。

    await sendgrid.send({
      from: 'test-taro@shinagawa-web.com',
      to: email,
      subject: 'メールアドレスの確認',
      html: `<p>以下のリンクをクリックして、メールアドレスの確認を行ってください。<br><a href="${confirmLink}">ここをクリック</a></p>`,
    })

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

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

④アカウント登録処理とログイン処理に組み込む

トークンを生成する処理とメールを送信する処理をアカウント登録処理に組み込みます。

register.ts
+ import { generateVerificationToken } from '@/lib/tokens'
+ import { sendVerificationEmail } from '@/lib/mail'

(※一部省略)


  try {
    await db.user.create({
      data: {
        name,
        email,
        password: hashedPassword,
      },
    })

+     const verificationToken = await generateVerificationToken(email)
+     const sendResult = await sendVerificationEmail(
+       verificationToken.email,
+       verificationToken.token,
+     )
+     if (sendResult.success) {
+       return {
+         success:
+           '確認メールを送信しました。メール内のリンクをクリックしてアカウントを有効化してください。',
+       }
+     } else {
+       return { error: '確認メールの送信に失敗しました' }
+     }

-     return { success: 'アカウントを登録しました' }
  } catch (e) {

アカウントをデータベースに登録した後に、トークンの生成処理を追加します。
トークンが生成できたら、そのトークンとメールアドレスをsendVerificationEmailに渡してメールの送信を行います。メールの送信まで正常に行えましたら、「確認メールを送信しました。メール内のリンクをクリックしてアカウントを有効化してください。」というメッセージをアカウント登録画面に表示させ、ユーザーにメールを確認してもらうよう促します。

今回比較的長いメッセージを表示させることになったので入力フォームのCSSを修正します。

form-success.tsx
  return (
    <div className="flex items-center gap-x-2 rounded-md bg-emerald-500/15 p-3 text-sm text-emerald-500">
-       <CheckCircledIcon className="size-4" />
+       <CheckCircledIcon className="size-4 shrink-0" />
      <p>{message}</p>
    </div>
  )

ログイン処理時にメール認証が済んでいないアカウントの場合は、再度認証用のメールを送信する処理を組み込みます。

login.ts
+ import { getUserByEmail } from '@/data/user'
+ import { sendVerificationEmail } from '@/lib/mail'
+ import { generateVerificationToken } from '@/lib/tokens'

(※一部省略)

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

  const { email, password } = validatedFields.data

+   const existingUser = await getUserByEmail(email)
+   if (!existingUser || !existingUser.email || !existingUser.password) {
+     return { error: 'メールアドレスもしくはパスワードが間違っています。' }
+   }
+   if (!existingUser.emailVerified) {
+     const verificationToken = await generateVerificationToken(
+       existingUser.email,
+     )
+     const sendResult = await sendVerificationEmail(
+       verificationToken.email,
+       verificationToken.token,
+     )
+     if (sendResult.success) {
+       return {
+         success:
+           'メールアドレスが未認証です。認証リンクをメールで再送しました。',
+       }
+     } else {
+       return { error: '確認メールの送信に失敗しました' }
+     }
+   }

  try {
    await signIn('credentials', {

ログインを実際に行うsignInの手前にメール認証を済ませているかをチェックします。そのさらに前段階として、ログイン時に使用したメールアドレスが存在するかのチェックが必要となるためgetUserByEmailを使ってチェックしています。メールアドレスが存在しなかった場合に、「メールアドレスが存在しません。」と、入力画面に表示することも可能ですが、悪意のあるユーザーからのアクセスの場合に不要に情報を与えることとなるため、メッセージを「メールアドレスもしくはパスワードが間違っています。」としています。

  if (!existingUser.emailVerified) {
    const verificationToken = await generateVerificationToken(
      existingUser.email,
    )
    const sendResult = await sendVerificationEmail(
      verificationToken.email,
      verificationToken.token,
    )
    if (sendResult.success) {
      return {
        success:
          'メールアドレスが未認証です。認証リンクをメールで再送しました。',
      }
    } else {
      return { error: '確認メールの送信に失敗しました' }
    }
  }

その次にメール認証済みかどうかをチェックします。emailVerifiedがまだ何もセットされていない場合は、未認証と判断しトークン生成、メール送信を行います。現時点ではまだ実装していませんが、emailVerifiedに日付がセットされていた場合は特に何も処理をせず、後続のsignInに処理をつなげています。

またログイン処理については、これまで成功した場合はすぐに設定画面へリダイレクトする設定としていたため、成功した場合のメッセージ表示機能は設けていませんでした。今回、メールを再送したメッセージを成功したメッセージとして表示する必要が出てきたのでログインの入力フォームも修正します。

login-form.tsx
+ import { FormSuccess } from '../form-success'

  const [error, setError] = useState<string | undefined>('')
+   const [success, setSuccess] = useState<string | undefined>('')
  const [isPending, setTransition] = useTransition()

(※一部省略)

  const onSubmit = async (values: z.infer<typeof LoginSchema>) => {
    setError('')
+     setSuccess('')
    setTransition(async () => {
      try {
        const response = await login(values)
-         if (response && response.error) {
-           setError(response.error)
-         }
+         setError(response?.error)
+         setSuccess(response?.success)
      } catch (e) {
        setError('エラーが発生しました')
      }
   })
  }

(※一部省略)

      <FormError message={error} />
+       <FormSuccess message={success} />
      <Button type="submit" className="w-full" disabled={isPending}>
        ログイン
      </Button>

成功した時のメッセージをuseStateで管理するようにし、それを表示するFormSuccessを追加しています。

動作確認

まずはアカウント登録画面で実際に存在するメールアドレスを使って、アカウント登録を行います。

Image from Gyazo

登録するとメール送信が行われ画面にメッセージが表示されます。

Image from Gyazo

実際にメールが届いていることを確認します。合わせてリンクをホバーし想定していたアドレスとなっていることを確認します。

localhost:3000/auth/new-verification?token=xxxxxxxxxx

想定通りのアドレスになっていない場合は.envNEXT_PUBLIC_APP_URLの設定ができているか?など確認することをお勧めします。また実装が間違っていたために再度テストしたい場合は、Prisma Studioで作成済みのアカウントを消してからテストすることをお勧めします。

Image from Gyazo

実際にリンクをクリックします。すると恐らくログイン画面にリダイレクトするかと思います。これはNext.jsのミドルウェアによるものとなります。現状の設定では/auth/new-verificationがログイン後しかアクセスできない設定となっているため、未ログインのユーザーがリンクをクリックしてもログイン画面にリダイレクトされてしまいます。このルートを未ログインでもアクセスできるよう/route.tsファイルを修正します。

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

修正後にNext.jsを再起動し、再度メールのリンクをクリックします。すると404ページが表示され、URLを確認すると、想定したアドレスになっているかと思います。

Image from Gyazo

?token=xxxで生成されたトークンが確認できますのでprisma studioでデータベースに登録されたtokenが一致していることを確認できます。

Image from Gyazo

次はログイン処理の動作確認を行います。先ほど登録したメールアドレスを使ってログインを行います。すると、メール認証が完了していないため、認証用のメールが再送されます。

Image from Gyazo

以上で今回実装した範囲の動作確認となります。

さいごに

今回はトークンの生成とメール送信処理の実装を行い既存のアカウント登録、ログインに組み込む実装を行いました。この次は404で返してしまっているページでトークンを受け取り生成したトークンと一致しているか?また、有効期限内か?などのチェックをする仕組みを実装します。
これまでの未ログイン、ログイン済みに加え、メール認証済み、未認証と管理する状態が増え、その分アクセス制御や各種サーバーアクションの処理で考慮するべき点が増えてきました。実際の現場では、遷移図などを活用し、それぞれの状態でどのようなアクションを返すのか整理してから実装していくといいかと思います。

次の記事はこちら

メールを使って実際の認証処理をする「トークン検証画面」についてはこちらの記事でご紹介しております。

https://shinagawa-web.com/blogs/email-verification-token-validation

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

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

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

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