Design User Authentication Flow on Next.js 14 App Router with Firebase Auth

Oct 1, 2024

Overview

Next.js 14 App RouterFirebase를 활용하여 Web App Platform을 구축하면서, Admin 서비스를 구현하는데 관리자만이 접근할 수 있도록 인증 기능을 구현하기로 하였습니다.

Next.js, Firebase 환경에서 사용자에게 노출되는 User Route와 관리자만이 접근 가능한 Admin Route를 나누어 구축하는 과정에서, <RootLayout/>을 분리하여 구현하였습니다.

Admin 서비스는 굳이 SSR로 구축하지 않아도 되었지만, 레포지토리를 분리하여 새로운 React SPA 프로젝트를 제작하기에 시간 상의 제약이 컸고, 같은 도메인 내에서 "path""/admin"을 붙이는 것만으로도 어드민 페이지에 접속 가능하도록 해달라는 클라이언트의 요구에 따라, 어플리케이션을 분리하지 않고 같은 Next.js 어플리케이션에 <RootLayout/>을 분리하여 구현하였습니다.

일반 사용자 - "(user)/layout.jsx"
어드민 - "(admin)/admin/layout.jsx"

일반적으로 Next.js를 활용한 서비스 구성 시 로그인과 같은 Auth 요청은 아래와 같은 레이어로 요청과 응답을 주고 받습니다.

Next.js Client ↔️ Next.js Server ↔️ Backend Framework(e.g. Spring) ↔️ DB

Problem

Backend Framework를 설계하여 구축하고 있지 않았으며, BaaS(Backend-as-a-Service)를 사용하고 있는 환경임에 따라, 위에서 이야기한 많이 사용되는 레이어로 구축할 수 없어, 보안을 위해 Next.js, Firebase 환경에서 레이어를 이중화 할 수는 없을까 고민하게 되었습니다.

아래와 같은 옵션들이 있었습니다.

  1. Next Auth 활용
  2. Express를 Next.js Server에 적용 후, passport 모듈 활용

위와 같은 라이브러리를 활용할 경우, 로그인 인증을 구현할 때 훨씬 수월해지는 장점이 있지만, 앱의 용량이 상대적으로 커지기 때문에, build할 때마다 오랜 시간이 소요될 뿐만 아니라, 앱의 초기 동작에도 영향을 줄 수 있다고 판단하였습니다.

Firebase의 인증을 도와주는 Authentication Solution이 있기에, Next.js의 Route API와 Firebase의 Authentication, Next.js에 추상화된 Cookie를 활용하여 로그인, 로그아웃, 비밀번호 재설정 등과 같은 기능을 구현해 보고자 하였습니다.

How to design Authentication Flow in this App?

Admin Route에서 일반적인 Authentication 과정을 다음과 같이 도식화 하였습니다.

authenticate.png

실질적으로 프로젝트에 적용한 로그인 과정은 다음과 같습니다.

authenticate.png
  1. Next.js의 <Form/> 컴포넌트에서 로그인 요청(이메일, 비밀번호 활용)을 보낸다.
  2. 해당 요청은 이메일과 비밀번호를 활용하여, Firebase의 Auth 로그인(signInWithEmailAndPassword) 과정을 거친다.
  3. Firebase Auth 로그인 성공
    1. Next.js의 Route API에 활용하여, 구현한 Custom Request Handler의 Request Header에 응답 받는 데이터 중 **accessToken(idToken)**을 cookie에 담아 보낸다. (단, "fetch"의 전달 인자에 "credentials: true" 속성을 추가하였으므로 현재 도메인에 설정된 cookie를 자동으로 Request Header에 담아 보낸다.)
    2. "(admin)/admin/login/api" 경로의 "route.js" 내에서 Header에 담겨온 cookie를 활용하여, "Set-Cookie"(httponly, secure 등 속성 부여)에 담아 다시 응답(response)을 보낸다.
    3. 응답(response)이 성공일 경우, 응답 데이터 중 "email" 을 활용하여 Firebase의 Firestore에서 이메일과 일치하는 관리자 데이터를 가져온다.
    4. Firestore에서 가져온 관리자 데이터(name)를 전역 상태(+Local Storage)와 accessToken, refreshToken을 메모리에 저장한다.
    5. 성공하였으므로, Home Route로 Redirect한다.
    6. Home Route, 즉 "(admin)/admin" 경로에 접근 시 해당하는 "layout.jsx" 를 보여주기 전 middleware에서 cookie에 세션 쿠키(accessToken이 담긴)가 있는지 확인한다.
    7. 있다면 Home Route로, 없다면 비정상적인 로그인이므로 Login Route로 다시 보낸다.
  4. Firebase Auth 로그인 실패
    1. 로그인이 실패하였다는 Toast UI를 띄운다.
    2. email과 password 필드를 초기화한다.

"credentials: 'include'"를 추가하면, 브라우저가 쿠키를 포함하여 요청을 보낼 때, 현재 도메인에 설정된 쿠키가 자동으로 request header에 포함됩니다.

일반적으로 "credentials: 'include'"세션 쿠키를 포함한 인증을 처리하는 데 사용되며, 이 쿠키는 서버 측에서 사용자의 인증 상태를 추적하는 데 필요합니다.

만약 accessTokencookie에 저장되어 있다면, 이 cookie는 자동으로 요청 헤더(request header)에 포함될 수 있습니다. 하지만, accessTokencookie에 저장되지 않고 Authorization Header에 담아서 보내야 한다면, 명시적으로 헤더에 accessToken을 추가해야 합니다.

