メール認証機能を実装する その2:トークンの検証

2024/09/13に公開

はじめに

前回、トークンを生成し認証用のメールを送信する処理を実装しました。今回はその続きの処理を実装していきます。リンクをクリックしてアクセスした画面でトークンが生成済みトークンであることを確認したら、対象のメールアドレスをメール認証済みとします。

今回のゴール

アカウント登録 -> メールを確認しリンクをクリック -> メール認証処理 -> ログイン -> 設定画面にリダイレクト

という一連の流れを実装していきます。

Image from Gyazo

トークンの検証

最初にトークンを受け取ってサーバーサイドでトークンの検証をする処理を実装します。どのような処理の流れになるかをまとめます。

  • ①受け取ったトークンが正しいかデータベースに問い合わせチェックしもし存在しなかったらエラーを返す
  • VerificationTokenにあるトークンとメールアドレスの組み合わせからメールアドレスを取得
  • ③メールアドレスがメール認証済みかをチェックし既に認証済みだったら正常終了を返す
  • ④トークンの有効期限をチェックし有効期限切れの場合はエラーを返す
  • ⑤チェックが一通り終わったら対象のメールアドレスをメール認証済みに変更し正常終了を返す

基本的にはトークンのチェックは①④の2箇所となります。ただ、既に認証済みのユーザーが再度メールのリンクをクリックした場合を考慮し、③で早期リターンするような対応を入れています。

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

new-verification.ts
'use server'

import { getUserByEmail } from '@/data/user'
import { getVerificationTokenByToken } from '@/data/verification-token'
import db from '@/lib/db'

const successMessage =
  'メールアドレスの認証が完了しました。ログイン画面よりログインしてください。'
export const newVerification = async (token: string) => {
  const existingToken = await getVerificationTokenByToken(token)

  if (!existingToken) {
    return { error: 'トークンが見つかりません。' }
  }

  const existingUser = await getUserByEmail(existingToken.email)

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

  if (existingUser.emailVerified) {
    return { success: successMessage }
  }

  const hasExpired = new Date(existingToken.expires) < new Date()

  if (hasExpired) {
    return { error: 'トークンの有効期限が過ぎています。' }
  }

  try {
    await db.user.update({
      where: { id: existingUser.id },
      data: {
        emailVerified: new Date(),
        email: existingToken.email,
      },
    })
    return { success: successMessage }
  } catch {
    return { error: 'エラーが発生しました。' }
  }
}

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

    await db.user.update({
      where: { id: existingUser.id },
      data: {
        emailVerified: new Date(),
        email: existingToken.email,
      },
    })

emailVerifiedに認証時点の日時をセットするのと同時に、emailVerificationTokenにあるメールアドレスに変更しています。これまで一連の処理を実装していた方なら、同じメールアドレスなのになぜ更新する?と、違和感を感じる箇所となるかと思います。
現在の実装時点であれば同じメールアドレスとなるため、不要なアップデートではあります。ですが、今後メールアドレスを変更しそのメールアドレスでメール認証を行うタイミングではメールアドレスを更新する必要が出てくるため、このタイミングで対応を行なっています。

(メールアドレスを更新するケースは別記事でご紹介したいと思います。)

検証用のフォーム作成

サーバーサイドでトークンの検証をする処理ができましたので、画面起動時にこの処理を呼び出せるよう実装を進めていきます。

ここではuseSearchParamsでURLからトークンを取得する処理と、useEffectでサーバーアクションを呼び出す処理を実装するのですがどちらもクライアントコンポーネントのhooksのため、クライアントコンポーネントを作成します。

ローディング用のライブラリの導入

トークンを検証中のローディングをライブラリを使って実装します。こちらのREACT SPINNERではさまざまなローディングスタイルが提供されています。今回はBeatLoaderを使っていきます。

https://www.davidhu.io/react-spinners/

使い方もシンプルでReactのコンポーネントをそのまま呼び出して使えますし、サイズや高さなどのpropsを渡してスタイルをカスタマイズすることも可能です。

https://github.com/davidhu2000/react-spinners

下記のコマンドでライブラリをインストールします。

npm i react-spinners

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

new-verification.tsx
'use client'

import { useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { BeatLoader } from 'react-spinners'
import { newVerification } from '@/actions/new-verification'
import { CardWrapper } from '@/components/auth/card-wrapper'
import { FormError } from '@/components/form-error'
import { FormSuccess } from '@/components/form-success'

export const NewVerificationForm = () => {
  const [error, setError] = useState<string | undefined>()
  const [success, setSuccess] = useState<string | undefined>()

  const searchParams = useSearchParams()

  const token = searchParams.get('token')

  const onSubmit = useCallback(() => {
    if (success || error) return

    if (!token) {
      setError('トークンが見つかりません。')
      return
    }

    newVerification(token)
      .then((data) => {
        setSuccess(data.success)
        setError(data.error)
      })
      .catch(() => {
        setError('エラーが発生しました。')
      })
  }, [token, success, error])

  useEffect(() => {
    onSubmit()
  }, [onSubmit])

  return (
    <CardWrapper
      headerLabel="メールアドレスの認証処理中"
      buttonLabel="ログイン画面はコチラ"
      buttonHref="/auth/login"
    >
      <div className="flex w-full items-center justify-center">
        {!success && !error && <BeatLoader />}
        <FormSuccess message={success} />
        {!success && <FormError message={error} />}
      </div>
    </CardWrapper>
  )
}

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

  const searchParams = useSearchParams()

  const token = searchParams.get('token')

useSearchParamsを使ってURLの?token=xxxxxxxxxxの部分を取得しています。

  const onSubmit = useCallback(() => {
    if (success || error) return

    if (!token) {
      setError('トークンが見つかりません。')
      return
    }

    newVerification(token)
      .then((data) => {
        setSuccess(data.success)
        setError(data.error)
      })
      .catch(() => {
        setError('エラーが発生しました。')
      })
  }, [token, success, error])

  useEffect(() => {
    onSubmit()
  }, [onSubmit])

後続の処理として取得したトークンを引数に先ほど作成したサーバアクションのnewVerificationを実行します。実行した結果で成功、エラーそれぞれのパターンでメッセージを格納します。

      <div className="flex w-full items-center justify-center">
        {!success && !error && <BeatLoader />}
        <FormSuccess message={success} />
        <FormError message={error} />
      </div>

検証の状況に応じて適宜ユーザーにメッセージを返す箇所となります。画面を起動したタイミングでは成功もエラーもどちらのメッセージの存在しないため、BeatLoaderというローディングを表示させます。どちらかのメッセージがセットされるとローディングのアニメーションは消えます。
あとはアカウント登録画面でも使っているFormSuccessFormErrorでそれぞれのメッセージが入ったら表示をさせる形となります。

  return (
    <CardWrapper
      headerLabel="メールアドレスの認証処理中"
      buttonLabel="ログイン画面はコチラ"
      buttonHref="/auth/login"
    >

メール認証の処理が終わったらログイン画面にスムーズに移動できるようリンクを設置しておきます。
この画面ではユーザーが何かを入力するようなものはありません。

トークン検証画面の作成

最後に作成したクライアントコンポーネントを画面に表示できるようにします。

前回の記事でメール本文にセットしたリンクのアドレスがありますので

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

それに合わせるよう/auth/new-verification/page.tsxファイルを作成し下記のコードを書いていきます。

import { NewVerificationForm } from '@/components/auth/new-verification-form'

const NewVerificationPage = () => {
  return <NewVerificationForm />
}

export default NewVerificationPage

動作確認

画面の作成まで終わったら動作確認をします。既にアカウント登録をしていたらデータベースで登録済みのアカウントを削除します。

npx prisma studio

削除したいレコードを選択しDelete 1 recordをクリックすれば削除となります。

Image from Gyazo

アカウント登録 -> メールを確認しリンクをクリック -> メール認証処理 -> ログイン -> 設定画面にリダイレクト

という流れになります。

Image from Gyazo

成功パターンを確認できましたら他のパターンの動作確認もします。

  • トークンの有効期限が過ぎたパターン

メール送信から1時間後にリンクをクリックした場合には下記のメッセージが出ます。

Image from Gyazo

  • トークンが見つかりません

悪意のあるユーザーがメール認証画面に直接アクセスした場合(例えば、http://localhost:3000/auth/new-verificationをブラウザで直接入力)

Image from Gyazo

さいごに

前回からご紹介したメール認証機能は以上となります。処理自体はそこまで複雑ではないかと思いますが、様々な状態を考慮し適宜メッセージを変える必要があり、それなりのコード量になる処理になったかと思います。実際のサービスではメール認証を忘れたユーザーのためにメールの再送を行う画面を提供しているケースもあるかと思います。今回はログイン画面でその機能を代替してしまいましたが、機会があればメール再送専用の画面実装もご紹介できたらと思います。

おすすめの記事

https://shinagawa-web.com/blogs/password-reset-email-sending

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

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

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

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