프론트 엔드에서 JWT: AccessToken, RefreshToken 다루기

바다·2023년 5월 17일
9

web

목록 보기
2/2
post-thumbnail

팀프로젝트에서 로그인에 JWT 방식을 사용하기로 했고, 이를 다루면서 배우게 되고 해결한 오류들에 대해 정리해보려 한다.

1.JWT🔐 ?

JWT?

인증에 필요한 정보를  Base64 URL-safe Encode을 통해 암화시킨 JSON 토큰이다.

JWT 기반 인증?

로그인시 서버는 클라이언트에게 JWT토큰을 넘겨주고 이후에 인증이 필요한 API 요청에서 JWT토큰을 서버에 보내면 서버는 서버의 key를 이용해 해당 토큰이 유효한지 판단하고 유효한 토큰이라면 인증을 통과시키는 것이다.

JWT의 장담점

JWT는 인증에 필요한 정보가 담긴 토큰을 클라이언트에서 저장하기 때문에 DB조회를 하지않아도 되고 서버 확장성에 좋다. 그러나 이러한 장점은 클라이언트에서 토큰이 탈취될 수 있다는 단점이기도 하다.

그래서 토큰을 AccessToken, RefreshToken 으로 이중으로 나누어서 사용하고 있다.

AccessToken 🔑

  • 인증시, 필요한 정보가 담긴 토큰으로 인증을 통과할 수 있는 key라고 보면 된다.
  • 인증에 필요한 정보가 담기기 때문에 탈취에 대한 위험을 줄이기 위해 토큰의 유효기간이 매우 짧다.

RefreshToken 🔑

  • 유효 기간이 짧은 Access Token을 보조해주는 토큰으로 Access Token보다 유효기간이 길며, Access Token을 발급하는데 사용된다.

🤓 JWT에 대해 더 자세한 설명이 필요하다면 아래의 글들을 추천한다.

2. 프론트에서 AccessToken, RefreshToken 다루기

유저 인증 방식에 JWT를 사용하기로 했다면, 프론트 엔드가 해야할 일은 무엇일까?
토큰을 만드는 것은 백엔드의 몫이고 프론트 엔드는 서버가 보내주는 토큰을 받고 저장하고 인증 요청이 필요할 때 서버에 토큰을 보낼 수 있도록 해야한다.

1) 토큰 저장

토큰을 어디에 저장할 것인지에 대해 찾아보면 많이 추천하는 방식은 다음과 같다.

🙋 AccessToken은 코드 내의 변수로 저장하자!

🙋RefreshToken은 httpOnly 쿠키로 받아서 저장하자!

AccessToken은 아무래도 인증에 사용되는 토큰이기때문에 외부에 노출되지 않도록 변수에 저장하는 방법을 많이 추천했다.

RefreshToken은 인증에 직접적으로 사용하는 토큰이 아니지만 RefreshToken이 없어지면 로그인을 통해 다시 토큰을 받아야하기 때문에 브라우저의 어디인가에 저장되어야하고 로그인 유지 기능을 사용한다면 브라우저 창이 닫아도 저장이 유지되는 로컬 스토리지와 쿠키 중에서 선택해야한다.

둘 다 탈취의 위험은 존재한다.
로컬 스토리는 XSS(Cross Site Scripting) 공격에 취약하고 쿠키는 CSRF(Cross-Site Request Forgery) 공격에 취약하다.

그럼에도 많은 글들에서 쿠키를 추천한 이유는 XSS는 토큰 값 자체를 탈 취하는 방식이며 RefreshToken는 AccessToken을 발급받는 용도일뿐 사용자의 정보가 담기기 않으며 httpOnly를 설정해 JS를 통한 접근을 막고 RefreshToken을 일회용 처럼 사용하는 RTR(Refresh Token Rotation)을 도입해 탈취된 RefreshToken의 사용을 막을 수 있다는 이유였다.

2) React에서 AccessToken, RefreshToken 다루기

A. AccessToken

API 요청으로 서버에서 받은 AccessToken을 서버와 통신하는데 사용하는 라이브러리(axios, fetch)에 default값으로 header에 넣어 주면 된다.

axios.create를 사용하고 있었기 때문에 나는 다음과 같이 코드를 짰다.

  • src/api/index.ts
 export const httpClientForCredentials = axios.create({
  baseURL: import.meta.env.PROD ? import.meta.env.VITE_SERVER_API_URL : '/',
   // 서버와 클라이언트가 다른 도메인일 경우 필수 
  withCredentials: true, 
});
  • src/api/auth/login.ts
export const onLogInSuccess = (response: AxiosResponse) => {
  const { accessToken } = response.data;
    //access token - 변수로 이용 
httpClientForCredentials.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
  
  //  AccessToken 만료 1분전에 RefreshToken으로 AccessToken을 받아오는 함수를 실행하는 코드
  ....
};

