😀

【Next.js 13 + Auth.js(Next-Auth) + prisma + postgresql】appRo

2023/08/25に公開

はじめに

Auth.jsはNext.jsアプリケーションに認証を追加するためのライブラリです。
OAuth認証・メールアドレスによるマジックリンク認証・JWT認証などの認証プロトコルをサポートしています。

Auth.js

今回は、そんなAuth.jsを使用して、Emailとパスワードを用い、自作のログイン・サインアップ画面からのJWT認証を実装してみたので、その実装をハンズオンでできるようにまとめました。

ディレクトリ構成

app/
├── api
│   ├── auth
│   │   └── [...nextauth]
│   │       └── route.ts
│   ├── register
│       └── route.ts
│   
├── components
|   ├── button-area.tsx
│   └── provider.tsx
├── globals.css
├── layout.tsx
├── page.tsx
├── login
│   └── page.tsx
├── register
|   └── page.tsx
prisma
├── migrations
├── schema.prisma
lib
├── prismadb.ts
├── options.ts
.env
docker-compose.yml
middleware.ts

実装

早速、実装していきましょう。
※私が実装した順になりますが、大枠は間違っていないと思います。

ライブラリの導入

まず、以下のライブラリをインストールします。

  • next-auth
  • @next-auth/prisma-adapter
  • prisma
  • @prisma/client
zsh
yarn add next-auth @prisma/client @next-auth/prisma-adapter
zsh
yarn add -D prisma

シークレット情報の生成

以下のコマンドを実行し、環境変数にしてください。

zsh
openssl rand -base64 32
NEXTAUTH_URL = "http://localhost:3000"
NEXTAUTH_JWT_SECRET = "uGIxige(ランダム生成された文字列)joegaea="
NEXTAUTH_SECRET = "uGIxige(ランダム生成された文字列)joegaea="

Prismaの設定

まず、以下のコマンドを実行してください。

zsh
npx prisma init

このコマンドを実行するとプロジェクトのルートディレクトリにprismaディレクトリ(schema.prismaが格納されている)と.envファイルが作成されます。

schema.prismaと.envファイルの内容は以下のようになっています。

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 = "postgresql"
  url      = env("DATABASE_URL")
}
# 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 = "postgresql://johndue:randompassword@localhost:5432/mydb?schema=public"

schema.prismaの編集

次に、作成されたスキーマを編集します。
next-authとprismaを使用する場合は、公式で作成する項目が決まっているので、公式をそのままコピペします。
ただし、今回はemailpasswordで認証を行うため、公式に記載のないpasswordだけは自分で追加します。

@auth/prisma-adapter

※公式では@auth/prisma-adapterを導入する手順がありますが、それはOAuthGoogleProviderなどを使用する場合で導入する必要があるためです。今回は自作のログイン・サインアップのJWT認証のため、単純にschema.prismaをコピーするだけでOKです。

schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
  shadowDatabaseUrl = env("SHADOW_DATABASE_URL") // Only needed when using a cloud provider that doesn't support the creation of new databases, like Heroku. Learn more: https://pris.ly/d/migrate-shadow
}

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["referentialActions"] // You won't need this in Prisma 3.X or higher.
}

