Next.js와 Firebase로 인증(Authentication) 구현해보기

Next.js와 Firebase를 사용하고 server side와 next.js의 server action을 사용하여 로그인, 로그아웃, 회원가입(인증)을 구현하는 방법을 시도해봤습니다.

  • next.js 15.1.4
  • firebase ^11.2.0

소개

이번에는 Next.js와 Firebase를 사용하여 인증(Authentication)을 구현하는 방법을 알아보겠습니다.

Firebase는 구글에서 제공하는 백엔드 서비스로, 실시간 데이터베이스, 인증, 스토리지, 호스팅 등 다양한 기능을 제공합니다.

Firebase는 React 처럼 클라이언트 사이드에서 사용할 수도 있지만, 이번에 server actionserver side에서 middleware를 사용하여 서버 사이드에서 사용할 것입니다.

Firebase 설정

먼저 Firebase를 사용하기 위해 Firebase 프로젝트를 생성해야 합니다.

Firebase 프로젝트를 생성하려면 `Firebase Console`로 이동하여 프로젝트를 생성합니다.

순서대로 프로젝트 이름을 입력하고 프로젝트를 생성합니다.

프로젝트가 생성되면 Dashboard(console)에서 중앙의 아이콘 </>을 클릭하여 웹 앱을 추가합니다.

메인 화면

메인 화면

웹 앱을 추가하면 Firebase SDK 설정이 나타납니다.

SDK 설정

SDK 설정

이 설정은 Firebase SDK를 사용하여 Firebase 프로젝트와 연결할 수 있도록 도와줍니다.

만약 해당 페이지가 닫혔다면, Firebase Console에서 사이드 메뉴의 프로젝트 개요 옆 톱니바퀴 아이콘을 클릭하여 프로젝트 설정으로 이동합니다.

일반 탭에서 웹 앱을 클릭하면 SDK 설정을 확인할 수 있습니다.

다음은 로그인 제공업체를 선택해야 합니다.

좌측 메뉴에서 build(빌드) 메뉴 -> Authentication을 클릭하고 이메일 및 비밀번호를 활성화 하고 저장합니다.

Next.js 프로젝트 생성

이제 Next.js 프로젝트를 생성합니다.

Next.js 프로젝트는 간단히 설명하겠습니다.

먼저 Next.js를 설치합니다.

npx create-next-app "프로젝트 이름"

프로젝트 이름을 입력하고 엔터를 눌러 사용할 프로젝트를 선택 후 설치를 진행합니다.

설치가 완료되면 프로젝트 폴더로 이동하여 Firebase SDK를 설치합니다.

npm install firebase

Firebase SDK를 설치하면 Firebase 프로젝트와 연결할 수 있습니다.

Firebase SDK 설정

Firebase SDK를 사용하여 Firebase 프로젝트와 연결합니다.

services폴더를 만들고 firebase.js 파일을 생성하여 아래와 같이 Firebase SDK를 설정합니다.

services/firebase.js
import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";
 
const firebaseConfig = {
  apiKey: process.env.FIREBASE_API_KEY, // 환경 변수로 설정
  authDomain: process.env.FIREBASE_AUTH_DOMAIN,
  projectId: process.env.FIREBASE_PROJECT_ID,
  storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.FIREBASE_APP_ID,
};
 
const app = initializeApp(firebaseConfig);
 
export const auth = getAuth(app);

위 코드에서 process.env.FIREBASE_API_KEY와 같이 환경 변수로 설정한 이유는 보안을 위해서입니다.

Firebase SDK를 사용할 때 API 키와 같은 중요한 정보는 환경 변수로 설정하여 사용하는 것이 좋습니다.

이제 환경 변수를 설정합니다.

.env 파일을 생성하여 아래와 같이 환경 변수를 설정합니다.

.env
FIREBASE_API_KEY=API_KEY
FIREBASE_AUTH_DOMAIN=AUTH_DOMAIN
FIREBASE_PROJECT_ID=PROJECT_ID
FIREBASE_STORAGE_BUCKET=STORAGE_BUCKET
FIREBASE_MESSAGING_SENDER_ID=MESSAGING_SENDER_ID
FIREBASE_APP_ID=APP_ID
환경 변수 설정시 NEXT_PUBLIC을 사용하지 않습니다.

