🌊

Next.jsとPrismaとNext Auth v5で作る最新の認証機能

2024/04/11に公開

どうもこんにちは、たくびーです。

今回はNext.js、Prisma、NextAuthv5を使った認証機能を実装しました。
サインアップからパスワード認証を使ったサインイン機能を使った方法を書いていきますので、ぜひさんこうにしてください。

環境構築

以下のバージョンで環境を構築します。

  • Next.js 14.1.4
  • Prisma 5.11.0
  • NextAuth 5.0.0-beta.16

まずはNext.jsのプロジェクトを作成します。
以下のコマンドをTerminalに入力し、質問に答えてセットアップしてください。

terminal
npx create-next-app@latest

その後以下のファイルをそれぞれ編集していきます。

globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
layout.tsx
import '@/app/globals.css';
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Prisma Auth Demo',
  description: 'Prisma and Next Auth App with Next.js',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang='ja'>
      <body>{children}</body>
    </html>
  );
}
page.tsx
import { prisma } from '@/globals/db';

export default async function Home() {
  return (
    <main className='flex min-h-screen flex-col items-center justify-between p-24'>
      <div>Home</div>
    </main>
  );
}

tailwind.config.ts
import type { Config } from 'tailwindcss';

const config: Config = {
  content: [
    './pages/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
    './app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};
export default config;

DBにユーザー登録

Prismaを使ってDBにユーザーを登録する処理をNext.jsで作っていきます。

Prismaのセットアップ

まずはプロジェクトのPrismaをインストールします。

terminal
npm install prisma --save-dev

その後、Prismaの初期設定を行います。
今回はローカルでも動かせるようにSQLiteを使いますので、--datasource-provider sqliteを指定しましょう。

terminal
npx prisma init --datasource-provider sqlite

作成されたprisma/schema.prismaにUserのモデルを追加します。

prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id        String  @id     @default(cuid())
  email     String  @unique
  password  String
}

DBをマイグレートします。
以下のコマンドを入力してください。

terminal
npx prisma migrate dev --name init

Prisma Studioを起動し、DB内にUserモデルが作成されていればOKです。

terminal
npx prisma studio

Next.jsにDB操作関連のコードを追加

PrismaClientで不必要にインスタンスが増えないように以下のようなコードを書いてグローバルに1つのみに制限します。
こちらの詳細は以下のページを参照してください。

https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/databases-connections#prevent-hot-reloading-from-creating-new-instances-of-prismaclient

globals/db.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = global as unknown as { prisma: PrismaClient | undefined };

export const prisma = globalForPrisma.prisma || new PrismaClient();

認証で使う以下のパッケージを追加します。

terminal
npm install zod
npm install next-auth@beta
npm install bcrypt
npm install @types/bcrypt --save-dev

Zodを使用してスキーマの型を作っています。
メールアドレスとパスワードに関するスキーマです。

app/lib/schemas.ts
import { z } from 'zod';

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

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

emailからユーザーを取得する処理を作成します。
ここではprisma経由でDBにアクセスしています。

app/db/user.ts
import { prisma } from '@/globals/db';

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

    return user;
  } catch (error) {
    return null;
  }
};

次はフォームで使うアクションを作っていきます。
サインアップの場合、フォームから送られてきたデータをZodで検証し、問題なければDBに追加しています。
サインイン、サインアウトはNextAuthの機能を使うので実装がとても簡単です。

app/lib/actions.ts
'use server';

import { getUserByEmail } from '@/app/db/user';
import { signUpSchema } from '@/app/lib/schemas';
import { signIn, signOut } from '@/auth';
import { prisma } from '@/globals/db';
import bcrypt from 'bcrypt';
import { AuthError } from 'next-auth';
import { redirect } from 'next/navigation';

export type SignUpState = {
  errors?: {
    email?: string[];
    password?: string[];
  };
  message?: string | null;
};

export async function signUp(prevState: SignUpState, formData: FormData): Promise<SignUpState> {
  const validatedFields = signUpSchema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
  });

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: '入力項目が足りません。',
    };
  }

  const { email, password } = validatedFields.data;

  try {
    const hashedPassword = await bcrypt.hash(password, 10);
    const existingUser = await getUserByEmail(email);

    if (existingUser) {
      return {
        message: '既に登録されているユーザーです。',
      };
    }

    await prisma.user.create({
      data: {
        email: email,
        password: hashedPassword,
      },
    });
  } catch (error) {
    throw error;
  }

  redirect('/login');
}

