第 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。