Firebase SDK는 클라이언트 사이드에서 사용되기도 하기때문에 클라이언트에서 사용하려면 NEXT_PUBLIC_을 사용하여 환경 변수를 설정해야 합니다.

하지만 이번 예제에서는 서버 사이드에서만 사용하기 때문에 NEXT_PUBLIC_을 사용하지 않습니다.

Next.js의 환경변수는 NEXT_PUBLIC_을 사용하면 클라이언트 사이드에서 사용할 수 있지만, NEXT_PUBLIC_을 사용하지 않으면 서버 사이드에서만 사용할 수 있습니다.

클라이언트에서 사용되는 키는 브라우저에 노출됩니다.

이제 Firebase SDK를 사용하여 Firebase 프로젝트와 연결할 수 있습니다.

이제 Firebase SDK를 사용하여 로그인, 로그아웃, 회원가입을 구현해 보겠습니다.

회원가입 구현

먼저 회원가입을 구현해 보겠습니다.

components 폴더를 만들고 forms폴더 생성 후 SignUpForm.tsx 파일을 만듭니다.

간단하게 구현하기 위해 chat gpt를 사용해 SignUpForm 컴포넌트를 만들어 달라고했습니다.

gpt야 react에서 tailwind css를 사용한 SignUpForm 컴포넌트 ui를 만들어줘라고 말하면 SignUpForm 컴포넌트를 손쉽게 만들 수 있습니다.

components/forms/SignUpForm.tsx
"use client";
import Link from "next/link";
 
const SignUpForm = () => {
  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-100">
      <div className="w-full max-w-lg p-8 space-y-6 bg-white shadow-md rounded-lg">
        <h2 className="text-2xl font-bold text-center text-gray-800">
          회원가입
        </h2>
        <form className="space-y-4">
          {/* 이름 입력 */}
          <div>
            <label
              htmlFor="name"
              className="block text-sm font-medium text-gray-700"
            >
              이름
            </label>
            <input
              type="text"
              id="name"
              name="name"
              className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
              placeholder="이름을 입력하세요"
              autoComplete={"name"}
            />
          </div>
          {/* 이메일 입력 */}
          <div>
            <label
              htmlFor="email"
              className="block text-sm font-medium text-gray-700"
            >
              이메일
            </label>
            <input
              type="email"
              id="email"
              name="email"
              className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
              placeholder="이메일을 입력하세요"
              autoComplete={"email"}
            />
          </div>
          {/* 비밀번호 입력 */}
          <div>
            <label
              htmlFor="password"
              className="block text-sm font-medium text-gray-700"
            >
              비밀번호
            </label>
            <input
              type="password"
              id="password"
              name="password"
              className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
              placeholder="비밀번호를 입력하세요"
              autoComplete={"new-password"}
            />
          </div>
          {/* 회원가입 버튼 */}
          <div>
            <button
              type="submit"
              className="w-full px-4 py-2 text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
            >
              회원가입
            </button>
          </div>
        </form>
        {/* 로그인 링크 */}
        <div className="text-center">
          <p className="text-sm text-gray-600">
            이미 계정이 있으신가요?{" "}
            <Link
              href="/login"
              className="font-medium text-indigo-600 hover:text-indigo-500"
            >
              로그인
            </Link>
          </p>
        </div>
      </div>
    </div>
  );
};
 
export default SignUpForm;

이제 /app/signup/page.tsx 페이지를 만들고 SignUpForm 컴포넌트를 렌더링합니다.

app/signup/page.tsx
const SignUpPage = () => {
  return <SignUpForm />;
};
 
export default SignUpPage;
회원가입 페이지에 SignUpForm 컴포넌트를 따로 만든것은, server action의 form은 'use client' 사용해야 하기 때문입니다.

Page에 'use client'를 사용할 수 있지만, Next.js는 서버사이드를 최대한 사용하기 위해 Page에는 'use client'를 사용하지 않는것이 바람직합니다.

회원가입 페이지를 만들었습니다.

이제 Firebase SDK를 사용하여 회원가입을 구현해 보겠습니다.

우선 server action을 사용하기 위해서 actions 폴더를 만들고 signup.ts 파일을 만듭니다.

