개발 환경

  • Java 17
  • SpringBoot 3.2.1
  • React 18.2.0
  • Zustand 4.5.0
  • mem 10.0.0


JWT 도입 배경

인증 정보는 세션방식으로 서버에 저장하거나 토큰 방식으로 클라이언트 사이드에 저장할 수 있습니다.

인증 정보를 서버에 저장하지 않고 클라이언트 측에 저장하게 된다면 토큰이 탈취당할 우려가 있어, 보안적인 측면에서는 좋지 않습니다.

그럼에도 불구하고 JWT를 도입한 배경은 다음과 같습니다.

  1. JWT는 서버에 상태를 저장하지 않고 클라이언트에서 정보를 저장하므로, 서버에서 상태를 관리할 필요가 없어집니다. 따라서 서버 부하가 감소한다는 이점이 있습니다.
  2. 만약 서버를 분리하게 된다면, 클라이언트 측에서 토큰 정보를 받으면 되기 때문에 확장성이 용이합니다.


이러한 이점이 크기 때문에 토큰 인증 방식을 도입했으며, 보안적인 측면은 다음 요소들로 강화했습니다.

  1. Access Token의 적절한 만료 기간 설정 및 사용자의 메모리에 저장
  2. Refresh TokenHttpOnly 속성 적용으로 JavaScript에서 접근 불가능
  3. 로그아웃 시, Redis에서 Access Token 정보 블랙리스트 적용


XSS와 CSRF 공격

그렇다면 XSS, CSRF란 무엇일까요?


XSS (Cross-Site Scripting) 공격

XSS는 악의적인 스크립트가 웹 페이지에 주입되어 사용자 브라우저에서 실행되는 경우입니다.

JavaScript를 삽입하여 정보를 빼가는 경우가 있습니다.

따라서 HttpOnly 속성을 적용해서 JavaScript에서 접근하지 못하도록 할 수 있습니다.


CSRF (Cross-Site Request Forgery) 공격

CSRF는 사용자가 의도하지 않은 요청을 악의적인 웹사이트를 통해 실행되도록 하는 공격입니다.

사용자가 이미 인증된 세션을 가지고 있을 때 공격자가 의도하지 않은 동작을 수행하게 하는 것입니다.

CSRF 공격을 막기 위해서는 Access Token을 특정 요청을 통해 수행하지 못하도록 해야 합니다.

따라서 Access Token은 사용자의 메모리에 저장하고, 새로고침 했을 때 메모리에서 날아가는 Access TokenRefresh Token으로 갱신하도록 구현했습니다.


Server 에서 로그인 처리하기

@PostMapping("/auth/sign-in")
public ResponseEntity<Void> signIn(@RequestBody @Valid SignInRequestDto requestDto) {
    Tokens tokens = authService.signIn(requestDto);
    ResponseCookie refreshTokenCookie =
            ResponseCookie
                    .from("refreshToken", tokens.getRefreshToken().getValue())
                    .path("/")
                    .httpOnly(true)
                    .maxAge(Duration.between(Instant.now(), tokens.getRefreshToken().getExpiredAt()))
                    .build();

    return ResponseEntity.status(HttpStatus.OK)
            .headers(headers -> {
                headers.add(
                        HttpHeaders.AUTHORIZATION,
                        BEARER_TYPE + tokens.getAccessToken().getValue()
                );
                headers.add(
                        HttpHeaders.SET_COOKIE,
                        refreshTokenCookie.toString()
                );
            })
            .build();
}
  • 로그인이 정상적으로 처리되면 Refresh TokenCookie로 설정합니다.
  • path("/")는 모든 경로에서 쿠키에 접근할 수 있도록 설정합니다.
  • httpOnly(true)를 적용하여 JavaScript를 통한 접근을 막습니다.
  • maxAge()Refresh Token의 만료 기간을 설정합니다.
  • 이후 HTTP 응답을 생성하여, Authorization headerAccess Token을 설정하고, SET_COOKIE headerRefresh Token을 설정한 Cookie를 추가합니다.