model Account {
  id                 String  @id @default(cuid())
  userId             String
  type               String
  provider           String
  providerAccountId  String
  refresh_token      String?  @db.Text
  access_token       String?  @db.Text
  expires_at         Int?
  token_type         String?
  scope              String?
  id_token           String?  @db.Text
  session_state      String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model User {
  id                  String    @id @default(cuid())
  name                String?
  email               String?   @unique
  emailVerified       DateTime?
  hashedpassword      String  // ここに追加  
  image               String?
  accounts            Account[]
  sessions            Session[]
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

docker-compose.ymlの作成

続いてprismaとDBを接続するために、docker-compose.ymlを作成します。

docker-compose.yml
version: '3.8'
services:
  postgres:
    image: postgres:15-alpine
    container_name: postgres
    ports:
      - 5432:5432
    volumes:
      - ./docker/postgres/init.d:/docker-entrypoint-initdb.d
      - ./docker/postgres/pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_INITDB_ARGS: '--encoding=UTF-8'
      POSTGRES_DB: sampledb
    hostname: postgres
    restart: always
    user: root

作成したら、.envファイルのURLを書き換えます。

DATABASE_URL = "postgresql://[POSTGRES_USER]:[POSTGRES_PASSWORD]@localhost:5432/[POSTGRES_DB]?schema=public"

**[]**で囲ってある箇所とポート番号はdocker-compose.ymlに合わせるようにしてください。

マイグレーションの実行

schema.prismaの編集が完了したら、以下のコマンドを実行してください。

zsh
npx prisma migrate dev --name init

上記コマンドで、マイグレーションファイルが作成されます。

DB起動

ここでDBを起動してみましょう。
以下のコマンドでdocker-composeを実行します。

zsh
docker-compose up -d

完了したら、次のコマンドでDocker内のpostgresに入り、テーブルが作成されているか確認してみてください。

zsh
docker exec -it postgres psql -U [POSTGRES_USER] [POSTGRES_DB]

macの場合optionと¥を押して「dt」と入力してください。
※windowsの場合は「¥dt」でよいです

zsh
#postgres \dt

schema.prismaのmodelで記載したテーブルが作成されていれば、成功です。

lib/prismadb.tsの作成

開発中にnext devコマンドを実行すると、Node.jsのキャッシュがクリアされます。これにより、ホットリロードが行われるたびに新たなPrismaClientインスタンスが初期化され、データベースへの接続が作成されます。PrismaClientインスタンスごとに独自の接続プールが保持されるため、これによりすぐにデータベースの接続が枯渇する可能性があります。
そのため、prismaをグローバルに扱えるようlib/prismadb.tsを作成します。

Best practice for instantiating PrismaClient with Next.js

lib/prismadb.ts
import { PrismaClient } from '@prisma/client'

const client = global.prismadb || new PrismaClient()

if (process.env.NODE_ENV === 'production') global.prisma = client

export default client

ここまででprismaの設定は完了です。

APIの作成

サインアップ用のAPIを作成していきます。
そのために、まずappディレクトリ内にapiディレクトリを作成してください。
apiディレクトリ内でAPIを作成していきます。

サインアップAPI

::: note info
bcryptのインストール
:::
以下のコマンドでbcryptというライブラリを導入してください。
このライブラリはパスワードのハッシュ化とデコードを行うことができ、セキュアにパスワードをDBに保存することを可能にしてくれます。

zsh
yarn add bcrypt
zsh
yarn add -D @types/bcrypt

::: note info
ディレクトリ作成
:::
apiディレクトリ内にregisterディレクトリを作成し、registerディレクトリにroute.tsを作成します。
それにより、APIのエンドポイントが「http://localhost:3000/api/register」となります。

register/route.ts
import bcrypt from 'bcrypt'
import { NextResponse } from 'next/server'

import prismadb from '@/lib/prismadb'

// ユーザー新規登録API
export const POST = async (req: Request, res: NextResponse) => {
  try {
    if (req.method !== 'POST')
      return NextResponse.json({ message: 'Bad Request' }, { status: 405 })

    const { name, email, password } = await req.json()

    const existingUser = await prismadb.user.findUnique({ where: { email } })

    if (existingUser)
      return NextResponse.json({ message: 'Email taken' }, { status: 422 })

    const hashedPassword = await bcrypt.hash(password, 12)

    const user = await prismadb.user.create({
      data: {
        email,
        name,
        hashedPassword,
        image: '',
        emailVerified: new Date(),
      },
    })

    return NextResponse.json({ user }, { status: 201 })
  } catch (err: any) {
    return NextResponse.json({ message: err.message }, { status: 500 })
  }
}

やっていることはコードに書いてあるままなので、分からな部分は調べてください。

next-authのAPIルートの作成

next-authのAPIルートは必ずapi/auth/[...nextauth]/route.ts中に作成する必要があります。

[...nextauth]/route.ts
import NextAuth from 'next-auth/next'

import options from '@/lib/options'

const handler = NextAuth(options)

export { handler as GET, handler as POST }
lib/options.ts
import { PrismaAdapter } from '@next-auth/prisma-adapter'
import bcrypt from 'bcrypt'
import type { NextAuthOptions } from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import GitHubProvider from 'next-auth/providers/github'
import GoogleProvider from 'next-auth/providers/google'

import prismadb from '@/lib/prismadb'

const options: NextAuthOptions = {
  providers: [
    GitHubProvider({
      clientId: process.env.GITHUB_CLIENT_ID || '',
      clientSecret: process.env.GITHUB_CLIENT_SECRET || '',
    }),
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID || '',
      clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
    }),
    Credentials({
      id: 'credentials',
      name: 'Credentials',
      credentials: {
        email: {
          label: 'Email',
          type: 'text',
        },
        password: {
          label: 'Password',
          type: 'password',
        },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials.password) {
          throw new Error('Email and password required')
        }

        const user = await prismadb.user.findUnique({
          where: { email: credentials.email },
        })

        if (!user || !user.hashedPassword) {
          throw new Error('Email does not exists')
        }

        const isCorrectPassword = await bcrypt.compare(
          credentials.password,
          user.hashedPassword,
        )

        if (!isCorrectPassword) {
          throw new Error('Incorrect password')
        }

        return user
      },
    }),
  ],
  pages: {
    signIn: '/auth',
  },
  debug: process.env.NODE_ENV === 'development',
  adapter: PrismaAdapter(prismadb),
  session: {
    strategy: 'jwt',
  },
  jwt: {
    secret: process.env.NEXTAUTH_JWT_SECRET,
  },
  secret: process.env.NEXTAUTH_SECRET,
}

