[TIL] RefreshToken

우기·2023년 4월 25일
2
post-thumbnail

📒 오늘 공부한 내용

🔍수업목차

[30-1] RefreshToken
[30-2] 새로고침시 토큰 유지하는 방법

✅ RefreshToken


📂 AccessToken

  • AccessToken : 사용자의 로그인 정보를 담고 있는 JWT토큰 데이터
  • AccessToken 데이터는 일정 시간 동안만 사용할 수 있도록 만료 기한이 정해져 있다.
  • 만료 기한이 지나고 사용자가 로그인 정보가 필요한 페이지에 접근하려고 하면 백엔드에서 미리 지정해둔 경로로 redirect 되거나 에러가 뜨게 된다.
  • AccessToken의 만료 기한이 지나면 새로운 AccessToken을 받아와야 하고 이러한 과정에 사용되는 토큰을 RefreshToken 이라고 한다.

📂 RefreshToken

  • 사용자가 올바른 이메일과 비밀번호를 입력해서 로그인하면 백엔드에서 AccessToken과 RefreshToken을 받아오게 된다.
  • AccessToken : 1~2시간 정도의 짧은 만료 기한
  • RefreshToken : 2주~1개월 정도의 긴 만료 기한
  • 로컬/세션 스토리지의 경우 보안에 취약하기 때문에 토큰을 취급할 때에는 사용하지 않고 쿠키에 RefreshToken을 담아서 받아오게 된다.

💡 쿠키의 secure / httpOnly 옵션

로컬 / 세션 스토리지와는 다르게 쿠키는 secure, httpOnly 등의 옵션을 설정할 수 있다.

  • httpOnly : 브라우저에서 Javascript를 이용해 쿠키에 접근할 수 없고, 통신으로만 해당 데이터를 주고받을 수 있다.
  • secure : https 통신 시에만 해당 쿠키를 받아올 수 있다.

📂 AccessToken과 RefreshToken 두 가지 종류의 토큰을 받아오는 이유

  • AccessToken 을 1시간 동안만 사용할 수 있다고 가정했을 때 1시간 동안은 AccessToken을 인가 시에 사용할 수 있다.
  • 하지만 1시간 1분이 되자마자 이전에 발급 받은 AccessToken은 시간 만료로 인해 사용할 수 없는 토큰이 되어버린다.
  • 새로운 AccessToken을 발급 받기 위해 다시 로그인을 진행해야 하는데 1시간마다 로그인을 새로 해야 한다면 사용자 입장에서는 엄청난 불편함을 느끼게 된다.
  • 이런 불편함을 해결하기 위해서, AccessToken을 발급할 때 RefreshToken이 함께 발급된다.
  • 발급된 AccessToken의 유효 기간이 지나 만료 되는 시점에서 RefreshToken을 통해서 로그인 과정 없이 새로운 AccessToken을 받아올 수 있게 된다.

📂 RefreshToken을 이용해 AccessToken을 새로 발급받는 과정

1️⃣ AccessToken 만료 후 인가 요청
2️⃣ 해당 오류를 포착해서 인가 에러인지 체크
3️⃣ RefreshToken으로 AccessToken 재발급 요청
4️⃣ 발급 받은 AccessToken을 state에 재저장
5️⃣ 방금 실패했던(error) API를 재요청

📂 마이크로 서비스 아키텍쳐 (Micro Service Architecture)

  • 백엔드의 서비스를 작은 단위로 쪼개 서로 다른 컴퓨터에 담는 서비스 구조

  • 인증 서비스 : 일반적으로 인증/인가와 관련된 API
  • 리소스 서비스 : 컨텐츠를 처리하는 것과 관련된 API
  • 리소스 서비스도 각각의 API의 용도에 따라 더 잘게 쪼개는 것이 가능하다.

