QueenCards / ProjectAnalysis

플젝뿌셔
1 stars 0 forks source link

[11] 소셜 로그인은 어떤 방식으로 구현했나요? 토큰은 어디에 저장하죠? 왜 그렇게 했나요? #12

Closed hyeyoonS closed 1 month ago

hyeyoonS commented 2 months ago

📎 질문

소셜 로그인은 어떤 방식으로 구현했나요?

토큰은 어디에 저장하죠? 왜 그렇게 했나요?

✏ 구술 답변 키워드

소셜 로그인은 어떤 방식으로 구현했나요?

토큰은 어디에 저장하죠? 왜 그렇게 했나요?

✏ 서술 답변

앗 가독성 너무 구리다...! 수정중,,,,,

1. 소셜로그인 구현 방식 (oauth 직접구현)

Oauth로 직접 구현했습니다. 라이브러리를 사용하지 않은 이유 (마이펫로그) : Next-Auth는 Provider를 사용해서 여러 소셜로그인을 간편하게 구현할 수 있는 편리한 라이브러리이지만, Next-Auth에서 제공하는 가이드를 준수해야 합니다. 처음 기능구현을 할 때에 일반 로그인만을 염두에 두고, 소셜로그인을 후순위로 구현하였기에 Next-Auth에서 요구하는 파일 컨벤션과 맞지 않아 불필요한 파일을 중복 생성하는 등의 문제가 발생했고, 에러가 발생한 경우 어디에서 에러가 난 것인지 알 수 없어 TroubleShooting이 어려웠습니다. 이에 유지보수가 어렵다고 판단이 되어 Oauth를 사용해서 직접 구현하는 방식을 채택했습니다.

2. 카카오 로그인 REST API방식과 SDK방식 중 REST API방식을 채택한 이유?

🤔 oauth 로그인이 뭐지?

kakaologin_sequence_restapi

Oauth를 사용한 방식(마이펫로그)

✨ 1. 구글/카카오에서 인가 코드 받기

  1. 사용자가 소셜로그인 버튼을 클릭합니다.
const KakaoButton = () => {
  const router = useRouter();

  const onClick = () => {
    **router.push(Oauth.kakao);**
  };
  return (
    <div>
      <SignButton type="kakao" action="시작하기" onClick={onClick} />
    </div>
  );
};

export default KakaoButton;
  1. 마이펫로그 서버 마이펫로그 클라이언트에서 카카오/구글 인증 서버로 인가 코드 받기를 요청합니다.
//카카오에서 제공하는 예시코드 
https://kauth.kakao.com/oauth/authorize?
response_type=code&
client_id=${REST_API_KEY}
&redirect_uri=${REDIRECT_URI}

//구글에서 제공하는 예시코드
https://accounts.google.com/o/oauth2/v2/auth?
 client_id=client_id&
 redirect_uri=${REDIRECT_URI}
 scope=scope&
 response_type=code //혹은 token 
//받고 싶은 정보로 설정한 마이펫로그의 redirect uri
export const Oauth = {
  kakao: `https://kauth.kakao.com/oauth/authorize?
                  client_id=${process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID}&
                    redirect_uri=${process.env.NEXT_PUBLIC_REDIRECT_URI_KAKAO}&
                    response_type=code`,
  google: `https://accounts.google.com/o/oauth2/v2/auth?
                  client_id=${process.env.NEXT_PUBLIC_GOOGLE_OAUTH_ID}&
                  redirect_uri=${process.env.NEXT_PUBLIC_REDIRECT_URI_GOOGLE}&
                  response_type=code&
                  scope=https://www.googleapis.com/auth/userinfo.email`,
};

