본문 바로가기
Web/Frontend

[Next.js] API route와 firebase로 인증과 글 쓰기 구현하기

by r4bb1t 2024. 1. 21.
반응형

파이어베이스 Auth 세팅

우선 파이어베이스에서 로그인 제공업체를 선택합니다. github으로 진행해보겠습니다.

github에 접속하여, Settings / Developer Settings / OAuth Apps에서 New OAuth App을 만들고 Client ID와 Client secrets를 복사해둡니다.

복사해둔 ID와 secret을 입력하고, callback URL도 복붙해 깃허브에 입력해줍니다.

Next.js 설정

패키지 관리자로 firebase와 firebase-admin을 설치해줍니다.

pnpm add firebase firebase-admin

다음과 같이 .env를 구성합니다. 서버 측 코드(route.ts 등)에서는 firebase-admin SDK를 이용해서 접근해야 합니다.

NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=

FIREBASE_CLIENT_EMAIL=
FIREBASE_PRIVATE_KEY=

JWT_SECRET=

파이어베이스 프로젝트 설정의 일반 탭에서 키들을 복사해 넣어줍니다. client email과 private key는 파이어베이스의 프로젝트 설정에서 서비스 계정에 들어가면 생성할 수 있습니다.

lib/firebase/client.ts는 다음과 같이 구성하였습니다.

import { getApp, getApps, initializeApp } from "firebase/app";
import {
  GithubAuthProvider,
  getAuth,
  signInWithPopup,
} from "firebase/auth";
import { getFirestore } from "firebase/firestore";

export const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};

export const app = !getApps().length ? initializeApp(firebaseConfig) : getApp();

export const db = getFirestore(app);

export const auth = getAuth(app);

const providers = {
  github: new GithubAuthProvider(),
};

export const signIn = async (provider: "github") => {
  const rawUserData = (await signInWithPopup(auth, providers[provider])).user;
  if (!rawUserData) {
    throw new Error("No user data returned");
  }
  const user = await (
    await fetch(`/api/auth/sign-in`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(rawUserData),
      cache: "no-store",
    })
  ).json();

  return user;
};

export async function signOut() {
  try {
    return auth.signOut();
  } catch (error) {
    console.error("Error signing out", error);
  }
}

lib/firebase/admin.ts는 아래와 같습니다.

import "server-only";

import * as admin from "firebase-admin";
import { cert } from "firebase-admin/app";

if (!admin.apps.length) {
  admin.initializeApp({
    credential: cert({
      projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
      clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
      privateKey: process.env.FIREBASE_PRIVATE_KEY,
    }),
    databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
  });
}

export const db = admin.firestore();
export const adminAuth = admin.auth();

signIn의 경우, 각 프로바이더를 이용해 인증을 한 후 파이어스토어에 유저 정보를 만들어주기 위해 /api/auth/sign-in에 POST를 날리도록 했습니다.

/api/auth/sign-in은 아래처럼 구성하였는데, cookie에 user의 uid를 저장해두고 차후 요청 시 사용할 계획입니다. db에 해당 유저의 정보가 있으면 그 유저의 정보를 반환하고, 없으면 만들어줍니다.

import { db } from "@/app/lib/firebase/admin";
import { UserType } from "@/app/types/user";
import { convertUserType } from "@/app/utils/convert-user-type";
import { User } from "firebase/auth";
import { cookies } from "next/headers";

export async function POST(request: Request) {
  const rawUserData: User = await request.json();
  cookies().set("user", rawUserData.uid);

  const docRef = db.doc(`user/${rawUserData.uid}`);
  const doc = await docRef.get();

  if (doc.exists) {
    return Response.json(doc.data());
  }

  const user: UserType = convertUserType(rawUserData);

  docRef.set(user);

  return Response.json(user);
}

convertUserType은 파이어베이스 인증 시 받아오는 유저 정보를 사용할 프로젝트에 맞게 변환해주는 함수입니다.

import { User } from "firebase/auth";

export const convertUserType = (rawUserData: User) => {
  return {
    id: rawUserData.uid,
    email: rawUserData.email,
    username: rawUserData.displayName,
    image: rawUserData.photoURL,
    provider: rawUserData.providerData[0].providerId,
    role: "user",
  };
};

