パスワードリセット処理を実装する②トークンの検証

2024/04/22に公開

はじめに

前回、トークンを生成し認証用のメールを送信する処理を実装しました。今回はその続きの処理を実装していきます。リンクをクリックすると新しいパスワードを入力できる画面を実装します。
ユーザーが新しいパスワードを入力すると、サーバー側でトークンが生成済みトークンであることを確認したら、新しいパスワードを受け付ける処理を実装します。

今回のゴール

メールにあるリンクをクリックすると、今回作成した画面が表示されるかと思います。パスワードを入力し「新しいパスワードを設定」ボタンをクリックすると「パスワードを更新しました。ログイン画面よりログインをお願いします。」というメッセージが表示されます。
ログイン画面から新しいパスワードでログインできることを確認します。

Image from Gyazo

トークンの検証

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

  • ①受け取ったトークンが正しいかデータベースに問い合わせチェックしもし存在しなかったらエラーを返す
  • PasswordResetTokenにあるトークンを取得
  • ③トークンの有効期限をチェックし有効期限切れの場合はエラーを返す
  • ④チェックが一通り終わったらユーザーが入力したパスワードを新しいパスワードとして更新する

基本的にはトークンのチェックは①③の2箇所となります。

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

まず最初にユーザーが入力するパスワードに関するZodスキーマを実装します。

schema/index.tsファイルに下記のコードを追加します。

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回入力するような画面にしているためpasswordconfirmPasswordという2つを定義しています。
また比較を行い不一致の場合にエラーを表示できるよう定義しています。

作成したスキーマを使いつつサーバーアクションを実装します。

actions/new-password.tsに下記のコードを書いていきます。

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文字以上かつpasswordconfirmPasswordが一致しているか)
②サーバーアクションに渡されたトークンが生成済みのトークンであるか?
③トークンが有効期限内であるか?

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

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}`
page.tsx
import { NewPasswordForm } from '@/components/auth/new-password-form'

const NewPasswordPage = () => {
  return <NewPasswordForm />
}

export default NewPasswordPage

動作確認

一通り必要な実装が終わったので実際に画面にアクセスしパスワード更新が行えるか確認します。

画面へのアクセスは前回の記事で送信したメールを使って行います。メールにあるリンクをクリックすると、今回作成した画面が表示されるかと思います。パスワードを入力し「新しいパスワードを設定」ボタンをクリックすると「パスワードを更新しました。ログイン画面よりログインをお願いします。」というメッセージが表示されます。
ログイン画面から新しいパスワードでログインできることを確認します。

Image from Gyazo

パスワードのバリデーションが機能しているか確認しておきます。

パスワードを5文字入力して「新しいパスワードを設定」ボタンをクリックするとエラーが表示されます。

Image from Gyazo

また、新しいパスワードと確認用パスワードで異なる値をセットし「新しいパスワードを設定」ボタンをクリックするとエラーが表示されます。

Image from Gyazo

さいごに

2つの記事に渡って、パスワードをリセットする機能を実装していきました。パスワードのリセットが必要な場面としては未ログイン状態である前提を考慮し、未ログイン状態でアクセスできるようルーティングの設定についても配慮しておく必要があります。機能によってユーザーがどの状態で使うのかは常に考慮し実装する必要がありますので、ミドルウェアの機能や認証状態を取得できるAuth.jsについても必要に応じて見返して頂けたらと思います。

おすすめの記事

https://shinagawa-web.com/blogs/nextjs-email-verification-implementation-sending-email

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

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

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

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