【Next.js 13 + Auth.js(Next-Auth) + prisma + postgresql】appRo
はじめに
Auth.jsはNext.jsアプリケーションに認証を追加するためのライブラリです。
OAuth認証・メールアドレスによるマジックリンク認証・JWT認証などの認証プロトコルをサポートしています。
今回は、そんな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
yarn add next-auth @prisma/client @next-auth/prisma-adapter
yarn add -D prisma
シークレット情報の生成
以下のコマンドを実行し、環境変数にしてください。
openssl rand -base64 32
NEXTAUTH_URL = "http://localhost:3000"
NEXTAUTH_JWT_SECRET = "uGIxige(ランダム生成された文字列)joegaea="
NEXTAUTH_SECRET = "uGIxige(ランダム生成された文字列)joegaea="
Prismaの設定
まず、以下のコマンドを実行してください。
npx prisma init
このコマンドを実行するとプロジェクトのルートディレクトリにprisma
ディレクトリ(schema.prisma
が格納されている)と.env
ファイルが作成されます。
schema.prismaと.envファイルの内容は以下のようになっています。
// 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を使用する場合は、公式で作成する項目が決まっているので、公式をそのままコピペします。
ただし、今回はemail
とpassword
で認証を行うため、公式に記載のないpassword
だけは自分で追加します。
※公式では@auth/prisma-adapter
を導入する手順がありますが、それはOAuth
のGoogleProvider
などを使用する場合で導入する必要があるためです。今回は自作のログイン・サインアップのJWT認証のため、単純にschema.prismaをコピーするだけでOKです。
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
を作成します。
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の編集が完了したら、以下のコマンドを実行してください。
npx prisma migrate dev --name init
上記コマンドで、マイグレーションファイルが作成されます。
DB起動
ここでDBを起動してみましょう。
以下のコマンドでdocker-composeを実行します。
docker-compose up -d
完了したら、次のコマンドでDocker内のpostgresに入り、テーブルが作成されているか確認してみてください。
docker exec -it postgres psql -U [POSTGRES_USER] [POSTGRES_DB]
macの場合optionと¥を押して「dt」と入力してください。
※windowsの場合は「¥dt」でよいです
#postgres \dt
schema.prismaのmodelで記載したテーブルが作成されていれば、成功です。
lib/prismadb.tsの作成
開発中にnext devコマンドを実行すると、Node.jsのキャッシュがクリアされます。これにより、ホットリロードが行われるたびに新たなPrismaClientインスタンスが初期化され、データベースへの接続が作成されます。PrismaClientインスタンスごとに独自の接続プールが保持されるため、これによりすぐにデータベースの接続が枯渇する可能性があります。
そのため、prismaをグローバルに扱えるよう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に保存することを可能にしてくれます。
yarn add bcrypt
yarn add -D @types/bcrypt
::: note info
ディレクトリ作成
:::
apiディレクトリ内にregisterディレクトリを作成し、registerディレクトリにroute.ts
を作成します。
それにより、APIのエンドポイントが「http://localhost:3000/api/register」となります。
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
中に作成する必要があります。
import NextAuth from 'next-auth/next'
import options from '@/lib/options'
const handler = NextAuth(options)
export { handler as GET, handler as POST }
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
を使用します。
ここは公式と同じです。
公式では、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: {}の部分)
どういうものかというと、セッションがチェックされるたびに呼び出される処理になります。
デフォルトではトークンのサブセットのみ返却されるようですが、jwt()
コールバックでトークンに追加したものをクライアントで利用できるようにする処理です。
要するに「セッションにユーザー情報を含めたりできるもの」ということです。
具体的な利用方法はNextで実装する、getServerSession()
やuseSession()
を使用することにより、jwt()
関数を呼び出すことができます。
ただし、jwt()
はJWT セッション
を利用している場合のみ呼び出すことが可能です。
(最初のsession: {strategy: 'jwt'}の部分で定義しました)
※私の説明では難解な箇所もあると思うので公式を参照してください
今回の場合は、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'}
などとして設定してください)
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を導入してから行ってください。
まずは、サインアップとログインページを作成します。
サインアップページ
'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
ログインページ
'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-auth
のgetServerSession()
を使用してセッション情報を取得します。
また、このpage.tsxにはクライアントコンポーネントをimportしてあるので、次の解説でセッション情報の取得と合わせて見ていきましょう。
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-auth
のSessionProvider
で囲ったコンポーネント内でセッションの情報を扱うことができます。
このSessionProvider
で囲ったコンポーネントはSessionProvder
コンポーネント(今回はProvider
コンポーネント)含め全て、クライアントコンポーネントになる点に留意が必要です。
'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-auth
のsignOut()
を使用することで実現できます。
挙動はセッション情報(今回の場合はJWTの削除)をします。
ちなみに、useRouter()
のpushで遷移先を指定できます。
'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