사용자의 보안과 개인 정보 보호를 보장하는 것이 그 어느 때보다 중요합니다. 웹 인증은 이러한 측면에서 중요한 역할을 하며 사용자의 정보와 데이터를 보호하기 위한 첫 번째 방어선 역할을 합니다.
오늘날 우리는 작업을 훨씬 쉽게 만들어주는 NextAuth.js와 같은 도구를 사용하여 Next.js 애플리케이션에서 다양한 유형의 인증을 쉽게 구현할 수 있습니다.
이 튜토리얼 시리즈에서는 기본 사항인 이메일과 비밀번호를 사용한 인증부터 시작하여 Next.js 14에서 완전한 인증 시스템을 구축할 것입니다.
JavaScript 생태계 내에서, 특히 Next.js로 개발된 애플리케이션에서 인증 처리를 위한 가장 유명한 라이브러리 중 하나는 NextAuth.js입니다.
이 도구는 애플리케이션에 인증을 추가하기 위한 간단하고 구현하기 쉬운 솔루션을 제공합니다. 가장 좋은 점은 유연성입니다. 이를 통해 기존 이메일 및 비밀번호와 같은 자격 증명 기반 인증 외에도 Google, Facebook, Twitter와 같은 다양한 인증 공급자를 통합할 수 있습니다.
자격 증명 인증은 인증 프로세스 및 사용자 자격 증명 저장을 완전히 제어해야 하는 응용 프로그램이나 외부 인증 공급자에 의존하고 싶지 않은 경우에 특히 유용합니다.
src/
폴더를 사용하겠습니다.
npx create-next-app@latest
2. 프로젝트에 필요한 종속성을 설치합니다. 이번에는 pnpm
사용하겠습니다. 원하는 패키지 관리자를 사용할 수 있습니다.
pnpm install next-auth prisma react-hook-form zod, bcrypt
UI로는 Shadcn/ui를 사용하겠습니다.
pnpm dlx shadcn-ui@latest init
3. 프로젝트에 대해 다음 구조를 생성합니다.
... ├── 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 │ ... ...
우리는 Prisma를 사용하여 데이터베이스에 사용자를 저장하고 검색할 것입니다. Prisma를 사용하면 다양한 데이터베이스 유형을 통합할 수 있으므로 필요한 모든 데이터베이스를 사용할 수 있습니다. 여기서는 SQLite를 사용하겠습니다.
npx prisma init --datasource-provider sqlite
그러면 스키마가 포함된 프리즘 폴더가 생성됩니다.
모델을 생성하기 위해 @auth/prisma-adapter 에서 제공하는 모델을 사용하고 다음과 같이 약간 사용자 정의합니다.
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. 첫 번째 만들기
npx prisma migrate dev --name first-migration
이 명령을 사용하면 Prisma 폴더에 더 많은 파일이 생성되고 데이터베이스가 모델과 동기화되었습니다.
4.
마지막으로 Prisma 클라이언트를 생성합니다.
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/
이 경로를 사용하면 단일 엔드포인트에서 모든 인증 요청(예: 로그인, 로그아웃, 공급업체 콜백)을 처리할 수 있습니다.
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. 인증 공급자 생성.
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> }
이 구성 요소는 인증을 위해 NextAuth를 사용하는 Next.js 애플리케이션의 세션 공급자 역할을 합니다.
이 공급자의 구성 요소나 페이지를 래핑하면 세션 컨텍스트에 대한 액세스 권한이 부여되어 하위 구성 요소가 useSession
과 같은 NextAuth 후크 및 기능을 사용하여 사용자의 현재 세션 상태에 액세스하거나 수정할 수 있습니다.
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
원하는 테마를 사용하시면 됩니다.
2. 구현
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. 다음 shadcn/ui 구성 요소를 설치합니다.
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> ) }
이 구성 요소는 사용자의 세션 상태에 따라 인증 옵션을 동적으로 표시합니다. 사용자가 로그인한 경우 사용자별 탐색이 표시됩니다. 그렇지 않으면 원활한 사용자 경험을 위해 Next.js의 라우팅 및 NextAuth의 인증 기능을 활용하여 로그인 또는 가입 버튼을 제공합니다.
홈페이지를 수정하고 인증 버튼을 추가하세요. 이것이 어떻게 보이는지입니다:
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> ) }
이 구성 요소는 양식 상태 관리를 위해 React-hook-form을 활용하고 스키마 검증을 위해 Zod를 활용하여 사용자 등록 양식을 캡슐화합니다.
이는 이러한 기술에 대한 튜토리얼은 아니지만 기본 사항만 사용하고 있습니다. 더 많은 질문이 있는 경우 해당 설명서를 참조하세요.
페이지에 더 많은 스타일을 추가했는데 다음과 같습니다.
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.' } } }
registerUser
기능은 id
, phone
, emailVerified
및 image
와 같은 필드를 제외하고 제공된 사용자 정보로 데이터베이스에 레코드를 생성하여 새로운 사용자를 안전하게 등록하도록 설계되었습니다.
안전한 저장을 위해 bcrypt를 사용하여 사용자 비밀번호를 해시합니다.
가입을 테스트하고 사용자가 올바르게 등록하고 있는지 확인하려면 몇 가지 콜백을 추가해야 합니다. 이는 인증 및 세션 관리 동작을 사용자 정의할 수 있는 기능입니다.
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 }, }, } ...
콜백 jwt
: 이 콜백은 인증 수명 주기 동안 JWT(JSON 웹 토큰)가 생성되거나 업데이트될 때마다 실행됩니다. 이를 통해 토큰이 서명되어 클라이언트에 전송되거나 서버에 저장되기 전에 토큰의 내용을 수정할 수 있습니다.
이는 애플리케이션 로직과 관련될 수 있는 추가 정보를 토큰에 추가하는 데 유용합니다.
콜백 session
: 이 콜백은 서버 측 렌더링이나 보호된 API 요청 등 세션 데이터를 읽을 때마다 호출됩니다. 세션 데이터를 클라이언트에 전송하기 전에 수정할 수 있습니다.
이는 JWT 또는 기타 기준에 저장된 정보를 기반으로 세션 데이터를 추가하거나 수정하는 데 특히 유용합니다.
마지막으로 추가 사용자 정보를 포함하도록 NextAuth Session
및 JWT
유형 정의를 확장해야 합니다.
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 } }
이제 양식을 작성하여 제출하면 성공 건배를 볼 수 있게 됩니다. 사용자가 데이터베이스에 저장되었는지 확인하기 위해 다음 명령을 사용하여 Prisma에서 생성된 테이블을 그래픽으로 볼 수 있습니다.
nxp prisma studio
다음 경로를 사용할 수 있습니다 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> ) }
🎉 끝났어요!
NextAuth.js로 기본 인증 구현을 완료했습니다. 완전한 인증 시스템을 갖기 위해서는 아직 해야 할 일이 많이 있으며, 다음 튜토리얼에서 이에 대해 다룰 것입니다.
🔗
요약하면, NextAuth를 사용하여 Next.js에서 인증 시스템을 구현하고 사용자 정의하는 방법, 사용자 관리를 강화하기 위해 세션 및 JWT를 확장하는 방법, React-hook-form 및 Zod를 사용하여 효과적인 유효성 검사로 양식을 처리하는 방법을 살펴보았습니다.
저자와 연결하고 싶나요?
전 세계 친구들과 소통하는 것을 좋아합니다.
여기에도 게시됨