export async function login(prevState: string | undefined, formData: FormData) {
  try {
    await signIn('credentials', formData);
  } catch (error) {
    if (error instanceof AuthError) {
      switch (error.type) {
        case 'CredentialsSignin':
          return 'Invalid credentials.';
        default:
          return 'Something went wrong.';
      }
    }

    throw error;
  }
}

export async function logout() {
  try {
    await signOut();
  } catch (error) {
    throw error;
  }
}

サインアップ機能を追加

サインアップフォームをコンポーネントとして作成します。
ここではuseFormStateを使用するのでクライアントコンポーネントとなります。
そのため、ファイルの先頭でuse client宣言が必要です。

app/ui/signup-form.tsx
'use client';

import { signUp } from '@/app/lib/actions';
import { useFormState } from 'react-dom';

export default function SignUpForm() {
  const initialState = { message: null, error: {} };
  const [state, dispatch] = useFormState(signUp, initialState);

  return (
    <form action={dispatch} className='w-full'>
      <div className='w-full rounded-lg bg-gray-50 pt-6 pb-4 px-6'>
        <div>
          <label htmlFor='email' className='block mb-2 text-gray-800'>
            Email
          </label>
          <input
            className='block w-full rounded-md border border-gray-200 pl-2 py-2 outline-2'
            id='email'
            type='email'
            name='email'
            placeholder='メールアドレス'
            required
          />
          {state.errors?.email &&
            state.errors.email.map((error: string) => (
              <div key={error} className='mt-2'>
                <p className='text-red-500'>{error}</p>
              </div>
            ))}
        </div>

        <div className='mt-4'>
          <label htmlFor='password' className='block mb-2 text-gray-800'>
            Password
          </label>
          <input
            className='block w-full rounded-md border border-gray-200 pl-2 py-2 outline-2'
            id='password'
            type='password'
            name='password'
            placeholder='パスワード'
            required
          />
          {state.errors?.password &&
            state.errors.password.map((error: string) => (
              <div key={error} className='mt-2'>
                <p className='text-red-500'>{error}</p>
              </div>
            ))}
        </div>

        <button className='mt-8 w-full rounded-lg bg-blue-500 text-white h-10 hover:bg-blue-400 focus-visible:outline-offset-2'>
          サインアップ
        </button>
        <div className='flex h-8 items-end space-x-1'>
          {state.message ? <p className='text-red-500'>{state.message}</p> : null}
        </div>
      </div>
    </form>
  );
}

こちらはサインアップページです。
/registerにアクセスすると見られるページになります。

app/register/page.tsx
import SignUpForm from '@/app/ui/signup-form';
import Link from 'next/link';

export default function SignUpPage() {
  return (
    <main className='flex justify-center md:h-screen'>
      <div className='flex flex-col items-center w-full max-w-[400px]'>
        <h1 className='my-6 w-full text-center text-2xl'>サインアップページ</h1>
        <SignUpForm />
        <div className='flex flex-col mt-8 text-center'>
          <Link
            href='/login'
            className='bg-blue-500 text-white rounded-lg px-8 py-2 hover:bg-blue-400 focus-visible:outline-offset-2'
          >
            ログインページ
          </Link>
          <Link
            href='/'
            className='bg-green-500 text-white rounded-lg px-8 py-2 mt-2 hover:bg-green-400 focus-visible:outline-offset-2'
          >
            ホーム
          </Link>
        </div>
      </div>
    </main>
  );
}

その後、.env.localにランダムなシークレットキーを設定します。
terminalでopenssl rand -base64 32と入力すると生成できます。

# `openssl rand -base64 32`
AUTH_SECRET=シークレットキー

認証機能の実装

ログイン、ログアウトの機能を作っていきます。
NextAuthにログイン、ログアウトの機能が含まれているので、NextAuthの設定から始めていきましょう。

認証設定の作成

認証で制御するルーティングについてのファイルです。
直接コード内に書くことを避けるためにファイルとして切り出しています。

routes.ts
export const publicRoutes = ['/'];
export const authRoutes = ['/login', '/register'];
export const DEFAULT_LOGIN_REDIRECT = '/';

NextAuthで使用する設定ファイルです。
今回はcallbacksを使用して認証前と認証後にアクセスできるページの制御をしています。

auth.config.ts
import { DEFAULT_LOGIN_REDIRECT, authRoutes, publicRoutes } from '@/routes';
import { NextAuthConfig } from 'next-auth';

export const authConfig = {
  pages: {
    signIn: '/login',
  },
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const isAuthRoute = authRoutes.includes(nextUrl.pathname);
      const isPublicRoute = publicRoutes.includes(nextUrl.pathname);

      if (isAuthRoute) {
        if (isLoggedIn) {
          return Response.redirect(new URL(DEFAULT_LOGIN_REDIRECT, nextUrl));
        }

        return true;
      }

      if (!isPublicRoute && !isLoggedIn) {
        return false;
      }

      return true;
    },
  },
  providers: [],
} satisfies NextAuthConfig;

auth.tsではProviderの設定を行っています。
今回はパスワード認証を使用するのでCredentialsを使い、内部で検証用のコードを書いています。

auth.ts
import { getUserByEmail } from '@/app/db/user';
import { signInSchema } from '@/app/lib/schemas';
import { authConfig } from '@/auth.config';
import bcrypt from 'bcrypt';
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';

export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      async authorize(credentials) {
        const parsedCredentials = signInSchema.safeParse(credentials);

        if (parsedCredentials.success) {
          const { email, password } = parsedCredentials.data;
          const user = await getUserByEmail(email);

          if (!user) return null;

          const passwordMatch = await bcrypt.compare(password, user.password);

          if (passwordMatch) return user;
        }

        return null;
      },
    }),
  ],
});

middleware.tsではNextAuthの設定を読み込み、認証機能を追加しています。

middleware.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';

export default NextAuth(authConfig).auth;

