どうもこんにちは、たくびー(@takubii)です。
今回はNext.js、Prisma、NextAuthv5を使った認証機能を実装しました。
サインアップからパスワード認証を使ったサインイン機能を使った方法を書いていきますので、ぜひさんこうにしてください。
環境構築
以下のバージョンで環境を構築します。
- Next.js 14.1.4
- Prisma 5.11.0
- NextAuth 5.0.0-beta.16
まずはNext.jsのプロジェクトを作成します。
以下のコマンドをTerminalに入力し、質問に答えてセットアップしてください。
npx create-next-app@latest
その後以下のファイルをそれぞれ編集していきます。
@tailwind base;
@tailwind components;
@tailwind utilities;
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>
);
}
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>
);
}
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をインストールします。
npm install prisma --save-dev
その後、Prismaの初期設定を行います。
今回はローカルでも動かせるようにSQLiteを使いますので、--datasource-provider sqlite
を指定しましょう。
npx prisma init --datasource-provider sqlite
作成されたprisma/schema.prisma
にUserのモデルを追加します。
// 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をマイグレートします。
以下のコマンドを入力してください。
npx prisma migrate dev --name init
Prisma Studioを起動し、DB内にUserモデルが作成されていればOKです。
npx prisma studio
Next.jsにDB操作関連のコードを追加
PrismaClientで不必要にインスタンスが増えないように以下のようなコードを書いてグローバルに1つのみに制限します。
こちらの詳細は以下のページを参照してください。
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient | undefined };
export const prisma = globalForPrisma.prisma || new PrismaClient();
認証で使う以下のパッケージを追加します。
npm install zod
npm install next-auth@beta
npm install bcrypt
npm install @types/bcrypt --save-dev
Zod
を使用してスキーマの型を作っています。
メールアドレスとパスワードに関するスキーマです。
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にアクセスしています。
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の機能を使うので実装がとても簡単です。
use server
指定しないとbcryptでエラーが出ます。
'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
宣言が必要です。
'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
にアクセスすると見られるページになります。
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の設定から始めていきましょう。
認証設定の作成
認証で制御するルーティングについてのファイルです。
直接コード内に書くことを避けるためにファイルとして切り出しています。
export const publicRoutes = ['/'];
export const authRoutes = ['/login', '/register'];
export const DEFAULT_LOGIN_REDIRECT = '/';
NextAuth
で使用する設定ファイルです。
今回はcallbacks
を使用して認証前と認証後にアクセスできるページの制御をしています。
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
を使い、内部で検証用のコードを書いています。
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の設定を読み込み、認証機能を追加しています。
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
を使うのでクライアントコンポーネントとなります。
'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
でアクセスできます。
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を取得しています。
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>
);
}
最後にトップページを整えたら完成です。
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
に書いてあったコードを消します。
import { NextAuthConfig } from 'next-auth';
export const authConfig = {
pages: {
signIn: '/login',
},
providers: [],
} satisfies NextAuthConfig;
そして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
で実装しました。
終わりに
全体のコードは以下のリポジトリを参照してください。
NextAuth v5(beta)がLearn Next.jsで紹介されており、その後認証関連の情報を調べても現行バージョンの情報ばかりだったので新しい情報を今回ご紹介しました。
もし、今後こちらの情報が参考になれば幸いです。
それではこのあたりで締めたいと思います。
ここまで読んでいただきありがとうございます。
また機会があればお会いしましょう。