首页 / 文章

使用 Auth.js 和 Prisma 实现 Google 登录:整合 Next.js 指南内容

将 Prisma 与 Auth.js Next.js 的指南整合为一个可运行的应用,支持 Google 登录、持久化存储用户信息,并将帖子与会话绑定,同时包含文档中未提及的修复内容。

发布者:REACTAPP.TOP阅读约 10 分钟2404 词

来源: Prisma + NextAuth setup for NextJS

在 Next.js 应用中添加账户和保存的数据并不需要单独的后端。借助用于身份验证的 Auth.js(NextAuth 的继任者)、作为 ORM 的 Prisma 以及像 Neon 这样的托管 Postgres 数据库,同一个 Next.js 项目就能处理登录、会话管理以及数据持久化。这些工具各自都有不错的 Next.js 使用指南,但指南在文件夹结构上存在差异,参考的规范也已过时,且从未展示各组件之间的连接方式。本教程将按顺序遵循这些指南,并指出所有需要做的调整,最终实现通过 Google 登录、用户数据存储在 Postgres 中以及文章归属于已登录用户的功能。

为何要将身份验证和数据处理放在 Next.js 应用内部

对于最初主要为客户端工具的产品,常见的架构是设置独立的后端,例如搭配 Postgres 的 Django 以及 Auth0 这类托管身份验证服务。虽然这种方式可行,但意味着需要额外部署、监控并支付第二项服务的费用。而对于小型产品而言,如果需要添加保存工作流等功能,直接复用 Next.js 的服务器来处理身份验证和数据库访问,就能降低托管成本及系统复杂度。相应的代价则是需要自行处理三个库之间的集成问题,这也是本文后续要探讨的内容。

这些软件包更新速度很快。Next.js 的 Auth.js 仍处于测试版状态,而 Prisma 和 Next.js 在最近的重大版本中都改变了默认设置。请将以下步骤视为参考指南,并根据各项目的最新文档核实具体细节。

安装不含托管服务的 Prisma

Next.js 的 Prisma 使用指南是基于该公司自带的托管数据库 Prisma Postgres 编写的。如果您打算使用其他版本的 Postgres,则可以跳过 @prisma/extension-accelerate,因为该组件仅适用于那种托管服务。

对于普通的 Postgres 环境,您需要进行两次安装:作为开发依赖的 CLI 和 tsx,以及作为运行时依赖的客户端库和 dotenv。代码片段中将这两个命令显示在同一行中,需依次执行:

npm install prisma tsx --save-dev npm install @prisma/client dotenv

接下来,在项目中初始化 Prisma:

npx prisma init

指南还提到了 init 命令的 --db 参数。该参数用于创建 Prisma Postgres 实例,因此如果您使用自带的数据库,则无需启用此参数。

若不想安装 Postgres 就想在本地运行数据库,Prisma 可以为你启动一个开发服务器:

npx prisma dev

init 步骤已经将指向该本地服务器的 DATABASE_URL 写入你的 .env 文件中。如果你已经在本地运行了 Postgres,或者希望连接到 Neon 分支,则需用自己定制的连接字符串替换该值。如需详细了解 Prisma 7 在 Next.js 16 项目中的运行方式,包括新的配置文件,请参阅我们的 Prisma 7 设置指南。

构建示例文章应用

Prisma指南的其余部分将指导你创建一个包含用户和文章的小型博客。如果你是在现有的应用中添加Prisma,可以直接跳到认证部分。不过还是建议按部就班地学习,因为后续步骤会假设这些模型已经存在。

首先按照架构定义步骤中的说明将模型添加到schema.prisma中,然后按照dotenv步骤的指示在Prisma配置文件中加载环境变量。

修正生成的客户端导入路径

在初始化数据库时,现有的种子脚本直接从生成的prisma文件夹导入,这会导致错误。生成的代码会将客户端暴露在client路径下,因此种子脚本的第一行导入语句必须指向该位置:

import { PrismaClient, Prisma } from "../app/generated/prisma/client";

@/app别名与相对路径../app的效果相同。

在lib/prisma.ts中设置共享的Prisma客户端时,也需要进行相同的修改:

import { PrismaClient } from '../app/generated/prisma/client'

指南在各个步骤之间还会在src/app和单纯的app/这两种命名规范之间切换。请选择你的项目实际使用的那种:如果根目录为src/,路径则为../src/app/generated;如果没有src/目录,则为../app/generated。此阶段出现的大多数“模块未找到”错误都是由于混用了这两种命名规范所致。

在客户端配置完成后,按照指南添加相应的页面:首页查询功能、文章列表、文章详情页以及用于创建文章的表单。

添加Auth.js

该库有两个文档站点:较旧的NextAuth 网站以及较新的针对 Next.js 的 Auth.js 参考文档。Auth.js 的文档描述的是当前版本,应以此为依据。