💡 MSA의 장점

  • 각각의 서비스를 필요에 따라 다른 언어나 구조로 만들 수 있다.
  • 백엔드 서비스가 다운되더라도 문제가 발생한 일부 마이크로 서비스만 다운될 뿐 서비스 전체가 접속 불가능해지는 사태는 일어나지 않는다.

🎯 MSA의 단점

  • 서비스의 구조가 복잡하다.

📂 여러가지 쿼리 방식

  • useQuery : 페이지에 접속하면 자동으로 바로 실행되어 data라는 변수에 fetch해온 데이터를 담아주며, 리렌더링 된다.
  • useLazyQuery : useQuery를 원하는 시점에 실행(버튼 클릭시)후 fetch해온 데이터를 data변수에 담아준다.
  • useApolloClient : 원하는 시점에 실행 후 fetch해온 데이터를 원하는 변수에 담을 수 있습니다. 따라서 axios 같은 느낌으로 사용이 가능하다.
import { gql, useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
import { IQuery } from "../../src/commons/types/generated/types";

const FETCH_USER_LOGGED_IN = gql`
  query fetchUserLoggedIn {
    fetchUserLoggedIn {
      email
      name
    }
  }
`;

export default function LoginSuccessPage() {
  // 1. 페이지 접속하면 자동으로 data에 받아지고, 리렌더링됨
  // const { data } = useQuery<Pick<IQuery, "fetchUserLoggedIn">>(FETCH_USER_LOGGED_IN);

  // 2. 버튼 클릭시 직접 실행하면 data에 받아지고, 리렌더링됨
  // const [myquery, { data }] = useLazyQuery(FETCH_USER_LOGGED_IN);

  // 3. axios와 동일
  // const client = useApolloClient();

  const client = useApolloClient();

  const onClickButton = async () => {
    const result = await client.query({
      query: FETCH_USER_LOGGED_IN,
    });
    console.log(result);
  };

  return (
    <button onClick={onClickButton}>클릭하세요</button>
  );
}

📂 RefreshToken 적용

1️⃣ apollo 파일세팅

  • 리프레시 토큰을 받아오는 작업은 아폴로세팅을 해주는 부분에서 진행

src/apollo/index.tsx 파일

export default function ApolloSetting(props: IApolloSettingProps) {
  const [accessToken, setAccessToken] = useRecoilState(accessTokenState);
  const [userInfo, setUserInfo] = useRecoilState(userInfoState);
	if (!accessToken || !userInfo) return;
	    setUserInfo(JSON.parse(userInfo));
	  }, []);

	const errorLink = onError(({ graphQLErrors, operation, forward })=>{
		// 1-1. 에러를 캐치

		// 1-2. 해당에러가 토큰만료 에러인지 체크(UNAUTHENTICATED)		

    // 2-1. refreshToken으로 accessToken을 재발급 받기
				
		// 2-2. 재발급 받은 accessToken 저장하기

		// 3-1. 재발급 받은 accessToken으로 방금 실패한 쿼리정보 수정하기

		// 3-2. 재발급 받은 accessToken으로 방금 수정한 쿼리 재요청하기

	})

	  const uploadLink = createUploadLink({
	    uri: "graphql주소",
	    headers: { Authorization: `Bearer ${accessToken}` },
		  credentials: "include",
	  });

	  const client = new ApolloClient({
	    link: ApolloLink.from([uploadLink]),
	    cache: APOLLO_CACHE,
	    connectToDevTools: true,
	  });


	  return (
	    <ApolloProvider client={client}>
	        {props.children}
	    </ApolloProvider>
	  )
	}

💡 graphQLErrors / operation / forward

  • graphQLErrors : 에러들을 캐치해준다.
  • operation : 방금전에 실패했던 쿼리가 뭐였는지 알아둔다.
  • forward : 실패했던 쿼리들을 재전송 한다.

2️⃣ errorLink 생성 및 구조 기반 설정

src/apollo/index.tsx 파일의 errorLink부분

import { onError } from "@apollo/client/link/error";

