发布于 2026-01-06 9 阅读
0

Next.js 身份验证与 NextAuth、tRPC 和 Prisma ORM 简介 前提条件 入门 配置 Next Auth 创建前端 结论 DEV 的全球展示挑战赛 由 Mux 呈现:展示你的项目!

使用 NextAuth、tRPC 和 Prisma ORM 实现 Next.js 身份验证

介绍

先决条件

入门

配置下一个身份验证

创建前端

结论

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

许多应用程序需要以某种方式知道用户是谁,以及他是否有权访问特定页面,而这正是我们今天这篇文章要做的事情。

在今天的文章中,我们将创建一个应用程序,用于验证用户身份,包括注册新用户、登录已有帐户的用户,甚至注销帐户。

介绍

在 Web 应用中创建身份验证和授权系统有多种方法,但当涉及到服务端渲染 (SSR) 时,选择范围会迅速缩小。然而,仍有几个因素需要考虑,为了简化实现,我们将使用next-auth依赖项来全面管理用户会话。

Next Auth 提供了几个我们可以使用的提供商,但今天我将重点介绍凭据,因为互联网上的资源很少,而且大多数应用程序只需要使用电子邮件和密码登录。

先决条件

在继续之前,您需要:

  • 节点
  • NPM
  • Next.js

此外,您还应具备这些技术的基本知识。

入门

考虑到以上所有因素,我们现在可以开始配置我们的项目了。

项目设置

让我们搭建一个 Next.js 应用框架,并进入项目目录:

npx create-next-app@latest --ts auth-project
cd auth-project
Enter fullscreen mode Exit fullscreen mode

现在我们要配置 Tailwind,但应用程序的重点不是应用程序的设计,而是功能,为此我们将使用一个名为DaisyUI 的库。

npm install -D tailwindcss postcss autoprefixer
npm install daisyui
npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

在文件中tailwind.config.js添加页面和组件文件夹的路径,添加 daisyUI 插件并选择一个主题:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/pages/**/*.{js,ts,jsx,tsx}",
    "./src/components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [require("daisyui")],
  daisyui: {
    themes: ["dracula"],
  },
};
Enter fullscreen mode Exit fullscreen mode

现在让我们把 Tailwind 指令添加到 globals.css 文件中:

