Day 30

리프레쉬 토큰

JWT토큰을 이용해서 accesstoken으로 사용했었음
근데 토큰을 탈취해서 주인행세를 하는 사람에 대비해서 만료시간을 30분-2시간으로 설정해둠
그래서 시간이 지나면 토큰이 만료되서 재로그인을 해야하거나 localstorage의 토큰을 삭제하고 다시 로그인을 해야했음

근데 리프레쉬 토큰을 이용하면 이런 일이 줄어든다. 알아보자

리프레쉬 토큰의 개념 및 과정

브라우저 백엔드 데이터베이스

loginUser,logoutUser 를 담당하는 백엔드와 데이터베이스가 따로있고

createUseditem, fetchBoard 백엔드와 데이터베이스가 따로있다고(분리되어있음) 생각해보자

로그인을 담당하는 덩어리를 '서비스' 한번더 AuthService,
게시판을 담당하는 덩어리를 '서비스', ResourceServie라고 하자

나눠놓은 서비스 안에서는 백엔드의 api는 똑같다
브라우저에서 처음 로그인 했을때 토큰을 1개 줬었는데(디비에 접근하기위해서)
이번엔 JWT토큰을 두개 준다


1. 엑세스토큰으로 사용, 2. 리프레시 토큰으로 사용
이후 브라우저의 state에 엑세스토큰을 저장해두고, cookie에 리프레시 토큰을 저장해둠
secrue: true -> https (secure의 s)
httpOnly:true -> document로 cookie에 접근할 수가 없다, 자바스크립트로 접근이 불가
이런 옵션들로 안전하게 보관해둔다

쿠키들은 api를 요청할때 자동으로 따라다니고 있음, 따로 저장하지 않아도됨

그러니 createUseditem 같은걸 한다고 하면 리프레시토큰은 이미 갖고있는셈

엑세스 토큰이 만료됐을 때

문제는 엑세스 토큰이 만료됐을 때 생김
브라우저에서 리소스 서비스 이용하면서 권한이 필요한 일들을 하고있었음(예시 creaeUsedItem)
근데 백엔드에서 확인해보니까 JWT토큰이 만료됨
DB에 저장할 수가 없음 -> 에러 던짐 error: UNAUTHENTICATED(인증이 안됨, 만료됨)

  • 브라우저에서는 에러를 캐치함(UNAUTHENTICATED) 그럼 보안서비스API로 가지고있던 refreshToken을 API이름은 백엔드마다 다름(여기선 restoreAccessToken)
    그럼 리프레쉬 토큰을 검증 후 엑세스 토큰을 보내줌

??리프레쉬 토큰도 JWT아님?
-> 맞는데 대신 유효기간이 길음, 1달~2달 결국 지나면 재로그인은 해야됨, 이것도 자동화하고싶으면 엑세스토큰 보내줄때마다 리프레쉬 토큰 유효기간도 갱신시켜줄 수 있음

  • 아무튼 브라우저는 새로받은 엑세스토큰을 다시 state에 저장함(리프레쉬토큰은 똑같)

  • 실패했던 요청 재시도(리소스 서비스 이용)

이렇게 3단계 일이 벌어지고 있는데 빠르게 이루어지므로 사용자는 무슨 일이 일어났는지 모름

++보너스 MSA

리소스 서비스 또한 쪼개지고있음, 게시판을 담당하는 서비스, 마켓을 담당하는 서비스, 마이페이지를 담당하는 서비스 등등
이런걸 마이크로서비스 아키텍쳐 MSA라고 함Micro Service Arcitehcture
장점
다양한 언어로 서비스들을 구성할 수 있음 , 규모가 커지면서 더 다양하게 뽑을 수 있음 (하나는 자바, 하나는 파이썬 - - )
규모가 커졌을때 yarn dev는 쉽게 안되고 build를 먼저 해야되는데 더 커질수록 한참(몇시간) 더 걸릴 수 있음, 근데 마이크로 서비스를 하게되면 배포도 금방할 수 있게됨

에러가 났을 때 하나의 서비스가 중단(장애)될 수 있음, 만약 큰 덩어리의 서비스면 다 같이 작동을 안하겠지만 마이크로 서비스의 경우 장애가 생긴 서비스만 작동하지 않음(장바구니만 안됨)

실제 리프레쉬토큰 적용하기

에러캐치 / 리프레쉬토큰을 보내서 엑세스토큰 받기 / 실패했던 요청 재시도를 해야함
(근데 이번 백엔드api는 토큰을 보내면 재시도를 함)

loginUserExample을 사용함(만료시간5초)

로그인페이지 만들기

import { ChangeEvent, useContext, useState } from "react";
import { gql, useMutation } from "@apollo/client";
import {
  IMutation,
  IMutationLoginUserExampleArgs,
} from "../../src/commons/types/generated/types";
import { GlobalContext } from "../_app";
import { useRouter } from "next/router";

