[Web] 보안성을 고려한 클라이언트 JWT 도입
개발 환경
- Java 17
- SpringBoot 3.2.1
- React 18.2.0
- Zustand 4.5.0
- mem 10.0.0
JWT 도입 배경
인증 정보는 세션방식으로 서버에 저장하거나 토큰 방식으로 클라이언트 사이드에 저장할 수 있습니다.
인증 정보를 서버에 저장하지 않고 클라이언트 측에 저장하게 된다면 토큰이 탈취당할 우려가 있어, 보안적인 측면에서는 좋지 않습니다.
그럼에도 불구하고 JWT
를 도입한 배경은 다음과 같습니다.
JWT
는 서버에 상태를 저장하지 않고 클라이언트에서 정보를 저장하므로, 서버에서 상태를 관리할 필요가 없어집니다. 따라서 서버 부하가 감소한다는 이점이 있습니다.- 만약 서버를 분리하게 된다면, 클라이언트 측에서 토큰 정보를 받으면 되기 때문에 확장성이 용이합니다.
이러한 이점이 크기 때문에 토큰 인증 방식을 도입했으며, 보안적인 측면은 다음 요소들로 강화했습니다.
Access Token
의 적절한 만료 기간 설정 및 사용자의 메모리에 저장Refresh Token
에HttpOnly
속성 적용으로JavaScript
에서 접근 불가능- 로그아웃 시,
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 Token
은 Refresh 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 Token
을Cookie
로 설정합니다. path("/")
는 모든 경로에서 쿠키에 접근할 수 있도록 설정합니다.httpOnly(true)
를 적용하여JavaScript
를 통한 접근을 막습니다.maxAge()
로Refresh Token
의 만료 기간을 설정합니다.- 이후 HTTP 응답을 생성하여,
Authorization header
에Access Token
을 설정하고,SET_COOKIE header
에Refresh 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 }
);
reissue
는Refresh 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;
});
authAxios
의Request 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);
});
authAxios
의Response Interceptor
를 설정합니다.- 모든 HTTP 응답을 받은 후 실행되는 함수로, 성공적인 응답은
onSuccess
함수로 전달되고, 실패 시에는 에러 처리 로직이 수행됩니다. - 토큰이 만료되었거나 인증이 실패했을 때, 자동으로 토큰을 재발급하는
reissue()
요청이 수행됩니다. reissue()
요청에 실패 시에는 토큰을 비우고 로그인 페이지로 이동하게 됩니다.
Leave a comment