请从其测试版渠道安装该包:

npm install next-auth@beta

接着运行 npx auth secret。该命令会生成一个随机的 AUTH_SECRET,Auth.js 会用它来签名和加密令牌及 Cookie,并将其写入 .env.local 文件中。由于该项目已将变量存储在 .env 中,因此应将该值移至 .env 中并删除 .env.local,以便保持配置来源的统一。请勿将此文件纳入版本控制之中。

现在请按照安装步骤操作。核心文件是项目根目录下的auth.ts,它会调用一次NextAuth,并导出四个供应用程序其他部分使用的辅助函数:处理HTTP请求的handlers、登录功能signIn、登出功能signOut,以及用于读取当前会话信息的auth函数:

import NextAuth from "next-auth"

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [],
})

位于./app/api/auth/[...nextauth]/route.ts中的路由处理程序只是重新导出GET和POST处理函数,这样Auth.js就能提供登录、回调及会话相关的接口。在示例代码中,注释和导出被写在同一行;在你的文件中,请将导出语句放在导入语句之后的单独一行上:

import { handlers } from "@/auth" // Referring to the auth.ts we just created export const { GET, POST } = handlers

安装指南中还包含一个middleware.ts文件。不过Next.js后来已将中间件改名为代理(详见Next.js关于此变更的说明),因此文档中描述的文件与当前版本的Next.js不匹配。对于这种配置而言,您并不需要该文件,因为下面所有的受保护页面和操作都会自行检查会话信息。如果您日后需要请求级保护功能,则需按照代理机制进行相应调整。

将Google设置为登录提供商

在Google Cloud Console中,创建类型为“Web应用程序”的OAuth客户端。将授权的JavaScript源地址设置为localhost:3000,将重定向URI设置为localhost:3000/api/auth/callback/google,这是Auth.js为Google提供商提供的回调路径。部署时还需添加生产环境下的源地址和回调地址。将Google提供的客户端ID和密钥存储在环境文件中,使用Auth.js文档指定的变量名,切勿将其放在源代码中。

OAuth指南接着会添加一个登录组件。它是一个表单,其动作是一个内联的Server Action,用于调用signIn函数:

import { signIn } from "@/auth"

export function SignIn() {
  return (
    <form
      action={async () => {
        "use server"
        await signIn()
      }}
    >
      <button type="submit">Sign in</button>
    </form>
  )
}

signIn()在无参数调用时,会将用户引导至列出所有已配置提供方的内置页面。由于仅配置了Google,传入"google"即可跳过该中间页面。

会话管理文档中提供了对应的登出组件,其结构与前者相同:

import { signOut } from "@/auth"

export function SignOut() {
  return (
    <form
      action={async () => {
        "use server"
        await signOut()
      }}
    >
      <button type="submit">Sign Out</button>
    </form>
  )
}

将按钮放入导航栏

文档并未说明这些组件的放置位置。一个合理的方案是将它们放在本身为异步服务器组件的导航栏中:该组件会调用auth(),当有用户存在时渲染SignOut,否则渲染SignIn。

import Link from "next/link";
import { auth } from "../auth"
import SignIn from "./sign-in";
import SignOut from "./sign-out";

export default async function Navigation() {
    const session = await auth();

    return (
        <>
            <nav className="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-200">
                <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
                    <div className="flex items-center h-16">
                        {/* Logo */}
                        <Link href="/" className="flex items-center space-x-2">
                        <span className="text-xl font-semibold text-[#369b6f]">
                            Prisma + NextAuth + Neon
                        </span>
                        </Link>

                        {/* Spacer */}
                        <div className="flex-1"></div>

                        {/* Navigation Links */}
                        <div className="hidden md:flex items-center mr-8">
                        <Link
                            href="/posts"
                            className="text-gray-700 font-medium hover:text-[#369b6f] transition-all duration-200"
                        >
                            Posts
                        </Link>
                        </div>

                        {/* Auth Buttons */}
                        <div className="flex items-center space-x-2 sm:space-x-4">
                        {session?.user ? (
                            <SignOut/>
                        ) : (
                            <SignIn/>
                        )}
                        </div>
                    </div>
                </div>
            </nav>
        </>
    );
}

注意这里的导入方式:该文件对SignIn和SignOut使用了默认导入,而上面的组件则使用了命名导出。要么将导入方式改为import { SignIn } from "./sign-in",要么将这些组件作为默认导出;否则构建将会失败。

接着在根布局中渲染导航栏,这样它就会显示在每个页面上:

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
          <Navigation/>
          <main>
            {children}
          </main>
      </body>
    </html>
  );
}

