[OMT] 로그인 Token 보안2 (refreshToken과 Cookie)

2024.03.07(목)

OMT

accessToken을 메모리에 저장하는 것을 완료한 뒤에 refreshToken을 쿠키에 담아 보내기만 하면 모든것이 끝나겠다 생각했고 그 과정은 정말 간단할 거라고 생각했다.

큰 오산이였다.

우선 accessToken이 만료되었을 때 갱신 방법이다.

1. 인터셉터 설정하기

​ 요청을 보내기 전에 요청을 가로채는 인터셉터를 설정한다.

axiosInstance.tsx
const axiosInstance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL
});

export function setupAxiosInterceptors({
  setAccessToken
}: {
  setAccessToken: (token: string) => void;
}) {
  axiosInstance.interceptors.response.use(
    (response) => response,
    async (error) => {
      const originalRequest = error.config;

      if (error.response?.status === 401 && !originalRequest._retry) {
        originalRequest._retry = true;

        try {
          const response = await axiosInstance.get('/api/v1/auth/refresh');
          console.log('instance :', response);
          const newAccessToken = response.data.body.token;
          setAccessToken(newAccessToken);

          originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
          return axiosInstance(originalRequest);
        } catch (refreshError) {
          return Promise.reject(refreshError);
        }
      }

      return Promise.reject(error);
    }
  );
}

export default axiosInstance;
2. AccessToken 만료 확인

​ 인터셉터에서 보내는 요청의 AccessToken이 만료되었는지 확인한다.

3. RefreshToken으로 AccessToken 재발급 받기
4. 재발급 받은 AccessToken으로 전역상태 업데이트
5. 기존 요청 재개하기
6. RefreshToken이 만료라면 재로그인 요청

Interceptor 함수 로직까지 작성해놓고 accessToken 만료까지 확인했다.. 그 다음에 문제가 생겼다…

🧐 왜 내 쿠키가 안담기는거야?

삽질 첫 번째

브라우저 쿠키는 요청시 자동으로 담긴다고 해서 내 쿠키가 당연히 가고있는 줄 알았어

클라이언트에서 요청 헤더에 만료된 AccessToken을 담아서 /api/refresh로 Get 요청을 보내면 우리 서버에서는 아래와 같은 응답을 보내주었다.

AccessToken이 아직 만료되지 않았을 때

{
  "header": {
    "code": 500,
    "message": "Not expired token yet."
  },
  "body": null
}

유효하지 않은 RefreshToken일 때

{
    "header": {
        "code": 500,
        "message": "Invalid refresh token."
    },
    "body": null
}

유효한 RefreshTokne 일 때

 {
     "header": {
         "code": 200,
         "message": "SUCCESS"
     },
     "body": {
         "token": "eyJhbGciOiJIUz~~~~~~~~"
     }
 }

여기서 내가 가장 많이 본 응답이 Invalid refresh token 이다.

무엇이 잘못된 걸까.. 내 코드만 계속해서 보았고 수정했다.

내 코드를 보고 해결되는게 아니라 개발자도구에서 Network 탭을 클릭해 정확한 문제를 파악하고자 했다.

스크린샷 2024-03-07 18 49 54

…? 없다.. Tab에도 없고 요청 헤더에도 없다..

삽질 두번째

아 .. withCredentials: true 설정을 해야 쿠키가 전송되는구나

스크린샷 2024-03-07 18 49 54

?? Cookies Tab은 생겼는데 요청 헤더에 쿠키가 안보인다.

사실 처음에 요청 헤더에 없지만 Tab에 Cookie가 보이니 전송된거라고 생각했다. 이게 가장 한심했던 생각이였고 이것 때문에 가장 오랜기간 삽질을 했다.

왜냐면 쿠키탭을 눌러보면

스크린샷 2024-03-07 18 53 26

뭔가 잘 간 것 같았다… 하지만 저기 느낌표가 보이는가… (이땐 몰랐다..)

삽질 세번째

PostMan Test는 잘 되는데 …?

postman

도대체 차이가 뭘까… 여기서 머리가 깨질 것 같았다.

그렇다.. postman은 Chrom과 다르기에 브라우저의 기본 설정이 다르기에 잘 가고 잘 온 것이다..

삽질 네번째

이건 잘 가고 있는게 아니다 위의 SameSite 속성에 막혀 가지 못하고 있는 것이다.