그리고 서버 컴포넌트를 사용하기 위해 server-only패키지를 설치합니다.

npm install server-only

이제 signup.ts 파일에 회원가입을 구현합니다.

actions/signup.ts
"use server";
import "server-only";
import { FormState, SignupFormSchema } from "@/schema/signup.schema";
import { createUserWithEmailAndPassword } from "firebase/auth";
import { auth } from "@/services/firebase";
import { redirect } from "next/navigation";
 
export const signup = async (prevState: FormState, formData: FormData) => {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
 
  // 회원가입 폼 유효성 검사
  // 저는 zod를 사용했지만 다른 라이브러리를 사용 및 직접 구현해도 됩니다.
  const validatedFields = SignupFormSchema.safeParse({
    name,
    email,
    password,
  });
 
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }
 
  try {
    // Firebase SDK를 사용하여 회원가입
    // createUserWithEmailAndPassword는 auth에서 제공하는 이메일과 비밀번호로 사용자를 생성하는 함수입니다.
    const response = await createUserWithEmailAndPassword(
      auth, // auth 인스턴스는 반드시 필요합니다.
      email,
      password
    );
  } catch (error) {
    console.error(error);
    return {
      errors: {
        email: ["이미 사용 중인 이메일입니다."],
      },
    };
  }
 
  redirect("/dashboard");
};

위 코드에서 createUserWithEmailAndPassword 함수는 Firebase SDK에서 제공하는 함수로, 이메일과 비밀번호로 사용자를 생성하는 함수입니다.

createUserWithEmailAndPassword에서 자체적으로 이메일 중복 검사, 비밀번호 hash 등을 처리하므로 따로 구현할 필요가 없습니다.

이제 SignUpForm 컴포넌트에서 signup 함수를 호출하여 회원가입을 구현합니다.

만들어진 SignUpForm 컴포넌트에 signup 함수를 호출하는 코드를 추가합니다.

server action을 사용하기 위해 useActionState 훅을 사용합니다.

react의 모든 훅은 next.js에서 반드시 use client를 사용해야 합니다.

components/forms/SignUpForm.tsx
"use client"; // 반드시 사용해야 합니다.
import Link from "next/link";
import { useActionState } from "react";
import { signup } from "@/actions/signup";
 
const SignUpForm = () => {
  const [state, action, pending] = useActionState(signup, undefined);
 
  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-100">
      <div className="w-full max-w-lg p-8 space-y-6 bg-white shadow-md rounded-lg">
        <h2 className="text-2xl font-bold text-center text-gray-800">
          회원가입
        </h2>
        {/* action 추가 */}
        <form className="space-y-4" action={action}>
          {/* 이름 입력 */}
          <div>
            <label
              htmlFor="name"
              className="block text-sm font-medium text-gray-700"
            >
              이름
            </label>
            <input
              type="text"
              id="name"
              name="name"
              className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
              placeholder="이름을 입력하세요"
              autoComplete={"name"}
            />
            {state?.errors?.name && (
              <div className="text-red-500 text-sm mt-2">
                {state.errors.name.map((item) => (
                  <div key={item}>- {item}</div>
                ))}
              </div>
            )}
          </div>
          {/* 이메일 입력 */}
          {/* 생략 */}
          {/* 회원가입 버튼 */}
          <div>
            <button
              type="submit"
              className="w-full px-4 py-2 text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
              disabled={pending} // pending 상태일 때 버튼 비활성화
            >
              회원가입
            </button>
          </div>
        </form>
      </div>
    </div>
  );
};
 
export default SignUpForm;

이제 회원가입 페이지를 열고 회원가입을 해보겠습니다.

npm run dev를 실행하여 개발 서버를 실행합니다.

http://localhost:3000/signup으로 이동하여 회원가입을 해보겠습니다.

회원가입이 성공하면 /dashboard로 이동합니다.

회원가입이 성공하면 Firebase Console에서 사용자를 확인할 수 있습니다.

이제 로그인을 구현해 보겠습니다.

로그인 구현

components 폴더를 만들고 LoginForm.tsx 파일을 만듭니다.

gpt야 react에서 tailwind css를 사용한 LoginForm 컴포넌트 ui를 만들어줘

코드는 대략 생략합니다.

