Next.jsでログイン画面を作ってメールアドレス/パスワードでログインできるようにする

2024/02/27に公開

はじめに

前回まではリクエストがきたセッションに対してログイン済み、未ログインどちらなのかをAuth.jsの仕組みを活用して判定しつつ、それぞれの状態でアクセスできる画面の設定をNext.jsのミドルウェアを使って行いました。
今回はログイン処理を実装しつつ、ログイン状態でアクセス可能な画面にアクセスできることを確認していきます。

https://shinagawa-web.com/blogs/nextjs-middleware-auth-access-control

今回のゴール

ログインの入力フォームを作成しログインに成功した場合、設定画面にリダイレクトしセッション情報を表示させます。またログアウトの処理を行うと再びログイン画面に戻ってくる流れとなります。

Image from Gyazo

ログイン時にもチェック機能を実装しパスワードが未入力だった場合や入力したメールアドレス/パスワードが誤っていた場合にユーザーに通知する仕組みも併せて導入します。

メールアドレス/パスワードによるログイン

Auth.jsのCredentials Providerを使うことで実装が可能となります。

https://authjs.dev/getting-started/providers/credentials

実装の流れ

実装する箇所がこれまでより多岐に渡りコード量もそれなりに多いです。ただ新しいライブラリなどは登場しないため比較的スムーズにコードは理解できるかと思います。

  • ①ログイン用のZodスキーマ作成
  • ②メールアドレスをキーにデータベースにアクセスする処理の作成
  • ③認証処理の作成
  • ④ログイン用のサーバーアクションを作成し認証処理の組み込み
  • ⑤ログインフォームの作成(バリデーション処理含む)
  • ⑥ログイン画面にログインフォームの組み込み
  • ⑦設定画面にログアウト処理の作成

①ログイン用のZodスキーマ作成

まず最初にログイン用のZodスキーマを作成していきます。
アカウント作成用のスキーマと似ていますが2点変えています。

  • 名前の入力チェックはなし
  • パスワードは最低1文字(アカウント作成時にパスワード強度を設定しているため)

/schema配下のindex.tsに下記を追記します。

index.ts
+ export const LoginSchema = z.object({
+   email: z.string().email({
+     message: 'メールアドレスを入力してください',
+   }),
+   password: z.string().min(1, {
+     message: 'パスワードを入力してください',
+   }),
+ })

②メールアドレスをキーにデータベースにアクセスする処理の作成

メールアドレスを元にユーザー情報をデータベースに問い合わせする処理を作成します。
こちらは今後も他の箇所で使用する共通処理とするため、この処理単独で作成します。

/data配下にuser.tsファイルを作成します。

user.ts
import db from '@/lib/db'

export const getUserByEmail = async (email: string) => {
  try {
    const user = await db.user.findUnique({ where: { email } })

    return user
  } catch {
    return null
  }
}

③認証処理の作成

①と②で作成したスキーマ定義とデータベース問い合わせ処理で使って、認証処理を行います。

auth.config.ts
+ import bcryptjs from 'bcryptjs'
import type { NextAuthConfig } from 'next-auth'
+ import Credentials from 'next-auth/providers/credentials'
import github from 'next-auth/providers/github'
+ import { getUserByEmail } from './data/user'
+ import { LoginSchema } from './schema'

- export default { providers: [github] } satisfies NextAuthConfig
+ export default {
+   providers: [
+     github,
+     Credentials({
+       async authorize(credentials) {
+         const validatedFields = LoginSchema.safeParse(credentials)
+
+         if (!validatedFields.success) {
+           return null
+         }

+         const { email, password } = validatedFields.data
+         const user = await getUserByEmail(email)
+         if (!user || !user.password) {
+           return null
+         }
+         const passwordMatch = await bcryptjs.compare(password, user.password)
+         if (passwordMatch) {
+           return user
+         }
+         return null
+       },
+     }),
+   ],
+ } satisfies NextAuthConfig

コードの解説をします。

