Jansiel Notes

Next.js中构建完整的身份验证系统【翻译】

简要概述:

确保用户的安全和隐私比以往任何时候都更加重要。 Web认证在这方面发挥着至关重要的作用,是保护用户信息和数据的第一道防线。
今天,我们拥有像 NextAuth.js 这样的工具,使我们的工作变得更加轻松,使我们能够在 Next.js 应用程序中轻松实现不同类型的身份验证。
在本系列教程中,我们将在 Next.js 14 中构建一个完整的身份验证系统,从基础知识开始:使用 电子邮件和密码 进行身份验证。

什么是 NextAuth.js (Auth.js)?

在 JavaScript 生态系统中,更具体地说,在使用 Next.js 开发的应用程序中,处理身份验证的最著名的库之一是 NextAuth.js。
该工具提供了一个简单且易于实施的解决方案,用于向我们的应用程序添加身份验证。最好的一点是它的灵活性;除了基于凭证的身份验证(例如经典的电子邮件和密码)之外,它还允许集成不同的身份验证提供商(例如 Google、Facebook 和 Twitter)。

实现凭证认证 Authentication

凭证验证是非常有用的在需要完全控制验证过程和用户凭证存储的应用程序中,或者当您不想依赖外部身份验证提供商时。

起步

  1. 使用以下命令创建一个新的Next.js项目,并按照指示的步骤进行操作。我们将使用TypeScript和src/文件夹。
1npx create-next-app@latest
2
  1. 安装项目需要的依赖,使用pnpm进行依赖管理
1pnpm install next-auth prisma react-hook-form zod, bcrypt
2

我们使用 Shadcn/ui 组件

1pnpm dlx shadcn-ui@latest init
2
  • prisma: 是一个开源数据库工具包。我们将使用它来存储用户凭据。
  • next-auth: Next.js的身份验证。
  • react-hook-form: 一个帮助你在React中验证表单的库。
  • zod: 数据验证器。
  • bcrypt: 对密码进行哈希运算。
  • shadcn/ui: 可重用UI组件的集合。
  1. 为项目创建以下结构
 1...
 2├── prisma/
 3...
 4├── src/
 5   ├── actions/
 6      └── auth-actions.tsx
 7   ├── app/
 8      ├── api/auth/[...nextauth]
 9         └── route.ts
10      ├── auth/
11         ├── signin
12            └── page.tsx
13         └── signup
14             └── page.tsx
15         ...
16   ├── components/
17      ├── auth/
18         ├── auth-buttons.tsx
19         ├── signin-form.tsx
20         ├── signup-form.tsx
21         └── user-nav.ts
22      ├── ui/
23         ...
24      ├── auth-provider.tsx
25      ├── icons.tsx
26      └── theme-provider.tsx
27   ├── lib/
28      ├── prisma.ts
29      ├── types.d.ts
30      └── utils.ts
31   ...
32...
33

设置Prisma,初始化数据结构

我们将使用Prisma在数据库中存储和检索用户。Prisma允许集成不同的数据库类型,因此您可以使用所需的任何数据库,我们将使用 SQLite

初始化Prisma

1npx prisma init --datasource-provider sqlite
2

这将创建包含其数据model的数据文件夹。

创建 models.

为了创建模型,我们将使用@auth/prisma-adapter提供的模型,并对其进行一些自定义,如下所示

 1generator client {
 2  provider = "prisma-client-js"
 3  output = "../../node_modules/.prisma/client"
 4}
 5
 6datasource db {
 7  provider = "sqlite"
 8  url      = env("DATABASE_URL")
 9}
10
11...
12model User {
13  id            String    @id @default(cuid())
14  username      String
15  password      String
16  email         String    @unique
17  emailVerified DateTime?
18  phone         String?
19  image         String?
20}
21

创建第一个migration

1npx prisma migrate dev --name first-migration
2

使用此命令,在Prisma文件夹中创建了更多文件,数据库已与模型同步。

Prisma客户端

最后,我们创建一个Prisma客户端代码。

 1import { PrismaClient } from "@prisma/client";
 2
 3const globalForPrisma = global as unknown as {
 4  prisma: PrismaClient;
 5};
 6
 7export const prisma = globalForPrisma.prisma || new PrismaClient();
 8
 9if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
10
11export default prisma;
12

设置NextAuth.js

创建 .env 环境变量

1# Secret key for NextAuth.js, used for encryption and session security.  It should be a long,
2# random string unique to your application.
3NEXTAUTH_SECRET=XXX3B2CC28F123456C6934531CXXXXX
4
5# Base URL for your Next.js app, used by NextAuth.js for redirects and callbacks.
6NEXTAUTH_URL=http://localhost:3000/
7

创建身份auth验证路由

此路径允许在单个端点上处理所有身份验证请求(如登录、注销和供应商回调)。
src/app/api/auth/[...nextauth]

创建providers

 1...
 2// Imports the Prisma User type for typing.
 3import { User } from '@prisma/client'
 4
 5// Configuration of authentication options for NextAuth.
 6export const authOptions: AuthOptions = {
 7  ...
 8 // Defines authentication providers, in this case, only credentials.
 9 providers: [
10  CredentialsProvider({
11   name: 'Credentials',
12   // Defines the required fields for authentication.
13   credentials: {
14    email: { label: 'Email', type: 'text' },
15    password: { label: 'Password', type: 'password' },
16   },
17   // Function to authenticate the user with the provided credentials.
18   async authorize(credentials) {
19    // Searches for the user in the database by email.
20    const user = await prisma.user.findUnique({
21     where: {
22      email: credentials?.email,
23     },
24    })
25
26    // Checks if the user exists and if the password is correct.
27    if (!user) throw new Error('User name or password is not correct')
28
29    if (!credentials?.password) throw new Error('Please Provide Your Password')
30    const isPasswordCorrect = await bcrypt.compare(credentials.password, user.password)
31
32    if (!isPasswordCorrect) throw new Error('User name or password is not correct')
33
34    // Returns the user without including the password.
35    const { password, ...userWithoutPass } = user
36    return userWithoutPass
37   },
38  }),
39 ],
40}
41
42// Exports the configured NextAuth handler to handle GET and POST requests.
43const handler = NextAuth(authOptions)
44export { handler as GET, handler as POST }
45

创建 Auth Provider

src/components/auth-provider.tsx:

1'use client'
2
3import { SessionProvider } from 'next-auth/react'
4
5export default function AuthProvider({ children }: { children: React.ReactNode }) {
6 return <SessionProvider>{children}</SessionProvider>
7}
8

此组件充当使用NextAuth进行身份验证的Next.js应用程序的会话提供者。
将组件或页面包装在此提供程序中,可以授予它们访问会话上下文的权限,允许子组件使用NextAuth钩子和功能,例如useSession来访问或修改用户当前会话的状态。
src/app/layout.tsx:

 1export default function RootLayout({ children }: { children: React.ReactNode }) {
 2 return (
 3  <html
 4   lang='en'
 5   suppressHydrationWarning
 6  >
 7   <body className={`${inter.className} relative`}>
 8    <AuthProvider>
 9      <main>{children}</main>
10    </AuthProvider>
11   </body>
12  </html>
13 )
14}
15

使用Shadcn/UI设置用户界面

按照文档安装shadcn/ui

 1Would you like to use TypeScript (recommended)? yes
 2Which style would you like to use?  Default
 3Which color would you like to use as base color?  Slate
 4Where is your global CSS file?  src/app/globals.css
 5Do you want to use CSS variables for colors?  yes
 6Are you using a custom tailwind prefix eg. tw-? Leave blank
 7Where is your tailwind.config.js located?  tailwind.config.js
 8Configure the import alias for components:  @/components
 9Configure the import alias for utils:  @/lib/utils
10Are you using React Server Components?  yes
11

实施黑暗模式

src/app/layout.tsx

 1export default function RootLayout({ children }: { children: React.ReactNode }) {
 2 return (
 3  <html
 4   lang='en'
 5   suppressHydrationWarning
 6  >
 7   <body className={`${inter.className} relative`}>
 8    <AuthProvider>
 9     <ThemeProvider
10      attribute='class'
11      defaultTheme='dark'
12      enableSystem
13      disableTransitionOnChange
14     >
15      <main>{children}</main>
16
17      <Toaster />
18     </ThemeProvider>
19    </AuthProvider>
20   </body>
21  </html>
22 )
23}
24

安装以下shadcn/ui组件:

1pnpm dlx shadcn-ui@latest add avatar button dropdown-menu form input label tabs toast
2

创建身份验证组件

src/components/auth-buttons.tsx:

 1'use client'
 2
 3import Link from 'next/link'
 4import { signIn, useSession } from 'next-auth/react'
 5
 6import { Button } from '../ui/button'
 7import { UserNav } from './user-nav'
 8
 9export default function AuthButtons() {
10 // Use the useSession hook to access session data
11 const { data: session } = useSession()
12
13 return (
14  <div className='flex justify-end gap-4'>
15   {session && session.user ? (
16    <UserNav user={session.user} />
17   ) : (
18    <>
19     <Button
20      size={'sm'}
21      variant={'secondary'}
22      onClick={() => signIn()}
23     >
24      Sign In
25     </Button>
26     <Button
27      size={'sm'}
28      asChild
29      className='text-foreground'
30     >
31      <Link href='/auth/signup'>Sign Up</Link>
32     </Button>
33    </>
34   )}
35  </div>
36 )
37}
38