const LOGIN_USER = gql`
  mutation loginUserExample($email: String!, $password: String!) {
    loginUserExample(email: $email, password: $password) {
      accessToken
    }
  }
`;

export default function LoginPage() {
  const router = useRouter();
  const { setAccessToken } = useContext(GlobalContext);

  const [myEmail, setMyEmail] = useState("");
  const [myPassword, setMyPassword] = useState("");
  const [loginUser] = useMutation<
    Pick<IMutation, "loginUserExample">,
    IMutationLoginUserExampleArgs
  >(LOGIN_USER);

  function onChangeMyEmail(event: ChangeEvent<HTMLElement>) {
    setMyEmail(event.target.value);
  }

  function onChangeMyPassword(event: ChangeEvent<HTMLElement>) {
    setMyPassword(event.target.value);
  }

  async function onClickLogin() {
    const result = await loginUser({
      variables: {
        email: myEmail,
        password: myPassword,
      },
    });
    // localStorage.setItem(
    //   "accessToken",
    //   result.data?.loginUserExample.accessToken || ""
    // );
    setAccessToken?.(result.data?.loginUserExample.accessToken || ""); // 여기서 setAccesToken 필요! (글로벌 스테이트에...)

    // 로그인 성공된 페이지로 이동시키기!!
    router.push("/30-02-login-success");
  }

  return (
    <>
      이메일:
      <input type="text" onChange={onChangeMyEmail} />
      비밀번호:
      <input type="password" onChange={onChangeMyPassword} />
      <button onClick={onClickLogin}>로그인하기!!</button>
    </>
  );
}

로그인 완료 페이지 만들기

import { useQuery, gql } from "@apollo/client";

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

export default function LoginSuccessPage() {
  const { data } = useQuery(FETCH_USER_LOGGED_IN);
  return (
    <>
      <div>로그인에 성공하셨습니다!</div>
      <div>{data?.fetchUserLoggedIn.name}님 환영합니다.</div>
    </>
  );
}

이후 yarn dev, 이전 로컬 스토리지에 있던 토큰은 다 지워줌

그럼 로그인이성공하는데
뒤로갔다가 다시 로그인완료페이지로 이동하게 되면 토큰이 만료됐다는 메세지를 볼 수 있다.

여기서 우리는 엑세스 토큰을 볼 수 있고

여기서 우리는 쿠키가(리프레쉬 토큰이)저장되어있는 모습을 볼 수 있다.

httpOnly와 secure 체크를 통해서 자바스크립트로 접근이 안되고 애플리케이션-쿠키도 비어있다

이걸 어떻게 넘겨야되느냐?

app.tsx로 간다.

1. 백엔드의 uri부분의 주소를 http -> https로 바꾼다
-> https로 들어가면 secure 옵션이 true인 쿠키를 받을 수 있게된다.

  1. credentials: "include", 를 추가한다.

그럼 이제 다시 로그인 해보자

짜잔 애플리케이션 부분에 쿠키가 정상적으로 들어와있다.

이렇게 되면 api요청할 때마다 쿠키에 저장되어있던 이 리프레쉬 토큰이 계속 따라다님

fetchedloggedin의 graphql- 헤더를 보면 토큰이 따라다니는 걸 볼 수 있다.

엑세스토큰은 글로벌스테이트, 리프레쉬토큰은 쿠키에 있다

여기까지하면 쿠키에 리프레쉬 토큰만 넣은것
여기서 에러가 나왔을 때 브라우저에서 캐치를 해서 재발급API를 요청해보자

app.tsx를 다시 수정한다.
(사실 이건 아폴로의 doc에 있다)

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

