HeyBinyang

第 7 篇:為 blog-demo 接入簡單鑑權(Next.js + Prisma + Auth.js)

·技術·visibility4 閱讀 ·開發

前一篇,我們已經讓 blog-demo 在 Next.js 裡跑起來了:可以列出文章、建立文章、查看詳情、新增評論。

但有個明顯問題:所有寫操作都預設用硬編碼的 authorId = 1,完全沒有登入概念。

這一篇,我們來做三件事:

  1. 給專案接入一個最小可用的登入系統(Auth.js / NextAuth 風格);

  2. 把 blog-demo 的「新建文章 / 發表評論」改成「必須登入才能操作」;

  3. 在 API Route 裡,從會話裡拿當前用戶 id,替代硬編碼的 authorId。

我們不會做第三方 OAuth,也不做複雜的註冊流程,只用最簡單版本的 Email/Password 或 Credentials 登入,把「Prisma + Next.js + 鑑權」的整體骨架搭建出來。

提醒:這一篇偏「工程化連接」,不是講 Auth.js 全功能,所以配置會盡量收斂、清晰。


一、選擇鑑權方案:為什麼用 Auth.js / NextAuth

Next.js 官方推薦的鑑權方案是 Auth.js(原 NextAuth),社群範例也幾乎都用它配 Prisma。

好處是:

  • 有專門的 Prisma Adapter,可以直接把用戶表、會話表寫進資料庫;

  • 和 App Router 相容良好,官方文件有範例;

  • 不需要我們親手設計所有安全細節。

這一篇我們就以 Auth.js 為例,重點是講:

  • Prisma 的 User 表如何和 Auth.js 整合

  • 如何在 API Route / Server Component 中取得當前用戶


二、擴展 schema:為 Auth.js 添加用戶表結構

Auth.js 一般會要求有一組模型(User / Account / Session / VerificationToken),官方有生成命令可以直接把這些加到 Prisma schema 中。

但我們已經有自己的 blog-demo User 模型了,不想完全被覆蓋。做法可以有兩種:

  • 完全讓 Auth.js 接管用戶模型:讓它添加自己的 User,我們再在 blog-demo 的關係裡引用這個 User

  • 讓 Auth.js 使用我們現有的 User,但補充必要欄位。

為了簡單,這裡我們選擇:統一一個 User 模型,讓 Auth.js 也用它

你可以對現有 User 模型做一點擴展,如增加用於登入的欄位(例如 username 或 hashed password)以及可能的 role 欄位(角色控制以後會用到)。

model User {
  id        Int       @id @default(autoincrement())
  email     String    @unique
  name      String
  // 简化起见,用 username + hashedPassword
  username  String?   @unique
  password  String?   // 存加密后的密码(生产环境必须 hash)

  role      String    @default("USER") // 简单角色

  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt

  profile   Profile?
  posts     Post[]
  comments  Comment[]

  // 如果后面用 Auth.js PrismaAdapter,你还需要按其文档加 Account/Session 等模型
}

如果你打算使用完整的 Auth.js PrismaAdapter,最好按照官方指南讓它一次性把所有模型補齊,然後把 blog-demo 的關係改到它的 User 上去。

這裡為了聚焦,我們假設:

  • 你已經擁有一個可以登入的 User(無論是 Auth.js 幫你建立,還是你預先插入);

  • 我們重點看「取得當前用戶 + 限制寫操作」這一部分。


三、在 Next.js 裡配置 Auth.js:auth.ts + /api/auth

按照 App Router 的推薦用法,我們一般會在 lib/auth.ts 中配置 Auth.js,並在 app/api/auth/[...nextauth]/route.ts 中匯出 handler。

3.1 安裝依賴

在專案根目錄:

pnpm add next-auth @auth/prisma-adapter

3.2 配置 Auth.js:lib/auth.ts

lib/auth.ts 範例(簡化版,使用 Credentials Provider):

// lib/auth.ts
import NextAuth, { type NextAuthConfig } from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { blogPrisma } from './prisma-blog'
import type { User as PrismaUser } from '../generated/blog'

