はじめに
前回、トークンを生成し認証用のメールを送信する処理を実装しました。今回はその続きの処理を実装していきます。リンクをクリックしてアクセスした画面でトークンが生成済みトークンであることを確認したら、対象のメールアドレスをメール認証済みとします。
今回のゴール
アカウント登録 -> メールを確認しリンクをクリック -> メール認証処理 -> ログイン -> 設定画面にリダイレクト
という一連の流れを実装していきます。
トークンの検証
最初にトークンを受け取ってサーバーサイドでトークンの検証をする処理を実装します。どのような処理の流れになるかをまとめます。
- ①受け取ったトークンが正しいかデータベースに問い合わせチェックしもし存在しなかったらエラーを返す
- ②
VerificationToken
にあるトークンとメールアドレスの組み合わせからメールアドレスを取得 - ③メールアドレスがメール認証済みかをチェックし既に認証済みだったら正常終了を返す
- ④トークンの有効期限をチェックし有効期限切れの場合はエラーを返す
- ⑤チェックが一通り終わったら対象のメールアドレスをメール認証済みに変更し正常終了を返す
基本的にはトークンのチェックは①④の2箇所となります。ただ、既に認証済みのユーザーが再度メールのリンクをクリックした場合を考慮し、③で早期リターンするような対応を入れています。
/actions/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
に認証時点の日時をセットするのと同時に、email
をVerificationToken
にあるメールアドレスに変更しています。これまで一連の処理を実装していた方なら、同じメールアドレスなのになぜ更新する?と、違和感を感じる箇所となるかと思います。
現在の実装時点であれば同じメールアドレスとなるため、不要なアップデートではあります。ですが、今後メールアドレスを変更しそのメールアドレスでメール認証を行うタイミングではメールアドレスを更新する必要が出てくるため、このタイミングで対応を行なっています。
(メールアドレスを更新するケースは別記事でご紹介したいと思います。)
検証用のフォーム作成
サーバーサイドでトークンの検証をする処理ができましたので、画面起動時にこの処理を呼び出せるよう実装を進めていきます。
ここではuseSearchParams
でURLからトークンを取得する処理と、useEffect
でサーバーアクションを呼び出す処理を実装するのですがどちらもクライアントコンポーネントのhooksのため、クライアントコンポーネントを作成します。
ローディング用のライブラリの導入
トークンを検証中のローディングをライブラリを使って実装します。こちらのREACT SPINNERではさまざまなローディングスタイルが提供されています。今回はBeatLoader
を使っていきます。
使い方もシンプルでReactのコンポーネントをそのまま呼び出して使えますし、サイズや高さなどのpropsを渡してスタイルをカスタマイズすることも可能です。
下記のコマンドでライブラリをインストールします。
npm i react-spinners
/components/auth/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=xxxxx
のxxxxx
の部分を取得しています。
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
というローディングを表示させます。どちらかのメッセージがセットされるとローディングのアニメーションは消えます。
あとはアカウント登録画面でも使っているFormSuccess
とFormError
でそれぞれのメッセージが入ったら表示をさせる形となります。
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
をクリックすれば削除となります。
アカウント登録 -> メールを確認しリンクをクリック -> メール認証処理 -> ログイン -> 設定画面にリダイレクト
という流れになります。
成功パターンを確認できましたら他のパターンの動作確認もします。
- トークンの有効期限が過ぎたパターン
メール送信から1時間後にリンクをクリックした場合には下記のメッセージが出ます。
- トークンが見つかりません
悪意のあるユーザーがメール認証画面に直接アクセスした場合(例えば、http://localhost:3000/auth/new-verification
をブラウザで直接入力)
さいごに
前回からご紹介したメール認証機能は以上となります。処理自体はそこまで複雑ではないかと思いますが、様々な状態を考慮し適宜メッセージを変える必要があり、それなりのコード量になる処理になったかと思います。実際のサービスではメール認証を忘れたユーザーのためにメールの再送を行う画面を提供しているケースもあるかと思います。今回はログイン画面でその機能を代替してしまいましたが、機会があればメール再送専用の画面実装もご紹介できたらと思います。
おすすめの記事