arrow_back返回文章列表
技术

第 7 篇:给 blog-demo 接上简单鉴权(Next.js + Prisma + Auth.js)

前一篇,我们已经让 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。

好处是:

这一篇我们就以 Auth.js 为例,重点是讲:


二、扩展 schema:为 Auth.js 添加用户表结构

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

但我们已经有自己的 blog-demo 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 上去。

这里为了聚焦,我们假设:


三、在 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)

说明一下几个关键点:

实际生产中要用正确的密码哈希验证,这里为了专注 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

目标是:

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 },
    )
  }
}

关键变化:

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 的“写”接口已经和登录状态绑定:


五、在页面中使用登录状态:只显示该显示的按钮

现在再调整一下前一篇中的前端页面,把硬编码的 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 的视角来看,这几乎没改变什么: