はじめに
これまでの章はNext.jsとAuth.jsを使ってメールアドレスとパスワードによるアカウント登録からログイン、ログアウトの実装を行なってきました。最低限の認証機能は実現できましたがWebサービスとして運営していくには様々なケースを想定して機能を実装しておく必要があります。
- 登録したメールアドレスが正しいか検証(メールアドレスを活用して様々な案内、通知をするため)
- パスワードのリセット(パスワードを忘れてしまった利用者のため)
- ワンタイムトークンを発行し二要素認証によるセキュリティ強化
今回はそのうちの「パスワードのリセット(パスワードを忘れてしまった利用者のため)」を2回の記事に渡って実装していきます。
パスワードリセットの実装方針
今回は新たにパスワードリセットをしたいメールアドレスを入力する画面を用意します。
メールアドレスを入力するとパスワードを新たにセットできる画面のリンクを載せたメールを送信します。
ユーザーはそのメールを開きリンクをクリックすると、新しいパスワードを設定する画面をユーザーに表示します。
ユーザーは表示された画面で新しいパスワードを入力します。その後、サーバ側ではトークンの検証と、あっていればパスワードを更新します。
ユーザーは新しいパスワードを使ってログインします。
今回のゴール
メールを送信する処理を実装します。メールのリンクをクリックして実際のパスワードリセットする処理についてはこの次の記事にて実装していきます。
実装の流れ
下記の流れで実装を進めていきます。
- ①トークンをデータベースで保持できるようにする
- ②トークンの作成処理
- ③メール送信処理の実装
- ④パスワード再発行画面の作成
- ⑤サーバーアクションを作成しユーザーの入力後にメールを送信
- ⑥動作確認
①トークンをデータベースで保持できるようにする
発行したトークンをデータベースで保持できるよう新たなモデルを作成します。既存の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
②トークンの作成処理
最初にトークンを生成するときに使うuuid
をインストールします。
npm i uuid
npm i --save-dev @types/uuid
ランダムな文字列を生成するのに便利なライブラリとなります。
lib/tokens.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
ファイルを作成し下記のコードを書いていきます。
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
ファイルに戻り、トークンが存在していたら削除します。複数回、パスワードリセット用のトークンを発行した場合に最新のトークンのみ有効としておくためとなります。
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
ファイルに追加します。
SENDGRID_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Sendgridはnode.js
向けにライブラリを提供しておりコチラを使うとメール送信処理がスムーズに進みます。
npm i @sendgrid/mail
上記のライブラリの使い方はコチラが参考になります。
lib/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
ファイルに下記のコードを追加します。
+ export const ResetSchema = z.object({
+ email: z.string().email({
+ message: 'Email is required',
+ }),
+ })
ユーザーがメールアドレスを入力するため、その入力内容をチェックするスキーマとなります。
作成したスキーマを使いつつサーバーアクションを実装します。
actions/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
というファイルを作成し下記のコードを書いていきます。
'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
ここではメールアドレスを渡して実行しています。
処理の結果success
、error
どちらかのメッセージを受け取りuseState
で保持します。
<FormError message={error} />
<FormSuccess message={success} />
success
、error
どちらかのメッセージを受け取った後、表示している箇所となります。
作成した入力フォームを使って画面を実装します。
/app/auth/reset/page.tsx
ファイルを作成し下記のコードを書いていきます。
import { ResetForm } from '@/components/auth/reset-form'
const ResetPage = () => {
return <ResetForm />
}
export default ResetPage
これでパスワード再発行画面の作成は完了となります。
次に、この画面に利用者が辿り着くための導線を確保します。ログイン画面に、パスワード再発行画面へ遷移できるようリンクを表示させます。
components/auth/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>
)}
/>
パスワードを入力するフォームのすぐそばにリンクが表示されました。
最後に、ルーティングの設定をします。
現状のミドルウェアの設定では未ログインの状態では新しい画面にアクセスできないため、アクセスできるようパスを追加します。
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です。
さいごに
今回はトークンの生成とメール送信処理の実装を行い画面からパスワードリセットをしたいメールアドレスを入力しメールが実際に届く仕組みを実装しました。
「メール認証機能を実装する①メール送信」の実装と似ている部分が多かったと思います。コードの書き方によっては関数などの共通化もできるかと思いますので、機会がありましたら共通化による工数削減に関する記事も書いてご紹介できたらと思います。
次の記事はこちら