이렇게 생겼습니다.

로컬에서 user 정보 관리는 일단 zustand로 해줄 겁니다. 이 과정은 생략하겠습니다.

import { IoLogoGithub, IoLogoGoogle } from "react-icons/io5";
import { signIn } from "../lib/firebase/client";
import { useCallback, useState } from "react";
import { useUserStore } from "../store/user-store";

export default function SignIn({ close }: { close: () => void }) {
  const [error, setError] = useState<string | null>(null);
  const { setUser } = useUserStore();

  const handleSignIn = useCallback(
    async (provider: "github" | "google") => {
      const user = await signIn(provider);
      if (user) {
        setUser(user);
        setError(null);
        close();
        return;
      }
      setError("Failed to sign in");
    },
    [close, setError, setUser]
  );

  return (
    <div className="bg-base-100 p-12 flex flex-col gap-4">
      {error && <div className="text-red-500">{error}</div>}
      <button
        className="btn bg-[#333] text-white justify-between"
        onClick={() => {
          handleSignIn("github");
        }}
      >
        <IoLogoGithub />
        Sign in with GitHub
      </button>
    </div>
  );
}

이런 식으로 로그인을 하면 user를 로컬값으로 저장해두고 씁니다.

그리고 새로고침이나 재접속을 해도 user 값을 유지해주기 위해, 어딘가에 아래와 같은 코드를 넣어줍니다. (저의 경우 헤더 컴포넌트에 넣었습니다.)

  useEffect(() => {
    const unsubscribe = auth.onAuthStateChanged((rawUserData) => {
      if (rawUserData) {
        setUser(convertUserType(rawUserData));
      } else {
        setUser(null);
      }
    });
    return () => unsubscribe();
  }, []);

로그아웃 버튼은 아래처럼 만들어주는데, 쿠키를 삭제하기 위해 서버 함수를 사용했습니다.

import { signOut } from "@/app/lib/firebase/client";

...

const handleSignOut = async () => {
  deleteCookie();
  await signOut();
};

...
"use server";

import { cookies } from "next/headers";

export const deleteCookie = async () => {
  cookies().delete("user");
};

이제 signIn, signOut 함수를 구현했습니다.

article을 로그인한 유저만 작성할 수 있도록 firebase rule을 아래와 같이 업데이트 해줍니다.

service cloud.firestore {
  match /databases/{database}/documents {
    match /article/{document=**} {
      allow read: if true;
      allow write: if request.auth != null;
    }
  }
}

클라이언트 컴포넌트에서 /api/articles에 POST 메소드로 보낼 때는 jwt 토큰을 생성해서 헤더에 넣어 사용합니다.

const result = await fetch(`/api/articles`, {
  method: "POST",
  headers: {
    authorization: `Bearer ${createToken(user!.id)}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(article),
  cache: "no-store",
});
const json = await result.json();
if (json.error) throw new Error(json.error);

/api/articles/route.ts는 아래처럼 작성합니다. 여기서 db는 admin에서 정의한 db입니다.

export async function POST(request: Request) {
  const article: ArticleType = await request.json();
  
  const userId = decodeToken(
    (request.headers.get("authorization") as string).split(" ")[1]
  )?.userId;
  const userData = userId
    ? (await db.doc(`user/${userId}`).get()).data()
    : null;

  if (!userData) {
    return Response.json({ error: "User not found" });
  }

  const newDoc = await db.collection("article").add({
    ...article,
    createdAt: new Date(),
  });

  return Response.json({ ...article, id: newDoc.id });
}

유저가 존재하지 않으면 에러를 반환합니다.

서버 쪽에서 보낼 때는 쿠키에 저장해둔 uid값을 사용해 똑같이 요청을 보냅니다. 코드는 아래와 같습니다.

const getData = async (id: string) => {
  try {
    const userId = cookies().get("user")?.value || "";
    const token = createToken(userId);
    const result = await fetch(`${process.env.APP_URL}/api/articles/${id}`, {
      headers: {
        credentials: "include",
        authorization: `Bearer ${token}`,
      },
      cache: "no-cache",
    }).then((res) => res.json());
    if (!result.article) return null;
    return result.article as ArticleType;
  } catch (e) {
    console.log(e);
    return null;
  }
};
반응형

댓글