【Next.js】アクセス制御でユーザーの権限を管理する|簡単なToDoアプリの作成

Next.jsとNextAuthを使って、簡単なToDoアプリにユーザーごとのアクセス制御を実装する方法を解説します。データベースを修正し、各ToDoがどのユーザーのものかを記録。API側でログイン状態を確認し、自分自身のToDoだけを操作できるよう制限をかけます。Webアプリ開発における基本的な権限管理の仕組みが学べます。

作成日: 更新日:

開発環境

  • OS: Windows10
  • Visual Studio Code: 1.73.0
  • node: 22.14.0
  • react: 19.0.0
  • next: 15.3.2
  • tailwindcss: 4
  • Prisma: 6.8.2
  • NextAuth: 5.8.0
  • @auth/prisma-adapter: 2.9.1
  • @prisma/client: 6.8.2
  • bcrypt: 6.0.0
  • @types/bcrypt: 5.0.2

サンプルコード

/prisma/migrations/20250531045705_add_user_id_to_todo/migration.sql

/prisma/migrations/20250531045705_add_user_id_to_todo/migration.sql
1+-- AlterTable
2+ALTER TABLE `todos` ADD COLUMN `userId` INTEGER NULL;
3+
4+-- AddForeignKey
5+ALTER TABLE `todos` ADD CONSTRAINT `todos_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
6

/prisma/schema.prisma

/prisma/schema.prisma
1 }
2 
3 model Todo {
4-  id    Int    @id @default(autoincrement())
5-  text  String
6+  id     Int    @id @default(autoincrement())
7+  text   String
8+  userId Int?
9+  user   User?  @relation(fields: [userId], references: [id])
10 
11   @@map("todos")
12 }
13   password  String
14   createdAt DateTime @default(now())
15   updatedAt DateTime @updatedAt
16+  todos     Todo[]
17 
18   @@map("users")
19 }
20

/src/app/api/auth/[...nextauth]/route.tsx

/src/app/api/auth/[...nextauth]/route.tsx
1       name: "Credentials",
2       credentials: {
3         email: { label: "Email", type: "text" },
4-        password: { label: "Password", type: "password" }
5+        password: { label: "Password", type: "password" },
6       },
7       async authorize(credentials) {
8         if (!credentials) return null
9 
10         const user = await prisma.user.findUnique({
11-          where: { email: credentials.email }
12+          where: { email: credentials.email },
13         })
14         if (!user) return null
15 
16         if (!isValid) return null
17 
18         return {
19-          ...user,
20           id: String(user.id),
21+          email: user.email,
22+          name: user.name,
23         }
24-      }
25-    })
26+      },
27+    }),
28   ],
29   session: { strategy: "jwt" },
30   pages: {
31     signIn: "/auth/signin",
32   },
33+  callbacks: {
34+    async jwt({ token, user }) {
35+      if (user) {
36+        token.id = user.id
37+      }
38+      return token
39+    },
40+    async session({ session, token }) {
41+      if (token?.id && session.user) {
42+        session.user.id = String(token.id)
43+      }
44+      return session
45+    },
46+  },
47 }
48 
49 const handler = NextAuth(authOptions)
50

/src/app/api/todos/route.tsx

/src/app/api/todos/route.tsx
1 import { NextResponse } from 'next/server'
2+import { getServerSession } from 'next-auth'
3+import { authOptions } from '../auth/[...nextauth]/route'
4 import { prisma } from '@lib/prisma'
5 
6+async function getAuthUser() {
7+  const session = await getServerSession(authOptions)
8+  if (!session?.user?.id) {
9+    throw new Error('Unauthorized')
10+  }
11+  return session.user
12+}
13+
14 export async function GET() {
15-  const todos = await prisma.todo.findMany({
16-    orderBy: { id: 'desc' },
17-  })
18-  return NextResponse.json(todos)
19+  try {
20+    const user = await getAuthUser()
21+    const todos = await prisma.todo.findMany({
22+      where: { userId: Number(user.id) },
23+      orderBy: { id: 'desc' },
24+    })
25+    return NextResponse.json(todos)
26+  } catch (error) {
27+    return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
28+  }
29 }
30 
31 export async function POST(req: Request) {
32-  const { text } = await req.json()
33-  const newTodo = await prisma.todo.create({
34-    data: { text },
35-  })
36-  return NextResponse.json(newTodo)
37+  try {
38+    const user = await getAuthUser()
39+    const { text } = await req.json()
40+    const newTodo = await prisma.todo.create({
41+      data: { text, userId: Number(user.id) },
42+    })
43+    return NextResponse.json(newTodo)
44+  } catch (error) {
45+    return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
46+  }
47 }
48 
49 export async function DELETE(req: Request) {
50-  const { id } = await req.json()
51-  await prisma.todo.delete({
52-    where: { id },
53-  })
54-  return NextResponse.json({ message: 'Deleted' })
55+  try {
56+    const user = await getAuthUser()
57+    const { id } = await req.json()
58+
59+    const todo = await prisma.todo.findUnique({ where: { id } })
60+    if (!todo || todo.userId !== Number(user.id)) {
61+      return NextResponse.json({ message: 'Forbidden' }, { status: 403 })
62+    }
63+
64+    await prisma.todo.delete({ where: { id } })
65+    return NextResponse.json({ message: 'Deleted' })
66+  } catch (error) {
67+    return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
68+  }
69 }
70 
71 export async function PATCH(req: Request) {
72-  const { id, text } = await req.json()
73-  await prisma.todo.update({
74-    where: { id },
75-    data: { text },
76-  })
77-  return NextResponse.json({ message: 'Updated' })
78+  try {
79+    const user = await getAuthUser()
80+    const { id, text } = await req.json()
81+
82+    const todo = await prisma.todo.findUnique({ where: { id } })
83+    if (!todo || todo.userId !== Number(user.id)) {
84+      return NextResponse.json({ message: 'Forbidden' }, { status: 403 })
85+    }
86+
87+    await prisma.todo.update({
88+      where: { id },
89+      data: { text },
90+    })
91+    return NextResponse.json({ message: 'Updated' })
92+  } catch (error) {
93+    return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
94+  }
95 }
96

/src/app/types/next-auth.d.ts

/src/app/types/next-auth.d.ts
1+import NextAuth from "next-auth"
2+
3+declare module "next-auth" {
4+  interface Session {
5+    user: {
6+      id: string
7+      name?: string | null
8+      email?: string | null
9+    }
10+  }
11+
12+  interface User {
13+    id: string
14+    name?: string | null
15+    email?: string | null
16+  }
17+}
18

/tsconfig.json

/tsconfig.json
1     "paths": {
2       "@/*": ["./src/*"],
3       "@lib/*": ["./lib/*"]
4-    }
5+    },
6+    "types": ["next-auth"],
7+    "typeRoots": ["./src/app/types", "./node_modules/@types"]
8   },
9   "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
10   "exclude": ["node_modules"]
11

コード解説

変更点: TodoとUserの関連付け (Prismaスキーマ)

/prisma/schema.prisma
1 model Todo {
2-  id    Int    @id @default(autoincrement())
3-  text  String
4+  id     Int    @id @default(autoincrement())
5+  text   String
6+  userId Int?
7+  user   User?  @relation(fields: [userId], references: [id])
8 
9   @@map("todos")
10 }
11   password  String
12   createdAt DateTime @default(now())
13   updatedAt DateTime @updatedAt
14+  todos     Todo[]
15 
16   @@map("users")
17 }

データベースの設計図であるprisma/schema.prismaファイルを修正しました。Todoモデル(todosテーブルの設計)にuserIdという項目を追加し、これがUserモデルのidと関連付くことを定義しています。これを「リレーション」と呼びます。具体的には、@relation(fields: [userId], references: [id])という記述で、「TodoモデルのuserIdフィールドは、Userモデルのidフィールドを参照する外部キーですよ」とPrismaに教えています。 逆にUserモデル側にもtodos Todo[]を追加し、一人のユーザーが複数のToDoを持つことができる関係性を定義しました。これにより、「どのToDoが、どのユーザーによって作成されたか」をデータベース上で管理できるようになります。

変更点: データベーステーブルの更新 (Prismaマイグレーション)

/prisma/migrations/20250531045705_add_user_id_to_todo/migration.sql
1+-- AlterTable
2+ALTER TABLE `todos` ADD COLUMN `userId` INTEGER NULL;
3+
4+-- AddForeignKey
5+ALTER TABLE `todos` ADD CONSTRAINT `todos_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;