components/LoginForm.tsx
const LoginForm = () => {
  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-100">
      <div className="w-full max-w-md p-8 space-y-6 bg-white shadow-md rounded-lg">
        <h2 className="text-2xl font-bold text-center text-gray-800">로그인</h2>
        <form className="space-y-4">
          {/* 이메일 입력 */}
          {/* 비밀번호 입력 */}
          {/* 로그인 버튼 */}
        </form>
      </div>
    </div>
  );
};

이제 /app/login/page.tsx 페이지를 만들고 LoginForm 컴포넌트를 렌더링합니다.

app/login/page.tsx
const LoginPage = () => {
  return <LoginForm />;
};
 
export default LoginPage;

이제 actions 폴더에 signIn.ts 파일을 만들고 로그인을 구현합니다.

구현 방법은 회원가입과 비슷합니다.

하지만 로그인은 signInWithEmailAndPassword 함수를 사용하여 로그인을 구현합니다.

유효성 검사는 클라이언트에서 처리해줍니다. 생략하겠습니다.

actions/signIn.ts
"use server";
import "server-only";
import { FormState } from "@/schema/signup.schema";
import { signInWithEmailAndPassword } from "firebase/auth";
import { auth } from "@/services/firebase";
import { redirect } from "next/navigation";
import { createSession } from "@/lib/session";
 
export const signIn = async (prevState: FormState, formData: FormData) => {
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
 
  // 유효성 검사 생략
 
  try {
    const response = await signInWithEmailAndPassword(auth, email, password);
  } catch (error) {
    console.error(error);
    return {
      errors: {
        email: ["이메일 또는 비밀번호가 일치하지 않습니다."],
      },
    };
  }
 
  redirect("/dashboard");
};

이제 LoginForm 컴포넌트에서 signIn 함수를 호출하여 로그인을 구현합니다.

components/LoginForm.tsx
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { signIn } from "@/actions/signIn";
 
const LoginForm = () => {
  const [state, action, pending] = useActionState(signIn, undefined);
 
  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-100">
      <div className="w-full max-w-md p-8 space-y-6 bg-white shadow-md rounded-lg">
        <h2 className="text-2xl font-bold text-center text-gray-800">로그인</h2>
        <form className="space-y-4" action={action}>
          {/* 이메일 입력 */}
          {/* 비밀번호 입력 */}
          {/* 로그인 버튼 */}
 
          {/* 에러 메시지 */}
          {state?.errors?.email && (
            <div className="text-red-500 text-sm">
              {state.errors.email.map((item) => (
                <div key={item}>- {item}</div>
              ))}
            </div>
          )}
          <div>
            <button
              type="submit"
              className="w-full px-4 py-2 text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
              disabled={pending}
            >
              로그인
            </button>
          </div>
        </form>
      </div>
    </div>
  );
};

이제 로그인 페이지를 열고 로그인을 해보겠습니다.

http://localhost:3000/login으로 이동하여 로그인을 해보겠습니다.

로그인이 성공하면 /dashboard로 이동합니다.

실패하면 state에 에러 메시지가 표시됩니다.

로그아웃을 구현하기 전에 세션을 관리하는 방법을 알아보겠습니다.

세션 관리

세션은 사용자가 웹 사이트에 접속한 상태를 유지하는 것을 말합니다.

세션을 추가하지 않으면 사용자가 로그인한 상태를 유지할 수 없습니다.

세션을 추가하려면 세션을 관리하는 방법을 알아야 합니다.

세션을 관리하는 방법은 다양합니다.

쿠키, 로컬 스토리지, 세션 스토리지 등 다양한 방법이 있습니다.

로컬 스토리지, 세션 스토리지는 클라이언트 사이드에서 사용되는 방법이라서 서버 사이드에서 사용하기 어렵습니다.

이번 블로그는 서버 사이드에서 사용하기 때문에 쿠키를 사용하여 세션을 관리하겠습니다.

lib 폴더를 만들고 session.ts 파일을 만듭니다.

lib/session.ts
"use server";
import "server-only";
import { cookies } from "next/headers";
 