const errorLink = onError(({ graphQLErrors, operation, forward }) => {
    // 1. 에러를 캐치
    if (graphQLErrors) {
      for (const err of graphQLErrors) {
        // 2. 해당 에러가 토큰 만료 에러인지 체크(UNAUTHENTICATED)
        if (err.extensions.code === "UNAUTHENTICATED") {
          // 3. refreshToken으로 accessToken을 재발급 받기
        }
      }
    }
  });
  • refreshToken을 사용하기 위해서는 graphQL 요청을 보내야 하는데, errorLink를 생성하는 코드는 ApolloProvider 바깥에 있기 때문에 useQuery나 useApolloClient등을 이용해 graphQL 요청을 보낼 수가 없다.

💡 graphql-requset 설치

yarn add graphql-request

src/apollo/index.tsx 파일

import { GraphQLClient } from "graphql-request";

export default function ApolloSetting(props: IApolloSettingProps) {
  const [accessToken, setAccessToken] = useRecoilState(accessTokenState);
  const [userInfo, setUserInfo] = useRecoilState(userInfoState);
	const RESTORE_ACCESS_TOKEN = gql`
	  mutation restoreAccessToken {
	    restoreAccessToken {
	      accessToken
	    }
	  }
	`;
	
	if (!accessToken || !userInfo) return;
	    setUserInfo(JSON.parse(userInfo));
	  }, []);

// 리프레시 토큰 만료 에러 캐치 & 발급
		const errorLink = onError(({ graphQLErrors, operation, forward })=>{
		// 1-1. 에러를 캐치
		if(graphQLErrors){
			for(const err of graphQLErrors){
				// 1-2. 해당 에러가 토큰만료 에러인지 체크(UNAUTHENTICATED)
				 if (err.extensions.code === "UNAUTHENTICATED") {

         // 2-1. refreshToken으로 accessToken을 재발급 받기
					const graphqlClient = new GraphQLClient(
          "https://backend-practice.codebootcamp.co.kr/graphql",
          { credentials: "include" }
	        );
					const result = await graphqlClient.request(RESTORE_ACCESS_TOKEN);
		      // RESTORE_ACCESS_TOKEM이라는 gql을 요청한 뒤 반환되는 결과값을 result에 담는다.
	        const newAccessToken = result.restoreAccessToken.accessToken;
	        // 2-2. 재발급 받은 accessToken 저장하기
	        setAccessToken(newAccessToken);

					//3-1. 재발급 받은 accessToken으로 방금 실패한 쿼리정보 수정하기
					if(typeof newAcessToken !== "string") return
					operation.setContext({
	                headers: {
	                  ...operation.getContext().headers,
	                  Authorization: `Bearer ${newAccessToken}`, // accessToken만 새걸로 바꿔치기
	                },
	              });
					//3-2. 재발급 받은 accessToken으로 방금 수정한 쿼리 재요청하기
					forward(operation)
        }
			}
		}
	})

	 const uploadLink = createUploadLink({
	    uri: "http://backend08.codebootcamp.co.kr/graphql",
	    headers: { Authorization: `Bearer ${accessToken}` },
		  credentials: "include",
	  });

	  const client = new ApolloClient({
	    link: ApolloLink.from([uploadLink]),
	    cache: APOLLO_CACHE,
	    connectToDevTools: true,
	  });

	  return (
	    <ApolloProvider client={client}>
	        {props.children}
	    </ApolloProvider>
	  )
	}

3️⃣ getAccessToken파일 분리

  • libraries 폴더로 이동해서 getAccessToken.ts 라는 이름의 파일을 신규 생성
  • graphql-request를 이용하여 accessToken을 재발급 받는 코드를 별도 함수로 분리하여 입력

getAccessToken.ts

import { gql } from "@apollo/client";
import { GraphQLClient } from "graphql-request";

const RESTORE_ACCESS_TOKEN = gql`
  mutation restoreAccessToken {
    restoreAccessToken {
      accessToken
    }
  }
`;