此组件根据用户的会话状态动态显示身份验证选项。如果用户已登录,则会显示用户特定的导航。此外,它还提供了登录或注册按钮,利用Next.js的路由和NextAuth的身份验证功能,提供流畅的用户体验。
src/components/auth/signup-form.tsx

  1'use client'
  2
  3/*
  4  all imports
  5*/
  6
  7// Function to register a new user
  8import { registerUser } from '@/actions/auth-actions'
  9
 10// Define the validation schema for the signup form using Zod
 11const formSchema = z
 12 .object({
 13  username: z
 14   .string({
 15    required_error: 'Username is required',
 16   })
 17   .min(2, 'User name must have at least 2 characters')
 18   .max(12, 'Username must be up to 12 characters')
 19   .regex(new RegExp('^[a-zA-Z0-9]+$'), 'No special characters allowed!'),
 20  email: z.string({ required_error: 'Email is required' }).email('Please enter a valid email address'),
 21  password: z
 22   .string({ required_error: 'Password is required' })
 23   .min(6, 'Password must have at least 6 characters')
 24   .max(20, 'Password must be up to 20 characters'),
 25  confirmPassword: z
 26   .string({ required_error: 'Confirm your password is required' })
 27   .min(6, 'Password must have at least 6 characters')
 28   .max(20, 'Password must be up to 20 characters'),
 29 })
 30 .refine(values => values.password === values.confirmPassword, {
 31  message: "Password and Confirm Password doesn't match!",
 32  path: ['confirmPassword'],
 33 })
 34
 35// Type inference for form inputs based on the Zod schema
 36type InputType = z.infer<typeof formSchema>
 37
 38export function SignUpForm() {
 39 const [isLoading, setIsLoading] = useState(false)
 40 const { toast } = useToast() // Hook to show toast notifications
 41
 42 // Initialize form handling with React Hook Form and Zod for validation
 43 const form = useForm<InputType>({
 44  resolver: zodResolver(formSchema),
 45 })
 46
 47 // Handles form submission
 48 async function onSubmit(values: InputType) {
 49  try {
 50   setIsLoading(true)
 51   const { confirmPassword, ...user } = values // Exclude confirmPassword from data to be sent
 52
 53   const response = await registerUser(user) // Register the user
 54   if ('error' in response) {
 55    toast({
 56     title: 'Something went wrong!',
 57     description: response.error,
 58     variant: 'success',
 59    })
 60   } else {
 61    toast({
 62     title: 'Account Created!',
 63     description: 'Your account has been created successfully! You can now login.',
 64    })
 65   }
 66  } catch (error) {
 67   console.error(error)
 68   toast({
 69    title: 'Something went wrong!',
 70    description: "We couldn't create your account. Please try again later!",
 71    variant: 'destructive',
 72   })
 73  } finally {
 74   setIsLoading(false)
 75  }
 76 }
 77
 78 return (
 79  <Form {...form}>
 80   <form onSubmit={form.handleSubmit(onSubmit)}>
 81    <div className='grid gap-2'>
 82     // Each FormField validates and displays an input
 83     <FormField
 84      control={form.control}
 85      name='username'
 86      render={({ field }) => (
 87       <FormItem>
 88        <FormControl>
 89         <div className='flex items-center gap-2'>
 90          <Icons.user
 91           className={`${form.formState.errors.username ? 'text-destructive' : 'text-muted-foreground'} `}
 92          />
 93          <Input
 94           placeholder='Your Username'
 95           className={`${form.formState.errors.username && 'border-destructive bg-destructive/30'}`}
 96           {...field}
 97          />
 98         </div>
 99        </FormControl>
100        <FormMessage />
101       </FormItem>
102      )}
103     />
104
105     // Repeated structure for email, password, and confirmPassword with respective validations and icons
106
107     <Button
108      className='text-foreground mt-4'
109      disabled={isLoading} // Disable button during form submission
110     >
111      {isLoading && <Icons.spinner className='mr-2 h-4 w-4 animate-spin' />} // Show loading icon if isLoading is true
112      Sign Up
113     </Button>
114    </div>
115   </form>
116  </Form>
117 )
118}
119