export const authConfig: NextAuthConfig = {
  adapter: PrismaAdapter(blogPrisma as any),
  session: {
    strategy: 'jwt',
  },
  providers: [
    Credentials({
      name: 'Credentials',
      credentials: {
        email: { label: '邮箱', type: 'email' },
        password: { label: '密码', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials.password) return null

        const user = await blogPrisma.user.findUnique({
          where: { email: credentials.email },
        })
        if (!user) return null

        // 这里应该做密码校验(BCrypt 等)
        // 为了简单,暂且允许任何密码通过
        // if (!compareHash(credentials.password, user.password)) return null

        return {
          id: String(user.id),
          name: user.name,
          email: user.email,
          role: user.role,
        }
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id
        token.role = (user as any).role
      }
      return token
    },
    async session({ session, token }) {
      if (token && session.user) {
        session.user.id = token.id as string
        ;(session.user as any).role = token.role
      }
      return session
    },
  },
}

export const { handlers, auth, signIn, signOut } = NextAuth(authConfig)

說明一下幾個關鍵點:

  • 使用 Credentials Provider:只展示一個「信箱 + 密碼」表單;

  • authorize 裡用 Prisma 讀 User 表;

  • callbacks 裡把 user.idrole 寫入 JWT,再同步到 session.user 上;

  • auth 函式可以在 Server Component/APIs 中呼叫,拿到當前請求的 session。

實際生產中要用正確的密碼哈希驗證,這裡為了專注 Prisma 結合 auth 的整體結構,省略了具體密碼邏輯。

3.3 /api/auth/[...nextauth] 路由

app/api/auth/[...nextauth]/route.ts

import { handlers } from '@/lib/auth'

// Auth.js 会用到 GET/POST 两个 handler
export const { GET, POST } = handlers

到這裡,Auth.js 基礎就接好了。你可以在客戶端用 next-auth/react 提供的鉤子來登入/登出;在服務端用 auth() 取得當前用戶資訊。


四、在 API Route 中使用 auth():限制寫操作必須登入

現在,我們回到第 6 篇中寫過的 API Route,比如 /api/posts/api/posts/[id]/comments

目標是:

  • 把 body 裡傳的 authorId 去掉;

  • 改成從 session 裡讀取 userId

  • 如果未登入,直接返回 401。

4.1 修改 /api/posts 的 POST:用當前登入用戶作為作者

app/api/posts/route.ts 中 POST 改造:

import { NextRequest, NextResponse } from 'next/server'
import { blogPrisma } from '@/lib/prisma-blog'
import { auth } from '@/lib/auth'

export async function POST(req: NextRequest) {
  try {
    const session = await auth()
    if (!session?.user?.id) {
      return NextResponse.json(
        { error: '未登录用户不能发文' },
        { status: 401 },
      )
    }

    const userId = Number(session.user.id)
    const body = await req.json()
    const { title, content, categorySlugs } = body

    if (!title) {
      return NextResponse.json(
        { error: 'title 必填' },
        { status: 400 },
      )
    }

    const post = await blogPrisma.$transaction(async (tx) => {
      const created = await tx.post.create({
        data: {
          title,
          content,
          published: true,
          authorId: userId,
        },
      })

      if (Array.isArray(categorySlugs) && categorySlugs.length > 0) {
        const categories = await tx.category.findMany({
          where: { slug: { in: categorySlugs } },
        })
        if (categories.length > 0) {
          await tx.postCategory.createMany({
            data: categories.map((c) => ({
              postId: created.id,
              categoryId: c.id,
            })),
            skipDuplicates: true,
          })
        }
      }

      return await tx.post.findUnique({
        where: { id: created.id },
        include: {
          author: { select: { id: true, name: true } },
          categories: { include: { category: true } },
        },
      })
    })

    if (!post) {
      return NextResponse.json({ error: '创建文章失败' }, { status: 500 })
    }

    const result = {
      id: post.id,
      title: post.title,
      content: post.content,
      createdAt: post.createdAt,
      author: post.author,
      categories: post.categories.map((pc) => pc.category),
    }

    return NextResponse.json(result, { status: 201 })
  } catch (e: any) {
    console.error(e)
    return NextResponse.json(
      { error: e.message || 'Failed to create post' },
      { status: 500 },
    )
  }
}

關鍵變化:

  • auth() 拿到 session,如果沒有則返回 401;

  • 不再允許客戶端傳 authorId,防止偽造;

  • 一切寫操作都以當前登入用戶為準。

4.2 修改 /api/posts/[id]/comments 的 POST:用當前登入用戶作為評論者

同樣方式給評論接口加上登入保護:

app/api/posts/[id]/comments/route.ts

import { NextRequest, NextResponse } from 'next/server'
import { blogPrisma } from '@/lib/prisma-blog'
import { auth } from '@/lib/auth'

type RouteParams = {
  params: { id: string }
}

export async function POST(req: NextRequest, { params }: RouteParams) {
  try {
    const session = await auth()
    if (!session?.user?.id) {
      return NextResponse.json(
        { error: '未登录用户不能评论' },
        { status: 401 },
      )
    }
    const authorId = Number(session.user.id)
    const postId = Number(params.id)
    const body = await req.json()
    const { content } = body

    if (!content || !Number.isFinite(postId)) {
      return NextResponse.json(
        { error: 'content 和有效的 postId 必填' },
        { status: 400 },
      )
    }

    const comment = await blogPrisma.$transaction(async (tx) => {
      const post = await tx.post.findUnique({
        where: { id: postId },
        select: { id: true },
      })
      if (!post) throw new Error('文章不存在')

      return await tx.comment.create({
        data: {
          content,
          postId,
          authorId,
        },
        include: {
          author: { select: { id: true, name: true } },
        },
      })
    })

    return NextResponse.json(comment, { status: 201 })
  } catch (e: any) {
    console.error(e)
    const message =
      e.message === '文章不存在' ? e.message : 'Failed to create comment'
    const status = message === '文章不存在' ? 400 : 500
    return NextResponse.json({ error: message }, { status })
  }
}

到這裡,blog-demo 的「寫」接口已經和登入狀態綁定:

  • 未登入的請求會收到 401;

  • 登入用戶的 id 來自 session,而不是客戶端傳參。


五、在頁面中使用登入狀態:只顯示該顯示的按鈕

現在再調整一下前一篇中的前端頁面,把硬編碼的 authorId = 1 改成依賴登入狀態,並在 UI 上做一點判斷:

  • 如果用戶沒登入,新建文章頁面提醒去登入;

  • 文章詳情頁,未登入時隱藏評論表單或提醒登入。

5.1 在 Client 元件中使用 useSession

如果你用的是 next-auth/react,可以在客戶端用 useSession 鉤子取登入狀態。

安裝:

pnpm add next-auth

app/layout.tsx 中注入 SessionProvider(略寫),然後在 comment-form.tsxnew/page.tsx 中使用。

以 CommentForm 為例:

'use client'

import { FormEvent, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'

export default function CommentForm({ postId }: { postId: number }) {
  const router = useRouter()
  const { data: session, status } = useSession()
  const [content, setContent] = useState('')
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  if (status === 'loading') {
    return <p className="text-xs text-gray-500">正在检查登录状态...</p>
  }

  if (!session?.user) {
    return (
      <p className="text-xs text-gray-500">
        登录后才能发表评论。
      </p>
    )
  }

  async function handleSubmit(e: FormEvent) {
    e.preventDefault()
    setError(null)
    setLoading(true)
    try {
      const res = await fetch(`/api/posts/${postId}/comments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content }),
      })
      const data = await res.json()
      if (!res.ok) {
        throw new Error(data.error || '评论失败')
      }
      setContent('')
      router.refresh()
    } catch (err: any) {
      setError(err.message)
    } finally {
      setLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-2">
      <textarea
        className="w-full rounded border px-3 py-2 text-sm min-h-[80px]"
        placeholder="写下你的评论..."
        value={content}
        onChange={(e) => setContent(e.target.value)}
        required
      />
      {error && <p className="text-xs text-red-500">{error}</p>}
      <button
        type="submit"
        disabled={loading}
        className="rounded bg-blue-600 px-3 py-1 text-xs text-white disabled:opacity-60"
      >
        {loading ? '提交中...' : '发表评论'}
      </button>
    </form>
  )
}

類似地,新建文章頁也可以在未登入時停用表單,顯示「請先登入」的提示。


六、小結:Prisma + Next.js + 鑑權的基本連接方式

這一篇其實只做了兩件事:

  • 在 Prisma 現有的 User 模型基礎上,接入了 Auth.js/NextAuth,讓 Next.js 有了登入狀態;

  • 在 API Route 裡使用 auth() 取得當前用戶,把 blog-demo 的「寫操作」從硬編碼 authorId 改成「真正的登入用戶」。

從 Prisma 的視角來看,這幾乎沒改變什麼:

  • Schema 裡多了幾個與鑑權相關的欄位/模型;

  • Prisma Client 的用法還是老樣子,只是現在你不再信任客戶端傳來的 userId,而是透過鑑權系統提供的 session 來拿 userId。

分享