Việc đảm bảo tính bảo mật và quyền riêng tư của người dùng trở nên quan trọng hơn bao giờ hết. Xác thực web đóng một vai trò quan trọng trong khía cạnh này, đóng vai trò là tuyến phòng thủ đầu tiên để bảo vệ thông tin và dữ liệu của người dùng.
Ngày nay, chúng tôi có các công cụ như NextAuth.js giúp công việc của chúng tôi dễ dàng hơn nhiều, cho phép chúng tôi triển khai các loại xác thực khác nhau một cách dễ dàng trong các ứng dụng Next.js của mình.
Trong loạt hướng dẫn này, chúng ta sẽ xây dựng một hệ thống xác thực hoàn chỉnh trong Next.js 14, bắt đầu từ những điều cơ bản: xác thực bằng email và mật khẩu.
Trong hệ sinh thái JavaScript và cụ thể hơn là trong các ứng dụng được phát triển bằng Next.js, một trong những thư viện nổi bật nhất để xử lý xác thực là NextAuth.js.
Công cụ này cung cấp giải pháp đơn giản và dễ thực hiện để thêm xác thực vào ứng dụng của chúng tôi. Điều tốt nhất là tính linh hoạt của nó; nó cho phép tích hợp các nhà cung cấp xác thực khác nhau như Google, Facebook và Twitter, ngoài xác thực dựa trên thông tin xác thực, chẳng hạn như email và mật khẩu cổ điển.
Xác thực thông tin xác thực đặc biệt hữu ích trong các ứng dụng mà bạn cần toàn quyền kiểm soát quá trình xác thực và lưu trữ thông tin xác thực người dùng hoặc khi bạn không muốn dựa vào các nhà cung cấp xác thực bên ngoài.
src/
.
npx create-next-app@latest
2. Cài đặt các phụ thuộc cần thiết trong dự án. Lần này, chúng ta sẽ sử dụng pnpm
; bạn có thể sử dụng trình quản lý gói mà bạn chọn.
pnpm install next-auth prisma react-hook-form zod, bcrypt
Đối với giao diện người dùng, chúng tôi sẽ sử dụng Shadcn/ui.
pnpm dlx shadcn-ui@latest init
3. Tạo cấu trúc sau cho dự án:
... ├── prisma/ ... ├── src/ │ ├── actions/ │ │ └── auth-actions.tsx │ ├── app/ │ │ ├── api/auth/[...nextauth] │ │ │ └── route.ts │ │ ├── auth/ │ │ │ ├── signin │ │ │ │ └── page.tsx │ │ │ └── signup │ │ │ └── page.tsx │ │ │ ... │ ├── components/ │ │ ├── auth/ │ │ │ ├── auth-buttons.tsx │ │ │ ├── signin-form.tsx │ │ │ ├── signup-form.tsx │ │ │ └── user-nav.ts │ │ ├── ui/ │ │ │ ... │ │ ├── auth-provider.tsx │ │ ├── icons.tsx │ │ └── theme-provider.tsx │ ├── lib/ │ │ ├── prisma.ts │ │ ├── types.d.ts │ │ └── utils.ts │ ... ...
Chúng tôi sẽ sử dụng Prisma để lưu trữ và truy xuất người dùng trong cơ sở dữ liệu. Prisma cho phép tích hợp các loại cơ sở dữ liệu khác nhau để bạn có thể sử dụng bất kỳ cơ sở dữ liệu nào bạn cần, chúng tôi sẽ sử dụng SQLite.
npx prisma init --datasource-provider sqlite
Điều này tạo ra thư mục lăng kính với các lược đồ của nó.
Để tạo các mô hình, chúng ta sẽ sử dụng các mô hình được cung cấp bởi @auth/prisma-adapter và tùy chỉnh chúng một chút như sau:
prisma/schema.prisma
:
generator client { provider = "prisma-client-js" output = "../../node_modules/.prisma/client" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } ... model User { id String @id @default(cuid()) username String password String email String @unique emailVerified DateTime? phone String? image String? } ...
3. Tạo cái đầu tiên
npx prisma migrate dev --name first-migration
Với lệnh này, nhiều tệp hơn đã được tạo trong thư mục Prisma và cơ sở dữ liệu đã được đồng bộ hóa với các mô hình.
Cuối cùng, chúng ta tạo Prisma Client.
lib/prisma.ts
:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = global as unknown as { prisma: PrismaClient; }; export const prisma = globalForPrisma.prisma || new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; export default prisma;
env
# Secret key for NextAuth.js, used for encryption and session security. It should be a long, # random string unique to your application. NEXTAUTH_SECRET=XXX3B2CC28F123456C6934531CXXXXX # Base URL for your Next.js app, used by NextAuth.js for redirects and callbacks. NEXTAUTH_URL=http://localhost:3000/
Đường dẫn này cho phép xử lý tất cả các yêu cầu xác thực (chẳng hạn như đăng nhập, đăng xuất và gọi lại của nhà cung cấp) trên một điểm cuối duy nhất.
src/app/api/auth/[...nextauth]
... // Imports the Prisma User type for typing. import { User } from '@prisma/client' // Configuration of authentication options for NextAuth. export const authOptions: AuthOptions = { ... // Defines authentication providers, in this case, only credentials. providers: [ CredentialsProvider({ name: 'Credentials', // Defines the required fields for authentication. credentials: { email: { label: 'Email', type: 'text' }, password: { label: 'Password', type: 'password' }, }, // Function to authenticate the user with the provided credentials. async authorize(credentials) { // Searches for the user in the database by email. const user = await prisma.user.findUnique({ where: { email: credentials?.email, }, }) // Checks if the user exists and if the password is correct. if (!user) throw new Error('User name or password is not correct') if (!credentials?.password) throw new Error('Please Provide Your Password') const isPasswordCorrect = await bcrypt.compare(credentials.password, user.password) if (!isPasswordCorrect) throw new Error('User name or password is not correct') // Returns the user without including the password. const { password, ...userWithoutPass } = user return userWithoutPass }, }), ], } // Exports the configured NextAuth handler to handle GET and POST requests. const handler = NextAuth(authOptions) export { handler as GET, handler as POST }
4. Tạo Nhà cung cấp xác thực.
src/components/auth-provider.tsx
:
'use client' import { SessionProvider } from 'next-auth/react' export default function AuthProvider({ children }: { children: React.ReactNode }) { return <SessionProvider>{children}</SessionProvider> }
Thành phần này hoạt động như một nhà cung cấp phiên cho các ứng dụng Next.js sử dụng NextAuth để xác thực.
Việc gói các thành phần hoặc trang trong nhà cung cấp này cấp cho chúng quyền truy cập vào ngữ cảnh phiên, cho phép các thành phần con sử dụng chức năng và hook NextAuth, chẳng hạn như useSession
, để truy cập hoặc sửa đổi trạng thái phiên hiện tại của người dùng.
src/app/layout.tsx
:
/* All imports */ export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang='en' suppressHydrationWarning > <body className={`${inter.className} relative`}> <AuthProvider> <main>{children}</main> </AuthProvider> </body> </html> ) }
Would you like to use TypeScript (recommended)? yes Which style would you like to use? › Default Which color would you like to use as base color? › Slate Where is your global CSS file? › src/app/globals.css Do you want to use CSS variables for colors? › yes Are you using a custom tailwind prefix eg. tw-? Leave blank Where is your tailwind.config.js located? › tailwind.config.js Configure the import alias for components: › @/components Configure the import alias for utils: › @/lib/utils Are you using React Server Components? › yes
Bạn có thể sử dụng chủ đề bạn chọn.
2. Thực hiện
src/app/layout.tsx
:
/* All imports */ export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang='en' suppressHydrationWarning > <body className={`${inter.className} relative`}> <AuthProvider> <ThemeProvider attribute='class' defaultTheme='dark' enableSystem disableTransitionOnChange > <main>{children}</main> <Toaster /> </ThemeProvider> </AuthProvider> </body> </html> ) }
2. Cài đặt các thành phần shadcn/ui sau:
pnpm dlx shadcn-ui@latest add avatar button dropdown-menu form input label tabs toast
src/components/auth-buttons.tsx
:
'use client' import Link from 'next/link' import { signIn, useSession } from 'next-auth/react' import { Button } from '../ui/button' import { UserNav } from './user-nav' export default function AuthButtons() { // Use the useSession hook to access session data const { data: session } = useSession() return ( <div className='flex justify-end gap-4'> {session && session.user ? ( <UserNav user={session.user} /> ) : ( <> <Button size={'sm'} variant={'secondary'} onClick={() => signIn()} > Sign In </Button> <Button size={'sm'} asChild className='text-foreground' > <Link href='/auth/signup'>Sign Up</Link> </Button> </> )} </div> ) }
Thành phần này hiển thị động các tùy chọn xác thực dựa trên trạng thái phiên của người dùng. Nếu người dùng đã đăng nhập, nó sẽ hiển thị điều hướng dành riêng cho người dùng. Mặt khác, nó cung cấp các nút để đăng nhập hoặc đăng ký, tận dụng khả năng định tuyến của Next.js và khả năng xác thực của NextAuth để mang lại trải nghiệm mượt mà cho người dùng.
Sửa đổi trang chủ và thêm các nút xác thực. Cái này nó thì trông như thế nào:
src/components/auth/signup-form.tsx
:
'use client' /* all imports */ // Function to register a new user import { registerUser } from '@/actions/auth-actions' // Define the validation schema for the signup form using Zod const formSchema = z .object({ username: z .string({ required_error: 'Username is required', }) .min(2, 'User name must have at least 2 characters') .max(12, 'Username must be up to 12 characters') .regex(new RegExp('^[a-zA-Z0-9]+$'), 'No special characters allowed!'), email: z.string({ required_error: 'Email is required' }).email('Please enter a valid email address'), password: z .string({ required_error: 'Password is required' }) .min(6, 'Password must have at least 6 characters') .max(20, 'Password must be up to 20 characters'), confirmPassword: z .string({ required_error: 'Confirm your password is required' }) .min(6, 'Password must have at least 6 characters') .max(20, 'Password must be up to 20 characters'), }) .refine(values => values.password === values.confirmPassword, { message: "Password and Confirm Password doesn't match!", path: ['confirmPassword'], }) // Type inference for form inputs based on the Zod schema type InputType = z.infer<typeof formSchema> export function SignUpForm() { const [isLoading, setIsLoading] = useState(false) const { toast } = useToast() // Hook to show toast notifications // Initialize form handling with React Hook Form and Zod for validation const form = useForm<InputType>({ resolver: zodResolver(formSchema), }) // Handles form submission async function onSubmit(values: InputType) { try { setIsLoading(true) const { confirmPassword, ...user } = values // Exclude confirmPassword from data to be sent const response = await registerUser(user) // Register the user if ('error' in response) { toast({ title: 'Something went wrong!', description: response.error, variant: 'success', }) } else { toast({ title: 'Account Created!', description: 'Your account has been created successfully! You can now login.', }) } } catch (error) { console.error(error) toast({ title: 'Something went wrong!', description: "We couldn't create your account. Please try again later!", variant: 'destructive', }) } finally { setIsLoading(false) } } return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)}> <div className='grid gap-2'> // Each FormField validates and displays an input <FormField control={form.control} name='username' render={({ field }) => ( <FormItem> <FormControl> <div className='flex items-center gap-2'> <Icons.user className={`${form.formState.errors.username ? 'text-destructive' : 'text-muted-foreground'} `} /> <Input placeholder='Your Username' className={`${form.formState.errors.username && 'border-destructive bg-destructive/30'}`} {...field} /> </div> </FormControl> <FormMessage /> </FormItem> )} /> // Repeated structure for email, password, and confirmPassword with respective validations and icons <Button className='text-foreground mt-4' disabled={isLoading} // Disable button during form submission > {isLoading && <Icons.spinner className='mr-2 h-4 w-4 animate-spin' />} // Show loading icon if isLoading is true Sign Up </Button> </div> </form> </Form> ) }
Thành phần này đóng gói biểu mẫu đăng ký người dùng, sử dụng biểu mẫu Reac-hook để quản lý trạng thái biểu mẫu và Zod để xác thực lược đồ.
Mặc dù đây không phải là hướng dẫn về các công nghệ này nhưng chúng tôi chỉ sử dụng những kiến thức cơ bản về chúng, nếu có thêm câu hỏi, bạn có thể truy cập tài liệu của chúng.
Tôi đã thêm một số kiểu khác vào trang và nó trông như thế này:
src/actions/auth-action/ts
:
'use server' /* all imports */ export async function registerUser(user: Omit<User, 'id' | 'phone' | 'emailVerified' | 'image'>) { try { // Attempt to create a new user record in the database const result = await prisma.user.create({ data: { ...user, // Hash the password before storing it password: await bcrypt.hash(user.password, 10), }, }) return result } catch (error) { console.log(error) // Handle known request errors from Prisma if (error instanceof Prisma.PrismaClientKnownRequestError) { // Check for unique constraint failure (eg, email already exists) if (error.code === 'P2002') { return { error: 'Email already exists.' } } } // Return a generic error message for any other errors return { error: 'An unexpected error occurred.' } } }
Hàm registerUser
được thiết kế để đăng ký người dùng mới một cách an toàn bằng cách tạo một bản ghi trong cơ sở dữ liệu với thông tin người dùng được cung cấp, ngoại trừ các trường như id
, phone
, emailVerified
và image
.
Nó sử dụng bcrypt để băm mật khẩu của người dùng để lưu trữ an toàn.
Để kiểm tra quá trình đăng ký của chúng tôi và xác thực rằng người dùng đang đăng ký chính xác, chúng tôi cần thêm một số lệnh gọi lại; đây là những chức năng cho phép bạn tùy chỉnh hành vi xác thực và quản lý phiên.
src/app/api/auth/[...nextauth]
:
... export const authOptions: AuthOptions = { // Define custom pages for authentication flow pages: { signIn: '/auth/signin', // Custom sign-in page }, // Configure session management to use JSON Web Tokens (JWT) session: { strategy: 'jwt', }, // JWT configuration, including secret for token signing jwt: { secret: process.env.NEXTAUTH_SECRET, // Secret used to sign the JWT, stored in environment variables }, ... // Callbacks for customizing JWT and session behaviors callbacks: { // Callback to modify the JWT content. Adds user information if available. async jwt({ token, user }) { if (user) token.user = user as User // Cast user object to User type and assign to token return token }, // Callback to modify session content. Adds user information to the session. async session({ token, session }) { session.user = token.user // Assign user information from token to session return session }, }, } ...
Gọi lại jwt
: lệnh gọi lại này được thực thi bất cứ khi nào Mã thông báo web JSON (JWT) được tạo hoặc cập nhật trong vòng đời xác thực. Nó cho phép bạn sửa đổi nội dung của mã thông báo trước khi nó được ký và gửi đến máy khách hoặc được lưu trữ trên máy chủ.
Điều này hữu ích khi thêm thông tin bổ sung vào mã thông báo, thông tin này có thể liên quan đến logic ứng dụng của bạn.
session
gọi lại: lệnh gọi lại này được gọi mỗi khi dữ liệu phiên được đọc, chẳng hạn như trong quá trình hiển thị phía máy chủ hoặc trong các yêu cầu API được bảo vệ. Nó cho phép sửa đổi dữ liệu phiên trước khi gửi đến máy khách.
Điều này đặc biệt hữu ích khi thêm hoặc sửa đổi dữ liệu phiên dựa trên thông tin được lưu trữ trong JWT hoặc các tiêu chí khác.
Cuối cùng, chúng ta cần mở rộng định nghĩa loại Session
NextAuth và loại JWT
để bao gồm thông tin người dùng bổ sung.
src/lib/types.d.ts
:
import { User } from '@prisma/client' declare module 'next-auth' { interface Session { user: User } } declare module 'next-auth/jwt' { interface JWT { user: User } }
Bây giờ, nếu chúng ta điền vào biểu mẫu và gửi nó, chúng ta sẽ có thể nâng cốc chúc mừng thành công. Để xác minh rằng người dùng đã được lưu trong cơ sở dữ liệu, chúng ta có thể xem đồ họa các bảng được tạo bởi Prisma bằng lệnh sau:
nxp prisma studio
Chúng tôi sẽ có sẵn tuyến đường sau http://localhost:5555
src/components/auth/user-nav.tsx
:
/* all imports */ interface Props { user: User // Expect a user object of type User from Prisma client } export function UserNav({ user }: Props) { return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant='ghost' className='relative h-8 w-8 rounded-full' > <Avatar className='h-9 w-9'> <AvatarImage src='/img/avatars/01.png' alt='' /> <AvatarFallback>UU</AvatarFallback> </Avatar> </Button> </DropdownMenuTrigger> <DropdownMenuContent className='w-56' align='end' forceMount > <DropdownMenuLabel className='font-normal'> <div className='flex flex-col space-y-1'> <p className='text-sm font-medium leading-none'>{user.username}</p> <p className='text-xs leading-none text-muted-foreground'>{user.email}</p> </div> </DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuItem> <Link href={'/api/auth/signout'} // Link to the signout API route className='w-full' > Sign Out </Link> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ) }
src/components/auth/signin-form.tsx
:
/* all imports */ // Schema definition for form validation using Zod const formSchema = z.object({ email: z.string({ required_error: 'Please enter your email' }).email('Please enter a valid email address'), password: z.string({ required_error: 'Please enter your password', }), }) // Type inference for form inputs based on the Zod schema type InputType = z.infer<typeof formSchema> // Props definition, optionally including a callback URL interface Props { callbackUrl?: string } export function SignInForm({ callbackUrl }: Props) { const [isLoading, setIsLoading] = useState(false) const { toast } = useToast() const router = useRouter() // Hook to control routing const form = useForm<InputType>({ resolver: zodResolver(formSchema), // Set up Zod as the form validation resolver }) // Function to handle form submission async function onSubmit(values: InputType) { try { setIsLoading(true) // Attempt to sign in using the 'credentials' provider const response = await signIn('credentials', { redirect: false, // Prevent automatic redirection email: values.email, password: values.password, }) // Handle unsuccessful sign in attempts if (!response?.ok) { toast({ title: 'Something went wrong!', description: response?.error, variant: 'destructive', }) return } toast({ title: 'Welcome back! ', description: 'Redirecting you to your dashboard!', }) router.push(callbackUrl ? callbackUrl : '/') // Redirect to the callback URL or home page } catch (error) { toast({ title: 'Something went wrong!', description: "We couldn't create your account. Please try again later!", variant: 'destructive', }) } finally { setIsLoading(false) } } return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)}> <div className='grid gap-2'> <div className='grid gap-1'> <FormField control={form.control} name='email' render={({ field }) => ( <FormItem> <FormControl> <div className='flex items-center gap-2'> <Icons.email className={`${form.formState.errors.email ? 'text-destructive' : 'text-muted-foreground'} `}/> <Input type='email' placeholder='Your Email' className={`${form.formState.errors.email && 'border-destructive bg-destructive/30'}`} {...field} /> </div> </FormControl> <FormMessage /> </FormItem> )} /> {/* Password field */} {/* Similar structure to email field, customized for password input */} </div> <Button className='text-foreground mt-4' disabled={isLoading} // Disable button while loading > {isLoading && <Icons.spinner className='mr-2 h-4 w-4 animate-spin' />} // Show loading spinner when processing Sign In </Button> </div> </form> </Form> ) }
🎉 Thế là xong!
Chúng tôi đã hoàn tất việc triển khai xác thực cơ bản với NextAuth.js. Vẫn còn nhiều việc phải làm để có một hệ thống xác thực hoàn chỉnh và chúng tôi sẽ đề cập đến chúng trong các hướng dẫn tiếp theo.
Tóm lại, chúng tôi đã khám phá cách triển khai và tùy chỉnh hệ thống xác thực trong Next.js bằng NextAuth, cách mở rộng phiên và JWT để nâng cao khả năng quản lý người dùng cũng như cách xử lý các biểu mẫu với xác thực hiệu quả bằng cách sử dụng Reac-hook-form và Zod.
Bạn muốn kết nối với Tác giả?
Thích kết nối với bạn bè khắp nơi trên thế giới trên
Cũng được xuất bản ở đây