Untitled

  1. 카카오/구글 인증 서버가 사용자에게 카카오계정 로그인을 통한 인증을 요청합니다.
    • 클라이언트에 유효한 카카오/구글 세션이 있거나, 인앱 브라우저에서의 요청인 경우 4단계로 넘어갑니다.
  2. 사용자가 카카오/구글 계정으로 로그인합니다.
  3. 카카오/구글 인증 서버가 사용자에게 동의 화면을 출력하여 [인가](https://developers.kakao.com/docs/latest/ko/kakaologin/common#intro)를 위한 사용자 동의를 요청합니다.
  4. 사용자가 필수 동의항목, 이 외 원하는 동의항목에 동의한 뒤 [동의하고 계속하기] 버튼을 누릅니다.

  1. 카카오/구글 인증 서버는 마이펫로그 서버 마이펫로그 클라이언트의 Redirect URI로 인가 코드를 전달합니다.
//코드와 함께 돌아온 모습,,, 
https://mypetlog.site/oauth/callback/kakao?code=**2busLd1TBTjs22M-Y45loGTU74NNWVT7Mx7bP_eSzfKGV0YYSQ19IAAAAAQKPXLrAAABj3qbKZ3dCc_9be4aqQ**

✨ 2. 토큰 받기

  1. 마이펫로그 서버 ****마이펫로그 클라이언트가 Redirect URI로 전달받은 인가 코드로 구글·카카오 전용 토큰 받기를 요청합니다.
  2. 카카오/구글 인증 서버가 카카오·구글 전용 토큰을 발급해서 마이펫로그 서버 마이펫로그 클라이언트에 전달합니다.
//카카오
{
  grant_type: "authorization_code",
  client_id: process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID,
  client_secret: process.env.NEXT_PUBLIC_KAKAO_CLIENT_SECRET,
  redirect_uri: process.env.NEXT_PUBLIC_REDIRECT_URI_KAKAO,
  code,
  },
 { headers: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8" } },
 );

//구글
{
 grant_type: "authorization_code",
 client_id: process.env.NEXT_PUBLIC_GOOGLE_OAUTH_ID,
 client_secret: process.env.NEXT_PUBLIC_GOOGLE_OAUTH_SECRET,
 redirect_uri: process.env.NEXT_PUBLIC_REDIRECT_URI_GOOGLE,
 code: code,
 },
{ headers: { "Content-Type": "application/x-www-form-urlencoded" } },
 );

//여기까지 카카오 전체 코드! 
"use client";

import axios from "axios";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect } from "react";

export default function OAuth() {
  const pathname = usePathname();
  const provider = pathname.split("/").at(-1);
  const searchParam = useSearchParams();
  const code = searchParam.get("code");

  const router = useRouter();
  const handleOAuth = useCallback(async () => {
    try {
      let email = "";
      if (provider === "kakao") {
        const kakaoToken = await axios.post(
          "https://kauth.kakao.com/oauth/token",
          {
            grant_type: "authorization_code",
            client_id: process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID,
            client_secret: process.env.NEXT_PUBLIC_KAKAO_CLIENT_SECRET,
            redirect_uri: process.env.NEXT_PUBLIC_REDIRECT_URI_KAKAO,
            code,
          },
          { headers: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8" } },
        );
        console.log("kakaoToken:", kakaoToken); // 

카카오토큰반환모습.png

⇒ 토큰을 잘받았다! 토큰을 보내서 사용자 정보를 가져오장

✨ 3. 사용자 정보 가져오기

카카오 유저정보.png

        const accessToken = kakaoToken?.data.access_token;
        const emailRes = await axios.get("https://kapi.kakao.com/v2/user/me", {
          headers: {
            Authorization: `Bearer ${accessToken}`,
            "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
          },
        });
        console.log("accessToken:", accessToken);
        email = emailRes.data.kakao_account.email;
      }
      console.log("email:", email);

카카오get.png

✨ 4. 마이펫로그 서버에 로그인 요청

//app/_api/index.ts

interface SocialData {
  email?: string | null;
  loginType?: "KAKAO" | "GOOGLE";
}

export const postSocial = async ({ email, loginType }: SocialData) => {
  try {
    const res = await instance.post("/auth/login/social", {
      email,
      loginType,
    });

    if (res.status === 200) {
      const expiresAt = new Date(Date.now() + (23 * 60 + 59) * 60 * 1000);
      cookies().set("expire", "expire", { expires: expiresAt });
      cookies().set("accessToken", res.data.access_token);
      cookies().set("refreshToken", res.data.refresh_token);
      return "signin success";
    }
  } catch (error: any) {
    console.error(error);
    return null;
  }
};
//Oauth함수의 나머지 코드

      const res = (await postSocial({ email, loginType }));
      console.log("res:", res);
      if (res === 200) {
        router.push("/home");
      }
    } catch (error: any) {
      console.error(error);
      router.push("/login");
    }
  }, []);
  useEffect(() => {
    handleOAuth();
  }, []);

    return <Spinner />;
}

💖 다시 살펴보는 전체 코드

//app/(auth)/login/page.tsx

const Page = () => {
  return (
    <>
      <div className={styles.container}>
        <div className={styles.imgWrapper}>
          <Image src={Logo} alt="로고" width={171} height={171} />
        </div>
        <p className={styles.p}>
          회원이 아니신가요?
          <Link className={styles.link} href="/signup">
            회원가입 하기
          </Link>
        </p>
        <div className={styles.buttonWrapper}>
          <KakaoButton />
        </div>
        <div className={styles.buttonWrapper}>
          <GoogleButton />
        </div>
        <div className={styles.lineWrapper}>
          <Line alt="로고" width={300} height={1} />
        </div>
        <Link className={styles.emailWrapper} href="/login/email">
          <SignButton type="email" action="시작하기" />
        </Link>
      </div>
    </>
  );
};

export default Page;
//app/(auth)/_components/SignButton/KakaoButton.tsx

"use Client";
import SignButton from ".";
import { useRouter } from "next/navigation";
import { Oauth } from "@/app/_constants/oauth";

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

  const onClick = () => {
    router.push(Oauth.kakao);
  };
  return (
    <div>
      <SignButton type="kakao" action="시작하기" onClick={onClick} />
    </div>
  );
};

