使用 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
现在我们要配置 Tailwind,但应用程序的重点不是应用程序的设计,而是功能,为此我们将使用一个名为DaisyUI 的库。
npm install -D tailwindcss postcss autoprefixer
npm install daisyui
npx tailwindcss init -p
在文件中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"],
},
};
现在让我们把 Tailwind 指令添加到 globals.css 文件中:
/* @/src/styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
如您所见,我们所有的源代码,包括样式,都将位于该src/文件夹内。
设置 Prisma
首先,让我们安装依赖项并初始化 Prisma 设置:
npm install prisma
npx prisma init
让我们将以下模式添加到我们的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
}
定义好模式后,就可以运行我们的第一个迁移了:
npx prisma migrate dev --name init
最后,我们可以创建 Prisma 客户端了:
// @/src/common/prisma.ts
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
如果你已经按照所有步骤操作,那么项目的基础工作就已经完成了。
设置 tRPC
在 tRPC 的这一部分,我们将实现一些与身份验证相关的功能,但在讨论这些功能之前,让我们先在项目中配置 tRPC:
npm install @trpc/client @trpc/server @trpc/react @trpc/next zod react-query
安装好依赖项后,我们可以创建一个名为 `<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>;
然后我们将使用 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>;
上面的代码中已经包含了登录模式、注册模式及其数据类型,只需安装以下依赖项即可:
npm install argon2
定义好模式并安装好依赖项后,我们就可以开始开发 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;
上面的代码从 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,
});
现在需要_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);
然后我们将创建 tRPC 钩子,并将路由器的数据类型作为泛型添加到该createReactQueryHooks()函数中,以便我们可以进行 API 调用:
// @/src/common/client/trpc.ts
import { createReactQueryHooks } from "@trpc/react";
import type { ServerRouter } from "../../server/router";
export const trpc = createReactQueryHooks<ServerRouter>();
鉴于目前为止所做的一切,我们终于可以进入下一步了。
配置下一个身份验证
如前所述,我们将使用凭据提供程序,它的结构与其他提供程序非常相似,唯一的区别是我们需要考虑一些方面:
- 旨在与现有系统一起使用,也就是说,您需要使用该
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 = {
// ...
};
我们首先要定义的属性是提供者和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
},
}),
],
// ...
};
该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,
};
},
}),
],
// ...
};
配置好提供程序后,现在我们需要定义另一个属性,即回调函数。我们要定义的第一个回调函数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()每当检查会话时都会调用它,它只会从 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;
},
},
// ...
};
最后,我们还需要添加两个与 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",
},
};
现在我们只需要为 NextAuth 创建API 路由:
// @/src/pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth";
import { nextAuthOptions } from "../../../common/auth";
export default NextAuth(nextAuthOptions);
我们的身份验证系统已经完成,但现在我们需要创建一个高阶函数 (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);
};
现在回到后端,回到 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>;
现在,为了完成身份验证系统的配置,我们需要返回到组件中,并将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);
现在,我们终于可以开始创建前端并专注于用户界面了。
创建前端
现在我们已经做了很多可以在前端使用的事情,但是我们的应用程序仍然没有用户,因此我们将从创建新用户注册页面开始。
为此,我们需要安装一些依赖项来验证应用程序的表单,为此我们将使用 React Hook Form:
npm install react-hook-form @hookform/resolvers
这样一来,注册页面将如下所示:
// @/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;
如您在上面的代码中可能已经注意到的那样,我们有三个输入(用户名、电子邮件、密码),每个输入都对应于我们登录模式的一个属性。
此时,您应该已经注意到,我们使用 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;
注册和登录页面创建完成后,我们现在可以创建仪表盘页面,该页面将是一个受保护的路由(通过使用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;
结论
和往常一样,希望您喜欢这篇文章,并且觉得它对您有所帮助。如果您在文章中发现任何错误,请在评论区留言告知,以便我进行更正。
在结束之前,我将与大家分享本文项目代码的 GitHub 仓库链接。
下次见!
文章来源:https://dev.to/franciscomendes10866/nextjs-authentication-with-next-auth-trpc-and-prisma-kgl