このファイルは、先ほどのschema.prismaの変更を、実際のデータベースに反映させるためのSQLコマンドが書かれたマイグレーションファイルです。prisma migrate devコマンドなどを実行すると自動で生成されます。 ALTER TABLE \todos` ADD COLUMN `userId` INTEGER NULL;という行で、実際のtodosテーブルにuserIdという名前の整数型カラムを追加しています。 次のADD CONSTRAINT ... FOREIGN KEY ...の部分で、追加したuserIdカラムを外部キーとして設定し、usersテーブルのid`と紐付けています。これにより、データベースレベルでデータの整合性が保たれるようになります。

変更点: NextAuthの認証情報にユーザーIDを追加

/src/app/api/auth/[...nextauth]/route.tsx
1         if (!isValid) return null
2 
3         return {
4-          ...user,
5           id: String(user.id),
6+          email: user.email,
7+          name: user.name,
8         }
9-      }
10-    })
11+      },
12+    }),

認証処理を担うNextAuthの設定ファイルです。authorize関数は、ユーザーがメールアドレスとパスワードでログインしようとした際に実行されます。ここでは、認証が成功した場合に返すユーザー情報のオブジェクトを修正しました。 以前はデータベースから取得したuserオブジェクトをそのまま返していましたが、NextAuthがセッションで扱う情報としてid, email, nameを明示的に返すように変更しています。特にidを文字列型(String(user.id))に変換して含めることが、後のセッション管理で重要になります。

