2023. 4. 24

Junghan Lee·2023년 4월 24일
0

TIL Diary

목록 보기
41/52

Index

RefreshToken - 마이크로 서비스 아키텍쳐
새로고침 시 토큰 유지
promise&observable
flatmap

intro

RefreshToken

AccessToken 은 사용자의 로그인 정보를 담고 있는 JWT토큰 데이터, 로그인 인증/인가 과정은 ?

AccessToken 데이터는 일정 시간 동안만 사용할 수 있도록 만료 기한이 정해져 있다. 만료 기한이 지나고 사용자가 로그인 정보가 필요한 페이지에 접근하려고 하면 백엔드에서 미리 지정해둔 경로로 redirect 되거나 에러가 뜨게 된다.

그렇기 때문에 **AccessToken의 만료 기한이 지나면 새로운 AccessToken을 받아와야 함.**

이러한 과정에 사용되는 토큰을 RefreshToken 이라고 한다.

AccessToken은 전역 변수에 담음

sessionStorage.getItem()
localStorage.getItem()
document.cookie


사용자가 올바른 이메일과 비밀번호를 입력해서 로그인하면 백엔드에서 AccessToken과 RefreshToken을 받아오게 되는데

이 때 **AccessToken**1~2시간 정도의 짧은 만료 기한을 가지고 있고,

**RefreshToken**2주~1개월 정도의 긴 만료 기한을 가지고 있다. 그렇다면 이 RefreshToken을 어디에 담아서 받아올까?

로컬 스토리지, 세션 스토리지, 쿠키가 그것인데,

로컬/세션 스토리지의 경우 보안에 취약하기 때문에 토큰을 취급할 때에는 사용하지 않고

쿠키에 RefreshToken을 담아서 받아오게 된다.

쿠키의 secure/httponly 옵션
쿠키라고 해서 매우 안전한 것은 아니지만 로컬/세션 스토리지와는 다르게 secure, httpOnly 등의 옵션을 설정할 수 있다.
httpOnly: 브라우저에서 Javascript를 이용해 쿠키에 접근할 수 없고, 통신으로만 해당 데이터를 주고받을 수 있다 (백엔드에서만 가능)
secure: https 통신 시에만 해당 쿠키를 받아올 수 있다.(자물쇠 걸린 사이트에서만 받아올 수 있게 한다)

AccessToken도 쿠키에 담지 왜? RecoilState에 담아야 써먹을 수 있음, 혹여나 https로 되어 있으면 꺼내서 쓸 수가 없다. + CSRF 공격에 대비

왜 번거롭게 AccessToken과 RefreshToken 두 가지 종류의 토큰을 받아오는 거지?

AccessToken 을 1시간 동안만 사용할 수 있다고 가정했을 때 1시간 동안은 AccessToken을 인가 시에 사용할 수 있다.

하지만 1시간 1분이 되자마자 이전에 발급 받은 AccessToken은
시간 만료로 인해 사용할 수 없는 토큰이 된다.

그럼, 새로운 AccessToken을 발급 받기 위해 다시 로그인을 진행해야 하는데 1시간마다 로그인을 새로 해야 한다면 사용자 입장에서는 엄청난 불편함을 느끼게 될 것이다.

이런 불편함을 해결하기 위해서,

AccessToken을 발급할 때 RefreshToken이 함께 발급되는 것.
발급된 AccessToken의 유효 기간이 지나 만료 되는 시점에서

RefreshToken을 통해서 로그인 과정 없이 새로운 AccessToken을 받아올 수 있게 된다.

단, RefreshToken 역시 만료 기간이 있기 때문에

RefreshToken 의 만료 기간이 지나게 된다면 이때는

다시 로그인을 진행해 새로운 RefreshToken 을 가져오는 과정이 필요하다.

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

소셜 로그인 (OpenAuth 서비스)
위와 같은 로그인 서비스를 카카오에서 제공을 하고, 네이버에서 제공을 하고, 구글에서 제공을 한다. 이런 서비스들을 OAuth 서비스라고 하며, 흔히 소셜 로그인이라고 부른다.

마이크로 서비스 아키텍쳐

일반적으로 인증/인가와 관련된 API를 담아 놓은 **인증 서비스**,

컨텐츠를 처리하는 것과 관련된 API를 담아 놓은 **리소스 서비스**로 나뉘며,

리소스 서비스도 각각의 API의 용도에 따라 더 잘게 쪼개는 것이 가능하다.

현대 웹의 백엔드 서비스마이크로 서비스 아키텍쳐로 이루어져 있는 경우가 많다.

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

MSA의 단점
서비스의 구조가 복잡해집니다.

여러 쿼리 방식