export default options

ここの記述は参考文献にある記述とほぼ同じになります。
まず、session: { strategy: 'jwt' }とすることでjwtをトークンとして持つように定義しています。

続いて、providersの部分(以下の部分)ですが、CredentialsProviderを使用します。
ここは公式と同じです。

Credentials

公式では、emailの部分にusernameを使用していますが、私はemailとpasswordの認証をするので、emailとしています。そこは各自の要件で変更可能です。

そしてauthorize()ですが、ここでサインアップAPIを呼び出し、実際に認証を行います。

引数のcredentialsには認証の資格情報(ユーザーの入力値)が入ってくるので、それをAPIに渡してあげます。
そして、レスポンスが正常でuserが存在する場合には、ユーザーの情報を返り値とするようにします。

providers: [
    Credentials({
      id: 'credentials',
      name: 'Credentials',
      credentials: {
        email: {
          label: 'Email',
          type: 'text',
        },
        password: {
          label: 'Password',
          type: 'password',
        },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials.password) {
          throw new Error('Email and password required')
        }

        const user = await prismadb.user.findUnique({
          where: { email: credentials.email },
        })

        if (!user || !user.hashedPassword) {
          throw new Error('Email does not exists')
        }

        const isCorrectPassword = await bcrypt.compare(
          credentials.password,
          user.hashedPassword,
        )

        if (!isCorrectPassword) {
          throw new Error('Incorrect password')
        }

        return user
      },
    }),
  ],

次に、callbacks以下ですが、ここではSession Callbackを実装しています。(session: {}の部分)

Session callback

どういうものかというと、セッションがチェックされるたびに呼び出される処理になります。
デフォルトではトークンのサブセットのみ返却されるようですが、jwt()コールバックでトークンに追加したものをクライアントで利用できるようにする処理です。

要するに「セッションにユーザー情報を含めたりできるもの」ということです。

具体的な利用方法はNextで実装する、getServerSession()useSession()を使用することにより、jwt()関数を呼び出すことができます。
ただし、jwt()JWT セッションを利用している場合のみ呼び出すことが可能です。
(最初のsession: {strategy: 'jwt'}の部分で定義しました)

JWT callback

※私の説明では難解な箇所もあると思うので公式を参照してください

今回の場合は、tokenの値をuserに追加でコピーしてユーザー情報をsession?.userのような形式で取得できるようにしているという内容になります。

callbacks: {
    async jwt({ token, user, account, profile }) {
      if (user) {
        token.user = user
        const u = user as any
        token.role = u.role
      }
      if (account) {
        token.accessToken = account.access_token
      }

      return token
    },
    session: ({ session, token }) => {
      token.accessToken
      return {
        ...session,
        user: {
          ...session.user,
          role: token.role,
        },
      }
    },
  },

このoptionsの実装がnext-authで一番重要な部分となります。

middlewareの実装

ミドルウェアはnext-authが提供するnext-auth/middlewareをインポートすると簡単に実装できます。config.matcherにプロテクションを除外するURLパターンを設定します。ここでは、/loginと/apiを認可チェックから外します。
それ以外のURLにブラウザからアクセスした場合はログインページにリダイレクトされます。

カスタマイズしたいときは、withAuthでラップします。第2引数のオプションのcallbacks.authorizedで認可処理をカスタマイズしています。デフォではJWTトークンがあれば許可し、middlewareに進みます。ここでは、roleがadminの場合だけ許可しています。この場合、adminしか利用できないWebサービスになります。
(roleの追加は、authorize(){...user, role: 'admin'}などとして設定してください)