export const createSession = async (token: string) => {
  const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); // 7일 후 만료
 
  const cookieStore = await cookies(); // next/headers에서 제공하는 함수
 
  cookieStore.set("session", token, {
    httpOnly: true, // 클라이언트에서 쿠키를 읽을 수 없음
    secure: true, // https에서만 사용
    expires: expiresAt, // 만료일
    sameSite: "lax", // 쿠키가 다른 도메인으로 전송되지 않음
    path: "/", // 모든 경로에서 사용
  });
};
 
// 세션 삭제
export const deleteSession = async () => {
  const cookieStore = await cookies();
 
  cookieStore.delete("session");
};

위 코드에서 createSession 함수는 세션을 생성하는 함수입니다.

firebase에서 로그인이 성공하면 token을 받아서 createSession 함수를 호출하여 세션을 생성합니다.

deleteSession 함수는 세션을 삭제하는 함수입니다. 로그아웃 시 호출하여 세션을 삭제합니다.

이제 앞서 만들었던 signIn 함수에서 createSession 함수를 호출하여 세션을 생성합니다.

actions/signIn.ts
// ... 생략
 
export const signIn = async (prevState: FormState, formData: FormData) => {
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
 
  try {
    const response = await signInWithEmailAndPassword(auth, email, password);
 
    // firebase에서 token을 가져옵니다.
    const token = await response.user.getIdToken();
 
    // 세션 생성
    await createSession(token);
  } catch (error) {
    console.error(error);
    return {
      errors: {
        email: ["이메일 또는 비밀번호가 일치하지 않습니다."],
      },
    };
  }
};

이제 로그인시 쿠키에 세션을 생성합니다. 만료되기 전까지 사용자가 로그인 상태를 유지할 수 있고, 세션이 만료되면 쿠키가 삭제되고 로그아웃됩니다.

이제 로그아웃을 구현해 보겠습니다.

로그아웃 구현

로그아웃은 세션을 삭제하는 것을 말합니다.

로그아웃을 구현하려면 세션을 삭제하는 deleteSession 함수를 호출하면 됩니다.

actions 폴더에 signOut.ts 파일을 만들고 로그아웃을 구현합니다.

actions/signOut.ts
"use server";
import { deleteSession } from "@/lib/session";
import { redirect } from "next/navigation";
 
export async function signOut() {
  await deleteSession();
  redirect("/login");
}

이제 SignOutButton.tsx 컴포넌트를 만들어 로그아웃 버튼을 만듭니다.

components/SignOutButton.tsx
"use client";
import { signOut } from "@/actions/signOut";
 
const SignOutButton = () => {
  const handleSignOut = async () => {
    try {
      await signOut();
    } catch (error) {
      console.error(error);
    }
  };
 
  return (
    <button type="button" onClick={handleSignOut}>
      로그아웃
    </button>
  );
};
 
export default SignOutButton;

이제 로그아웃 버튼을 만들었습니다.

로그아웃 버튼을 클릭하면 세션이 삭제되고 /login으로 이동합니다.

이제 로그인, 로그아웃, 회원가입을 구현했습니다.

로그인, 로그아웃, 회원가입을 구현하였지만, 사용자가 로그인 하지 않아도 /dashboard로 이동할 수 있습니다.

이를 해결하기 위해 미들웨어(middleware)를 만들어 사용자가 로그인하지 않으면 경로를 보호하고 사용자를 /login으로 이동하도록 만들어 보겠습니다.

미들웨어(middleware) 구현

middleware는 사용자가 로그인하지 않으면 경로를 보호하고 사용자를 로그인 페이지로 이동하는 middleware입니다.

미들웨어를 사용하면 요청이 완료되기 전에 코드를 실행할 수 있습니다. 그런 다음 들어오는 요청에 따라 요청 또는 응답 헤더를 다시 작성, 리디렉션, 수정하거나 직접 응답하여 응답을 수정할 수 있습니다.

next.js에서는 middleware.ts 파일을 만들어 middleware를 구현할 수 있습니다.

자세한 내용은 `Next.js middleware` 를 참고하세요.

root 폴더에 middleware.ts 파일을 만들고 middleware를 구현합니다.

만약 src 폴더를 사용중이라면 src 폴더에 middleware.ts 파일을 만들어야 합니다.
middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
 
// 1. 보호된 경로와 공개 경로 지정
const protectedRoutes = ["/dashboard"]; // 보호된 경로
const publicRoutes = ["/login", "/signup", "/"]; // 공개된 경로
 
