axios interceptors를 사용하여 auth 에러 똑똑하게 처리하기

chaaerim·2023년 1월 31일
6

사건의 발단 🤗

호기롭게 해보겠다는 이 한 마디를 시작으로 Toks의 OAuth2.0 인증 구현을 맡아서 하게 되었다.
덕분에 고통스럽지만 매우 매우 성장할 수 있었다고 생각한다.


그래서 OAuth2.0이 무엇인디

OAuth 2.0은 인증을 위한 개방형 표준 프로토콜이다.
유저가 똑스 서비스에 접속을 했을 때 만약 똑스에서 퀴즈 문제를 풀면 카카오톡으로 알림을 보내는 기능을 제공하고 싶다고 해보자. 그렇다면 유저가 똑스에 카카오 아이디와 비밀번호를 입력하고 똑스가 유저의 카카오에 접근할 수 있도록 하면 될 것이다. 그러나 이는 너무 위험해 보이는 방법이 아닌가.. ?! 만약 똑스의 서버가 털려서 유저의 카카오 아이디와 비밀번호가 유출되는 사고가 발생한다면 .....
이 때 OAuth를 이용하면 유저의 아이디와 비밀번호를 직접 똑스에 가지고 있지 않고 카카오에서 발급해주는 Access Token를 가지고 카카오에 접근할 수 있게 해준다. Access Token을 사용하게 된다면 유저의 정보를 보관할 필요 없이 로그인을 다룰 수 있게 된다.

이를 조금 있어보이게 얘기하면

OAuth2.프로토콜에서는 Third-Party 프로그램에게 리소스 소유자를 대신하여 리소스 서버에서 제공하는 자원에 대한 접근 권한을 위임하는 방식을 제공한다.

라고 할 수 있다.

OAuth2.0의 주요용어

OAuth2.0의 주요 용어로는 다음이 있다.

  • Authentication: 인증, 접근 자격이 있는지 검증하는 단계
  • Authorization: 인가, 자원에 접근할 권한을 부여하는 것이다. 인가가 완료되면 리소스 접근 권한이 담긴 Access Token이 클라이언트에 부여된다.
  • Access Token: 리소스 서버에게서 리소스 소유자의 보호된 자원을 획득할 때 사용되는 만료 기간이 있는 Token이다.
  • Refresh Token: Access Token 만료시 이를 갱신하기 위한 용도로 사용하는 Token이다. 따라서 Refresh Token은 일반적으로 Access Token보다 만료 기간이 길다.

카카오 로그인부터 시작

똑스는 일단 소셜로그인으로 카카오 로그인만 사용하기로 결정했다.
일단 내가 위의 인증 과정을 보고 기존에 카카오 소셜 로그인을 구현하던 방법을 기반으로 이해한 바를 간단하게 설명해보자면

  1. 클라이언트 쪽에서 카카오 로그인 페이지로 리다이렉트를 시킨다.
  2. 로그인 페이지에서 로그인에 성공하면 인가코드를 발급받고
  3. 인가 코드를 백으로 보내준다.
  4. 백에서는 클라이언트에서 보내준 인가코드를 바탕으로 Access Token과 Refresh Token을 생성하고 클라이언트에서 요청시 이를 보내준다.

⚡️ 그러나 우리가 구현한 방법은 좀 다르다 ?!?!

백에서 스프링 시큐리티를 이용하여 인증 구현을 해서 백에서 카카오 로그인페이지로 리다이렉트를 시키고 인가코드를 발급받고 이를 이용하여 토큰을 생성하는 작업까지 모두 처리해주었다. 그래서 다시 우리의 로그인 과정을 정리해보면..

  1. 카카오버튼을 눌렀을 때 백에서 지정한 API로 get요청을 보낸다.
  2. 백에서 인가코드를 발급받고 토큰 생성을 마치면
  3. 리다이렉트 페이지 Url의 쿼리로 Access Token과 Refresh Token을 넘겨준다.
  4. 클라이언트에서는 쿼리에서 Access Token과 Refresh Token을 꺼내 사용하면 된다.

이 과정을 이해하는 데까지 시간이 꽤 오래 걸렸다. 이해하는 과정에서 백에서 로그인을 맡은 분과 엄청나게 많은 대화를 주고 받았는데 죄송하기도 하면서 동시에 성장하는 과정이었다고 생각한다.


그래 토큰이 넘어오는 과정까진 이해가 되었어요. 그래서 로그인 구현은요 ?

