Auth.js로 NextJs 인증 구현하기

2024-10-27조회수 0
NextJs
Post thumbnail

1. Auth.js란?

Auth.js는 표준 웹 API를 기반으로 하는 런타임 독립적인 라이브러리입니다. 다양한 최신 Javascript 프레임워크와 깊이 있게 통합되어, 간단하게 시작할 수 있고 확장이 용이하며 항상 프리이버시와 보안이 보장되는 인증 경험을 제공합니다!

Note: 이 글은 next-auth@5.0.0-beta 이상 버전과 @auth/* 네임스페이스 아래의 모든 프레임워크를 다루고 있습니다.

  • Next.js: 14.2.15
  • Auth.js: 5.0.0-beta

위 버전으로 코드를 작성하였습니다.

2. 설치 및 환경설정

npm install next-auth@beta

npx auth secret

필수적으로 설정해야 하는 환경변수는 AUTH_SECRET뿐입니다. 이는 라이브러리가 토큰과 이메일 인증 해시를 암호화하는데 사용하는 임의의 값입니다.

Auth_SECRET은 Auth.js(NextAuth)에서 매우 중요한 보안 키로 사용됩니다.

  1. 세션암호화: JWT토큰을 암호화하고 서명하는데 사용
  2. 이메일 검증: 이메일 확인 링크의 토큰을 생성 할 때 사용
  3. CSRF 보호: CSRF 토큰을 생성하는데 사용

3. Credentials(자격 증명)

Auth.js에서는 자체 인증 방식이나 외부 인증을 적용할 때 Credentials Provider를 사용할 수 있습니다. 이 제공자는 사용자 이름(이메일)과 비밀번호와 같은 자격 정보를 로그인 폼으로 입력하게 해, 이를 설정된 인증 서비스로 전달하는 역할을 합니다.

Credentials Providers는 authorize 콜백을 통해 자격 정보(이메일, 패스워드)를 서버로 전달하여 인증을 처리합니다. 이 콜백 함수에서는 입력된 자격 정보가 올바른지 검증하고, 유효한 경우에는 사용자 정보를 반환합니다.

이 방식은 Google, Github과 같은 외부 제공자를 사용하는 것과 달리, 애플리케이션 자체에서 사용자 자격 정보를 직접 다루고자 할 때 유용합니다.

Note: 이번 포스팅에서는 Google, Github등 소셜로그인은 다루지 않습니다.

4. 회원가입 및 로그인 처리

4.1 회원가입

Next-Auth는 기본적으로 로그인(인증) 기능만 제공하고, 회원가입은 따로 자원하지 않습니다. 그래서 회원가입 같은 경우는 별도의 회원가입 API 엔드포인트를 사용하던지 , signIn 함수의 인자값으로 로그인, 회원가입을 구분한 후 처리해도 됩니다.

이 포스팅에서는 signIn 함수의 인자값으로 구분하여 회원가입을 하도록 하겠습니다.


// config/auth-config.ts

export const authConfig: NextAuthConfig = {
  providers: [
    Credentials({
      async authorize(
        credentials: Partial<Record<string, unknown>>,
      ): Promise<User | null> {
        const creds = credentials as UserCredentials;

        try {
          if (creds?.isSignup && creds.name) {
            const { name, email, password } = creds;

            const { response, data } = await registerUser({
              name,
              email,
              password,
            });

            throw new AuthError(
              response.status,
              "User registered successfully but requires login.",
            );
          }

          const { email, password } = creds;

          const data = await requestSignIn({ email, password });
          return {
            id: data?.user.id,
            email: data?.user.email,
            name: data?.user?.name ?? "user",
            accessToken: data?.tokens.accessToken,
          };
        } catch (err) {
          if (err instanceof AuthError) {
            throw new AuthError(
              err.statusCode || 400,
              err.message || "요청에 문제가 생겼습니다.",
            );
          }
          throw new AuthError(400, "요청에 문제가 생겼습니다.");
        }
      },
    }),
  ],
}
import NextAuth from "next-auth";

import { authConfig } from "@/config/auth-config";

export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);

사용자 이메일과 비밀번호로 설정할려면 Credentials Provider를 사용해야 합니다. 이 프로바이더는 로그인 폼에 입력된 사용자 이름과 비밀번호 등의 인증 정보를 authorize 콜백을 통해 인증 서비스로 전달하도록 설계되었습니다.

먼저 설정 파일에서 Credentials Provider를 초기화해야 합니다. 이 작업을 위해 프로바이더를 import 한 후 provider 배열에 추가해야 합니다.(구글,깃헙등 소셜로그인을 추가할려면 이 Provider 배열에 추가하면 됩니다.)

저 같은 경우는 회원가입이 정상적으로 성공 하였으면 throw new AuthError() Error 객체를 확장한 AuthError 객체를 사용해서 메세지를 통해서 회원가입 성공여부를 체크했습니다. (깔끔한 방법은 아닌거 같은데 억지로 하다보니 이렇게 작성하였고, 만약 회원가입 후 바로 로그인을 처리할려면 바로 로그인 API를 호출해서 진행하면 됩니다.)

authorize함수에 첫번째 매개변수 credentials에 들어오는 값은 로그인 시 사용되는 signIn 함수의 두번째 매개변수의 값이 여기로 전달됩니다.


// SignIn Page
"use client";
import { useFormState } from "react-dom";
import { handleSignupAction } from "@/app/actions/signup-action";

interface Props {
  errors: any;
  message: string;
  success?: boolean;
}
const initialState: Props = {
  errors: {},
  message: "",
  success: false,
};

export default function Page() {
  const [state, formAction, isPending] = useFormState(
    handleSignupAction,
    initialState,
  );
  const router = useRouter();

  useEffect(() => {
    if (state && state.success) {
      alert("회원가입에 성공했습니다. 로그인 페이지로 이동합니다.");
      router.push("/signin");
    }
  }, [state]);

  return (
    <div className="min-h-screen flex items-center justify-center bg-gradient-to-r from-blue-100 to-purple-100">
      <div className="bg-white p-8 rounded-xl shadow-2xl w-full max-w-md">
        <div className="text-center mb-8">
          <h1 className="text-3xl font-bold text-gray-800 mb-2">회원가입</h1>
          <p className="text-gray-600">새로운 계정을 만들어보세요</p>
        </div>

        <form action={formAction} className="space-y-6">
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">
              이름
            </label>
            <input
              name="name"
              type="text"
              className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition"
              placeholder="홍길동"
              required
            />
            {state.errors?.name && (
              <p className="mt-1 text-sm text-red-500">{state.errors.name}</p>
            )}
          </div>

          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">
              이메일
            </label>
            <input
              name="email"
              type="email"
              className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition"
              placeholder="example@email.com"
              required
            />
            {state.errors?.email && (
              <p className="mt-1 text-sm text-red-500">{state.errors.email}</p>
            )}
          </div>

          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">
              비밀번호
            </label>
            <input
              name="password"
              type="password"
              className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition"
              placeholder="••••••••"
              required
            />
            {state.errors?.password && (
              <p className="mt-1 text-sm text-red-500">
                {state.errors.password}
              </p>
            )}
          </div>

          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">
              비밀번호 확인
            </label>
            <input
              name="confirmPassword"
              type="password"
              className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition"
              placeholder="••••••••"
              required
            />
            {state.errors?.confirmPassword && (
              <p className="mt-1 text-sm text-red-500">
                {state.errors.confirmPassword}
              </p>
            )}
          </div>

          <button
            type="submit"
            disabled={isPending}
            className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 focus:ring-4 focus:ring-blue-200 transition duration-200 flex items-center justify-center"
          >
            {isPending ? (
              <>
                <svg
                  className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
                  xmlns="http://www.w3.org/2000/svg"
                  fill="none"
                  viewBox="0 0 24 24"
                >
                  <circle
                    className="opacity-25"
                    cx="12"
                    cy="12"
                    r="10"
                    stroke="currentColor"
                    strokeWidth="4"
                  ></circle>
                  <path
                    className="opacity-75"
                    fill="currentColor"
                    d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
                  ></path>
                </svg>
                처리중...
              </>
            ) : (
              "회원가입"
            )}
          </button>

          {state.message && !state.success && (
            <p className="text-center text-sm text-red-500">{state.message}</p>
          )}

          {state.message && state.success && (
            <p className="text-center text-sm text-green-600">
              {state.message}
            </p>
          )}
        </form>

        <p className="mt-8 text-center text-sm text-gray-600">
          이미 계정이 있으신가요?{" "}
          <a
            href="/login"
            className="text-blue-600 hover:text-blue-700 font-medium"
          >
            로그인하기
          </a>
        </p>
      </div>
    </div>
  );
}

서버컴포넌트로 로그인 폼을 생성 한 후 서버액션으로 바로 로그인 함수를 생성해도 되지만 useFromState 훅을 이용해서 pending 상태를 처리 및 에러처리를 하였습니다.

이후 state의 success:true값이라면 signIn페이지로 이동합니다.

"use server"
import { signIn } from "@/auth";

export async function registerUser({ name, email, password, }: SignupCredentials) {
  try {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/signup`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password, name }),
    });

    const data = await response.json();
    if (!response.ok) {
      throw new AuthError(response.status, data.message, data);
    }
    return { data, response };
  } catch (err) {
    throw new Error("알 수 없는 에러가 발생했습니다.");
  }
}


export async function handleSignupAction(prevState: any, formData: FormData) {
  try {
    const rowFormData = {
      username: formData.get("name")?.toString(),
      email: formData.get("email")?.toString(),
      password: formData.get("password")?.toString(),
      confirmPassword: formData.get("confirmPassword")?.toString(),
    };

    const validatedFields = signupSchema.safeParse(rowFormData);

    if (!validatedFields.success) {
      return {
        errors: validatedFields.error.flatten().fieldErrors as any,
        message: "입력 정보를 확인해주세요.",
      };
    }

    await signIn("credentials", {
      name: rowFormData.username,
      email: rowFormData.email,
      password: rowFormData.password,
      isSignup: true,
      redirect: false,
    });

    return {
      success: true,
      message: "회원가입이 완료되었습니다.",
    };
  } catch (err: any) {
    if (
      err?.cause?.err?.message ===
      "User registered successfully but requires login." &&
      err?.cause?.err.statusCode === 201
    ) {
      return {
        success: true,
        message: "회원가입이 완료되었습니다.",
      };
    }

    return {
      errors: true,
      message: err?.cause?.err?.message || "요청에 실패했습니다.",
    };
  }
}

signIn 함수에 특정 provider를 전달하면(여기선 credentials 구글로그인이면 google 등), 해당 provider를 통해 바로 로그인 시도가 가능합니다. 커스텀 로그인 페이지를 설정하지 않은 경우에는 기본 로그인 페이지인 /[basePath]/signin으로 이동합니다.

이후 섹션에서 config 값에 추가하는 방법을 알려드리겠습니다.

만약 로그인 후 /dashboard와 같은 특정 페이지로 리디렉션하려면, signIn옵션에 redirectTo를 설정하여 원하는 URL로 사용자를 보내면 됩니다. 하지만 여기서는 useFormState를 이용해서 sccuess: true인 경우 로그인 페이지로 이동하였습니다.

그리고 여기서는 auth-config.ts 에서 authorzie 함수에서 회원가입을 성공하면 Error throw 했기 때문에 위처럼 catch문에서 message, status 값으로 확인 후 처리를 했습니다.

4.2 로그인 하기

import { NextAuthConfig, User } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import {  UserCredentials } from "@/types/auth";
import { registerUser } from "@/app/actions/signup-action";
import { AuthError } from "@/app/lib/auth-error";
import { requestSignIn } from "@/app/actions/signin-action";
import { AUTH_CONSTANTS } from "@/contants/auth";
import { handleTokenRefreshAction } from "@/app/actions/auth-action";

export const authConfig: NextAuthConfig = {
  providers: [
    Credentials({
      async authorize(
        credentials: Partial<Record<string, unknown>>,
      ): Promise<User | null> {
        const creds = credentials as UserCredentials;

        try {
          if (creds?.isSignup && creds.name) {
            const { name, email, password } = creds;

            const { response, data } = await registerUser({
              name,
              email,
              password,
            });

            throw new AuthError(
              response.status,
              "User registered successfully but requires login.",
            );
          }

          const { email, password } = creds;

          const data = await requestSignIn({ email, password });
          return {
            id: data?.user.id,
            email: data?.user.email,
            name: data?.user?.name ?? "user",
            accessToken: data?.tokens.accessToken,
          };
        } catch (err) {
          if (err instanceof AuthError) {
            throw new AuthError(
              err.statusCode || 400,
              err.message || "요청에 문제가 생겼습니다.",
            );
          }
          throw new AuthError(400, "요청에 문제가 생겼습니다.");
        }
      },
    }),
  ],
  pages: { // 로그인 페이지 커스텀 경로 처리
    signIn: "/signIn",
  },
  session: {
    strategy: 'jwt', 
    maxAge: AUTH_CONSTANTS.NEXT_SERVER_JWT_EXPIRY,
  },
  callbacks: {
    async authorized({ auth }) {
      return !!auth;
    },

    async jwt({ token, user }) {
      if (user) {
        return {
          ...token,
          accessToken: user.accessToken,
          accessTokenExpires:
            Date.now() + AUTH_CONSTANTS.ACCESS_TOKEN_EXPIRE_TIME,
        };
      }

      if (
        token.accessTokenExpires &&
        Date.now() <
          token.accessTokenExpires - AUTH_CONSTANTS.REFRESH_TIME_BEFORE_EXPIRY
      ) {
        return token;
      }

      try {
        const result = await handleTokenRefreshAction();

        return {
          ...token,
          accessToken: result.accessToken,
          accessTokenExpires:
            Date.now() + AUTH_CONSTANTS.ACCESS_TOKEN_EXPIRE_TIME,
        };
      } catch (error) {
        return null;
      }
    },

    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.sub!;
      }
      
      session.accessToken = token.accessToken;
      session.accessTokenExpires = token.accessTokenExpires;
      return session;
    },
  },
};

Note: 포스팅에 나오는 signIn 함수는 auth.ts 에서 export 한 signIn 함수입니다.(next-auth/react 🙅‍♀️)

auth-config의 값을 우선 위에 코드처럼 완성하였고, 하나하나씩 분해해서 보겠습니다. signIn 함수를 호출을 하면 authorize 함수가 호출이 된 후 callbacks 안에 있는 jwt 함수 실행 => session 함수가 실행이 됩니다. authorize => jwt => session 이런 순서로 동작을 합니다.

  • callbacks.jwt: 토큰의 생성과 수정을 담당하고 반환한 토큰값을 가지고 세션을 생성 후 쿠키에 저장합니다. jwt함수의 매개변수 user객체는 authorize 함수(provider)에서 반환한 객체가 포함이 되어 있습니다. user값은 최초 로그인시에만 값이 있습니다. next-auth는 기본적으로 토큰을 암호화해서 next서버의 토큰을 쿠키에 저장합니다. (jwt 만료기한은 기본값은 30일입니다. session 객체의 maxAge값으로 바꿀수 있습니다. )
  • callbacks.session: 세션을 확인할때 마다 호출됩니다. auth를 호출해서 session값을 확인 할 수 있고 추가할 값을 추가하면 다른컴포넌트에서 사용할 수 있습니다.
  • callback.authorized : authorized() 함수는 미들웨어를 통해 사용자가 인증이 필요한 경우 호출됩니다. authorized 함수는 인증이 필요한 요청에서 실행되며, 특정 조건에 따라 NextResponse를 반환하여 기본 동작을 변경할 수 있습니다. params.auth: 인증된 사용자 또는 토큰 정보 (없을 경우 null). true를 반환시 요청이 승인이되고 false를 반환하면 접근이 차단 됩니다. 자세한내용은 미들웨어에서 살펴보겠습니다.

jwt 함수에서는 외부 백엔드 API access_token 만료기한을 설정을 하고 만약 만료기한이 30초이내로 남아 있다면 refresh 토큰으로 다시 accessToken을 발급받는 방식으로 했습니다. 이후 리프레쉬 토큰도 만료가 된다면 null을 리턴해서 로그인 페이지로 이동하게했습니다.(로그인 페이지로 이동은 미들웨어에서 처리)

//LoginForm
export default function LoginForm() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [state, formAction, isPending] = useFormState(
    handleSignInAction,
    initialState,
  );

  const [error, setError] = useState("");
  const callbackUrl = useMemo(() => {
    return searchParams.get("callbackUrl") || "/dashboard";
  }, [searchParams]);

  useEffect(() => {
    if (state && state.success) {
      const redirectionUrl = callbackUrl ?? "/dashboard";
      router.replace(redirectionUrl);
    }
  }, [state]);
  return ( 
    <form action={formAction}>...</form>
  )

}


// /app/actions/sign-in-action.ts
"use server"

export async function requestSignIn({ email, password, }: SignInCredentials): Promise<SignInResponse> {
  try {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });
    if (!response.ok) {
      throw new AuthError(response.status, response.statusText, null);
    }
    const setCookieHeader = response.headers.get("set-cookie");
    if (setCookieHeader) {
      const cookieOptions = parseCookieHeader(setCookieHeader);

      const refreshToken = cookieOptions["refresh_token"];
      if (typeof refreshToken === "string") {
        setRefreshTokenCookie(refreshToken, {
          httpOnly: cookieOptions["httponly"] === true,
          sameSite:
            (cookieOptions["samesite"] as "lax" | "strict" | "none") || "lax",
          path:
            typeof cookieOptions["path"] === "string"
              ? cookieOptions["path"]
              : undefined,
          maxAge:
            typeof cookieOptions["max-age"] === "string"
              ? parseInt(cookieOptions["max-age"])
              : undefined,
        });
      }
    }

    const data = await response.json();

    return data;
  } catch (err) {
    throw new Error("알 수 없는 에러가 발생했습니다.");
  }
}


export async function handleSignInAction(_: any, formData: FormData) {
  try {
    const rowFormData = {
      email: formData.get("email")?.toString(),
      password: formData.get("password")?.toString(),
    };

    const validationFields = signInSchema.safeParse(rowFormData);

    if (!validationFields.success) {
      return {
        errors: validationFields.error.flatten().fieldErrors as any,
        message: "입력 정보를 확인해주세요.",
      };
    }

    await signIn("credentials", {
      email: rowFormData.email,
      password: rowFormData.password,
      redirect: false,
    });

    return {
      success: true,
      message: "로그인이 완료되었습니다.",
    };
  } catch (err: any) {
    return {
      errors: true,
      message: err?.cause?.err?.message || "요청에 실패했습니다.",
    };
  }
}

5. 미들웨어로 인증 확인

미들웨어를 통해 페이지 접근을 제어하고 인증을 관리할 수 있고, 미들웨어는 요청이 완료되기 전에 실행되는 코드입니다. Next.js에서는 페이지가 렌더링되기 전에 미들웨어가 실행되며, 이를 통해:

  • 인증 확인
  • 리다이렉션
  • 요청/응답 헤더 수정
  • 라우트 보호
import { auth } from "@/auth";
export default auth((req) => {
  if (!req.auth) {
    const newUrl = new URL("/signIn", req.nextUrl.origin);
    return Response.redirect(newUrl);
  }
});

export const config = {
  matcher: [
    "/((?!api|_next/static|_next/image|favicon.ico|signIn|signup|posts).*)",
  ],
};
  1. matcher 설정
    • config 객체의 matcher는 미들웨어가 적용될 경로를 정의합니다. 이 코드에서는 api, _next/static, _next/image, favicon.ico, signIn, signup, posts 경로를 제외한 모든 경로에서 미들웨어가 실행됩니다.
    • signIn, signup, posts와 같은 페이지는 인증 없이 접근 가능하며, 나머지 경로는 인증이 필요한 보호된 경로로 설정됩니다.
  2. 작동방식
    • 요청이 들어오면 Next Auth는 authorized 콜백을 실행(auth-config.ts > callbacks > authorized)
    • 콜백의 결과가 req.auth에 반영
    • authorized 콜백이 false를 반환하면 req.auth는 null
    • true를 반환하면 req.auth에 세션정보가 포함됨.
    • null이면 로그인 페이지로 이동

6. 컴포넌트에서 Session 사용해보기

6.1 서버 컴포넌트

import { auth } from "@/auth";

export default async function Page() {
  const data = await auth();
  return <div>Dashboard Page</div>;
}

auth.ts에서 export 한 auth값을 불러와서 사용하면 됩니다.

6.2 클라이언트 컴포넌트

기존에는 next-auth/react 값을 사용하면 되지만, 사용하지 않으므로 커스텀훅으로 작성해서 사용해서 값을 불러올 수 있습니다.

// SessionContext.tsx
"use client";

import { useContext, useEffect, useState, createContext } from "react";
import type { Session } from "next-auth";
import { usePathname } from "next/navigation";
import { getSession } from "@/app/actions/signin-action";

const SessionContext = createContext<Session | null>(null);

export const SessionProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const pathname = usePathname();
  const [session, setSession] = useState<Session | null>(null);

  useEffect(() => {
    getSession().then((res) => {
      setSession(res);
    });
  }, [pathname]);
  return (
    <SessionContext.Provider value={session}>
      {children}
    </SessionContext.Provider>
  );
};

export const useSession = () => {
  return useContext(SessionContext);
};


//layout.tsx
<SessionProvider>
   <Header />
   {children}
</SessionProvider>

// Profile Page
"use client";
import { useSession } from "@/app/context/SessionContext";

export default function Page() {
  const session = useSession();
  return <div>Profile Page</div>;
}

참고

Auth.js V5 공식문서

Auth.js(NextAuth.js) 핵심 정리