请记得在 app/layout.tsx 的顶部导入 Navigation。开发服务器重新加载后,每个页面上方都会出现一个带有登录或注销按钮的栏。需要注意的是,这两个按钮都依赖于内联的 Server Actions,因此只能在 Server Components 中使用。Client Component 无法直接定义服务器函数;在这种情况下,需要将相关功能移到标有 "use server" 的单独文件中,然后再进行导入。

使用 Prisma adapter 保存用户信息

目前登录功能已经可用,但用户的任何信息都未存储在数据库中。Prisma adapter 负责将 Auth.js 与 Prisma 客户端连接起来,从而使用户信息、关联的 OAuth 账户及会话数据能够被写入 Postgres 数据库。请先安装该适配器:

npm install @auth/prisma-adapter

接着更新 auth.ts,将共享的 Prisma 客户端传递给适配器,并注册 Google 提供商:

import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import { PrismaAdapter } from "@auth/prisma-adapter";
import prisma from "@/lib/prisma";

export const { handlers, signIn, signOut, auth } = NextAuth({
    adapter: PrismaAdapter(prisma),
    providers: [Google],
})

配置好适配器后,Auth.js 会默认将会话存储在数据库中而非带签名的 Cookie 中,这样虽然可以在服务器端撤销会话,但每次检查会话都需要进行数据库查询。只要环境变量遵循 Auth.js 的命名规范,Google 提供商就能自动获取其凭证。

用户模型的协调

该适配器需要特定的表和列,详细信息见Prisma适配器架构。将这些内容复制到schema.prisma文件中。但这会与示例应用产生冲突:Prisma指南创建的User模型拥有自己的ID类型,而Auth.js则要求使用字符串类型的ID以及若干其他字段。对于全新的示例项目,最简单的做法是替换该模型并从头开始重建数据库。如果您要将其集成到已有真实用户的应用中,则不要重置任何内容,而应规划合理的迁移方案,将现有用户映射到新的结构中。这是另一个问题,超出了本指南的讨论范围。

为确保干净地重建数据库,首先需停止再次运行数据初始化脚本,因为该脚本使用的是旧的用户结构。请在prisma.config.ts文件中删除相关初始化代码:

seed: `tsx prisma/seed.ts`

然后重置数据库,这将删除所有的表和数据:

npx prisma migrate reset

仅可在开发数据库上执行此操作。最后,为新的架构创建迁移文件并应用它:

npx prisma migrate dev

再次启动应用程序(npm run dev),并通过 Google 登录。在 User 表中应出现一条新记录,同时 Account 表中也会有对应 Google 链接的记录。若要查看这些记录,可启动 npx prisma studio,即 Prisma 自带的可视化数据库浏览器。

将文章关联到已登录的用户

Prisma指南中的示例表单使用硬编码的authorId来创建文章。现在有了会话功能,页面可以读取会话信息并使用真实的用户ID。下面的页面会调用auth()函数,向匿名访问者显示消息,并在创建文章时将session.user.id传递给Prisma。之后它会重新加载文章列表并跳转至该页面:

export default async function NewPost() {
  const session = await auth();

  async function createPost(formData: FormData) {
    "use server";

    if(!session?.user?.id){
      return;
    }

    const title = formData.get("title") as string;
    const content = formData.get("content") as string;

    await prisma.post.create({
      data: {
        title,
        content,
        authorId: session.user.id,
      },
    });

    revalidatePath("/posts");
    redirect("/posts");
  }

  if(!session?.user){
    return <div>You need to sign in to create a post</div>
  }

  ...

}

在将其用于生产环境之前,有一个改进值得考虑。当前的实现是通过闭包从页面渲染中读取session数据。而服务器动作是一个可直接调用的公共端点,因此更安全的做法是在动作体内部调用auth()并在此处检查结果,这样每次提交时都会进行验证。我们在为何服务器动作需要在每个函数体中添加授权验证一文中解释了原因。此外,最好对title和content进行验证,而非使用as string将其强制转换为字符串。

总结

  • 遵循 Prisma 和 Auth.js 的官方指南,但需修正生成的客户端导入路径,使其指向 generated/prisma/client,并统一使用 src/app 还是 app/ 的格式。
  • 将所有密钥和连接字符串保存在不会被提交到版本控制中的环境文件中。
  • 可跳过文档中提到的 middleware.ts;当前的 Next.js 已采用代理机制,页面级的 auth() 校验即可满足需求。
  • 添加 Prisma 适配器会改变所需的 User 结构。在演示环境中重置结构并无问题,但现有数据需要真正的迁移操作。
  • 应在每个服务器端函数中检查会话状态,而不仅仅是在渲染表单的页面上进行检查。

接下来,自然要做的就是实现客户端组件的登录功能,并将 DATABASE_URL 设置为托管的 PostgreSQL 实例,以便部署。