Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
hyeyoonS opened this issue May 2, 2024 · 1 comment
Assignees
Labels

Comments

@hyeyoonS
Copy link
Contributor

hyeyoonS commented May 2, 2024

📎 질문

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

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

✏ 구술 답변 키워드

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

  • oauth로 직접 구현했습니다.
  • 서버에 특별한 소셜로그인 로직이 없고, 프론트에서 인증 인가를 모두 처리합니다. (마이펫로그)

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

  • 로그인 시 서버에서 토큰을 response로 보내주면 프론트에서 쿠키에 저장
  • 마이펫로그는 토큰의 존재 여부에 따라 middleware로 접근 권한을 확인하기에 토큰을 쿠키에 저장하면 cookies.get(), cookies.set()으로 접근 가능
  • 쿠키에 토큰을 저장하면 HTTP 요청 시에 자동으로 쿠키가 서버로 전송 → 편리

✏ 서술 답변

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

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

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

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

  • 다른 도메인의 소셜 로그인도 사용 ⇒ 코드 일관성 유지하기 위해
  • (리스티웨이브: 백엔드에서 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 헤더에 포함시키지 않아도 됩니다.

@hyeyoonS hyeyoonS self-assigned this May 2, 2024
@Nahyun-Kang
Copy link
Collaborator

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

8-Sprinters/ListyWave-front#23

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

  • 카카오 OAuth 로그인 방식
  • 사용자 정보를 로컬 스토리지에 저장하고 업데이트(zustand persist 사용)
  • Axios로 리퀘스트 요청 시, Authorization Headers에 accessToken 첨부
  • 플로우 : /login 페이지 -> 카카오 로그인 버튼 클릭 -> 리다이렉트 페이지 -> 로그인 성공 후 홈페이지

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants