본문 바로가기

개발 공부/React

NextAuth.js 사용자 인증 로직 (2)

코드 출처 : https://github.com/Cluster-Taek/next14-boilerplate

 

GitHub - Cluster-Taek/next14-boilerplate

Contribute to Cluster-Taek/next14-boilerplate development by creating an account on GitHub.

github.com

 

login-form.tsx

'use client';

import Button from '@/components/common/button';
import { ILoginFormValue } from '@/lib/common/account';
import { sva } from '@/styled-system/css';
import { Box } from '@/styled-system/jsx';
import { signIn } from 'next-auth/react';
import { useSearchParams } from 'next/navigation';
import { Controller, useForm } from 'react-hook-form';

const LoginForm = () => {
  const loginFormStyle = LoginFormSva();
  const searchParams = useSearchParams();
  const {
    handleSubmit: formHandleSubmit,
    formState,
    control,
  } = useForm<ILoginFormValue>({
    defaultValues: {
      login: 'test@gmail.com',
      password: '1234',
    },
  });

  const handleSubmit = formHandleSubmit(async (data) => {
    await signIn('login-credentials', { ...data, callbackUrl: searchParams.get('callbackUrl') ?? '/' });
  });

  return (
    <Box className={loginFormStyle.wrapper}>
      <Box className={loginFormStyle.title}>Login</Box>
      <form className={loginFormStyle.form} onSubmit={handleSubmit}>
        <Controller
          control={control}
          name="login"
          rules={{ required: 'Email is required' }}
          render={({ field }) => <input type="text" {...field} className={loginFormStyle.input} placeholder="email" />}
        />
        {formState.errors.login && <Box className={loginFormStyle.error}>{formState.errors.login.message}</Box>}
        <Controller
          control={control}
          name="password"
          rules={{ required: 'Password is required' }}
          render={({ field }) => (
            <input type="password" {...field} className={loginFormStyle.input} placeholder="password" />
          )}
        />
        {formState.errors.password && <Box className={loginFormStyle.error}>{formState.errors.password.message}</Box>}
        <Button type="submit">Submit</Button>
      </form>
      {searchParams.get('error') === 'CredentialsSignin' && (
        <Box>
          <Box className={loginFormStyle.error}>Invalid email or password</Box>
        </Box>
      )}
    </Box>
  );
};

export default LoginForm;

const LoginFormSva = sva({
  slots: ['wrapper', 'title', 'form', 'input', 'error'],
  base: {
    wrapper: {
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      borderRadius: 'md',
      shadow: 'md',
      padding: '4',
      width: 'sm',
      margin: '0 auto',
    },
    title: {
      fontSize: '2xl',
      fontWeight: 'bold',
    },
    form: {
      width: 'full',
      marginTop: '4',
    },
    input: {
      width: 'full',
      marginY: '2',
      padding: '2',
      borderRadius: 'md',
      border: '1',
    },
    error: {
      color: 'red.500',
    },
  },
});
  • next-auth와 react-hook-form을 사용하여 폼 데이터를 처리하고, 인증을 처리하는 구조로 되어 있습니다.
 
 

전체 흐름

  1. 사용자가 폼에 이메일과 비밀번호를 입력하고 제출.
  2. signIn 함수가 NextAuth 서버로 요청을 보냄.
  3. CredentialsProvider의 authorize 함수에서 입력값 검증.
  4. 성공: JWT 생성 및 세션 설정 → 리다이렉트.
  5. 실패: 오류 메시지 표시.
 
 

1. LoginForm 컴포넌트

LoginForm은 사용자가 이메일과 비밀번호를 입력하고 로그인할 수 있는 폼을 렌더링합니다.

상태와 폼 설정

  • useForm<ILoginFormValue>: react-hook-form을 사용하여 폼 데이터를 관리합니다. ILoginFormValue 타입을 통해 login(이메일)과 password(비밀번호) 값이 포함됩니다.
    • defaultValues: 폼의 기본값으로 login과 password에 test@gmail.com과 1234를 미리 설정.
    • handleSubmit: 폼 제출 시 실행될 함수. 제출된 데이터를 signIn 함수에 전달하여 인증을 처리합니다.
  • formState: 폼의 상태를 추적하며, 오류 메시지 등을 표시하는 데 사용됩니다.

폼 제출

  • handleSubmit: 폼 데이터를 제출할 때 호출되는 함수입니다.
    • signIn('login-credentials', {...data}): next-auth의 signIn 함수를 호출하여 사용자 인증을 시도합니다. 인증이 성공하면, callbackUrl(리다이렉트 URL)로 이동합니다. 실패하면 error 파라미터가 URL에 포함되어 로그인 폼에 오류 메시지가 표시됩니다.

 

2. 폼 요소

Controller는 react-hook-form의 컴포넌트로, 폼 필드를 관리합니다. 각 필드는 다음과 같이 설정됩니다:

  • 이메일 필드 (login):
    • rules: 필수 입력 사항으로, 이메일이 없으면 오류 메시지를 표시합니다.
    • render: 실제로 input 필드를 렌더링합니다.
    • 오류가 발생하면 formState.errors.login.message를 통해 오류 메시지를 표시합니다.
  • 비밀번호 필드 (password):
    • rules: 비밀번호 필드 역시 필수 입력 사항입니다.
    • render: 비밀번호 입력 필드를 렌더링합니다.
    • 오류 메시지를 동일하게 처리합니다.

