최종 프로젝트 트러블슈팅 - Http-only cookie refresh token과 Credentials

노재원·2024년 9월 11일
1

내일배움캠프

목록 보기
89/90
post-custom-banner

프론트엔드를 맡다

최종 프로젝트에선 크게 핵심 비즈니스 로직인 와인 추천을 담당하고 이후엔 그래도 나름 앱 클라이언트를 다뤄본 적 있으니 React로 프론트엔드 담당까지 맡게 되었다. 최종프로젝트인 만큼 눈에 보이는 정도의 결과물을 함께 제출해야 해서 어쩔 수 없었다.

아무래도 백엔드 취업할 건데 프론트를 담당하는게 상대적으로 선호되지 않긴 했고 나도 웹은 고등학생 시절부터 HTML-CSS-Javascript를 상당히 못다루는 편이었지만 다른 사람들에 비하면 훨씬 할만할테니 내가 단독으로 진행하게 됐다.

웹 못하는건 여전해서 왜 안되는지 모르겠는 부분이 많았지만 React + Typescript는 내가 잘 모르는 스크립트 언어의 모양이 아닌 비교적 익숙해진 Swift, Kotlin의 모양새도 나름 갖추고 있어 적응할만 했다. Swift나 Kotlin이 스크립트 언어의 특성을 오히려 따라갔다고 하는 얘기를 본 적 있는데 그래서 적응이 쉬웠던 게 아닌가 생각이 든다.

특히 React의 컴포넌트 기반 UI 관리는 동적으로 UI를 처리해야할 때 너무나도 어려웠던 HTML 구조를 훨씬 쉽게 짤 수 있어 이해가 됐었다.

다만 어디까지나 초간단 웹사이트의 모양새였기 때문에 가능했다고 생각하고 애니메이션, 컴포넌트의 배치 관리같은 속성 영역은 제대로 공부할 시간 없이 빠르게 적용만 하다보니 프론트엔드가 할만한가? 라고 묻는다면 전혀 그렇지 않다고 얘기할 수 있다.

아직까지는 차라리 앱 네이티브를 하겠다 라는 생각이 들기도 하지만 벌써 안한지 4년이나 된 앱을 다시 잡으면 Lifecycle이나 Context 영역은 그대로여도 UI/UX를 다루기엔 형편없지 않을까 생각이 든다.

다만 이번 프론트엔드의 경험은 나중에 백오피스라도 개발하게 된다면 React native 또는 Flutter 같은 새로운 기술을 배울 때 좋은 경험이 되지 않을까 싶다.

JWT 인증과 Refresh token

우리가 개발한 Spring 서버는 인증 절차를 JWT 기반의 인증 방식을 채택했다. Cookie나 Session 기반 인증이랑 비교하면 Stateless의 장점, 보안의 장점, 적절한 Payload를 넣어서 인가에 대한 처리도 쉬운 만큼 JWT 라이브러리 하나만 잘 써도 인증/인가의 구현을 간단히 처리할 수 있기 때문이다.

다만 Stateless의 장점을 그대로 유지하려다 보니 토큰이 탈취당할 수 있는 점은 항상 유의해야 하는데, 그 점을 보완하기 위해 Refresh token까지 도입했다. 이전에 앱 개발자일 때는 쓰는 입장만 됐었는데 생성하고 관리하는 입장이 된 셈이다.

로그인시 Refresh token을 클라이언트에 같이 보내주고 Access token은 생명 주기를 1시간으로 짧게 설정해서 탈취당해도 금방 만료되게 설정했고 Refresh token은 일주일로 설정해 한 번 로그인하면 일주일간은 로그인이 그대로 유지되는 셈이다.

이후 나는 Access token이 만료됐다는 Response를 받으면 Refresh token을 헤더에 포함해서 Access token을 새로 발급받는 Refresh API에 요청을 보내 새로운 Access token을 발급받는 방식으로 클라이언트를 구현했다. 나는 Axios의 Interceptor를 설정하는 방식으로 재발급을 쉽게 처리했다.

다만 이 과정에서 Http-only cookie로 설정한 Refresh token을 이용하는 방법에 대해서 새로 알아봐야 했다.

Http-only cookie과 Credentials

일반적인 Cookie는 그냥 Javascript로 조회할 수 있다. 그래서 해커가 Javascript에 간섭해 Cookie를 조회할 수 있게 하면 탈취가 가능한 것이고 탈취한 토큰으로 API를 마음대로 쓰게되는 상황이 벌어질 수도 있다. 이런 방식의 공격을 XSS라고 부른다.

그렇기에 Javascript로 접근할 수 없게 하려면 Http-only cookie를 설정해주면 된다. 이렇게 하면 개발자도 브라우저에 저장된 Cookie를 코드로 접근할 수 없지만 해커 또한 불가능해져서 공격을 방지하는 방법이다.

다만 이러면 내가 개발하는 Client에서도 Refresh token을 API의 Header에 코드로 조회해서 포함시키는게 아닌 Axios 요청에 withCredentials 속성을 사용하게 된다.

여기서 말하는 Credentials는 쿠키나 Authorization 헤더를 포함한 요청인지를 의미한다고 한다. 다만 Authorization 헤더는 굳이 withCredentials를 활성화 하지 않아도 Header payload에 잘 포함되어 요청이 날아간다.

그러니 개발자가 읽지 못하는 Http-only cookie를 요청에 첨부하고 싶을 때만 활성화하면 되니 Interceptor에서 Refresh token을 읽어야할 때만 withCredentials를 True로 만들어줬다. 그런데 Spring에서 제대로 Refresh token을 읽지 못하는 상황이 발생했다.