+     Credentials({
+       async authorize(credentials) {
+         const validatedFields = LoginSchema.safeParse(credentials)
+
+         if (!validatedFields.success) {
+           return null
+         }

credentialsの中にはログイン時にユーザーが入力したメールアドレスとパスワードがセットされています。スキーマチェックをして問題があれば、処理を終了します。

+         const { email, password } = validatedFields.data
+         const user = await getUserByEmail(email)
+         if (!user || !user.password) {
+           return null
+         }

ユーザーが入力したメールアドレスを元にデータベースにアクセスしアカウントが登録済みでない、もしくはパスワードが設定されていなければ処理を終了します。

+         const passwordMatch = await bcryptjs.compare(password, user.password)
+         if (passwordMatch) {
+           return user
+         }

ハッシュ化されたパスワードをデータベースから取得したら、ユーザーが入力したパスワードをハッシュ化した上で比較し一致していたらユーザー情報を返し正常終了とします。

④ログイン用のサーバーアクションを作成し認証処理の組み込み

上記で作成した認証処理をサーバーアクションに組み込みます。

/actions配下にlogin.tsファイルを作成します。

login.ts
'use server'
import { AuthError } from 'next-auth'
import { z } from 'zod'
import { signIn } from '@/auth'
import { DEFAULT_LOGIN_REDIRECT } from '@/route'
import { LoginSchema } from '@/schema'

export const login = async (values: z.infer<typeof LoginSchema>) => {
  const validatedFields = LoginSchema.safeParse(values)

  if (!validatedFields.success) {
    return { error: '入力内容を修正してください' }
  }

  const { email, password } = validatedFields.data
  try {
    await signIn('credentials', {
      email,
      password,
      redirectTo: DEFAULT_LOGIN_REDIRECT,
    })
  } catch (error) {
    if (error instanceof AuthError) {
      switch (error.type) {
        case 'CredentialsSignin':
          return {
            error: 'メールアドレスもしくはパスワードが間違っています。',
          }
        default:
          return { error: 'エラーが発生しました。' }
      }
    }

    throw error
  }
}

コードの解説をします。

    await signIn('credentials', {
      email,
      password,
      redirectTo: DEFAULT_LOGIN_REDIRECT,
    })

signIn関数を実行すると先ほどのauthorizeが呼び出されます。
処理が正常終了した際のリダイレクト先としてDEFAULT_LOGIN_REDIRECT(/settings)を設定しています。

⑤ログインフォームの作成(バリデーション処理含む)

ログイン処理の実装が完了しましたのでログインするための入力フォームを用意しログイン処理を組み込みます。

/component/auth配下にlogin-form.tsxファイルを作成します。

login-form.tsx
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { useState, useTransition } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { login } from '@/actions/login'
import { LoginSchema } from '@/schema'
import { FormError } from '../form-error'
import { Button } from '../ui/button'
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '../ui/form'
import { Input } from '../ui/input'
import { CardWrapper } from './card-wrapper'

export const LoginForm = () => {
  const [error, setError] = useState<string | undefined>('')
  const [isPending, setTransition] = useTransition()
  const form = useForm<z.infer<typeof LoginSchema>>({
    resolver: zodResolver(LoginSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  })
  const onSubmit = async (values: z.infer<typeof LoginSchema>) => {
    setError('')
    setTransition(async () => {
      try {
        const response = await login(values)
        if (response && response.error) {
          setError(response.error)
        }
      } catch (e) {
        setError('エラーが発生しました')
      }
    })
  }
  return (
    <CardWrapper
      headerLabel="メールアドレスとパスワードを入力してログイン"
      buttonLabel="アカウント作成がまだの方はコチラ"
      buttonHref="/auth/register"
      showSocial
    >
      <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>メールアドレス</FormLabel>
                  <FormControl>
                    <Input
                      {...field}
                      disabled={isPending}
                      placeholder="nextjs@example.com"
                      type="email"
                      autoComplete="off"
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="password"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>パスワード</FormLabel>
                  <FormControl>
                    <Input
                      {...field}
                      placeholder="******"
                      type="password"
                      disabled={isPending}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </div>
          <FormError message={error} />
          <Button type="submit" className="w-full" disabled={isPending}>
            ログイン
          </Button>
        </form>
      </Form>
    </CardWrapper>
  )
}

こちらについてはアカウント作成用の入力フォームと構造はほぼ同じなので、詳しい説明は割愛します。
1箇所異なる点としてはアカウント作成用の入力フォームでは成功した際にメッセージを返していましたが、ログインで成功した場合は設定画面にリダイレクトするため成功した際のメッセージを表示する必要がありません。
そのため、成功時のメッセージを表示するコンポーネントや成功したメッセージを状態管理する処理を削っています。

⑥ログイン画面にログインフォームの組み込み

作成したログインフォームを画面に組み込みます。

page.tsx
import { LoginForm } from '@/components/auth/login-form'

const LoginPage = () => {
  return <LoginForm />
}

export default LoginPage

⑦設定画面にログアウト処理の作成

動作確認用にログアウトの処理を設定画面に作成します。

page.tsx
+ import { auth, signOut } from '@/auth'
+ import { Button } from '@/components/ui/button'

- const SettingsPage = () => {
-   return <div>設定画面</div>

+ const SettingsPage = async () => {
+   const session = await auth()
+   if (!session) return null
+   const onSubmit = async () => {
+     'use server'
+     await signOut()
+   }
+   return (
+     <div>
+       設定画面
+       <pre>{JSON.stringify(session, null, 2)}</pre>
+       <form action={onSubmit}>
+         <Button type="submit" className="w-full" variant="secondary">
+           ログアウト
+         </Button>
+       </form>
+     </div>
+   )
}

export default SettingsPage

コードの解説をします。

+   const session = await auth()
+   if (!session) return null

(※一部省略)

+       <pre>{JSON.stringify(session, null, 2)}</pre>

await auth()auth.jsはこのコマンドでサーバーサイドでセッションを取得することが可能です。そして取得できたセッション情報を設定画面にて表示します。

+   const onSubmit = async () => {
+     'use server'
+     await signOut()
+   }

ログアウトする処理となります。

動作確認

一通りコードを実装しましたら、実装した箇所が想定通りの挙動を示すか動作確認をします。

①パスワードが未入力だった場合はバリデーション機能でエラーが返ること
②パスワードが正しくない場合は、ログイン用のサーバーアクションでエラーが返ること
③正しいメールアドレス/パスワードを入力した場合、設定画面にリダイレクトしログインできセッション情報が確認できること
④ログアウトするとログイン画面にリダイレクトすること(Next.jsのミドルウェアの機能)
⑤未ログイン状態で設定画面にアクセスした場合、ログイン画面にリダイレクトすること(Next.jsのミドルウェアの機能)

※④と⑤に関しては前回の記事で実装した機能になります。

Image from Gyazo

さいごに

今回の記事でアカウント作成〜ログイン〜ログアウトまでの一連の処理を終えることできました。
ただ、認証に関しては実施まだ考慮する点がいくつかあります。

  • メールアドレス存在有無の確認
  • パスワードリセット
  • 二要素認証

これらの機能がメールアドレス/パスワードによる認証を行う際には必須となってきます。次回以降はこれらの実装方法に関してもご紹介していきます。
またメールアドレス/パスワードでの認証を実装するのはAuth.jsなどのライブラリを活用してもそれなりに考慮すべき点があり大変かと思います。なるべく認証に関する処理に時間をかけたくないシステムのPoC段階などではGoogleやFacebookなどを活用したOauthによる認証を活用した方がいいかと思います。
Auth.jsでOauthによる認証を組み込むことも可能ですのでそちらについても次回以降ご紹介していきます。

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

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

https://shinagawa-web.com/blogs/nextjs-two-factor-authentication

https://shinagawa-web.com/blogs/nextjs-github-google-oauth

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

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

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

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