Client 구현

export const basicAxios = axios.create({
  baseURL: `${API_SERVER_URL}`,
  headers: {
    "Content-Type": "application/json",
  },
  withCredentials: true,
});

export const authAxios = axios.create({
  baseURL: `${API_SERVER_URL}`,
  headers: {
    "Content-Type": "application/json",
  },
  withCredentials: true,
});
  • Axios 인스턴스를 basicAxios, authAxios 두 가지로 나눠서 생성했습니다.
  • basicAxios는 인증이 필요하지 않은 요청에 사용됩니다.
  • authAxios는 인증이 필요한 요청에 사용되며, 요청 시 헤더에 인증 토큰이 추가됩니다.


const BEARER_TYPE = "Bearer ";

const parseToken = async (
  headers: RawAxiosResponseHeaders | AxiosResponseHeaders
) => {
  const authHeader: string = headers.authorization;
  if (!!authHeader && authHeader.startsWith(BEARER_TYPE)) {
    const accessToken = authHeader.substring(BEARER_TYPE.length);
    getTokenStore.getState().setToken(accessToken);
  }
};
  • parseToken은 HTTP 응답 헤더에서 토큰을 추출하고, 추출한 토큰을 상태 관리 객체에 저장하는 함수입니다.
  • 해당 함수를 통해 사용자의 메모리에 Access Token이 저장됩니다.


const reissue = mem(
  async () => {
    const { headers: responseHeaders } = await basicAxios.post(
      "/api/auth/reissue"
    );
    await parseToken(responseHeaders);
  },
  { maxAge: 1000 }
);
  • reissueRefresh Token 갱신을 수행하는 함수를 메모이제이션하여, 서버 부하를 줄이는 데 도움을 줍니다.


const onSuccess = (res: AxiosResponse) => {
  const { headers } = res;
  parseToken(headers);
  return res;
};

basicAxios.interceptors.response.use(onSuccess);
  • basicAxios를 통해 서버에 요청을 보낸 후, 해당 요청에 대한 응답이 도착하면 onSuccess 함수가 호출되어 응답 처리 로직이 수행됩니다.


authAxios.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  const { headers } = config;
  if (getTokenStore.getState().token) {
    headers.Authorization = BEARER_TYPE + getTokenStore.getState().token;
  }
  return config;
});
  • authAxiosRequest Interceptor를 설정합니다.
  • 모든 HTTP 요청을 보내기 전에 실행되는 함수로, config를 받아와서 처리합니다.
  • getTokenStore()를 통해 사용자의 토큰을 가져오고, 토큰이 있다면 요청 헤더의 Authorization 필드에 “Bearer “과 함께 추가합니다.


authAxios.interceptors.response.use(onSuccess, async (err) => {
  if (
    axios.isAxiosError < ErrorResponse > err &&
    err.response &&
    err.response.status === 401 &&
    err.config
  ) {
    const { config } = err;
    const { headers: configHeaders } = config;
    try {
      await reissue();
      configHeaders.Authorization =
        BEARER_TYPE + getTokenStore.getState().token;
      return basicAxios.request(config);
    } catch (e) {
      getAlertStore.getState().setAlertProps({
        children: "로그인이 필요한 서비스입니다.",
        handleConfirm: () => {
          getTokenStore.getState().clear();
          location.href = "/sign-in";
        },
      });
      return Promise.reject(e);
    }
  }
  return Promise.reject(err);
});
  • authAxiosResponse Interceptor를 설정합니다.
  • 모든 HTTP 응답을 받은 후 실행되는 함수로, 성공적인 응답은 onSuccess 함수로 전달되고, 실패 시에는 에러 처리 로직이 수행됩니다.
  • 토큰이 만료되었거나 인증이 실패했을 때, 자동으로 토큰을 재발급하는 reissue() 요청이 수행됩니다.
  • reissue() 요청에 실패 시에는 토큰을 비우고 로그인 페이지로 이동하게 됩니다.

Categories:

Updated:

Leave a comment