NextAuthとPrismaを使って認証機能の実装方法

### 実現したいこと
- ローカル環境でMySQLのDBを用意して、NextAuthのライブラリを使ってユーザーのログイン・ログアウトを実現する
- ログイン済みユーザー・未ログインユーザーで表示させる画面を制御する

### 実装手順
[1. プロジェクト作成](#プロジェクト作成)
[2. ローカル環境でMySQLを起動](#ローカル環境でmysqlを起動)
[3. Prismaを利用するための設定](#prismaを利用するための設定)
[4. PrismaDBのスキーマを定義してmigrate](#prismadbのスキーマを定義してmigrate)
[5. NextAuthを利用するための設定](#nextauthを利用するための設定)
[6. NextAuthを使ったログイン処理](#nextauthを使ったログイン処理)
[7. seedデータを用意してDBに登録](#seedデータを用意してdbに登録)
[8. ログインUIの作成](#ログインuiの作成)
[9. 新規登録のAPI登録](#新規登録のapi登録)

### プロジェクト作成
```zsh:ターミナル
// study-for-prisma という名前でNext13のプロジェクトを作成
$ mkdir study-for-prisma
$ cd study-for-prisma
$ npx create-next-app@13 . --typescript --tailwind --eslint

✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
✔ What import alias would you like configured? … @/*
```
```zsh:ターミナル
// Prismaを利用する上で必要なパッケージと設定
$ npm install typescript ts-node @types/node --save-dev
$ npm install prisma --save-dev
$ npm install @prisma/client
$ npx prisma init --datasource-provider mysql
```
```JavaScript:next.config.js
// serverActionの機能を有効にする設定を加える
/** @type {import('next').NextConfig} */
const nextConfig = {
  // ↓を追加
  experimental: {
    serverActions: true,
  },
}

module.exports = nextConfig
```

### ローカル環境でMySQLを起動
- Dockerを使ってローカル環境でMySQLを起動する
- Nextのプロジェクト内にdocker-compose.yamlファイルを作成してコンテナを立ち上げる
- MacのM1チップを使っている場合は `platform: linux/amd64` を指定しないと立ち上がらないので要注意
```yaml:docker-compose.yaml
version: "3.9"
services:
  db:
    image: mysql:8.0
    platform: linux/amd64
    ports:
      - 3306:3306
    volumes:
      - mysql:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=password
      - MYSQL_DATABASE=study-for-prisma
volumes:
  mysql:
```
```zsh:ターミナル
// コンテナ起動
$ docker-compose up -d
```

### Prismaを利用するための設定
https://www.prisma.io/docs/concepts/database-connectors/mysql

`DATABASE_URL=mysql://root:設定したパスワード@localhost:3306/DB名` で設定する
```:.env
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

DATABASE_URL=mysql://root:password@localhost:3306/study-for-prisma
```

https://www.prisma.io/docs/guides/other/troubleshooting-orm/help-articles/nextjs-prisma-client-dev-practices

- プロジェクトのsrcディレクトリ配下に `lib` ディレクトリを作成して `db.ts` ファイルを作って下記を記述
- `npm run dev` コマンドで開発環境を立ち上げた場合、実行時にNode.jsキャッシュをクリアされ、データベースへの接続を作成するホットリロードにより毎回新しいPrismaClientインスタンスを初期化される
- これにより各PrismaClientインスタンスが独自の接続プールを保持するため、データベース接続をすぐに使い果たす可能性がある
- 単一のインスタンスPrismaClientをインスタンス化し、globalThisオブジェクトに保存する処理を行うことで上記を回避する
- PrismaClientがglobalThisオブジェクトにない場合のみインスタンス化するチェックを行い、そうでない場合は同じインスタンスを再度使用し、余分なPrismaClientインスタンスのインスタンス化を防ぐ
```JavaScript:/lib/db.ts
import { PrismaClient } from '@prisma/client'

const prismaClientSingleton = () => {
  return new PrismaClient()
}

type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClientSingleton | undefined
}

export const prisma = globalForPrisma.prisma ?? prismaClientSingleton()

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
```

### PrismaDBのスキーマを定義してmigrate
prismaをインストールすると自動的に作られる `prisma/schema.prisma` のファイルにスキーマを記述していく
```prisma:prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

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

// ユーザーModelを定義し、Schemaを記述
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  password  String
  name      String?
}
```
```zsh:ターミナル
// initというマイグレートファイル名で上記のSchemaの内容をマイグレート
$ npx prisma migrate dev --name init
```

### NextAuthを利用するための設定
https://next-auth.js.org/getting-started/example

```zsh:ターミナル
$ npm install next-auth

// パスワードのハッシュ化や検証をしてくれるライブラリをインストール
$ npm install bcrypt
$ npm install @types/bcrypt --save-dev
```

### NextAuthを使ったログイン処理
https://next-auth.js.org/configuration/initialization#route-handlers-app

- Next13の `App Router` を使ったプロジェクトのため `Route Handlers` の記述に従って設定する
- src/app/api/auth/[...nextauth] ディレクトリを作成して、 `route.ts` ファイルを用意して記述する
```JavaScript:/app/api/auth/[...nextauth]/route.ts
import NextAuth, { type NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import { compare } from 'bcrypt'

import { prisma } from '@/lib/db'

export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      // サインインフォームに表示する名前を指定します(/api/auth/signinで使用されますが、今回は独自のサインインページを作成しているため、こちらの設定は使用されません)
      name: 'Sign in',
      // credentialsはサインインページにフォームを生成するために使用されます
      // credentialsオブジェクトにキーを追加することで、送信するフィールドを指定できます。
      credentials: {
        email: {
          label: 'Email',
          type: 'text',
          placeholder: 'test@example.com',
        },
        password: { label: 'Password', type: 'password' },
      },

      // authorizeメソッドは、サインインフォームから送信された認証情報を受け取ります。
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null // 認証情報がない場合はnullを返すことで、認証失敗を表現します。
        }

        // prismaを使用して、ユーザーが存在するか検証します。
        const user = await prisma.user.findUnique({
          where: {
            email: credentials.email,
          },
        })

        if (!user) {
          return null
        }

        // bcryptを使用して、パスワードが正しいか検証します。
        const isPasswordValid = await compare(
          credentials.password,
          user.password
        )

        if (!isPasswordValid) {
          return null
        }

        return {
          id: user.id.toString(),
          name: user.name,
          email: user.email,
        }
      },
    }),
  ],
  session: {
    strategy: 'jwt',
    maxAge: 30 * 24 * 60 * 60, // サーバー側でセッションを保持する時間を秒単位で指定します。
    updateAge: 24 * 60 * 60, // セッションの有効期限を更新する頻度を秒単位で指定します。
  },
  pages: {
    signIn: '/signin', // // サインインページのURLを指定します(今回は独自のサインインページを作成しているため、こちらの設定は使用されません)
  },
  secret: process.env.NEXTAUTH_SECRET,
}
```

### seedデータを用意してDBに登録
https://www.prisma.io/docs/guides/migrate/seed-database

- seed.tsにダミーデータを入れる
- package.json に設定を追記しないとエラーになるので注意
```JSON:package.jsonに追記
"prisma": {
  "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}
```
```JavaScript:prisma/seed.ts
import { PrismaClient } from '@prisma/client'
import { hash } from 'bcrypt'

const prisma = new PrismaClient()

async function main() {
  const password = await hash('test', 12)
  const user = await prisma.user.upsert({
    where: { email: 'test@test.com' },
    update: {},
    create: {
      email: 'test@test.com',
      name: 'Test User',
      password,
    },
  })
  console.log({ user })
}

main()
  .then(() => prisma.$disconnect())
  .catch(async (e) => {
    console.error(e)
    await prisma.$disconnect()
    process.exit(1)
  })
```
```zsh:ターミナル
$ npx prisma db seed
// seedデータがDBに反映される
```

### ログインUIの作成
- seedで作成したテストデータの情報を入力してログインボタンを押すとトップページに遷移すればOK
```JSX:/app/(auth)/login/page.tsx
import { LoginForm } from './_components/login-form'

export default function LoginPage() {
  return (
    <>
      <h1 className="text-2xl mb-8">login</h1>
      <LoginForm />
    </>
  )
}
```
- NextAuthのデフォルトの設定では認証に失敗するとブラウザをリロードする
    - これを回避するに `signIn()` メソッドに `redirect: false` を追加する
    - これによりリダイレクト後の処理を定義して動作させることができる
- **認証に成功した場合は `callbackUrl` にリダイレクトされる**
```JSX:/app/(auth)/login/_components/login-form.tsx
'use client'

import React, { FormEvent, useState } from 'react'
import { signIn } from 'next-auth/react'

export const LoginForm = () => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault()
    try {
      const res = await signIn('credentials', {
        redirect: false,
        email,
        password,
        callbackUrl: '/',
      })
      if (!res?.ok) {
        setError(`${res?.status}: ${res?.error}`)
      }
    } catch (error: any) {
      setError(error)
    }
  }

  return (
    <form className="flex flex-col gap-y-4" onSubmit={handleSubmit}>
      <input
        type="text"
        value={email}
        placeholder="email"
        className="px-4 py-2 rounded-md text-slate-700"
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        value={password}
        placeholder="password"
        className="px-4 py-2 rounded-md text-slate-700"
        onChange={(e) => setPassword(e.target.value)}
      />
      <button
        type="submit"
        className="px-4 py-2 rounded-md bg-white text-slate-700"
      >
        ログイン
      </button>
      {error && <p className="text-red-500">{error}</p>}
    </form>
  )
}
```

### 新規登録のAPI登録