useQuery : 페이지에 접속하면 자동으로 바로 실행되어 data라는 변수에 fetch해온 데이터를 담아주며, 리렌더링
useLazyQuery : useQuery를 원하는 시점에 실행(버튼 클릭시)후 fetch해온 데이터를 data변수에 담아준다.
useApolloClient : 원하는 시점에 실행 후 fetch해온 데이터를 원하는 변수에 담을 수 있다. 따라서 axios 같은 느낌으로 사용이 가능.

useApolloClient()를 이용해 버튼을 눌렀을 때 fetchUserLoggedIn을 받아와보도록 만들기

// 22-02-login-success 폴더를 복사해주세요

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>
  );
}

이렇게 만들어 둔 뒤 Network탭을 보면 페이지에 접속했을때는 요청을 보내지 않다가 버튼을 클릭했을 때 요청을 보내게 된다.
이렇게 useApolloClient를 이용하시면 axios와 같은 방식으로 사용이 가능

RefreshToken 실습
1) apollo 파일세팅 및 준비(onError(apollo-client제공))

// 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: "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>
	  )
	}

uploadLink의 요청을 보내실 때 **uri 경로를 http에서 https로** 바꾸고, 민감한 정보 포함을 승인한다는 뜻의 **credentials: “include”** 옵션을 추가해주어야 함

만일, **credentials: “include” 이 없다면 refreshToken을 쿠키에 못담을 뿐만아니라 쿠키에 담겨있는것들도 백엔드로 전송이 되지 않는다.**

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-request**라는 라이브러리를 사용

yarn add graphql-request

error를 캐치한 뒤 accessToken을 재발급 받는 코드

// 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);
 }
}

이렇게 코드를 분리하고 나면, errorLink내부의 로직도 살짝 바뀌어야 한다.

분리한 함수를 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>
	  )
	}

refreshToken을 이용해 accessToken을 재발급 받을 준비가 다 된 것.

4) refreshToken 확인
실습용 API 중, 만료 시간이 5초로 짧게 설정되어있는 테스트용 로그인 API가 있다. 테스트용 API인 **loginUserExample**로 로그인 요청을 보낸 뒤, refreshToken을 통해 accessToken이 정상적으로 갱신 되고 있는지 확인.

먼저 로그인 요청을 보낸다.
그리고 로그인 요청으로 발급 받은 AccessToken의 만료 기한이 지난 뒤,
뒤로 가기 버튼을 이용해 로그인 페이지로 나갔다가 다시 로그인 성공 페이지로 돌아와 보자.

마지막으로 네트워크 탭에서 API 요청 순서를 확인!

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

새로고침을 했을시에도 토큰이 유지될 수 있도록 바꿔보도록 하자.

아래 방법은 withAuth가 아닐시의 방법이므로 참고!

// 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);
    });
  }, []);

Promise와 Observable

Promise? 비동기 작업을 도와주는 도구

observable(옵저버블)? 연속적인 비동기 작업 도와주는 도구

연속적인 비동기 작업은 요청을 빠르게 여러번 보내는 것이다(연속적인 페이지 클릭 또는 연속적인 검색어 변경)

ex. 3번페이지를 요청했다가 빠르게 5번 페이지를 요청했을 경우 3번 페이지 요청을 취소 후 5번 페이지를 보내줘야 하는데 , 백엔드에서는 3번페이지를 보여주게 된다. 이런경우에는 3번 페이지 요청을 취소해야 한다. 그렇지 않으면 사용자의 불편한 경험을 초해 할 수 있기 때문이다.

하지만, 이런경우는 promise로 처리 하는게 쉽지 않다. 이럴 때 observable을 사용하게 된다.

apollo-client의 flatmap

여기서 사용하는 flatMap은 apollo-client에서 지원하는 flatMap으로 자바스크립트의 메소드와는 다르다

yarn add zen-observable
yarn add @types/zen-observable --dev
yarn add graphql-request

import {from} from 'zen-observable'

export default function (){

	const onClickButton = ()=>{
		// new promise(()=>{})
		// new observable(()=>{})

		// from을 hover해보시면 observable이 나옵니다.
		from(["","",""]) // fromPromise
			.flatMap((el)=> from([`${el} 결과에 qqq 적용`,`${el} 결과에 zzz 적용`]))
			.subscribe((el)=>(console.log(el)))
	}

	return <button onClick={onClickButton}> 클릭! </button>
}

fromPromise?
onError 라는 함수는 return타입으로 Observable타입을 받고 있다. 하지만, 리턴해주는 값은 promise이기 때문에 Observable타입으로 바꿔줄 도구가 필요하다. 해당 도구 역시 아폴로에서 지원해주고 있으며, 그 도구가 바로 fromPromise 인 것
정리 하자면, fromPromise 는 promise타입을 Observable타입으로 바꿔주는 도구로 from과 비슷하다고 보면 된다.

profile
Strive for greatness

0개의 댓글