카카오 로그인 with Next 13, react-query, strapi

2023. 5. 29. 01:38Web_Programming/Next

 

Next 13에서 카카오 로그인을 수행하는 과정을 포스팅해보려합니다.

 

개발환경은 다음과 같습니다.

Next 13, react-query, Strapi (CMS)

 

Next api route 에서 카카오 로그인을 처리하고 
User에 대한 접근은 strapi의 API를 통해 수행하였습니다.
 

Routing: Route Handlers | Next.js

Using App Router Features available in /app

nextjs.org

 

 

REST API | Strapi Documentation

Interact with your Content-Types using the REST API endpoints Strapi generates for you.

docs.strapi.io

 

 

로그인 로직은 크게

1. 기본 로그인 (useKakaoAuth)

2. 자동 로그인 및 로그인 검사 (useAuth)

로 나눠집니다.

 

두 로직 각각의 흐름과 코드에 대해 설명해보겠습니다.

1. useAuthKakao

기본 로그인 로직 흐름은 다음과 같습니다.

 

querystring code 값 가져오기

  const searchParams = useSearchParams();
  const query = searchParams?.get("code");

  useEffect(() => {
    if (query) {
      getKakaoToken.mutate(query);
    }
  }, [query]);

※ next 13 에서는 useRouter가 아닌, useSearchParams 로 query를 가져올 수 있습니다.

 

따라서,

useEffect hooks에서 query의 변경 시, code를 넘겨 카카오 AccessToken을 가져옵니다.

 

GET Kakao access_token

 const getKakaoToken = useMutation({
    mutationFn: async (code: string) => {
      const data = {
        grant_type: "authorization_code",
        client_id: process.env.NEXT_PUBLIC_KAKAO_REST_KEY || "",
        redirect_uri: "/*카카오 dev에서 설정한 redirect uri*/",
        code,
      };
      const queryString = Object.keys(data)
        .map((key) => `${key}=${data[key as keyof typeof data]}`)
        .join("&");
      return await axios.post(
        "https://kauth.kakao.com/oauth/token",
        queryString,
        {
          headers: {
            "Content-type": "application/x-www-form-urlencoded;charset=utf-8",
          },
        }
      );
    },
    onSuccess: (res: any) => {
      putKakaoTokenToServer.mutate(res.data.access_token);
    },
    retry: false,
  });

react-query를 사용한 코드입니다.

 

kakao API KEY, redirect uri, code를 담은 데이터로 

카카오 AccessToken을 받아오는 API를 요청

 

성공시, 해당 토큰으로 서버에 로그인 요청을 보냅니다.

 

server에 로그인 요청

const putKakaoTokenToServer = useMutation({
    mutationFn: async (token: string) => {
      return await axios.put("/api/auth/kakao", {
        access_token: token,
      });
    },
  });

토큰을 담아 서버에 요청을 보냅니다.

next 내의 /api/auth/kakao route로 요청

 

+ next 13에서 api 폴더 위치는 아래와 같습니다.

 src/app/api/하위 route

 

GET 카카오 user (user 식별 정보)

    const result = await KakaoAuth.getProfile(access_token);
    const kakaoUser = JSON.parse(result).kakao_account;
    let userInfo = {
      email: kakaoUser.email,
      userName: kakaoUser.profile.nickname,
      id: -1,
    };

↓ 하위 코드와 같이 모듈로 분리한 카카오 프로필 정보 요청 함수를 통해 user 정보를 가져옵니다.

 

DB에서 식별정보로 user의 email을 사용하고 있기 때문에

이를 이용하여 DB의 user에 접근합니다.

const request = require("request");

export default {
  getProfile(accessToken: string) {
    return new Promise((resolve, reject) => {
      request(
        {
          headers: {
            Authorization: `Bearer ${accessToken}`,
            "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
          },
          url: "https://kapi.kakao.com/v2/user/me",
          method: "GET",
        },
        (error: any, response: any, body: any) => {
          if (!error && response.statusCode === 200) {
            resolve(body);
          }
          reject(error);
        }
      );
    });
  },
};

 