이 속성에 막혀 가지 못한다는 걸 3일만에 알게되니 정말 내 스스로가 짜증나고 한심했다.

스크린샷 2024-03-07 18 56 02

하지만 한심한 건 한심한 거고 이걸 해결해야겠지?

쿠키의 보안은 크게 3가지가 있다.

Http Only

Cookie를 HTTP 통신에서만 사용될 수 있도록하는 옵션.

설정을 하지 않을 경우 XSS 공격을 통해 쿠키에 쉽게 접근할 수 있다. Cookie에 Refresh Token을 저장한다면 필수.


Secure

Secure 설정이 활성화되면 HTTPS 를 통해서만 Cookie가 발급.


SameSite ( 나를 괴롭힌 …)

Strict Cross Site에서 요청을 보내면 쿠키를 보내지 않는다.

Lax [ 현재 크롬에서 기본으로 설정되어 있다 ] Cross Site에서 요청을 보내면, 특정한 조건이 만족할 때만 쿠키를 전달. 특정한 조건이란 HTML의 최상단에서 페이지 이동이 일어나는 경우를 의미. <a> 태그나 location.href 속성을 사용하는 상황이 있다.

None Cross Site에서 요청을 보내면 쿠키를 전달.

chrom은 기본값이 None이었지만 2020년 2월 4일 chrome 80 버전이 배포되면서 CSRF 공격을 예방하기 위해 SameSite의 default 값이 Lax로 변경되었다.

또한 추후 장기적, 단계적으로 타사 쿠키에 대한 모든 지원을 완전히 중단한다고 선언했다.

그럼 SameSite의 속성을 변경하면 되는거 아니야?

음.. 지금까지 내가 알아본 바로 프론트단에서 sameSite의 속성을 변경하는 것은 거의 불가능하다.

백엔드 분에게 이를 말씀드렸고 논의 끝에 SameSite 속성을 None으로 변경하기로 했다. SameSite를 None으로 변경하고 Secure을 설정해 Https로 만의 쿠키 전달을 목적으로 리팩토링을 진행했지만 백엔드 분이 사용하시는 라이브러리와 툴이 SameSite의 속성을 변경하는 메서드가 없어져서 불가능하다는 답변을 받았다….

그래.. 이건 단기적인 해결 방법일 뿐 어차피 Chrom은 장기적으로 SameSite = none의 설정 자체를 모두 막아버릴 것이고 이는 보안적으로도 좋지 못하기 때문에 도메인을 일치시키는 것으로 해결방안을 마련했다.

SameSite를 판단하는 기준

www.google.comaaa.google.com

즉 서브 도메인만 다른 경우 SameSite인가? 이고 결론은 SameSite 이다.

다만 하나 알고가야할건, 이를 나누는 기준이 Public suffix(공개 접미사)에 명시된 최상위 도메인을 비교한다는 것이다. suffix가 .com 이라면 suffix 앞까지를 하나의 site로 보는 것이다. 위 예시에서는 google.com이 하나의 site이다.

그래서,

a.google.comb.google.com은 SameSite이지만,

a.github.iob.github.io는 cross-site 이다.

여기서는 io가 public suffix가 아니고 github.io 가 suffix이기 때문에 a.github.io 가 하나의 site이기 때문이다.

또한 suffix도 apigee.io 같이 .io로 다양하게 있지만 현재 도메인에 적용되는 제일 긴 suffix를 기준으로 한다는 점도 잊지 말아야 한다.

자 그럼 도메인을 변경해보자

현재 프론트 배포 URL은 https://omt-onmyticket.vercel.app/ , 백엔드 서버 배포 URL은 https://backomt.shop이다.

OO님 혹시 서버 도메인 바꿀 수 있나요 ? 그의 대답은 No였다.

그렇다면 내가 바꿀 수 밖에… 내가 바꿔야할 도메인은 front.backomt.shop 이런 식…? 이면 되지 않을까

AWS Route53

스크린샷 2024-03-07 19 18 53

가비아

스크린샷 2024-03-07 19 18 53

역시 지원하지 않는다..

일단은…이 Token 관련해서 너무 많은 시간을 쏟아서 여기서 잠시 중단하고 해결책을 다시 한번 찾아보기로 했다.

혹시 프론트단에서 더 할 수 있는게 있는지 계속해서 찾아보고 우리와 같은 케이스가 있는지도 계속해서 찾아봐야겠다…