export default KakaoButton;
//app/_contstants_oauth.ts

export const Oauth = {
  kakao: `https://kauth.kakao.com/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID}&redirect_uri=${process.env.NEXT_PUBLIC_REDIRECT_URI_KAKAO}&response_type=code`,
  google: `https://accounts.google.com/o/oauth2/v2/auth?client_id=${process.env.NEXT_PUBLIC_GOOGLE_OAUTH_ID}&redirect_uri=${process.env.NEXT_PUBLIC_REDIRECT_URI_GOOGLE}&response_type=code&scope=https://www.googleapis.com/auth/userinfo.email`,
};
//app/(auth)/oauth/callback/[provider]/page.tsx

"use client";

export default function OAuth() {
  const pathname = usePathname();
  const provider = pathname.split("/").at(-1);
  const loginType = provider!.toUpperCase() as "KAKAO" | "GOOGLE";
  const searchParam = useSearchParams();
  const code = searchParam.get("code");

  const router = useRouter();

  const handleOAuth = useCallback(async () => {
    try {
      let email = "";
      if (provider === "kakao") {
        const kakaoToken = await axios.post(
          "https://kauth.kakao.com/oauth/token",
          {
            grant_type: "authorization_code",
            client_id: process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID,
            client_secret: process.env.NEXT_PUBLIC_KAKAO_CLIENT_SECRET,
            redirect_uri: process.env.NEXT_PUBLIC_REDIRECT_URI_KAKAO,
            code,
          },
          { headers: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8" } },
        );
        console.log("kakaoToken:", kakaoToken);
        const accessToken = kakaoToken?.data.access_token;
        const emailRes = await axios.get("https://kapi.kakao.com/v2/user/me", {
          headers: {
            Authorization: `Bearer ${accessToken}`,
            "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
          },
        });
        console.log("accessToken:", accessToken);
        email = emailRes.data.kakao_account.email;
      }
      console.log("email:", email);
      console.log("loginType", loginType);

      if (provider === "google") {
        const googleToken = await axios.post(
          "https://oauth2.googleapis.com/token",
          {
            grant_type: "authorization_code",
            client_id: process.env.NEXT_PUBLIC_GOOGLE_OAUTH_ID,
            client_secret: process.env.NEXT_PUBLIC_GOOGLE_OAUTH_SECRET,
            redirect_uri: process.env.NEXT_PUBLIC_REDIRECT_URI_GOOGLE,
            code: code,
          },
          { headers: { "Content-Type": "application/x-www-form-urlencoded" } },
        );
        const accessToken = googleToken?.data.access_token;
        const emailRes = await axios.get("https://www.googleapis.com/oauth2/v2/userinfo", {
          headers: {
            Authorization: `Bearer ${accessToken}`,
          },
        });
        email = emailRes.data.email;
      }

      const res = (await postSocial({ email, loginType })) as any;
      console.log("res:", res);
      if (res === 200) {
        router.push("/home");
      } 
    } catch (error: any) {
      console.error(error);
      router.push("/login");
    }
  }, []);

  useEffect(() => {
    handleOAuth();
  }, []);

  return <Spinner />;
}

Oauth를 사용한 방식 (리스티웨이브)

        //백엔드 서버로 보내버림 
        <Link id={id} href={`${process.env.NEXT_PUBLIC_**SERVER_DOMAIN**}/auth/${oauthType.kakao}`}>
          <KakaoLoginIcon />
        </Link>
'use client';

import { useSearchParams, useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { AxiosError } from 'axios';

import axiosInstance from '@/lib/axios/axiosInstance';
import { useUser } from '@/store/useUser';
import { UserOnLoginType } from '@/lib/types/user';
import { setCookie } from '@/lib/utils/cookie';

import Loading from '@/components/loading/Loading';

export default function KakaoRedirectPage() {
  const router = useRouter();
  const { updateUser } = useUser();
  const searchParams = useSearchParams();
  const code = searchParams ? searchParams.get('code') : null;

  useEffect(() => {
    const controller = new AbortController();

    if (!code) {
      router.back();
      return;
    }

    // 브라우저 기본 동작으로 리다이렉트 페이지에 접근하지 못하도록 설정
    history.replaceState(null, '', '/');

    const loginKakao = async () => {
      try {
      //서버에서 지정한 redirect uri 
        const res = await axiosInstance.get<UserOnLoginType>(`/auth/redirect/kakao?code=${code}`, {
          signal: controller.signal,
        });

        const { id, accessToken, refreshToken  } = res.data;
        updateUser({ id, accessToken: '' }); // TODO id만 저장하기
        setCookie('accessToken', accessToken, 'AT');
        setCookie('refreshToken', refreshToken, 'RT');

        if (res.data.isFirst) {
          router.push('/start-listy');
        } else {
          router.push('/');
        }
      } catch (error) {
        if (error instanceof AxiosError) {
          if (error.response?.status === 400) {
            // 탈퇴한 사용자(status 400)일 경우, 리다이렉트
            router.push('/withdrawn-account');
          } else if (!controller.signal.aborted) {
            console.error(error.message);
          } else {
            console.log('Request canceled:', error.message);
          }
        }
      }
    };

    loginKakao();

    return () => {
      controller.abort(); // 마운트 해제 및 axios 요청 취소
    };
  }, [code]);

  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
        height: '100vh',
      }}
    >
      <Loading />
    </div>
  );
}

토큰

토큰은 어디에 저장하죠?

로그인 시 서버에서 토큰을 response로 보내주면 프론트에서 쿠키에 저장합니다.

왜 그렇게 했나요?

마이펫로그 프로젝트에서는 토큰의 존재 여부에 따라 middleware로 접근 권한을 확인합니다. 이 때 middleware는 서버에서 실행이 되는데, 토큰을 쿠키에 저장하면 cookies.get(), cookies.set()을 통해 ServerSide에서 토큰을 불러올 수 있습니다. 이와 비교해서 로컬스토리지에 저장을 하게 되면 클라이언트 컴포넌트에서만 접근이 가능합니다. 또한 쿠키에 토큰을 저장하면 HTTP 요청 시에 자동으로 쿠키가 서버로 전송되므로, 매번 토큰을 수동으로 HTTP 헤더에 포함시키지 않아도 됩니다.

Nahyun-Kang commented 2 months ago

*listywave 소셜 로그인 담당자 PR 확인 (정확한 이해는 실력 미달 이슈로 못했습니다.ㅠㅠ)

https://github.com/8-Sprinters/ListyWave-front/pull/23

리스티웨이브 OAuth(소셜 로그인) 구현