export const onLogIn = async (params: LogInAPIParams) => {
  try {
    const response = await httpClientForCredentials.post(LOG_IN_PATH, params);
    if (response.status === 200) {
	onLogInSuccess(response);
    }
  } catch (error) {
    const axiosError = error as AxiosError;
     // axiosError 를 다루는 코드 
  }
  return result;
};

B. RefreshToken

RefreshToken을 서버에서 쿠키로 받아오기로 했다면, 프론트 엔드에서 RefreshToken을 쿠키에 저장하기 위해 따로 코드를 짤 필요 없다. 알아서 저장되고 (서버와 클라이언트의 도메인이 같다면) HTTP 요청 시, 서버에 알아서 보내지기 때문이다.

그러나, AccessToken을 변수로 사용했을 경우 문제는 새로고침 시 변수로 저장된 AccessToken이 사라지기 때문에 새로고침 시 RefreshToken을 이용해 AccessToken을 받아와서 변수로 저장되게 해야한다.

  • src/api/auth/login.ts
export const onSilentRefresh = async () => {
    try {
      const response = await httpClientForCredentials.post(TOKEN_REFRESH_PATH);
      if (response.status === 200) {
        // AccessToken을 변수로 저장
        onLogInSuccess(response);
      }
    } catch (error) {
      const axiosError = error as AxiosError;
      if (axiosError.response?.status === 401) {
        // refresh token 만료 - 로그인 페이지 이동
      }
    }
  
};
  • src/App.tsx
  useEffect(() => {
    onSilentRefresh();
  }, []);

App.tsx에서 useEffect를 이용하면 페이지가 새로고침 될 때 RefreshToken으로 AccessToken을 재발급 받을 수 있지만, App 하위에 Route를 사용할 경우 페이지의 경로가 변경 될 때도 onSilentRefresh가 실행되는 문제가 있다.

3. 서로 다른 도메인간 쿠키 공유

만약 서버와 클라이언트의 도메인이 다를 경우에 RefreshToken을 쿠키로 공유할 거라면 주의해야할 부분이 있다.

1) 프론트 엔드

프론트 엔드에서는 withCredentials를 꼭 설정해주어야한다. 그래야 CORS오류를 피하고, 브라우저가 서버로부터 쿠키를 받아서 저장할 수 있다.

axios

axios.get(url, {withCredentials:true});
  • axios.create를 사용한다면 기본 설정으로 넣어주고 시작하거나 꼭 default로 설정을 넣어주어야한다

//CASE1  처음부터 설정
const instance = axios.create({
  baseURL: 'https://api.example.com',
  withCredentials: true,
});
// CASE2. 나중에 설정 
const instance = axios.create({
  baseURL: 'https://api.example.com',
});

// 인스턴스에 `withCredentials` 추가로 설정 
instance.defaults.withCredentials = true;

⚠️create로 만든 axios를 사용할 경우 option을 넣어주는 방식이 axios를 바로 사용하는 경우와 다르다는 것을 주의해야한다.

이 부분을 간과해서, 배포 사이트에서 쿠키가 저장이 되지 않는 오류가 있었다. proxy 서버를 이용한 개발 환경에서는 proxy서버를 이용했기에 CORS오류도 없었고 쿠키의 도메인과 클라이언트의 도메인이 같아서 쿠키가 저장이 되었다. 😥

fetch

fetch(url, {
  credentials: 'include'
})

2) 백엔드

백엔드에서 다른 도메인의 클라이언트에게 쿠키를 전달하고자 한다면 다음의 설정이 필요하다.

A. SameSite=None

Chrome의 SameSite 기본값이 Lax 변경되었기 때문에 SameSite=None으로 꼭 설정해주어야하고,

B. secrue

SameSite=None으로 했기 때문에 https로 통신하는 경우에서만 쿠키를 전달할 수 있도록 secrue 설정을 추가해주어햐한다.

C. Access-Control-Allow-Credentials :true

D. Access-Control-Allow-Origin : 클라이언트 도메인

3.마무리

로그인에 대해 다루고 싶어서 팀 프로젝트에서 로그인 부분을 맡겠다고 자원했다. 그리고 JWT 기반 인증 방식을 다루면서 백엔드 팀과 소통하고 오류를 해결하는 과정 속에서 API, axios.create , JWT , 세션스토리지/로컬스토리지/쿠키에 대해 배울 수 있는 귀한 경험을 했다.

개발 환경에서 되던 로그인이 배포 사이트에서 되지 않아서 배포에 이용되는 firebase문서과 여러 자료들을 찾아보고 백엔드 팀과 코드를 고치는 과정에서 문제가 풀리지 않아서... 힘들었지만 그래도 오류를 잡았을때 뭔가 허탈하면서도 좋았다.

참고 자료

profile
🐣프론트 개발 공부 중 (우테코 6기)

0개의 댓글