export default async function middleware(req: NextRequest) {
  // 2. 현재 경로가 보호되어 있는지 공개되어 있는지 확인
  const path = req.nextUrl.pathname;
  const isProtectedRoute = protectedRoutes.includes(path);
  const isPublicRoute = publicRoutes.includes(path);
 
  // 3. 세션 쿠키 가져오기
  const session = (await cookies()).get("session")?.value;
 
  // 4. 사용자가 인증되지 않은 경우 /login으로 리디렉션
  if (isProtectedRoute && !session) {
    return NextResponse.redirect(new URL("/login", req.nextUrl));
  }
 
  // 5. 사용자가 인증되면 /dashboard로 리디렉션
  if (
    isPublicRoute &&
    session &&
    !req.nextUrl.pathname.startsWith("/dashboard")
  ) {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  return NextResponse.next();
}
 
// 미들웨어가 실행되어서는 안 되는 경로 지정
export const config = {
  matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
};

위 코드에서 protectedRoutes는 보호된 경로, publicRoutes는 공개된 경로입니다.

middleware 함수에서 현재 경로가 보호되어 있는지, 공개되어 있는지 확인합니다.

session을 가져와 사용자가 인증되지 않은 경우 /login으로 리디렉션합니다.

사용자가 인증되면 /dashboard로 리디렉션합니다.

미들웨어 설정은 완료되었으나, 한가지 더 해야할 것이 있습니다.

현재 까지만든 코드에서 tokensession에 저장만 하였으나, token을 검증하지 않았습니다.

만약, 사용자가 개발자 도구에서 쿠키에 session을 name으로 추가하고 아무 값을 넣으면 로그인 상태로 인식됩니다.

이를 방지하기 위해 firebase에서 제공하는 getIdToken 함수를 사용하여 토큰을 검증하고, firebase에서 제공하는 api를 사용하여 사용자 정보를 가져오는 방법을 알아보겠습니다.

middleware는 edge function에서만 사용할 수 있습니다. 고로 firebase-admin sdk에서 제공하는 verifyIdToken 함수를 사용할 수 없습니다. 이를 해결하기 위해서 firebase에서 제공하는 공개 키를 사용하여 token을 검증해야 합니다.

firebase 공개 키 가져오기

firebase에서 제공하는 api를 사용하려면 firebase에서 제공하는 공개 키를 가져와야 합니다.

services/firebaseGetKey.ts
"use server";
interface FirebasePublicKeys {
  [key: string]: string;
}
 
export async function getFirebasePublicKeys(): Promise<FirebasePublicKeys> {
  const response = await fetch(
    "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com"
  );
 
  if (!response.ok) {
    throw new Error("Firebase 공개 키를 가져오지 못했습니다.");
  }
 
  return response.json();
}

위 코드에서 getFirebasePublicKeys 함수는 firebase에서 제공하는 공개 키를 가져오는 함수입니다.

firebase 공개 키의 역할은 token을 검증하는데 사용됩니다.

`Firebase 공개 키 가져오기`

token 검증

firebase에서 제공하는 공개 키를 가져왔으니, 이제 token을 검증하는 방법을 알아보겠습니다.

firebase 공개 키를 사용하여 token을 검증하려면 jose 라이브러리를 사용해야 합니다.

npm install jose

jose 라이브러리는 json web token(JWT)을 검증하는데 사용되는 라이브러리입니다.

jose 라이브러리를 사용하여 token을 검증하는 방법은 다음과 같습니다.

lib 폴더에 verifyToken.ts 파일을 만들고 token을 검증하는 함수를 구현합니다.

lib/verifyToken.ts
"use server";
import * as jose from "jose";
import { getFirebasePublicKeys } from "@/services/firebaseGetKey";
 
export const verifyToken = async (token: string) => {
  try {
    // 토큰이 없는 경우
    if (!token) {
      return null;
    }
 
    const publicKeys = await getFirebasePublicKeys(); // firebase 공개 키 가져오기
    const decodedHeader = jose.decodeProtectedHeader(token); // 토큰 헤더 디코딩
 
    // kid가 없는 경우
    if (!decodedHeader.kid || !publicKeys[decodedHeader.kid]) {
      console.error("잘못된 키 ID");
      return null;
    }
 
    try {
      const publicKey = await jose.importX509(
        publicKeys[decodedHeader.kid],
        "RS256"
      );
      const { payload } = await jose.jwtVerify(token, publicKey);
 
      // 토큰 만료 확인
      const currentTime = Math.floor(Date.now() / 1000);
      if (payload.exp && payload.exp < currentTime) {
        console.error("토큰이 만료되었습니다");
        return null;
      }
 
      return payload;
    } catch (error) {
      console.error("토큰 확인 실패:", error);
      return null;
    }
  } catch (error) {
    console.error("토큰 처리 실패:", error);
    return null;
  }
};

위 코드에서 verifyToken 함수는 token을 검증하는 함수입니다.

jose 라이브러리를 사용하여 token을 검증하고, 만료되었는지 확인합니다.

getFirebasePublicKeys 함수를 사용하여 firebase에서 제공하는 공개 키를 가져옵니다.

token header에서 kid를 가져와 firebase에서 제공하는 공개 키와 비교하여 token을 검증합니다.

이제 middleware 함수에서 verifyToken 함수를 사용하여 token을 검증하고, 사용자가 인증되지 않은 경우 /login으로 리디렉션하겠습니다.

이전의 middleware 함수를 수정하겠습니다.

공개된 경로외 전체 경로를 보호하기 위해 publicRoutes 배열만 사용합니다.

middleware.ts
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { verifyToken } from "@/lib/verifyToken";
 
// 공개 경로만 정의
const publicRoutes = ["/login", "/signup", "/"];
 
export default async function middleware(req: NextRequest) {
  const path = req.nextUrl.pathname; // 현재 경로
 
  // 공개 경로인 경우
  const isPublicRoute = publicRoutes.includes(path);
 
  // 세션 쿠키 가져오기
  const session = (await cookies()).get("session")?.value;
 
  // 토큰 검증
  const verifiedToken = session ? await verifyToken(session) : null;
 
  // 인증 확인 여부 -> !!를 사용하여 boolean으로 변환
  const isAuthenticated = !!verifiedToken;
 
  // public 경로가 아닌 모든 경로에 대해 인증 확인
  if (!isPublicRoute && !isAuthenticated) {
    const response = NextResponse.redirect(new URL("/login", req.nextUrl));
    response.cookies.delete("session"); // 세션 삭제
    return response;
  }
 
  // 인증된 사용자의 공개 경로 접근 처리
  if (isPublicRoute && isAuthenticated) {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
};

위 코드에서 middleware 함수에서 verifyToken 함수를 사용하여 token을 검증하고, 사용자가 인증되지 않은 경우 /login으로 리디렉션합니다.

publicRoutes 배열에 공개된 경로만 추가하고, isPublicRoute 변수를 사용하여 공개된 경로인 경우 /dashboard로 리디렉션합니다.

이제 사용자가 로그인하지 않으면 /login으로 이동하고, 사용자가 로그인하면 /dashboard로 이동합니다.

이제 인증에 대한 로그인, 로그아웃, 회원가입을 구현하였습니다.

발견한 문제점

next js는 빌드시 static 파일을 생성하는데, static 파일은 정적이라서 만약, 로그인 성공 후 브라우저 cookie의 session을 변경하면, 로그인이 되어있는 상태로 인식됩니다.

이를 해결하기 위해서는 해당 페이지를 다이나믹 (dynamic) 페이지로 변경해야 합니다.

app/dashboard/page.tsx
export const dynamic = "force-dynamic";

마무리

이번 글에서는 Next.js와 Firebase를 사용하여 클라이언트에서 사용하지 않고 서버 사이드, 서버 액션과 미들웨어를 사용하여 로그인, 로그아웃, 회원가입을 구현해 보았습니다.

firebase-admin sdk는 node 기반이라 edge function만 지원하는 next js 미들웨어에서 토큰 검증시 사용할 수 없다는 것을 알게 되었습니다.

이를 해결하기 위해 firebase에서 제공하는 공개 키를 사용하여 토큰을 검증하는 방법을 알게 되었습니다.

이번 글이 도움이 되었기를 바랍니다.



관련 태그