자 이제 겨우 백과 토큰을 주고 받는 과정까지는 이해가 되었는데, Toks에서 로그인을 구현하기 위해 고려해야 할 사항이 몇 가지 있었다.

  1. 우리는 모노레포다. 전역적으로 편리하게 auth 상태에 대한 관리를 어떻게 할 것인가?
    • axios interceptors를 사용하고 이를 패키지화해서 전역에서 auth 에러 처리를 일괄적으로 할 수 있도록 하자.
  2. Access Token과 Refresh Token은 어디에 저장할 것이고 백과 토큰은 무엇을 이용하여 주고 받을 것인가?
    • 클라이언트에서 Access Token과 Refresh Token은 세션 스토리지에 저장하고 백과 통신할 때에는 Access Token을 헤더에 넣어 Authentication 처리를 하자.
  3. 언제 로그인에 실패했다고 볼 것이고 언제 다시 로그인 요청을 할 것인가?
    • 우리는 특별히 유저 권한에 따라 접근이 가능한 페이지가 나누어져 있는 것이 아니기 때문에 모든 auth 관련 에러에 대해서는 401에러를 내려주는 것으로 하자. 따라서 401 status일 때 Access Token과 Refresh Token의 유효성을 검증하면 된다.


위의 질문과 답변을 보면서 생길 수 있는 궁금증 몇 가지..

axios interceptors가 무엇인가요?

axios interceptors를 사용하게 되면 비동기 응답이 then 또는 catch로 처리되기 전과 request를 하기 직전에 request와 response를 가로챌 수 있다.
즉, axios interceptor를 사용하여 401과 같은 auth 에러 처리를 해놓으면 axios reponse를 받는 곳에서 하나하나 에러 체킹을 하지 않아도 된다는 엄청난 장점이 있다.


왜 토큰을 쿠키가 아니라 세션스토리지에 저장해서 사용했나요?

사실 여기서는 크게 장단점을 따지고 진행을 했기 보다는 백과의 논의를 통해 결정했다. 백에서 쿠키 설정을 추가로 진행하는 것이 일정에 무리가 있다고 판단하여 비교적 간단한 헤더에 토큰을 넣어 통신하는 방식을 선택했다.

그러나 Access Token과 Refresh Token을 모두 로컬이나 세션 스토리지에 저장하는 것 보다는 Refresh Token을 쿠키에 저장하는 방식이 더 보안상 안전하다고 하여 이 부분은 2차 mvp에서 수정을 할 수도 있을 것 같다.
다만, 어떤 방법이든 trade-off가 존재하기 때문에 단순히 보안 상의 장단점만 따지기 보다는 팀원들과 어떤 방법이 현재 우리 서비스에 더 적합한 방법인지 충분히 논의를 해보고 결정을 내릴 것이다.


로그인 플로우 정리

위의 질문에 대해 현구님과 열심히 로그인 플로우에 대해 고민하고 정리한 결과물.. 아마 두고두고 꺼내볼 듯 싶다..

사진으로 정리한 위의 플로우를 글로 정리해보자.

Toks를 방문하는 사람을 1️⃣ 아예 Toks를 처음 방문하는 사람, 2️⃣ 회원이지만 Access Token이 만료된 사람, 3️⃣ 회원이지만 Refresh Token이 만료된사람 이렇게 세 가지 케이스로 분류했다.
1️⃣ 아예 처음 방문하는 사람은 바로 로그인을 하게 하면 된다.
2️⃣ 회원이지만 Access Token이 만료된 사람은 Refresh Token을 백으로 보내 Refresh Token이 유효하다면 새로운 Access Token을 발급받는다.
3️⃣ 만약 Refresh Token까지 만료가 된 회원이라면 로그인 페이지로 리다이렉트 시켜서 다시 로그인을 하게 한다. 바로 위의 이 부분들이 axios interceptors에서 처리되어야 하는 부분이다.

로그인 시 이미 닉네임이 설정이 되어 있는 회원이라면, 즉 이미 회원 가입을 진행한 회원이라면 닉네임 설정 페이지가 아닌 홈페이지로 리다이렉트 시키고 만약 닉네임 설정이 되어있지 않다면 닉네임 설정 페이지로 리다이렉트 시킨다.

또한 Token을 받는 리다이렉트 페이지에서 토큰을 꺼내 세션스토리지에 저장한다. 그리고 패키지에서 일단 로그인 로직을 시작할 때 세션스토리지에 토큰이 있는지 확인하여 토큰이 있는 경우 불필요한 로직을 수행하지 않도록 한다.


자, 그럼 이제 직접 만들어볼 차례 !!