export async function getAccessToken() {
  try {
    const graphqlClient = new GraphQLClient(
      "https://backend-practice.codebootcamp.co.kr/graphql",
      {
        credentials: "include",
      }
    );
    const result = await graphqlClient.request(RESTORE_ACCESS_TOKEN);
    const newAccessToken = result.restoreAccessToken.accessToken;

    return newAccessToken;
  } catch (error) {
    console.log(error.message);
  }
}
  • 분리한 함수를 import 해주고, 함수의 return 값이 promise 이므로 .then을 이용해 이후의 코드를 작성

src/apollo/index.tsx 파일

import getAccessToken from '파일 경로'

export default function ApolloSetting(props: IApolloSettingProps) {
  const [accessToken, setAccessToken] = useRecoilState(accessTokenState);
  const [userInfo, setUserInfo] = useRecoilState(userInfoState);
	const RESTORE_ACCESS_TOKEN = gql`
	  mutation restoreAccessToken {
	    restoreAccessToken {
	      accessToken
	    }
	  }
	`;
	
	if (!accessToken || !userInfo) return;
	    setUserInfo(JSON.parse(userInfo));
	  }, []);

// 리프레시 토큰 만료 에러 캐치 & 발급
	const errorLink = onError(({ graphQLErrors, operation, forward }) => {
	    // 1-1. 에러를 캐치
	    if (graphQLErrors) {
	      console.log(graphQLErrors);
	      for (const err of graphQLErrors) {
	        // 1-2. 해당 에러가 토큰만료 에러인지 체크(UNAUTHENTICATED)
	        if (err.extensions.code === "UNAUTHENTICATED") {

	          // 2-1. refreshToken으로 accessToken을 재발급 받기
	          return fromPromise(
	            getAccessToken().then((newAccessToken) => {
	              // 2-2. 재발급 받은 accessToken 저장하기
	              setAccessToken(newAccessToken);

	              // 3-1. 재발급 받은 accessToken으로 방금 실패한 쿼리 재요청하기
	              operation.setContext({
	                headers: {
	                  ...operation.getContext().headers,
	                  Authorization: `Bearer ${newAccessToken}`, // accessToken만 새걸로 바꿔치기
	                },
	              });
	            })
	          ).flatMap(() => forward(operation)); // 3-2. 변경된 operation 재요청하기!!!
	        }
	      }
	    }
	  });

	 const uploadLink = createUploadLink({
	    uri: "http://backend08.codebootcamp.co.kr/graphql",
	    headers: { Authorization: `Bearer ${accessToken}` },
		  credentials: "include",
	  });

	  const client = new ApolloClient({
	    link: ApolloLink.from([uploadLink]),
	    cache: APOLLO_CACHE,
	    connectToDevTools: true,
	  });

	  return (
	    <ApolloProvider client={client}>
	        {props.children}
	    </ApolloProvider>
	  )
	}

4️⃣ refreshToken 확인

  • 토큰 만료로 API 요청 실패 👉 restoreToken 요청 👉 AccessToken 재발급 받은 뒤 API 재요청 순으로 작업이 실행

✅ 새로고침시 토큰 유지하는 방법


// 3. 프리렌더링 무시 - useEffect 방법

// 토큰을 넣어두는 global state - recoilState
const [accessToken, setAccessToken] = useRecoilState(accessTokenState);

  useEffect(() => {
    // 1. 기존방식(refreshToken 이전)
    // console.log("지금은 브라우저다!!!!!");
    // const result = localStorage.getItem("accessToken");
    // console.log(result);
    // if (result) setAccessToken(result);

    // 2. 새로운방식(refreshToken 이후) - 새로고침 이후에도 토큰 유지할 수 있도록
    void getAccessToken().then((newAccessToken) => {
      setAccessToken(newAccessToken);
    });
  }, []);
profile
개발 블로그

0개의 댓글