DB user Find or Create

    const { attributes } = await strapiAuthUsersApi.getUser(userInfo.email);
    userInfo.id = attributes.id;

    if (!attributes) {
      const { attributes: created_attributes } =
        await strapiAuthUsersApi.createUser({
          userInfo,
          kakaoToken: access_token,
        });
      userInfo.id = created_attributes.id;
    }

아래와 같은 strapi API 모듈로 email을 통해 사용자를 가져오거나 생성 시켜줍니다.

import axios from "axios";

const getUser = async (email: string) => {
  try {
    const {
      data: { data },
    } = await axios.get(
      `http://localhost:1337/api/auth-users?filters[email][$eq]=${email}`
    );
    return { attributes: data[0].attributes };
  } catch (error) {
    console.error(error);
    return { attributes: undefined };
  }
};

const createUser = async ({
  userInfo,
  kakaoToken,
}: {
  userInfo: {
    email: string;
    userName: string;
    id: number;
  };
  kakaoToken: string;
}) => {
  try {
    const {
      data: { data },
    } = await axios.post("http://localhost:1337/api/auth-users", {
      data: { ...userInfo, kakaoToken },
    });
    return { attributes: data.attributes };
  } catch (error) {
    console.error(error);
    return { attributes: undefined };
  }
};
export const strapiAuthUsersApi = { getUser, createUser };

 

user 정보 + jwt 토큰

    return new Response(
      JSON.stringify({
        success: true,
        jwt: jwtUtil.getJWTToken(userInfo),
        user: { ...userInfo },
      }),
      {
        status: !attributes ? 201 : 200,
      }
    );

위에서 가져온 user 정보와 함께

JWT 토큰을 발행하여 응답해줍니다.

const jwt = require("jsonwebtoken");

const getJWTToken = (userInfo: { email: string; userName: string }) => {
  return jwt.sign(
    {
      ...userInfo,
    },
    process.env.NEXT_SERVER_JWT_SECRET,
    {
      issuer: "bbangsoon",
    }
  );
};
const getJWTUser = (jwtToken: string) => {
  return jwt.verify(jwtToken, process.env.NEXT_SERVER_JWT_SECRET);
};

export const jwtUtil = { getJWTToken, getJWTUser };

 

전체 서버 코드
import axios from "axios";
import KakaoAuth from "../utils/KakaoAuth";
import { jwtUtil } from "../utils/util";
import { strapiAuthUsersApi } from "@lib/apis/AuthUsersApis";

export async function PUT(req: Request) {
  try {
    const { access_token } = await req.json();

    if (!access_token)
      return new Response("token 없음.", {
        status: 401,
      });

    const result: any = await KakaoAuth.getProfile(access_token);
    const kakaoUser = JSON.parse(result).kakao_account;
    let userInfo = {
      email: kakaoUser.email,
      userName: kakaoUser.profile.nickname,
      id: -1,
    };

    const { attributes } = await strapiAuthUsersApi.getUser(userInfo.email);
    userInfo.id = attributes.id;

    if (!attributes) {
      const {
        data: { data },
      } = await axios.post("http://localhost:1337/api/auth-users", {
        data: { ...userInfo, kakaoToken: access_token },
      });
      userInfo.id = data.attributes.id;
    }

    return new Response(
      JSON.stringify({
        success: true,
        jwt: jwtUtil.getJWTToken(userInfo),
        user: { ...userInfo },
      }),
      {
        status: !attributes ? 201 : 200,
      }
    );
  } catch (err: any) {
    return new Response("test", {
      status: 500,
    });
  }
}

 

로그인 완료

서버로 전달 받은 jwt 토큰

자동 로그인을 위해, localStorage에 저장 해주고

이후 API 요청에 인증을 확인하기 위해

jwt토큰으로 Authrization 헤더 항목을 설정해줍니다.