変更点: セッションにユーザーIDを格納

/src/app/api/auth/[...nextauth]/route.tsx
1     signIn: "/auth/signin",
2   },
3+  callbacks: {
4+    async jwt({ token, user }) {
5+      if (user) {
6+        token.id = user.id
7+      }
8+      return token
9+    },
10+    async session({ session, token }) {
11+      if (token?.id && session.user) {
12+        session.user.id = String(token.id)
13+      }
14+      return session
15+    },
16+  },
17 }

NextAuthにcallbacksという設定を追加しました。これは、認証の特定のタイミングで独自の処理を挟むための機能です。 jwtコールバックは、JSON Web Token(JWT)が生成・更新されるたびに実行されます。ログイン直後、authorize関数から返されたuserオブジェクトが渡されるので、そのuser.idtokenオブジェクトに追加しています。 sessionコールバックは、セッションが参照されるたびに実行されます。ここでは、jwtコールバックでtokenに格納したIDを、最終的にアプリケーションで利用するsession.userオブジェクトにidとして追加しています。 この二段階の処理によって、サーバーサイドやクライアントサイドでセッション情報を取得した際に、ログインユーザーのIDが使えるようになります。

変更点: NextAuthのセッション型の拡張

/src/app/types/next-auth.d.ts
1+import NextAuth from "next-auth"
2+
3+declare module "next-auth" {
4+  interface Session {
5+    user: {
6+      id: string
7+      name?: string | null
8+      email?: string | null
9+    }
10+  }
11+
12+  interface User {
13+    id: string
14+    name?: string | null
15+    email?: string | null
16+  }
17+}

TypeScriptで開発している場合、ライブラリが元々持っている型定義にないプロパティを追加すると、型エラーが発生します。今回はNextAuthのsession.userオブジェクトにidプロパティを追加したため、TypeScriptに「session.userにはidという文字列型のプロパティが存在しますよ」と教える必要があります。 この.d.tsファイル(型定義ファイル)は、そのためのものです。declare module "next-auth"と書くことで、NextAuthモジュールの型定義を上書き・拡張しています。これにより、コード内でsession.user.idと記述してもTypeScriptがエラーを出さなくなります。

変更点: 拡張した型定義の読み込み設定

/tsconfig.json
1     "paths": {
2       "@/*": ["./src/*"],
3       "@lib/*": ["./lib/*"]
4-    }
5+    },
6+    "types": ["next-auth"],
7+    "typeRoots": ["./src/app/types", "./node_modules/@types"]
8   },

TypeScriptの設定ファイルであるtsconfig.jsonを修正しました。先ほど作成した型定義ファイル(next-auth.d.ts)をTypeScriptコンパイラが正しく認識できるように設定を追加しています。 "types": ["next-auth"]"typeRoots": [...]を追加することで、TypeScriptが型を探しに行く場所のリストに、自作の型定義ファイルが置かれているディレクトリ(src/app/types)を追加しています。この設定により、プロジェクト全体で拡張したSession型が有効になります。

変更点: APIでの認証ユーザー情報取得処理の共通化

/src/app/api/todos/route.tsx
1+import { getServerSession } from 'next-auth'
2+import { authOptions } from '../auth/[...nextauth]/route'
3+
4+async function getAuthUser() {
5+  const session = await getServerSession(authOptions)
6+  if (!session?.user?.id) {
7+    throw new Error('Unauthorized')
8+  }
9+  return session.user
10+}

ToDoを操作するAPIファイルに、getAuthUserという新しい関数を追加しました。この関数は、サーバーサイドで現在のログイン情報を取得するためのものです。 NextAuthが提供するgetServerSessionを使い、リクエストからセッション情報を安全に取得します。もしセッションが存在しない、またはセッションにユーザーIDが含まれていない場合(=未ログイン状態)は、エラーを発生させて処理を中断させます。ログインしている場合は、ユーザー情報を返します。 この関数を各API処理の冒頭で呼び出すことで、ログインしているユーザーだけがAPIを安全に利用できる仕組みを作ります。

変更点: ログインユーザー自身のToDoのみを取得

/src/app/api/todos/route.tsx
1 export async function GET() {
2-  const todos = await prisma.todo.findMany({
3-    orderBy: { id: 'desc' },
4-  })
5-  return NextResponse.json(todos)
6+  try {
7+    const user = await getAuthUser()
8+    const todos = await prisma.todo.findMany({
9+      where: { userId: Number(user.id) },
10+      orderBy: { id: 'desc' },
11+    })
12+    return NextResponse.json(todos)
13+  } catch (error) {
14+    return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
15+  }
16 }