export const config = {
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

ログイン機能

画面を作成しながらログイン機能を実装していきます。

まずはログイン用のフォームコンポーネントを作成します。
サインアップの時と同じく、useFormStateを使うのでクライアントコンポーネントとなります。

app/ui/login-form.tsx
'use client';

import { login } from '@/app/lib/actions';
import { useFormState } from 'react-dom';

export default function LoginForm() {
  const [errorMessage, dispatch] = useFormState(login, undefined);

  return (
    <form action={dispatch} className='w-full'>
      <div className='w-full rounded-lg bg-gray-50 pt-6 pb-4 px-6'>
        <div>
          <label htmlFor='email' className='block mb-2 text-gray-800'>
            Email
          </label>
          <input
            className='block w-full rounded-md border border-gray-200 pl-2 py-2 outline-2'
            id='email'
            type='email'
            name='email'
            placeholder='メールアドレス'
            required
          />
        </div>

        <div className='mt-4'>
          <label htmlFor='password' className='block mb-2 text-gray-800'>
            Password
          </label>
          <input
            className='block w-full rounded-md border border-gray-200 pl-2 py-2 outline-2'
            id='password'
            type='password'
            name='password'
            placeholder='パスワード'
            required
          />
        </div>

        <button className='mt-8 w-full rounded-lg bg-blue-500 text-white h-10 hover:bg-blue-400 focus-visible:outline-offset-2'>
          ログイン
        </button>
        <div className='flex h-8 items-end space-x-1'>
          {errorMessage && <p className='text-red-500'>{errorMessage}</p>}
        </div>
      </div>
    </form>
  );
}

こちらはログイン用のページになります。
認証前なら/loginでアクセスできます。

app/login/page.tsx
import LoginForm from '@/app/ui/login-form';
import Link from 'next/link';

export default function LoginPage() {
  return (
    <main className='flex justify-center md:h-screen'>
      <div className='flex flex-col items-center w-full max-w-[400px]'>
        <h1 className='my-6 w-full text-center text-2xl'>ログインページ</h1>
        <LoginForm />
        <div className='flex flex-col mt-8 text-center'>
          <Link
            href='/register'
            className='bg-blue-500 text-white rounded-lg px-8 py-2 hover:bg-blue-400 focus-visible:outline-offset-2'
          >
            ユーザー登録
          </Link>
          <Link
            href='/'
            className='bg-green-500 text-white rounded-lg px-8 py-2 mt-2 hover:bg-green-400 focus-visible:outline-offset-2'
          >
            ホーム
          </Link>
        </div>
      </div>
    </main>
  );
}

認証後に見られるページとして簡単なマイページを作成します。
session情報がどのように格納されているか見てみたいので、auth()関数でsessionを取得しています。

app/mypage/page.tsx
import { auth } from '@/auth';
import Link from 'next/link';

export default async function MyPage() {
  const session = await auth();

  return (
    <main className='flex min-h-screen flex-col items-center'>
      <h1 className='my-6 text-center text-2xl'>マイページ</h1>
      <div className='flex flex-col'>
        <div className='bg-gray-200 rounded-tl-md rounded-tr-md px-2 py-1.5 text-sm'>
          ユーザー情報
        </div>
        <pre className='bg-gray-100 rounded-bl-md rounded-br-md p-2'>
          {JSON.stringify(session, null, 2)}
        </pre>
      </div>
      <Link
        href='/'
        className='bg-green-500 text-white rounded-lg px-8 py-2 mt-6 hover:bg-green-400 focus-visible:outline-offset-2'
      >
        ホーム
      </Link>
    </main>
  );
}

最後にトップページを整えたら完成です。

app/page.tsx
import { logout } from '@/app/lib/actions';
import { auth } from '@/auth';
import Link from 'next/link';

export default async function Home() {
  const session = await auth();

  return (
    <main className='flex min-h-screen flex-col items-center'>
      {session ? (
        <div className='flex flex-col items-center'>
          <h1 className='my-6 w-full text-center text-2xl'>TOPページ</h1>
          <div className='flex flex-col text-center'>
            <Link
              href='/mypage'
              className='bg-blue-500 text-white rounded-lg px-8 py-2 hover:bg-blue-400 focus-visible:outline-offset-2'
            >
              マイページ
            </Link>
            <form action={logout}>
              <button className='bg-red-500 text-white rounded-lg px-8 py-2 mt-2 hover:bg-red-400 focus-visible:outline-offset-2'>
                ログアウト
              </button>
            </form>
          </div>
        </div>
      ) : (
        <div className='flex flex-col items-center'>
          <h1 className='my-6 w-full text-center text-2xl'>認証ページ</h1>
          <div className='flex flex-col text-center'>
            <Link
              href='/login'
              className='bg-blue-500 text-white rounded-lg px-8 py-2 hover:bg-blue-400 focus-visible:outline-offset-2'
            >
              ログイン
            </Link>
            <Link
              href='/register'
              className='bg-blue-500 text-white rounded-lg px-8 py-2 mt-2 hover:bg-blue-400 focus-visible:outline-offset-2'
            >
              サインアップ
            </Link>
          </div>
        </div>
      )}
    </main>
  );
}

おまけ

おまけとして、認証用のページの制御をCallbackではなくmiddlewareで行う方法も書いておきます。
参照したサイトによってCallback、middlewareどちらも使っていたので、ここのベストプラクティスは詳しい方がいたらご教示ください。

middlewareでのルーティング

まずはcallbacksに書いてあったコードを消します。

auth.config.ts
import { NextAuthConfig } from 'next-auth';

export const authConfig = {
  pages: {
    signIn: '/login',
  },
  providers: [],
} satisfies NextAuthConfig;

そしてmiddleware.tsを以下のように書き足します。

middleware.ts
import { authConfig } from '@/auth.config';
import { DEFAULT_LOGIN_REDIRECT, authRoutes, publicRoutes } from '@/routes';
import NextAuth from 'next-auth';

const { auth } = NextAuth(authConfig);

export default auth((req) => {
  const { nextUrl } = req;
  const isLoggedIn = !!req.auth;
  const isAuthRoute = authRoutes.includes(nextUrl.pathname);
  const isPublicRoute = publicRoutes.includes(nextUrl.pathname);

  if (isAuthRoute) {
    if (isLoggedIn) {
      return Response.redirect(new URL(DEFAULT_LOGIN_REDIRECT, nextUrl));
    }

    return;
  }

  if (!isLoggedIn && !isPublicRoute) {
    return Response.redirect(new URL('/login', nextUrl));
  }

  return;
});

export const config = {
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

挙動はほぼ同じですが、どちらのやり方を知っておいても得なので記載しておきます。
Learn Next.jsではcallbacksで行っていたので、最初はcallbacksで実装しました。

終わりに

全体のコードは以下のリポジトリを参照してください。

https://github.com/takubii/nextjs-prisma-auth-demo

NextAuth v5(beta)がLearn Next.jsで紹介されており、その後認証関連の情報を調べても現行バージョンの情報ばかりだったので新しい情報を今回ご紹介しました。

もし、今後こちらの情報が参考になれば幸いです。

それではこのあたりで締めたいと思います。
ここまで読んでいただきありがとうございます。
また機会があればお会いしましょう。

Discussion