const putKakaoTokenToServer = useMutation({
    mutationFn: async (token: string) => {
      return await axios.put("/api/auth/kakao", {
        access_token: token,
      });
    },
    onSuccess: (res: any) => {
      const { jwt, user } = res.data;

      if (res.status == 201 || res.status == 200) {
        setUserAtom(user);
        window.localStorage.setItem(
          "token",
          JSON.stringify({
            access_token: jwt,
          })
        );
        axios.defaults.headers.common["Authorization"] = `${jwt}`;
        router.push("/home");
      } else {
        window.alert("로그인에 실패하였습니다.");
        resetUserAtom();
      }
    },
  });

 

전체 useAuthKakao 코드
import { userInfoAtoms } from "@app/GlobalProvider";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { useSetAtom } from "jotai";
import { useResetAtom } from "jotai/utils";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";

const useAuthKakao = () => {
  const router = useRouter();

  const searchParams = useSearchParams();
  const query = searchParams?.get("code");

  const setUserAtom = useSetAtom(userInfoAtoms.userAtom);
  const resetUserAtom = useResetAtom(userInfoAtoms.userAtom);

  const getKakaoToken = useMutation({
    mutationFn: async (code: string) => {
      const data = {
        grant_type: "authorization_code",
        client_id: process.env.NEXT_PUBLIC_KAKAO_REST_KEY || "",
        redirect_uri: "http://localhost:3000/auth/login",
        code,
      };
      const queryString = Object.keys(data)
        .map((key) => `${key}=${data[key as keyof typeof data]}`)
        .join("&");
      return await axios.post(
        "https://kauth.kakao.com/oauth/token",
        queryString,
        {
          headers: {
            "Content-type": "application/x-www-form-urlencoded;charset=utf-8",
          },
        }
      );
    },
    onSuccess: (res: any) => {
      putKakaoTokenToServer.mutate(res.data.access_token);
    },
    retry: false,
  });

  const putKakaoTokenToServer = useMutation({
    mutationFn: async (token: string) => {
      return await axios.put("/api/auth/kakao", {
        access_token: token,
      });
    },
    onSuccess: (res: any) => {
      const { jwt, user } = res.data;

      if (res.status == 201 || res.status == 200) {
        setUserAtom(user);
        window.localStorage.setItem(
          "token",
          JSON.stringify({
            access_token: jwt,
          })
        );
        axios.defaults.headers.common["Authorization"] = `${jwt}`;
        router.push("/home");
      } else {
        window.alert("로그인에 실패하였습니다.");
        resetUserAtom();
      }
    },
  });

  useEffect(() => {
    if (query) {
      getKakaoToken.mutate(query);
    }
  }, [query]);
  return {
    isLoading: getKakaoToken.isLoading || putKakaoTokenToServer.isLoading,
  };
};

export default useAuthKakao;

 

 

2. useAuth

카카오 로그인 이후 로그인 검사는 다음과 같이 이루어집니다.

 

localStorage에서 jwt 토큰 가져오기

  const [jwtToken, setJwtToken] = useState(null);

  useEffect(() => {
    const token = JSON.parse(
      window?.localStorage.getItem("token") || "null"
    )?.access_token;

    if (token) {
      setJwtToken(token);
      axios.defaults.headers.common["Authorization"] = `${token}`;
    }
  }, [])

localStorage의 token을 검사하여 있다면,

헤더의 인증정보를 같이 설정해줍니다.

 

server에 auth check

  useQuery(["getAuth"], {
    queryFn: async () => {
      return await axios.get("/api/auth", {
        headers: { Authorization: `${jwtToken}` },
      });
    },
    onSuccess: (res: any) => {
      setUserAtom(res.data.user);
    },
    onError: (err: any) => {
      console.error(err);
      resetUserAtom();
      window.localStorage.removeItem("token");
    },
    enabled: !!jwtToken,
    retry: false,
  });

jwtToken이 존재하면,