수동으로 추가해야 한다면, Authorization Header에 명시적으로 설정해줘야 합니다. 단, firebase의 "signInWithEamilAndPassword" API를 활용해 획득할 수 있는 token은 "idToken""refreshToken" 등이 있으며, accessToken과 같은 역할을 할 수 있는 token은 "idToken" 입니다. 이를 세션 쿠키로서 request header에 담아 보냅니다.

// src/app/(admin)/_components/auth/LoginForm.jsx
export default function LoginForm() {
  const router = useRouter();

  const { userData, setUserData } = useUserStore();
  const notificationActions = useNotificationActions();

  const [isLoginLoading, setIsLoginLoading] = useState();

  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
    setFocus,
  } = useForm({
    mode: "onChange",
    resolver: zodResolver(loginSchema),
    defaultValues: { email: "", password: "" },
    shouldFocusError: true,
  });

  const onSubmit = async (data) => {
    let shouldRedirect = false;
    setIsLoginLoading(true);

    try {
      const userData = await login(data);

      if (userData) {
        shouldRedirect = true;

        setUserData(userData);

        notificationActions?.dispatchNotification({
          status: "success",
          message: "성공적으로 로그인하였습니다",
        });
      }
    } catch (error) {
      notificationActions?.dispatchNotification({
        status: "error",
        message: "이메일과 비밀번호를 다시 확인해 주세요",
      });

      setFocus("email");
      reset();
      console.error(error);
    } finally {
      setIsLoginLoading(false);
    }

    if (shouldRedirect) {
      router.replace(route.DASHBOARD);
      router.refresh();
    }
  };

  return (
    <div>
      <Form onSubmit={handleSubmit(onSubmit)} />
    </div>
  );
}

// src/lib/firebase/auth.js
const login = async ({ email, password }) => {
  try {
    // firebase로 1차 로그인
    const userCredential = await signInWithEmailAndPassword(
      auth,
      email,
      password
    );

    // Firebase로부터 ID 토큰을 받아옵니다 (like accessToken)
    const idToken = await userCredential.user.getIdToken();

    const response = await fetch("/admin/login/api", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${idToken}`, // 액세스 토큰을 헤더에 추가
      },
      body: JSON.stringify({
        email,
        password,
      }),
      credentials: "include",
    });

    if (response.error === 500) {
      throw new Error(response?.error?.message);
    }

    if (response.ok) {
      const data = await response.json();

      const userDocRef = doc(db, COLLECTION_NAME, email);
      const userSnapShot = await getDoc(userDocRef);
      const userData = userSnapShot.data();

      if (userData) {
        return { ...userData, email };
      } else {
        throw new Error(data.message);
      }
    }
  } catch (error) {
    console.error(error);
    throw new Error(error);
  }
};

// src/app/(admin)/admin/login/api/route.js
export async function POST(request) {
  try {
    const { email, password } = await request.json();
    const sessionId = cookies().get(SESSION) ?? uuidv4(); // cookies() 정보에 담긴 idToken(accessToken) 이 없을 경우 uuid를 활용하여 방어한다.

    // next server response
    const response = NextResponse.json({
      message: "Credentials are generated",
      email,
    });

    const session = await encrypt({ sessionId, userId: email.split("@")[0] });
    console.log("encrypt session", session);

    response.cookies.set(SESSION, session, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production", // Only set secure flag in production
      maxAge: 60 * 60 * 24 * 1, // Cookie expires in 1 day
      sameSite: "strict", // Adjust based on your needs (lax, strict, none)
      path: "/", // Cookie is valid for the whole site
    });
  } catch (error) {
    console.error(error);
    return NextResponse.json(
      { message: "A Server error occurred", error: error.message },
      { status: 500 }
    );
  }
}


  1. 인증을 해야 볼 수 있는 admin 페이지의 Route들에서는 "middleware" 를 활용해 접근을 검증한다.

// src/middleware.js

export function middleware(request) {
  const { pathname } = request.nextUrl;

  if (pathname.startsWith("/admin") && !pathname.startsWith("/admin/login")) {
    const credentials = request.cookies.get(SESSION);
    console.log("credentials", credentials);

    if (!credentials && !pathname.startsWith("/admin/resetpassword")) {
      console.log("no credentials");
      return NextResponse.redirect(new URL("/admin/login", request.url));
    }

    return NextResponse.next();
  }
}

Conclusion

사실, NextAuth 혹은 Express.js의 Passport 모듈을 사용한 것보다는 완벽한 솔루션을 아닐 수 있습니다.

Express 서버를 Next.js 프레임워크 내에 구축하여, passport 모듈을 활용해볼까도 고민하였었지만, Firebase의 Auth의 보안적인 강력함을 활용해보기로 하였습니다.

따라서, Firebase의 Auth에서 반환하는 token을 session cookie와 적절히 잘 활용하여 충분히 보안이 유지되는 어플리케이션을 구축하는 경험을 할 수 있었습니다.

코드에는 은탄환이 없고, 완벽한 어플리케이션도 없기 때문에, 유지보수를 하는 과정에서 취약점이 발견된다면, 언제든지 취약점을 해결하기 위한 코드로 언제든지 리팩토링 해보려 합니다.

이 프로젝트를 통해 프론트엔드 엔지니어로서 인증 처리에 한츰 익숙해질 수 있었습니다.