오류 메시지

  • 만약 이메일이나 비밀번호 입력에 오류가 있으면, formState.errors에 오류 메시지가 포함되어 해당 메시지가 폼 아래에 표시됩니다.
  • 로그인 시 CredentialsSignin 오류가 발생하면 "Invalid email or password"라는 오류 메시지가 나타납니다.

 

3. 로그인 실패 처리

  • searchParams.get('error') === 'CredentialsSignin': 로그인 실패 시 URL에 포함된 error=CredentialsSignin을 확인하여 사용자에게 "Invalid email or password" 메시지를 표시합니다.

 

handleSubmit

const handleSubmit = formHandleSubmit(async (data) => {
  await signIn('login-credentials', { 
    ...data, 
    callbackUrl: searchParams.get('callbackUrl') ?? '/' 
  });
});
  1. 사용자가 이메일과 비밀번호를 입력한 후 폼을 제출하면 handleSubmit 함수가 실행됩니다.
  2. formHandleSubmit: react-hook-form에서 폼 데이터를 검증한 후 호출되는 함수.
  3. data: 사용자가 입력한 이메일(login)과 비밀번호(password).
  4. signIn: next-auth의 내장 함수로, 인증 요청을 서버로 보냅니다.
    • 첫 번째 인자: 'login-credentials' → CredentialsProvider를 통해 정의된 인증 프로바이더 사용.
    • 두 번째 인자: 사용자 입력 데이터와 리다이렉트 URL 포함.

 

NextAuth로 인증 요청

signIn 함수가 호출되면, NextAuth는 auth-options.ts에서 정의된 인증 핸들러로 요청을 보냅니다.

이 요청은 CredentialsProvider에 의해 처리됩니다.

CredentialsProvider 설정

credentialsProviderOption에 정의된 authorize 함수가 호출되어 사용자의 입력값을 확인합니다.

async authorize(credentials: Record<string, unknown> | undefined) {
  try {
    if (credentials?.login === 'test@gmail.com' && credentials?.password === '1234') {
      const user = {
        name: 'John Doe',
        accessToken: 'jwt-token',
        refreshToken: 'refreshToken',
      };
      return {
        id: credentials?.login as string,
        name: user.name,
        accessToken: user.accessToken,
        refreshToken: user.refreshToken,
      };
    } else {
      return null; // 인증 실패 시 null 반환
    }
  } catch (error) {
    console.error(error);
    return null;
  }
}

 

  • 입력 검증: 이메일과 비밀번호를 확인.
    • 현재는 하드코딩된 값(test@gmail.com, 1234)과 비교.
    • 실제 구현에서는 API 요청으로 인증 처리(login 함수 주석 참고).
  • 성공: 사용자 정보를 반환(name, accessToken, refreshToken).
  • 실패: null을 반환하여 인증 실패 처리.

 

JWT 생성 및 관리

authorize 함수가 성공적으로 사용자 데이터를 반환하면, jwt 콜백이 실행됩니다.

JWT 처리 과정

async jwt({ token, user, account, trigger, session }) {
  if (account && user) {
    return {
      user,
      accessToken: user.accessToken,
      refreshToken: user.refreshToken,
      exp: jwtDecode(user.accessToken as string).exp as number,
    };
  }

  if (dayjs().isBefore(dayjs((token.exp as number) * 1000))) {
    return token;
  }

  const { accessToken, refreshToken } = await tokenRefresh(token.refreshToken as string);
  token.accessToken = accessToken;
  token.refreshToken = refreshToken;
  token.exp = jwtDecode(accessToken).exp as number;

  return token;
}

 

 

  • 최초 로그인:
    • account와 user 값이 존재 → 사용자의 accessToken, refreshToken 및 만료 시간(exp)을 토큰에 저장.
  • 토큰 유효성 검사:
    • 토큰 만료 전(exp 확인): 기존 토큰 그대로 사용.
    • 토큰 만료 후: tokenRefresh를 호출해 새로운 토큰을 발급받고 업데이트.

 

세션 설정

JWT가 생성된 후, session 콜백이 실행됩니다.

async session({ session, token }) {
  if (token.user) {
    session.user = token.user as User;
  }
  return session;
}

 

 

  • 세션 객체에 사용자 정보를 추가(session.user).
  • 이 세션은 클라이언트에서 useSession 훅 등을 통해 접근 가능.

 

로그인 완료

로그인이 성공하면 callbackUrl로 리다이렉트됩니다.

  • 기본값: / (홈페이지).
  • 지정된 URL이 있다면 해당 URL로 이동.

 

로그인 실패

로그인이 실패하면 searchParams.get('error')가 CredentialsSignin으로 설정되며, 사용자에게 오류 메시지가 표시됩니다.

{searchParams.get('error') === 'CredentialsSignin' && (
  <Box className={loginFormStyle.error}>Invalid email or password</Box>
)}