ToDoの一覧を取得するGETリクエストの処理を修正しました。 まずgetAuthUser()を呼び出して、ログインしているユーザーの情報を取得します。その後、prisma.todo.findManyでデータベースからToDoを検索する際に、whereという条件を指定しています。where: { userId: Number(user.id) }とすることで、「userIdカラムが、現在ログインしているユーザーのIDと一致するToDo」のみを絞り込んで取得するようになります。 これにより、他のユーザーが作成したToDoは表示されなくなり、自分だけのToDoリストが実現できます。

変更点: 作成したToDoにユーザーIDを紐付け

/src/app/api/todos/route.tsx
1 export async function POST(req: Request) {
2-  const { text } = await req.json()
3-  const newTodo = await prisma.todo.create({
4-    data: { text },
5-  })
6-  return NextResponse.json(newTodo)
7+  try {
8+    const user = await getAuthUser()
9+    const { text } = await req.json()
10+    const newTodo = await prisma.todo.create({
11+      data: { text, userId: Number(user.id) },
12+    })
13+    return NextResponse.json(newTodo)
14+  } catch (error) {
15+    return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
16+  }
17 }

新しいToDoを作成するPOSTリクエストの処理を修正しました。 ここでもまずgetAuthUser()でログインユーザー情報を取得します。そして、prisma.todo.createで新しいToDoをデータベースに保存する際に、リクエストボディから受け取ったToDoの内容textに加えて、userId: Number(user.id)というデータを一緒に保存しています。 これにより、新しく作成されたToDoには、必ず作成者であるユーザーのIDが記録されるようになります。

変更点: ToDoの更新・削除時に所有者を確認

/src/app/api/todos/route.tsx
1 export async function DELETE(req: Request) {
2+  try {
3+    const user = await getAuthUser()
4+    const { id } = await req.json()
5+
6+    const todo = await prisma.todo.findUnique({ where: { id } })
7+    if (!todo || todo.userId !== Number(user.id)) {
8+      return NextResponse.json({ message: 'Forbidden' }, { status: 403 })
9+    }
10+
11+    await prisma.todo.delete({ where: { id } })
12+    return NextResponse.json({ message: 'Deleted' })
13+  } catch (error) {
14+    return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
15+  }
16 }
17 
18 export async function PATCH(req: Request) {
19-  const { id, text } = await req.json()
20-  await prisma.todo.update({
21-    where: { id },
22-    data: { text },
23-  })
24-  return NextResponse.json({ message: 'Updated' })
25+  try {
26+    const user = await getAuthUser()
27+    const { id, text } = await req.json()
28+
29+    const todo = await prisma.todo.findUnique({ where: { id } })
30+    if (!todo || todo.userId !== Number(user.id)) {
31+      return NextResponse.json({ message: 'Forbidden' }, { status: 403 })
32+    }
33+
34+    await prisma.todo.update({
35+      where: { id },
36+      data: { text },
37+    })
38+    return NextResponse.json({ message: 'Updated' })
39+  } catch (error) {
40+    return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
41+  }
42 }

ToDoの削除(DELETE)と更新(PATCH)を行うAPI処理に、所有者を確認するロジックを追加しました。これは非常に重要なセキュリティ対策です。 処理の流れは以下の通りです。

  1. getAuthUser()でログインユーザーの情報を取得します。
  2. リクエストから受け取ったIDをもとに、操作対象のToDoをデータベースから一件取得します。
  3. 取得したToDoに記録されているuserIdと、現在ログインしているユーザーのID (user.id) を比較します。
  4. もしIDが一致しない場合(=他人のToDoを操作しようとしている場合)、処理を中断し、「Forbidden(禁止)」というエラーメッセージを返します。
  5. IDが一致した場合のみ、削除や更新の処理を続行します。

このチェックにより、ユーザーは自分自身が作成したToDoしか更新・削除できなくなります。

おわりに

お疲れ様でした。今回は、Next.jsとNextAuthを使って、ToDoアプリにユーザーごとのアクセス制御を実装する方法を解説しました。まずPrismaのスキーマを修正し、各ToDoがどのユーザーのものかを記録できるようにデータベース構造を変更しました。次にAPI側では、NextAuthで取得したログインユーザーのIDをもとに、ToDoの取得時は自分のデータのみを絞り込み、作成時には自分のIDを記録するように実装しました。さらに更新と削除の際には、操作対象のToDoの所有者とログインユーザーが一致するかを必ず確認することで、他人のデータを操作できない安全な仕組みを実現しています。

関連コンテンツ

関連IT用語