//http.ts
const authToken = {
  access: typeof window === 'undefined' && typeof global !== 'undefined' ? null : sessionStorage.getItem('accessToken'),
  refresh:
    typeof window === 'undefined' && typeof global !== 'undefined' ? null : sessionStorage.getItem('refreshToken'),
};

const redirectToLoginPage = () => {
  const isDev = window.location.hostname === 'localhost';
  window.location.href = isDev ? 'http://localhost:3000/login' : 'https://tokstudy.com/login';
};

//axios instance
const instance: ToksHttpClient = axios.create({
  baseURL: `${BASE_URL}`,
  headers: { Authorization: authToken?.access },
});

//1. 요청 인터셉터
instance.interceptors.request.use(
  function (config) {
    if (config?.headers == null) {
      throw new Error(`config.header is undefined`);
    }
    config.headers['Content-Type'] = 'application/json; charset=utf-8';
    config.headers['Authorization'] = authToken?.access;

    return config;
  },
  function (error) {
    return Promise.reject(error);
  }
);
  • 가장 먼저 세션스토리지에 토큰이 있는지 확인해서 유저가 로그인을 한 상태인지 파악한다.
  • axios instance를 생성하여 요청시 반복되는 baseURL 중복을 피한다.
  • resquest interceptors는 요청을 할 때마다 요청 직전에 수행되는 로직으로 요청을 할 때 먼저 다시 헤더에 Access Token을 넣어 헤더에 토큰이 없어 401에러가 나는 것을 방지한다.

사실상 에러는 response interceptors에서 처리하고 있다.

//2. 응답 인터셉터
instance.interceptors.response.use(
  response => response.data,
  async function (error) {
    if (error?.status === 401) {
      try {
        const {
          data: { refreshToken },
        } = await axios.post('/api/v1/user/renew', authToken.refresh);
        //refresh 유효한 경우 새롭게 accesstoken 설정

        if (error?.config.headers === undefined) {
          error.config.headers = {};
        } else {
          error.config.headers['Authorization'] = refreshToken;
          //sessionStorage에 새 토큰 저장
          sessionStorage.setItem('accessToken', refreshToken);
          // 중단된 요청 새로운 토큰으로 재전송
          const originalResponse = await axios.request(error.config);
          return originalResponse.data.data;
        }
      } catch (err) {
        redirectToLoginPage();
      }
    } else {
      throw error;
    }
  }
);

export const http: ToksHttpClient = instance;
  • response interceptors는 response를 받을 때마다 then과 catch로 처리되기 이전에 실행된다.
  • response의 error status가 401일 경우(Access Token에 문제가 있다는 것) Refresh Token을 가지고 새로운 Token을 발급받는다.
  • 그리고 새롭게 받은 Token을 다시 헤더와 세션스토리지에 넣는다.
  • 토큰을 저장한 이후 에러로 인해 받지 못한 응답을 새로운 토큰을 이용해 응답을 받는다.
  • 만약 Refresh Token도 만료가 되었다면 다시 에러가 내려올 것이므로 catch문에서 로그인 페이지로 리다이렉트 시켜준다.

마무리 👀

인증 구현을 처음 해보면서 고통스럽기도 했지만 그만큼 배운 것이 너무 많았다. 처음에 가장 어려웠던 것은 백과 토큰을 주고 받는 전체적인 플로우를 이해하는 것과 유저 케이스를 어떻게 나누어 로그인 분기처리를 어떻게 할 것인가를 결정하는 것이었다. 내가 작성한 로직이 모두 맞다고 할 수는 없겠지만 나와 같이 어려움을 겪는 사람이 또 있을 것 같아 로그인 작업을 하던 과정에서 고민했던 생각들과 과정을 자세하게 적어보았다. 글을 적으면서 또 다시 내가 생각했던 과정을 되짚어볼 수 있는 시간이었다. 분명 서비스가 확장되면 이 로그인 로직 또한 수정되어야 할 것이다. 그러나 앞으로는 훨씬 더 쉽고 좋은 방향으로 로그인 코드를 작성할 수 있을 것이라 믿기 때문에 기대가 된다 🤗


참고자료
https://axios-http.com/kr/docs/interceptors
https://dooopark.tistory.com/6
https://velog.io/@0307kwon/JWT%EB%8A%94-%EC%96%B4%EB%94%94%EC%97%90-%EC%A0%80%EC%9E%A5%ED%95%B4%EC%95%BC%ED%95%A0%EA%B9%8C-localStorage-vs-cookie

0개의 댓글