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

前一篇,我們已經讓 blog-demo 在 Next.js 裡跑起來了:可以列出文章、建立文章、查看詳情、新增評論。
但有個明顯問題:所有寫操作都預設用硬編碼的 authorId = 1,完全沒有登入概念。
這一篇,我們來做三件事:
給專案接入一個最小可用的登入系統(Auth.js / NextAuth 風格);
把 blog-demo 的「新建文章 / 發表評論」改成「必須登入才能操作」;
在 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)
說明一下幾個關鍵點:
使用
CredentialsProvider:只展示一個「信箱 + 密碼」表單;authorize裡用 Prisma 讀 User 表;callbacks 裡把
user.id和role寫入 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.tsx 和 new/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。
在 Google 上持續關注
把 HeyBinyang 加入 Google 首選來源
如果你希望之後在 Google 上更容易看到我的更新,可以把這個站點加入 preferred source,讓它在相關閱讀情境裡更容易被找到。
SHARE
分享
分享這篇文章。