graphQLErrors :그래프큐엘의 에러들
foward : 다시 요청
if (graphQLErrors) { 에러들이 있다면
for (const err of graphQLErrors) : 에러 갯수만큼 for문으로 반복
if(err.extensions?.code === "UNAUTHENTICATED") 에러 메세지가 만료메세지면(캐치)

++꿀팁 어렵거나 내가 만든 코드들은 주석을 다는게 좋지만 그게 아니면 최대한 이름 안에 주석을 담아내기!!

  const client = new ApolloClient({
    link: ApolloLink.from([errorLink, uploadLink as unknown as ApolloLink]),
    cache: new InMemoryCache(),
  });

이후 이렇게 client를 세팅값을 넘겨줘서 모든 곳에서 useMUtation, useQuery, data?.fetchUseditem 을 사용할 수 있게 한다.

근데 위의 에러링크에서도 useMutation을 써야한다.
근데 꼭 aplloclient 를 쓸 필요가 없음
여기선 graphqlrequest 를 사용한다

예시 중 Usage를 응용해본다

// yarn add graphql-request 
// graphql 은 이미 가지고 있음
import { GraphQLClient } from "graphql-request";


gql 가져오기
  const RESTORE_ACCESS_TOKEN = gql`
    mutation restoreAccessToken {
      restoreAccessToken {
        accessToken
      }
    }
  `;

const errorLink = onError(({ graphQLErrors, operation, forward }) => {
    if (graphQLErrors) {
      for (const err of graphQLErrors) {
        // 1. 토큰만료 에러를 캐치
        if (err.extensions?.code === "UNAUTHENTICATED") {
          // 2. refreshToken으로 accessToken 재발급 받기 => restoreAccessToken
          const graphQLClient = new GraphQLClient(
            "https://백엔드API/graphql",
            {
              credentials: "include",
            }
          );
          const result = await graphQLClient.request(RESTORE_ACCESS_TOKEN);
          const newAccessToken = result.restoreAccessToken.accessToken;
          setMyAccessToken(newAccessToken);
          // 3. 기존에 실패한 요청 다시 재요청하기
          operation.setContext({
            headers: {
              ...operation.getContext().headers,
              authorization : `Bearer ${newAccessToken}`
            }
          })
          return forward(operation);
        }
      }
    }
  });

return forward(operation); operation 안에는 이전의 토큰만료된 토큰의 상태를 가지고있음. 그래서 operation.setContext(가지고있는걸 바꾼다)
...operation.getContext().headers, operation의 모든걸 가져오고 헤더를 바꾸는데
authorization : Bearer ${newAccessToken} 모든걸 가져온 상태에서 authoriztion 부분만 newAccessToken으로 바꾼다

여기서 긴 부분을 함수로 잘라서 src/libraries/getAccessToken으로 뺀다

import { gql } from "@apollo/client";
import { GraphQLClient } from "graphql-request";
import { Dispatch, SetStateAction } from "react";

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

export async function getAccessToken(
  setMyAccessToken: Dispatch<SetStateAction<string>>
) {
  try {
    const graphQLClient = new GraphQLClient(
      "https://backend04.codebootcamp.co.kr/graphql",
      {
        credentials: "include",
      }
    );
    const result = await graphQLClient.request(RESTORE_ACCESS_TOKEN);
    const newAccessToken = result.restoreAccessToken.accessToken;
    setMyAccessToken(newAccessToken);

    return newAccessToken;
  } catch (error) {
    console.log(error.message);
  }
}

이후 app.tsx에서
import { getAccessToken } from "../src/commons/libraries/getAccessToken"을 해오고 나서 함수를 가져온다

  const errorLink = onError(({ graphQLErrors, operation, forward }) => {
    if (graphQLErrors) {
      for (const err of graphQLErrors) {
        // 1. 토큰만료 에러를 캐치
        if (err.extensions?.code === "UNAUTHENTICATED") {
          // 3. 기존에 실패한 요청 다시 재요청하기
          operation.setContext({
            headers: {
              ...operation.getContext().headers,
              authorization: `Bearer ${getAccessToken(setMyAccessToken)}`, 
// 2. refreshToken으로 accessToken 재발급 받기 => restoreAccessToken
            },
          });
          return forward(operation);
        }
      }
    }
  });

이렇게만 남게된다.

이제 되나 본다
로그인완료 후 5초 뒤에 다시 돌아갔는데도 이름이 살아있다

근데 빨간색 graphql 메세지를 보면 실제로 토큰이 만료가 되었다고 떠있다.

근데 우리는 재발급 신청했고

로그인이 완료됐던것 이렇게 개념은 어렵지만 사용자는 아무고토 모른다

+++ 여기서 빨간 graphql이 왜 두개냐?? 하면 토큰만료 - 리프레시 토큰으로 재발급 - 로그인 성공 과정중에 여전히 로그인 시도를 했고 그 중간에 백엔드는 정상 토큰이 아닌걸로 인식해서 다시 토큰이 만료되었다는 메세지를 뽑은 것이다(두번)

아직 새로고침 했을 때는 잘 되지 않는다.

  useEffect(() => {
    // const accessToken = localStorage.getItem("accessToken");
    // if (accessToken) setMyAccessToken(accessToken);
    getAccessToken(setMyAccessToken);
  }, []);
  

use effect를 수정해서 새로고침할때마다 토큰을 받아오게 한다.

로그인을 안했는데 restore을 날리고 싶지 않다면

로그인 페이지에서

    localStorage.setItem("refreshToken", "true")

리프레시토큰이 있다고 설정하고,

useEffect(() => {
  // const accessToken = localStorage.getItem("accessToken");
  // if (accessToken) setMyAccessToken(accessToken);
  if (localStorage.getItem("refreshToken")) getAccessToken(setMyAccessToken);
}, []);

이렇게 바꿔서 리프레시 토큰이 있다면 으로 작동하게 한다.

요약 토큰토큰토큰

엑세스 토큰은 기본이라서 완벽히 숙지해야함, 리프레시는 조금 어려울 수 있음
과제에 많이 등장할 수 있다고 한다.

리프레시 토큰을 하게되면서 cookie의 옵션(httponly-document로 접근안됨) secure 옵션은 https만가능, 백엔드의 서비스 구성(리소스, 인증서비스), 마이크로 서비스 아키텍쳐

좋은 웹페이지 즐겨찾기