该组件封装了一个用户注册表,使用react钩子表单进行表单状态管理,使用Zod进行模式验证。
我在页面上添加了更多样式,看起来像这样:
image.pngsrc/actions/auth-action.ts

 1'use server'
 2
 3/*
 4  all imports
 5*/
 6
 7export async function registerUser(user: Omit<User, 'id' | 'phone' | 'emailVerified' | 'image'>) {
 8 try {
 9  // Attempt to create a new user record in the database
10  const result = await prisma.user.create({
11   data: {
12    ...user,
13    // Hash the password before storing it
14    password: await bcrypt.hash(user.password, 10),
15   },
16  })
17
18  return result
19 } catch (error) {
20  console.log(error)
21  // Handle known request errors from Prisma
22  if (error instanceof Prisma.PrismaClientKnownRequestError) {
23   // Check for unique constraint failure (e.g., email already exists)
24   if (error.code === 'P2002') {
25    return { error: 'Email already exists.' }
26   }
27  }
28
29  // Return a generic error message for any other errors
30  return { error: 'An unexpected error occurred.' }
31 }
32}
33

registerUser 函数旨在通过在数据库中创建包含所提供用户信息的记录来安全地注册新用户,不包括id、phone、emailVerified和image等字段。
它使用bcrypt对用户的密码进行哈希运算,以实现安全存储。
为了测试我们的注册并验证用户是否正确注册,我们需要添加一些回调;这些功能允许您自定义身份验证和会话管理的行为。
src/app/api/auth/[...nextauth]

 1export const authOptions: AuthOptions = {
 2 // Define custom pages for authentication flow
 3 pages: {
 4  signIn: '/auth/signin', // Custom sign-in page
 5 },
 6 // Configure session management to use JSON Web Tokens (JWT)
 7 session: {
 8  strategy: 'jwt',
 9 },
10 // JWT configuration, including secret for token signing
11 jwt: {
12  secret: process.env.NEXTAUTH_SECRET, // Secret used to sign the JWT, stored in environment variables
13 },
14
15...
16
17// Callbacks for customizing JWT and session behaviors
18 callbacks: {
19  // Callback to modify the JWT content. Adds user information if available.
20  async jwt({ token, user }) {
21   if (user) token.user = user as User // Cast user object to User type and assign to token
22   return token
23  },
24
25  // Callback to modify session content. Adds user information to the session.
26  async session({ token, session }) {
27   session.user = token.user // Assign user information from token to session
28   return session
29  },
30 },
31}
32

回调jwt:

在身份验证生命周期中,每当创建或更新JSON Web令牌(jwt)时,都会执行此回调。它允许您在令牌被签名并发送到客户端或存储在服务器上之前修改令牌的内容。

这对于向令牌添加可能与您的应用程序逻辑相关的其他信息非常有用。
session 回调

每次读取会话数据时都会调用此回调,例如在服务器端呈现期间或在受保护的API请求中。它允许在将会话数据发送到客户端之前对其进行修改。

这对于基于JWT中存储的信息或其他标准添加或修改会话数据特别有用。
最后,我们需要扩展NextAuth Session和JWT类型定义,以包含其他用户信息。
src/lib/types.d.ts

 1import { User } from '@prisma/client'
 2
 3declare module 'next-auth' {
 4 interface Session {
 5  user: User
 6 }
 7}
 8
 9declare module 'next-auth/jwt' {
10 interface JWT {
11  user: User
12 }
13}
14

现在,如果我们填写表格并提交,我们将能够看到成功的提示语。为了验证用户是否保存在数据库中,我们可以使用以下命令以图形方式查看Prisma创建的表:

1nxp prisma studio
2

将提供以下路线http://localhost:5555image.png src/components/auth/user-nav.tsx:

 1/*
 2   all imports
 3*/
 4
 5interface Props {
 6 user: User // Expect a user object of type User from Prisma client
 7}
 8
 9export function UserNav({ user }: Props) {
10 return (
11  <DropdownMenu>
12   <DropdownMenuTrigger asChild>
13    <Button
14     variant='ghost'
15     className='relative h-8 w-8 rounded-full'
16    >
17     <Avatar className='h-9 w-9'>
18      <AvatarImage
19       src='/img/avatars/01.png'
20       alt=''
21      />
22      <AvatarFallback>UU</AvatarFallback>
23     </Avatar>
24    </Button>
25   </DropdownMenuTrigger>
26   <DropdownMenuContent
27    className='w-56'
28    align='end'
29    forceMount
30   >
31    <DropdownMenuLabel className='font-normal'>
32     <div className='flex flex-col space-y-1'>
33      <p className='text-sm font-medium leading-none'>{user.username}</p>
34      <p className='text-xs leading-none text-muted-foreground'>{user.email}</p>
35     </div>
36    </DropdownMenuLabel>
37    <DropdownMenuSeparator />
38    <DropdownMenuItem>
39     <Link
40      href={'/api/auth/signout'} // Link to the signout API route
41      className='w-full'
42     >
43      Sign Out
44     </Link>
45    </DropdownMenuItem>
46   </DropdownMenuContent>
47  </DropdownMenu>
48 )
49}
50