/* @/src/styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

如您所见,我们所有的源代码,包括样式,都将位于该src/文件夹内。

设置 Prisma

首先,让我们安装依赖项并初始化 Prisma 设置:

npm install prisma
npx prisma init
Enter fullscreen mode Exit fullscreen mode

让我们将以下模式添加到我们的schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id        Int      @id @default(autoincrement())
  username  String   @unique
  email     String   @unique
  password  String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
Enter fullscreen mode Exit fullscreen mode

定义好模式后,就可以运行我们的第一个迁移了:

npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

最后,我们可以创建 Prisma 客户端了:

// @/src/common/prisma.ts
import { PrismaClient } from "@prisma/client";

export const prisma = new PrismaClient();
Enter fullscreen mode Exit fullscreen mode

如果你已经按照所有步骤操作,那么项目的基础工作就已经完成了。

设置 tRPC

在 tRPC 的这一部分,我们将实现一些与身份验证相关的功能,但在讨论这些功能之前,让我们先在项目中配置 tRPC:

npm install @trpc/client @trpc/server @trpc/react @trpc/next zod react-query
Enter fullscreen mode Exit fullscreen mode

安装好依赖项后,我们可以创建一个名为 `<code_name>` 的文件夹server/,用于存放所有将在后端执行的代码。首先,让我们创建 tRPC 上下文,因为今天的示例中会用到一些上下文数据,但现在我们先添加 Prisma 客户端:

// @/src/server/context.ts
import * as trpc from "@trpc/server";
import * as trpcNext from "@trpc/server/adapters/next";

import { prisma } from "../common/prisma";

export async function createContext(ctx: trpcNext.CreateNextContextOptions) {
  const { req, res } = ctx;

  return {
    req,
    res,
    prisma,
  };
}

export type Context = trpc.inferAsyncReturnType<typeof createContext>;
Enter fullscreen mode Exit fullscreen mode

然后我们将使用 zod 库创建一个模式,该模式将在前端重用以验证表单,或在后端重用以定义 mutation 的输入:

// @/src/common/validation/auth.ts
import * as z from "zod";

export const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(4).max(12),
});

export const signUpSchema = loginSchema.extend({
  username: z.string(),
});

export type ILogin = z.infer<typeof loginSchema>;
export type ISignUp = z.infer<typeof signUpSchema>;
Enter fullscreen mode Exit fullscreen mode

上面的代码中已经包含了登录模式、注册模式及其数据类型,只需安装以下依赖项即可:

npm install argon2
Enter fullscreen mode Exit fullscreen mode

定义好模式并安装好依赖项后,我们就可以开始开发 tRPC 路由器了,它只包含一个过程,即注册新用户(signup):

// @/src/server/router.ts
import * as trpc from "@trpc/server";
import { hash } from "argon2";

import { Context } from "./context";
import { signUpSchema } from "../common/validation/auth";

export const serverRouter = trpc.router<Context>().mutation("signup", {
  input: signUpSchema,
  resolve: async ({ input, ctx }) => {
    const { username, email, password } = input;

    const exists = await ctx.prisma.user.findFirst({
      where: { email },
    });

    if (exists) {
      throw new trpc.TRPCError({
        code: "CONFLICT",
        message: "User already exists.",
      });
    }

    const hashedPassword = await hash(password);

    const result = await ctx.prisma.user.create({
      data: { username, email, password: hashedPassword },
    });

    return {
      status: 201,
      message: "Account created successfully",
      result: result.email,
    };
  },
});

export type ServerRouter = typeof serverRouter;
Enter fullscreen mode Exit fullscreen mode

上面的代码从 mutation 输入中获取用户名、邮箱和密码,然后检查应用程序中是否存在与提供的邮箱地址匹配的用户。如果不存在,则对密码进行哈希处理,最后创建一个新帐户。

tRPC 上下文和路由器创建完成后,我们现在可以创建API 路由了

// @/src/pages/api/trpc/[trpc].ts
import * as trpcNext from "@trpc/server/adapters/next";

import { serverRouter } from "../../../server/router";
import { createContext } from "../../../server/context";

export default trpcNext.createNextApiHandler({
  router: serverRouter,
  createContext,
});
Enter fullscreen mode Exit fullscreen mode

现在需要_app.tsx按如下方式配置该文件:

// @/src/pages/_app.tsx
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { withTRPC } from "@trpc/next";

import { ServerRouter } from "../server/router";

const App = ({ Component, pageProps }: AppProps) => {
  return <Component {...pageProps} />;
};

export default withTRPC<ServerRouter>({
  config({ ctx }) {
    const url = process.env.VERCEL_URL
      ? `https://${process.env.VERCEL_URL}/api/trpc`
      : "http://localhost:3000/api/trpc";

    return {
      url,
      headers: {
        "x-ssr": "1",
      },
    };
  },
  ssr: true,
})(App);
Enter fullscreen mode Exit fullscreen mode

然后我们将创建 tRPC 钩子,并将路由器的数据类型作为泛型添加到该createReactQueryHooks()函数中,以便我们可以进行 API 调用:

// @/src/common/client/trpc.ts
import { createReactQueryHooks } from "@trpc/react";

import type { ServerRouter } from "../../server/router";

export const trpc = createReactQueryHooks<ServerRouter>();
Enter fullscreen mode Exit fullscreen mode

鉴于目前为止所做的一切,我们终于可以进入下一步了。

配置下一个身份验证

如前所述,我们将使用凭据提供程序,它的结构与其他提供程序非常相似,唯一的区别是我们需要考虑一些方面:

  • 旨在与现有系统一起使用,也就是说,您需要使用该authorize()处理程序;
  • 与其他提供商不同,该会话是无状态的,即会话数据必须存储在 JSON Web Token 中,而不是数据库中。

现在我们记住了一些事项,可以继续配置提供商选项了,但首先让我们导入必要的依赖项:

// @/src/common/auth.ts

import { NextAuthOptions } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "argon2";

import { prisma } from "./prisma";
import { loginSchema } from "./validation/auth";

export const nextAuthOptions: NextAuthOptions = {
  // ...
};
Enter fullscreen mode Exit fullscreen mode

我们首先要定义的属性是提供者和authorize处理程序:

// @/src/common/auth.ts
import { NextAuthOptions } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "argon2";

import { prisma } from "./prisma";
import { loginSchema } from "./validation/auth";

export const nextAuthOptions: NextAuthOptions = {
  providers: [
    Credentials({
      name: "credentials",
      credentials: {
        email: {
          label: "Email",
          type: "email",
          placeholder: "jsmith@gmail.com",
        },
        password: { label: "Password", type: "password" },
      },
      authorize: async (credentials, request) => {
        // login logic goes here
      },
    }),
  ],
  // ...
};
Enter fullscreen mode Exit fullscreen mode

authorize()句柄将包含执行应用程序中逻辑所需的逻辑。因此,首先我们将使用该.parseAsync()方法检查凭据是否正确,然后我们将使用提供的电子邮件地址检查用户是否存在。

如果用户存在,我们会检查用户输入的密码是否与数据库中该用户的密码一致。如果所有步骤都顺利完成,则返回数据user;否则,返回错误信息null。例如:

// @/src/common/auth.ts
import { NextAuthOptions } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "argon2";

import { prisma } from "./prisma";
import { loginSchema } from "./validation/auth";

export const nextAuthOptions: NextAuthOptions = {
  providers: [
    Credentials({
      name: "credentials",
      credentials: {
        email: {
          label: "Email",
          type: "email",
          placeholder: "jsmith@gmail.com",
        },
        password: { label: "Password", type: "password" },
      },
      authorize: async (credentials, request) => {
        const creds = await loginSchema.parseAsync(credentials);

        const user = await prisma.user.findFirst({
          where: { email: creds.email },
        });

        if (!user) {
          return null;
        }

        const isValidPassword = await verify(user.password, creds.password);

        if (!isValidPassword) {
          return null;
        }

        return {
          id: user.id,
          email: user.email,
          username: user.username,
        };
      },
    }),
  ],
  // ...
};
Enter fullscreen mode Exit fullscreen mode

配置好提供程序后,现在我们需要定义另一个属性,即回调函数。我们要定义的第一个回调函数jwt()会在令牌创建或更新时被调用。

// @/src/common/auth.ts
import { NextAuthOptions } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "argon2";

import { prisma } from "./prisma";
import { loginSchema } from "./validation/auth";

export const nextAuthOptions: NextAuthOptions = {
  // ...
  callbacks: {
    jwt: async ({ token, user }) => {
      if (user) {
        token.id = user.id;
        token.email = user.email;
      }

      return token;
    },
    // ...
  },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

回调属性中我们需要添加的最后一个处理程序是,session()每当检查会话时都会调用它,它只会从 JWT 返回一些数据。

// @/src/common/auth.ts
import { NextAuthOptions } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "argon2";

import { prisma } from "./prisma";
import { loginSchema } from "./validation/auth";

export const nextAuthOptions: NextAuthOptions = {
  // ...
  callbacks: {
    jwt: async ({ token, user }) => {
      if (user) {
        token.id = user.id;
        token.email = user.email;
      }

      return token;
    },
    session: async ({ session, token }) => {
      if (token) {
        session.id = token.id;
      }

      return session;
    },
  },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

最后,我们还需要添加两个与 JWT 配置相关的属性(如 secret 和 max age),以及我们想要用于登录和注册的自定义页面。

// @/src/common/auth.ts
import { NextAuthOptions } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "argon2";

import { prisma } from "./prisma";
import { loginSchema } from "./validation/auth";

export const nextAuthOptions: NextAuthOptions = {
  // ...
  jwt: {
    secret: "super-secret",
    maxAge: 15 * 24 * 30 * 60, // 15 days
  },
  pages: {
    signIn: "/",
    newUser: "/sign-up",
  },
};
Enter fullscreen mode Exit fullscreen mode

现在我们只需要为 NextAuth 创建API 路由

// @/src/pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth";

import { nextAuthOptions } from "../../../common/auth";

export default NextAuth(nextAuthOptions);
Enter fullscreen mode Exit fullscreen mode

我们的身份验证系统已经完成,但现在我们需要创建一个高阶函数 (HOF) 来保护一些路由。我们将根据会话数据来决定用户是否有权访问某个路由,我从Next.js的文档页面中获得了许多灵感

这个 HOF 的想法是重用所有其他页面上的授权逻辑,我们始终可以使用它getServerSideProps(),如果用户尝试在没有会话的情况下访问受保护的页面,他将被重定向到登录页面。

// @/src/common/requireAuth.ts
import type { GetServerSideProps, GetServerSidePropsContext } from "next";
import { unstable_getServerSession } from "next-auth";

import { nextAuthOptions } from "./auth";

export const requireAuth =
  (func: GetServerSideProps) => async (ctx: GetServerSidePropsContext) => {
    const session = await unstable_getServerSession(
      ctx.req,
      ctx.res,
      nextAuthOptions
    );

    if (!session) {
      return {
        redirect: {
          destination: "/", // login path
          permanent: false,
        },
      };
    }

    return await func(ctx);
  };
Enter fullscreen mode Exit fullscreen mode

现在回到后端,回到 tRPC 上下文,我们可以采用类似的方法,从会话中获取数据并将其添加到我们的上下文中,以便我们可以在路由器上的任何过程中访问用户的会话数据。

// @/src/server/context.ts
import * as trpc from "@trpc/server";
import * as trpcNext from "@trpc/server/adapters/next";
import { unstable_getServerSession } from "next-auth"; // 👈 added this

import { prisma } from "../common/prisma";
import { nextAuthOptions } from "../common/auth"; // 👈 added this

export async function createContext(ctx: trpcNext.CreateNextContextOptions) {
  const { req, res } = ctx;
  const session = await unstable_getServerSession(req, res, nextAuthOptions); // 👈 added this

  return {
    req,
    res,
    session, // 👈 added this
    prisma,
  };
}

export type Context = trpc.inferAsyncReturnType<typeof createContext>;
Enter fullscreen mode Exit fullscreen mode

现在,为了完成身份验证系统的配置,我们需要返回到组件中,并将SessionProvider_app.tsx添加组件中:<App />

// @/src/pages/_app.tsx
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { SessionProvider } from "next-auth/react"; // 👈 added this
import { withTRPC } from "@trpc/next";

import { ServerRouter } from "../server/router";

// made changes to this component 👇
const App = ({ Component, pageProps }: AppProps) => {
  return (
    <SessionProvider session={pageProps.session}>
      <Component {...pageProps} />
    </SessionProvider>
  );
};

export default withTRPC<ServerRouter>({
  config({ ctx }) {
    const url = process.env.VERCEL_URL
      ? `https://${process.env.VERCEL_URL}/api/trpc`
      : "http://localhost:3000/api/trpc";

    return {
      url,
      headers: {
        "x-ssr": "1",
      },
    };
  },
  ssr: true,
})(App);
Enter fullscreen mode Exit fullscreen mode

现在,我们终于可以开始创建前端并专注于用户界面了。

创建前端

现在我们已经做了很多可以在前端使用的事情,但是我们的应用程序仍然没有用户,因此我们将从创建新用户注册页面开始。

为此,我们需要安装一些依赖项来验证应用程序的表单,为此我们将使用 React Hook Form:

npm install react-hook-form @hookform/resolvers
Enter fullscreen mode Exit fullscreen mode

这样一来,注册页面将如下所示:

// @/src/pages/sign-up.tsx
import type { NextPage } from "next";
import Head from "next/head";
import Link from "next/link";
import { useCallback } from "react";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

import { signUpSchema, ISignUp } from "../common/validation/auth";
import { trpc } from "../common/client/trpc";

const SignUp: NextPage = () => {
  const router = useRouter();
  const { register, handleSubmit } = useForm<ISignUp>({
    resolver: zodResolver(signUpSchema),
  });

  const { mutateAsync } = trpc.useMutation(["signup"]);

  const onSubmit = useCallback(
    async (data: ISignUp) => {
      const result = await mutateAsync(data);
      if (result.status === 201) {
        router.push("/");
      }
    },
    [mutateAsync, router]
  );

  return (
    <div>
      <Head>
        <title>Next App - Register</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main>
        <form
          className="flex items-center justify-center h-screen w-full"
          onSubmit={handleSubmit(onSubmit)}
        >
          <div className="card w-96 bg-base-100 shadow-xl">
            <div className="card-body">
              <h2 className="card-title">Create an account!</h2>
              <input
                type="text"
                placeholder="Type your username..."
                className="input input-bordered w-full max-w-xs my-2"
                {...register("username")}
              />
              <input
                type="email"
                placeholder="Type your email..."
                className="input input-bordered w-full max-w-xs"
                {...register("email")}
              />
              <input
                type="password"
                placeholder="Type your password..."
                className="input input-bordered w-full max-w-xs my-2"
                {...register("password")}
              />
              <div className="card-actions items-center justify-between">
                <Link href="/" className="link">
                  Go to login
                </Link>
                <button className="btn btn-secondary" type="submit">
                  Sign Up
                </button>
              </div>
            </div>
          </div>
        </form>
      </main>
    </div>
  );
};

export default SignUp;
Enter fullscreen mode Exit fullscreen mode

如您在上面的代码中可能已经注意到的那样,我们有三个输入(用户名、电子邮件、密码),每个输入都对应于我们登录模式的一个属性。

此时,您应该已经注意到,我们使用 React Hook 表单zodResolver()来验证表单。一旦表单验证通过,用户就会在数据库中创建并重定向到登录页面。现在我们可以向应用程序添加新用户了,终于可以使用 Next Auth 的一些功能了。

与注册页面不同,登录页面不会使用我们的 tRPC 客户端,而是使用signIn()Next Auth 本身的功能,我们只需要定义我们将使用我们的“凭据”提供程序启动会话(我们还必须传递用户提供的凭据和回调 URL)。

// @/src/pages/index.tsx
import type { NextPage } from "next";
import Head from "next/head";
import Link from "next/link";
import { useCallback } from "react";
import { signIn } from "next-auth/react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

import { loginSchema, ILogin } from "../common/validation/auth";

const Home: NextPage = () => {
  const { register, handleSubmit } = useForm<ILogin>({
    resolver: zodResolver(loginSchema),
  });

  const onSubmit = useCallback(async (data: ILogin) => {
    await signIn("credentials", { ...data, callbackUrl: "/dashboard" });
  }, []);

  return (
    <div>
      <Head>
        <title>Next App - Login</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main>
        <form
          className="flex items-center justify-center h-screen w-full"
          onSubmit={handleSubmit(onSubmit)}
        >
          <div className="card w-96 bg-base-100 shadow-xl">
            <div className="card-body">
              <h2 className="card-title">Welcome back!</h2>
              <input
                type="email"
                placeholder="Type your email..."
                className="input input-bordered w-full max-w-xs mt-2"
                {...register("email")}
              />
              <input
                type="password"
                placeholder="Type your password..."
                className="input input-bordered w-full max-w-xs my-2"
                {...register("password")}
              />
              <div className="card-actions items-center justify-between">
                <Link href="/sign-up" className="link">
                  Go to sign up
                </Link>
                <button className="btn btn-secondary" type="submit">
                  Login
                </button>
              </div>
            </div>
          </div>
        </form>
      </main>
    </div>
  );
};

export default Home;
Enter fullscreen mode Exit fullscreen mode

注册和登录页面创建完成后,我们现在可以创建仪表盘页面,该页面将是一个受保护的路由(通过使用requireAuth()HOF)。本文将展示该页面上的用户会话数据,并介绍signOut()用户注销的功能。页面可能如下所示:

// @/src/pages/dashboard/index.tsx
import type { NextPage } from "next";
import { useSession, signOut } from "next-auth/react";

import { requireAuth } from "../../common/requireAuth";

export const getServerSideProps = requireAuth(async (ctx) => {
  return { props: {} };
});

const Dashboard: NextPage = () => {
  const { data } = useSession();

  return (
    <div className="hero min-h-screen bg-base-200">
      <div className="hero-content">
        <div className="max-w-lg">
          <h1 className="text-5xl text-center font-bold leading-snug text-gray-400">
            You are logged in!
          </h1>
          <p className="my-4 text-center leading-loose">
            You are allowed to visit this page because you have a session,
            otherwise you would be redirected to the login page.
          </p>
          <div className="my-4 bg-gray-700 rounded-lg p-4">
            <pre>
              <code>{JSON.stringify(data, null, 2)}</code>
            </pre>
          </div>
          <div className="text-center">
            <button
              className="btn btn-secondary"
              onClick={() => signOut({ callbackUrl: "/" })}
            >
              Logout
            </button>
          </div>
        </div>
      </div>
    </div>
  );
};

export default Dashboard;
Enter fullscreen mode Exit fullscreen mode

结论

和往常一样,希望您喜欢这篇文章,并且觉得它对您有所帮助。如果您在文章中发现任何错误,请在评论区留言告知,以便我进行更正。

在结束之前,我将与大家分享本文项目代码的 GitHub 仓库链接。

下次见!

文章来源:https://dev.to/franciscomendes10866/nextjs-authentication-with-next-auth-trpc-and-prisma-kgl