서버에 인증 요청을 보냅니다.

 

※ 에러 발생시, user 정보를 Reset하고 (로그아웃) 토큰을 지웁니다.

 

jwt 토큰 으로 user 식별 정보 가져오기

    const jwtUser = jwtUtil.getJWTUser(
      req.headers.get("authorization") as string
    );
    let userInfo = {
      email: jwtUser.email,
      userName: jwtUser.userName,
      id: -1,
    };
const jwt = require("jsonwebtoken");

const getJWTUser = (jwtToken: string) => {
  return jwt.verify(jwtToken, process.env.NEXT_SERVER_JWT_SECRET);
};

export const jwtUtil = { getJWTToken, getJWTUser };

 

DB user 가져오기

위에서 가져온 JWT user의 emailDB user에 접근합니다.

    const { attributes } = await strapiAuthUsersApi.getUser(userInfo.email);
    userInfo.id = attributes.id;

    if (!attributes)
      return new Response("user 없음.", {
        status: 404,
      });

여기서 정보가 없을 경우 404 에러를 보냅니다.

 

전체 서버 코드
import { strapiAuthUsersApi } from "@lib/apis/AuthUsersApis";
import { jwtUtil } from "./utils/util";

export async function GET(req: Request) {
  try {
    if (!req.headers.get("authorization"))
      return new Response("token 없음.", {
        status: 401,
      });

    const jwtUser = jwtUtil.getJWTUser(
      req.headers.get("authorization") as string
    );
    let userInfo = {
      email: jwtUser.email,
      userName: jwtUser.userName,
      id: -1,
    };
    const { attributes } = await strapiAuthUsersApi.getUser(userInfo.email);
    userInfo.id = attributes.id;

    if (!attributes)
      return new Response("user 없음.", {
        status: 404,
      });

    return new Response(
      JSON.stringify({
        success: true,
        jwt: jwtUtil.getJWTToken(userInfo),
        user: { ...userInfo },
      }),
      {
        status: 200,
      }
    );
  } catch (err: any) {
    return new Response("test", {
      status: 500,
    });
  }
}

 

전체 useAuth 코드
import { userInfoAtoms } from "@app/GlobalProvider";
import { useMutation, useQuery } from "@tanstack/react-query";
import axios from "axios";
import { useAtomValue, useSetAtom } from "jotai";
import { useResetAtom } from "jotai/utils";
import { useEffect, useState } from "react";

const useAuth = () => {
  const [jwtToken, setJwtToken] = useState(null);

  const setUserAtom = useSetAtom(userInfoAtoms.userAtom);
  const user = useAtomValue(userInfoAtoms.userAtom);
  const resetUserAtom = useResetAtom(userInfoAtoms.userAtom);

  useQuery(["getAuth"], {
    queryFn: async () => {
      return await axios.get("/api/auth", {
        headers: { Authorization: `${jwtToken}` },
      });
    },
    onSuccess: (res: any) => {
      setUserAtom(res.data.user);
    },
    onError: (err: any) => {
      console.log(err);
      resetUserAtom();
      window.localStorage.removeItem("token");
    },
    enabled: !!jwtToken,
    retry: false,
  });

  useEffect(() => {
    const token = JSON.parse(
      window?.localStorage.getItem("token") || "null"
    )?.access_token;

    if (token) {
      setJwtToken(token);
      axios.defaults.headers.common["Authorization"] = `${jwtToken}`;
    }
  }, []);


  return;
};

export default useAuth;

 

이렇게 해서 Next 13, react-query, Strapi를 이용하여 카카오 로그인을 구현해봤습니다.

 

따로 서버를 두지 않고 Next의 장점인 프론트 서버를 구축해볼 수 있었고,

strapi로 직관적이고 간단하게 DB와 API를 이용하면서

프론트에 더 집중하여 코드를 작성해 볼 수 있었습니다.

 

 

 

궁금하시거나, 문제되는 부분이 있다면 댓글로 알려주세요!

 

반응형