はじめに
前回、トークンを生成し認証用のメールを送信する処理を実装しました。今回はその続きの処理を実装していきます。リンクをクリックすると新しいパスワードを入力できる画面を実装します。
ユーザーが新しいパスワードを入力すると、サーバー側でトークンが生成済みトークンであることを確認したら、新しいパスワードを受け付ける処理を実装します。
今回のゴール
メールにあるリンクをクリックすると、今回作成した画面が表示されるかと思います。パスワードを入力し「新しいパスワードを設定」ボタンをクリックすると「パスワードを更新しました。ログイン画面よりログインをお願いします。」というメッセージが表示されます。
ログイン画面から新しいパスワードでログインできることを確認します。
トークンの検証
最初にトークンを受け取ってサーバーサイドでトークンの検証をする処理を実装します。どのような処理の流れになるかをまとめます。
- ①受け取ったトークンが正しいかデータベースに問い合わせチェックしもし存在しなかったらエラーを返す
- ②
PasswordResetToken
にあるトークンを取得 - ③トークンの有効期限をチェックし有効期限切れの場合はエラーを返す
- ④チェックが一通り終わったらユーザーが入力したパスワードを新しいパスワードとして更新する
基本的にはトークンのチェックは①③の2箇所となります。
/actions/new-password.ts
ファイルを作成し以下のコードを書いていきます。
まず最初にユーザーが入力するパスワードに関するZodスキーマを実装します。
schema/index.ts
ファイルに下記のコードを追加します。
+ export const NewPasswordSchema = z
+ .object({
+ password: z.string().min(6, {
+ message: 'パスワードは6文字以上で入力してください',
+ }),
+ confirmPassword: z.string().min(6, {
+ message: 'パスワードは6文字以上で入力してください',
+ }),
+ })
+ .refine((data) => data.password === data.confirmPassword, {
+ message: '入力したパスワードが一致しません',
+ path: ['confirmPassword'],
+ })
パスワード更新については、2回入力するような画面にしているためpassword
とconfirmPassword
という2つを定義しています。
また比較を行い不一致の場合にエラーを表示できるよう定義しています。
作成したスキーマを使いつつサーバーアクションを実装します。
actions/new-password.ts
に下記のコードを書いていきます。
'use server'
import bcrypt from 'bcryptjs'
import * as z from 'zod'
import { getPasswordResetTokenByToken } from '@/data/password-reset-token'
import { getUserByEmail } from '@/data/user'
import db from '@/lib/db'
import { NewPasswordSchema } from '@/schema'
export const newPassword = async (
values: z.infer<typeof NewPasswordSchema>,
token?: string | null,
) => {
if (!token) {
return { error: 'トークンが見つかりません。' }
}
const validatedFields = NewPasswordSchema.safeParse(values)
if (!validatedFields.success) {
return { error: '入力内容を修正してください。' }
}
const existingToken = await getPasswordResetTokenByToken(token)
if (!existingToken) {
return { error: 'トークンが見つかりません。' }
}
const hasExpired = new Date(existingToken.expires) < new Date()
if (hasExpired) {
return { error: 'トークンの有効期限が過ぎています。' }
}
const existingUser = await getUserByEmail(existingToken.email)
if (!existingUser) {
return { error: 'メールアドレスが存在しません。' }
}
const { password } = validatedFields.data
const hashedPassword = await bcrypt.hash(password, 10)
try {
await db.user.update({
where: { id: existingUser.id },
data: { password: hashedPassword },
})
return {
success:
'パスワードを更新しました。ログイン画面よりログインをお願いします。',
}
} catch {
return { error: 'エラーが発生しました。' }
}
}
コードの解説をしていきます。
const validatedFields = NewPasswordSchema.safeParse(values)
if (!validatedFields.success) {
return { error: '入力内容を修正してください。' }
}
const existingToken = await getPasswordResetTokenByToken(token)
if (!existingToken) {
return { error: 'トークンが見つかりません。' }
}
const hasExpired = new Date(existingToken.expires) < new Date()
if (hasExpired) {
return { error: 'トークンの有効期限が過ぎています。' }
}
ここでは
①受け取ったパスワードが正しいか?(具体的には6文字以上かつpassword
とconfirmPassword
が一致しているか)
②サーバーアクションに渡されたトークンが生成済みのトークンであるか?
③トークンが有効期限内であるか?
という3つの観点のチェックを行なっています。
いずれも条件を満たしていない場合は即時エラーを返すようにします。
export const newPassword = async (
values: z.infer<typeof NewPasswordSchema>,
token?: string | null,
) => {
(※一部省略)
const { password } = validatedFields.data
const hashedPassword = await bcrypt.hash(password, 10)
ユーザーが入力した内容がvalues
で入ってくるため、その中からpassword
を取り出します。
パスワードをハッシュ化したのちにデータベースに格納します。
以前実装したアカウント登録と同じ処理の流れとなります。
try {
await db.user.update({
where: { id: existingUser.id },
data: { password: hashedPassword },
})
return {
success:
'パスワードを更新しました。ログイン画面よりログインをお願いします。',
}
} catch {
return { error: 'エラーが発生しました。' }
}
その後、パスワードを更新しエラーがなければ、正常終了のメッセージをクライアントサイドに返します。
画面の実装
新しいパスワードを入力する画面を実装していきます。
まず最初に新しいパスワードを入力するフォームを作成していきます。
/components/auth/new-password-form.tsx
ファイルを作成し、以下のコードを書いていきます。
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { useSearchParams } from 'next/navigation'
import { useState, useTransition } from 'react'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { newPassword } from '@/actions/new-password'
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 { NewPasswordSchema } from '@/schema'
export const NewPasswordForm = () => {
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [error, setError] = useState<string | undefined>('')
const [success, setSuccess] = useState<string | undefined>('')
const [isPending, startTransition] = useTransition()
const form = useForm<z.infer<typeof NewPasswordSchema>>({
resolver: zodResolver(NewPasswordSchema),
defaultValues: {
password: '',
confirmPassword: '',
},
})
const onSubmit = (values: z.infer<typeof NewPasswordSchema>) => {
setError('')
setSuccess('')
startTransition(() => {
newPassword(values, token).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="password"
render={({ field }) => (
<FormItem>
<FormLabel>新しいパスワード</FormLabel>
<FormControl>
<Input
{...field}
disabled={isPending}
placeholder="******"
type="password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>確認用パスワード</FormLabel>
<FormControl>
<Input
{...field}
disabled={isPending}
placeholder="******"
type="password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormError message={error} />
<FormSuccess message={success} />
<Button disabled={isPending} type="submit" className="w-full">
新しいパスワードを設定
</Button>
</form>
</Form>
</CardWrapper>
)
}
const searchParams = useSearchParams()
const token = searchParams.get('token')
ここではURLからトークンを取得しています。
前回の記事でURLを下記の形式で生成していました。
const resetLink = `${domain}/auth/new-password?token=${token}`
ここの中から、?token=
以下の文字をsearchParams.get('token')
で取得しています。
const [error, setError] = useState<string | undefined>('')
const [success, setSuccess] = useState<string | undefined>('')
(※一部省略)
const onSubmit = (values: z.infer<typeof NewPasswordSchema>) => {
setError('')
setSuccess('')
startTransition(() => {
newPassword(values, token).then((data) => {
setError(data?.error)
setSuccess(data?.success)
})
})
}
ユーザーがパスワードを入力後に「新しいパスワードを設定」ボタンをクリックすると、先ほど作成したサーバーアクションが呼び出されます。
正常終了、エラーそれぞれ発生したら画面に表示できるようメッセージをuseState
で状態管理しています。
return (
<CardWrapper
headerLabel="新しいパスワードを設定"
buttonLabel="ログイン画面はコチラ"
buttonHref="/auth/login"
>
パスワード更新が正常終了した後はログイン画面を使ってログインするため、ログイン画面の導線を用意しています。
最後にこの入力フォームを使って画面を実装していきます。
/app/auth/new-password/page.tsx
ファイルを作成し、以下のコードを書いていきます。
ディレクトリはリンクと揃えておく必要があります。
今回はリンクを下記のように定義しましたので、/auth/new-password
がディレクトリとなります。
const resetLink = `${domain}/auth/new-password?token=${token}`
import { NewPasswordForm } from '@/components/auth/new-password-form'
const NewPasswordPage = () => {
return <NewPasswordForm />
}
export default NewPasswordPage
動作確認
一通り必要な実装が終わったので実際に画面にアクセスしパスワード更新が行えるか確認します。
画面へのアクセスは前回の記事で送信したメールを使って行います。メールにあるリンクをクリックすると、今回作成した画面が表示されるかと思います。パスワードを入力し「新しいパスワードを設定」ボタンをクリックすると「パスワードを更新しました。ログイン画面よりログインをお願いします。」というメッセージが表示されます。
ログイン画面から新しいパスワードでログインできることを確認します。
パスワードのバリデーションが機能しているか確認しておきます。
パスワードを5文字入力して「新しいパスワードを設定」ボタンをクリックするとエラーが表示されます。
また、新しいパスワードと確認用パスワードで異なる値をセットし「新しいパスワードを設定」ボタンをクリックするとエラーが表示されます。
さいごに
2つの記事に渡って、パスワードをリセットする機能を実装していきました。パスワードのリセットが必要な場面としては未ログイン状態である前提を考慮し、未ログイン状態でアクセスできるようルーティングの設定についても配慮しておく必要があります。機能によってユーザーがどの状態で使うのかは常に考慮し実装する必要がありますので、ミドルウェアの機能や認証状態を取得できるAuth.jsについても必要に応じて見返して頂けたらと思います。
おすすめの記事
関連記事
- Next.jsとAuth.jsで認証機能を実装するチュートリアル
2024/09/13 - 10分で完成。AWS Amplifyを利用したNext.jsデプロイ環境の構築
2024/11/05 - Next.jsでメール認証機能を実装する その1:メール送信機能
2024/05/10 - Next.jsでメール認証機能を実装する その2:トークンの検証
2024/05/13 - Next.jsでGitHubとGoogleのOAuth認証を簡単実装する方法
2024/06/11 - Next.jsでログイン画面を作ってメールアドレス/パスワードでログインできるようにする
2024/02/27 - Next.jsとmicroCMSでチュートリアルを参考にしてブログサイトを作ってみた(公式チュートリアルより少し詳しく解説してます。)
2024/12/16 - Next.jsのミドルウェアとAuth.jsを活用して画面のアクセス制御をする
2024/03/02