Spring에서 추가로 Web config을 추가해줘야 하는게 있었다.

@Bean
    fun corsConfigurer(): WebMvcConfigurer {
        return object : WebMvcConfigurer {
            override fun addCorsMappings(registry: CorsRegistry) {
                registry.addMapping("/**")
                    .allowedOrigins("http://localhost:5173", "https://sober-wachu.com")
                    .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                    .allowedHeaders("*")
                    .allowCredentials(true)
            }
        }
    }

CORS 관련 요청을 처리하기 위한 Web config으로 Spring은 Credentials가 포함된 요청을 받으려면 설정이 필요했다. 핵심이 되는 부분은 allowedOriginsallowCredentials 이 된다.

allowCredentials을 활성화하면 요청에서 쿠키를 전부 받겠다는 의미인데 이를 활성화하면 각종 요청에서 전부 받을 수 없게 Spring이 제한을 해서 어떤 Origin만 허용할 지에 대한 주소도 설정이 필요하다.

그래서 FE의 VITE 개발 주소와 프론트 도메인을 함께 설정해서 Credentials를 받을 수 있게 처리하니 Retry API에서 정상적으로 Refresh token 조회가 가능했다.

여기서 추가적으로 보안을 위해 Methods를 제한하거나 Headers를 Authorization, Content-type 이나 우리가 설정한refreshToken 정도로 제한하면 미리 설정해둔 요청에만 보안을 열어줄 수 있으니 더욱 안전해진다.

다만 미리 좋은 습관을 들이면 좋겠지만 이런 점을 짚어두기만 하고 개발 속도를 위해 일단 열어둔 채로 개발을 진행했다.

Spring에서 구현한 Login / Refresh API 코드

    @PostMapping("/auth/login")
    fun login(@RequestBody request: LoginRequest): ResponseEntity<String> {
        val token = memberService.login(request)

        val cookie = ResponseCookie.from("refreshToken", token.refreshToken)
            .httpOnly(true)
            .secure(true) // HTTPS 통신이 아니면 Cookie를 전송하지 않는다
            // api 서브 도메인을 사용하는 API 서버와 서브도메인이 없는 클라이언트 주소는 크로스 도메인으로 인식한다.
            .sameSite("None") 
            .maxAge(7 * 24 * 60 * 60)
            .path("/")
            .build()

        return ResponseEntity.status(HttpStatus.OK).header(HttpHeaders.SET_COOKIE, cookie.toString())
            .body(token.accessToken)
    }
    
    @PostMapping("/auth/refresh-token")
    fun refreshAccessToken(
        @CookieValue("refreshToken") refreshToken: String
    ): ResponseEntity<String> {
        val tokenResponse = memberService.refreshAccessToken(refreshToken)
        return ResponseEntity.ok().body(tokenResponse.accessToken)
    }

로그아웃 진행시 Refresh token을 Cookie에서 삭제해야 하는데 이 또한 클라이언트는 Javascript에서 Cookie에 접근할 수 없으니 삭제도 불가능해져서 Logout API에서 해당 쿠키를 삭제해주는 것이 좋다.

    @PostMapping("/auth/logout")
    fun logout(response: HttpServletResponse): ResponseEntity<Void> {
        val deleteCookie = ResponseCookie.from("refreshToken", "")
            .httpOnly(true)
            .secure(true)
            .sameSite("None")
            .maxAge(0)
            .path("/")
            .build()
        return ResponseEntity.status(HttpStatus.NO_CONTENT).header(HttpHeaders.SET_COOKIE, deleteCookie.toString())
            .build()
    }

React에서 구현한 Client 코드

// axios client
export const wachuApiClient: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_ENDPOINT as string,
  paramsSerializer: (params) => {
    return qs.stringify(params, { arrayFormat: "repeat" });
  },
  headers: {
    "Content-Type": "application/json"
  },
});

// Request interceptor
wachuApiClient.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const requestKey = `${config.method}:${config.url}`;

    // 로그아웃 또는 Refresh 일 때만 Credentials를 설정한다.
    if (
      config.url?.includes("/logout") ||
      config.url?.includes("/refresh-token")
    ) {
      config.withCredentials = true;
    }

    return new Promise(/*...*/);
  },
  (error) => {
    /*...*/
  }
);

// Response interceptor
wachuApiClient.interceptors.response.use(
  (response) => {
    /*...*/
  },
  async (error) => {
    const originalRequest = error.config;

    // 401 Unauthorize Error가 발생하면 Retry를 설정하고
    // Refresh API를 호출해 저장된 Token을 새로운 Access token으로 변경한다
    if (
      error.response &&
      error.response.status === 401 &&
      !originalRequest._retry
    ) {
      originalRequest._retry = true;
      try {
        const response = await axios.post(
          `${import.meta.env.VITE_API_ENDPOINT}auth/refresh-token`,
          {},
          { withCredentials: true }
        );

        if (response.status === 200) {
          const newAccessToken = response.data;
          localStorage.setItem("token", newAccessToken);

          originalRequest.headers["Authorization"] = `Bearer ${newAccessToken}`;

          return axios(originalRequest);
        }
      } catch (refreshError) {
        // Refresh token이 만료됐으면 로그아웃 처리를 진행
        alert("로그인이 필요합니다.");
        await axios.post(
          `${import.meta.env.VITE_API_ENDPOINT}auth/logout`,
          {},
          { withCredentials: true }
        );
        localStorage.removeItem("token");
        window.location.href = "/login";
        return Promise.reject(refreshError);
      }
    } else {
      /*...*/
    }

    return Promise.reject(error);
  }
);
post-custom-banner

0개의 댓글