Advanced usage

middleware.ts
import { withAuth } from 'next-auth/middleware'

export default withAuth({
  callbacks: {
    // 認可に関する処理。ロールが `admin` ならOK
    authorized: ({ token }) => {
      return token?.role === 'admin'
    },
  },
  // リダイレクトページ
  pages: {
    signIn: '/login',
  },
})

export const config = {
  // ルートとregister・api・loginはリダイレクト対象から外す
  // matcher: ['/((?!register|api|login|).*)'],

  // register・api・loginはリダイレクト対象から外す
  matcher: ['/((?!register|api|login).*)'],
}

フロント実装

ようやくフロント部分の実装に入ります。
私の場合、MUIを使用して実装したので、コードを使いたい方はMUIを導入してから行ってください。
まずは、サインアップとログインページを作成します。

サインアップページ

register/page.tsx
'use client'
import LockOutlinedIcon from '@mui/icons-material/LockOutlined'
import Avatar from '@mui/material/Avatar'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import Container from '@mui/material/Container'
import CssBaseline from '@mui/material/CssBaseline'
import FormControlLabel from '@mui/material/FormControlLabel'
import Grid from '@mui/material/Grid'
import { createTheme, ThemeProvider } from '@mui/material/styles'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import React from 'react'
import { signIn } from 'next-auth/react'

const defaultTheme = createTheme()

const RegisterPage = () => {
  const router = useRouter()

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()

    const data = new FormData(e.currentTarget)

    const response = await fetch('/api/register', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        email: data.get('email'),
        name: data.get('name'),
        password: data.get('password'),
      }),
    })

    if (response.ok) {
      // 登録したあとにトップに遷移するためログイン処理をし、認証を完了させる
      signIn()
      router.push('/')
    } else {
      console.log('error')
    }
  }

  return (
    <ThemeProvider theme={defaultTheme}>
      <Container component="main" maxWidth="xs">
        <CssBaseline />
        <Box
          sx={{
            marginTop: 8,
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
          }}
        >
          <Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
            <LockOutlinedIcon />
          </Avatar>
          <Typography component="h1" variant="h5">
            Sign up
          </Typography>
          <Box
            component="form"
            noValidate
            onSubmit={handleSubmit}
            sx={{ mt: 3 }}
          >
            <Grid container spacing={2}>
              <Grid item xs={12}>
                <TextField
                  autoComplete="given-name"
                  name="name"
                  required
                  fullWidth
                  id="name"
                  label="name"
                  autoFocus
                />
              </Grid>
              <Grid item xs={12}>
                <TextField
                  required
                  fullWidth
                  id="email"
                  label="Email Address"
                  name="email"
                  autoComplete="email"
                />
              </Grid>
              <Grid item xs={12}>
                <TextField
                  required
                  fullWidth
                  name="password"
                  label="Password"
                  type="password"
                  id="password"
                  autoComplete="new-password"
                />
              </Grid>
              <Grid item xs={12}>
                <FormControlLabel
                  control={
                    <Checkbox value="allowExtraEmails" color="primary" />
                  }
                  label="I want to receive inspiration, marketing promotions and updates via email."
                />
              </Grid>
            </Grid>
            <Button
              type="submit"
              fullWidth
              variant="outlined"
              sx={{ mt: 3, mb: 2 }}
            >
              Register
            </Button>
            <Grid container justifyContent="flex-end">
              <Grid item>
                <Link href="/login">Already have an account? Login</Link>
              </Grid>
            </Grid>
          </Box>
        </Box>
      </Container>
    </ThemeProvider>
  )
}

export default RegisterPage

ログインページ

login/page.tsx
'use client'
import LockOutlinedIcon from '@mui/icons-material/LockOutlined'
import {
  Avatar,
  Box,
  Button,
  Container,
  CssBaseline,
  Grid,
  TextField,
  Typography,
} from '@mui/material'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { signIn } from 'next-auth/react'
import type { FormEvent } from 'react'
import React from 'react'