src/components/auth/signin-form.tsx

  1/*
  2  all imports
  3*/
  4
  5// Schema definition for form validation using Zod
  6const formSchema = z.object({
  7 email: z.string({ required_error: 'Please enter your email' }).email('Please enter a valid email address'),
  8 password: z.string({
  9  required_error: 'Please enter your password',
 10 }),
 11})
 12
 13// Type inference for form inputs based on the Zod schema
 14type InputType = z.infer<typeof formSchema>
 15
 16// Props definition, optionally including a callback URL
 17interface Props {
 18 callbackUrl?: string
 19}
 20
 21export function SignInForm({ callbackUrl }: Props) {
 22 const [isLoading, setIsLoading] = useState(false)
 23 const { toast } = useToast()
 24
 25 const router = useRouter() // Hook to control routing
 26
 27 const form = useForm<InputType>({
 28  resolver: zodResolver(formSchema), // Set up Zod as the form validation resolver
 29 })
 30
 31 // Function to handle form submission
 32 async function onSubmit(values: InputType) {
 33  try {
 34   setIsLoading(true)
 35
 36   // Attempt to sign in using the 'credentials' provider
 37   const response = await signIn('credentials', {
 38    redirect: false, // Prevent automatic redirection
 39    email: values.email,
 40    password: values.password,
 41   })
 42
 43   // Handle unsuccessful sign in attempts
 44   if (!response?.ok) {
 45    toast({
 46     title: 'Something went wrong!',
 47     description: response?.error,
 48     variant: 'destructive',
 49    })
 50    return
 51   }
 52
 53   toast({
 54    title: 'Welcome back! ',
 55    description: 'Redirecting you to your dashboard!',
 56   })
 57   router.push(callbackUrl ? callbackUrl : '/') // Redirect to the callback URL or home page
 58  } catch (error) {
 59   toast({
 60    title: 'Something went wrong!',
 61    description: "We couldn't create your account. Please try again later!",
 62    variant: 'destructive',
 63   })
 64  } finally {
 65   setIsLoading(false)
 66  }
 67 }
 68
 69 return (
 70  <Form {...form}>
 71   <form onSubmit={form.handleSubmit(onSubmit)}>
 72    <div className='grid gap-2'>
 73     <div className='grid gap-1'>
 74      <FormField
 75       control={form.control}
 76       name='email'
 77       render={({ field }) => (
 78        <FormItem>
 79         <FormControl>
 80          <div className='flex items-center gap-2'>
 81           <Icons.email className={`${form.formState.errors.email ? 'text-destructive' : 'text-muted-foreground'} `}/>
 82           <Input
 83            type='email'
 84            placeholder='Your Email'
 85            className={`${form.formState.errors.email && 'border-destructive bg-destructive/30'}`}
 86            {...field}
 87           />
 88          </div>
 89         </FormControl>
 90         <FormMessage />
 91        </FormItem>
 92       )}
 93      />
 94      {/* Password field */}
 95      {/* Similar structure to email field, customized for password input */}
 96     </div>
 97     <Button
 98      className='text-foreground mt-4'
 99      disabled={isLoading} // Disable button while loading
100     >
101      {isLoading && <Icons.spinner className='mr-2 h-4 w-4 animate-spin' />} // Show loading spinner when processing
102      Sign In
103     </Button>
104    </div>
105   </form>
106  </Form>
107 )
108}
109

image.png

我们已经完成了使用NextAuth.js实现基本身份验证。
项目仓库代码
要拥有一个完整的身份验证系统,还有很多事情要做,我们将在接下来的教程中介绍它们。
hackernoon.com/how-to-send…hackernoon.com/enhancing-p…

项目总结

总之,我们探讨了如何使用NextAuth在Next.js中实现和定制身份验证系统,如何扩展会话和JWT以丰富用户管理,以及如何使用react hook form和Zod通过有效验证来处理表单。

文章链接:hackernoon.com/how-to-impl…

上一篇: 测试