const LoginPage = () => {
  const router = useRouter()

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const data = new FormData(e.currentTarget)

    const email = data.get('email')
    const password = data.get('password')

    await signIn('credentials', {
      redirect: false,
      email: email,
      password,
    })
      .then((res) => {
        if (res?.error) {
          alert(res.error)
        }
        router.push('/')
      })
      .catch((err) => {
        console.log(err)
      })
  }

  return (
    <Container component="main" maxWidth="xs">
      <CssBaseline />
      <Box
        sx={{
          marginTop: 8,
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
        }}
      >
        <Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
          <LockOutlinedIcon />
        </Avatar>
        <Typography component="h1" variant="h5">
          Sign In
        </Typography>
        <Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
          <TextField
            margin="normal"
            required
            type="email"
            fullWidth
            id="email"
            label="email"
            name="email"
            autoComplete="email"
            autoFocus
          />
          <TextField
            margin="normal"
            required
            fullWidth
            type="password"
            id="password"
            label="password"
            name="password"
            autoComplete="current-password"
            autoFocus
          />
          <Button
            type="submit"
            fullWidth
            variant="outlined"
            sx={{ mt: 3, mb: 2 }}
          >
            Login
          </Button>
          <Grid container>
            <Grid item xs>
              <Typography variant="body2">
                <Link href="#">Forgot password?</Link>
              </Typography>
            </Grid>
            <Grid item>
              <Typography variant="body2">
                <Link href="/register">{"Don't have an account? Sign Up"}</Link>
              </Typography>
            </Grid>
          </Grid>
        </Box>
      </Box>
    </Container>
  )
}

export default LoginPage

セッションからユーザー情報を取得する方法

セッションの取得はサーバーコンポーネントとクライアントコンポーネントで違いがあります。
それぞれ見ていきましょう。

サーバーコンポーネント

サーバーコンポーネントの場合、next-authgetServerSession()を使用してセッション情報を取得します。

また、このpage.tsxにはクライアントコンポーネントをimportしてあるので、次の解説でセッション情報の取得と合わせて見ていきましょう。

page.tsx
import Link from 'next/link'
import { getServerSession } from 'next-auth'
import React from 'react'

import ButtonArea from './components/button-area'
import Provider from './components/provider'
import { options } from './options'

const Home = async () => {
  const session = await getServerSession(options)

  return (
    <main className="flex min-h-screen flex-col items-center">
      <Link href="/" className="inline-block my-5">
        HOME
      </Link>
      {session?.user ? (
        <>
          <div>Log in: {session?.user.name}</div>
          <Provider>
            <ButtonArea />
          </Provider>
        </>
      ) : (
        <Link href="/login">ログインする</Link>
      )}
    </main>
  )
}

export default Home

この部分がクライアントコンポーネントになっています。

<Provider>
    <ButtonArea />
</Provider>

クライアントコンポーネント

クライアントコンポーネントでは、next-authSessionProviderで囲ったコンポーネント内でセッションの情報を扱うことができます。
このSessionProviderで囲ったコンポーネントはSessionProvderコンポーネント(今回はProviderコンポーネント)含め全て、クライアントコンポーネントになる点に留意が必要です。

components/provider.tsx
'use client'

import { SessionProvider } from 'next-auth/react'
import React from 'react'

type Props = {
  children?: React.ReactNode
}

const Provider = ({ children }: Props) => {
  return <SessionProvider>{children}</SessionProvider>
}

export default Provider

以下がSessionProviderで囲まれた子コンポーネントになります。
useSession()を使用することでセッションからユーザー情報を取得することができます。
また、ログアウトですが、こちらもnext-authsignOut()を使用することで実現できます。

挙動はセッション情報(今回の場合はJWTの削除)をします。
ちなみに、useRouter()のpushで遷移先を指定できます。

components/button-area.tsx
'use client'
import { Button } from '@mui/material'
import { useRouter } from 'next/navigation'
import { signOut, useSession } from 'next-auth/react'
import React from 'react'

export const ButtonArea = () => {
  const router = useRouter()
  const { data: session } = useSession()
  const user = session?.user

  const handleSignOut = () => {
    signOut()
    router.push('/login')
  }
  return (
    <div className="w-full flex justify-evenly mt-5">
      <Button variant="outlined">{user?.name}のページ</Button>
      <Button
        className="border-red-500 text-red-500"
        variant="outlined"
        onClick={handleSignOut}
      >
        Sign out
      </Button>
    </div>
  )
}

export default ButtonArea

Vercelデプロイ

Vercelデプロイしたい人は、環境変数のDATABASE_URLを以下のように手順で設定する必要があります。
Vercelのダッシュボード> Project Settings > Environment Variables
(その他に環境変数があれば、それも設定が必須です)

また、デプロイ完了後APIのエンドポイントも本番用にする必要があるので、そちらも忘れないようにコードを変更しましょう。

参考文献

Discussion