지금까지 회사 내부에서 진행한 프로젝트에서는 access token과 refresh token 모두 쿠키로 관리해왔다. Nuxt.js를 기술 스택으로 활용하면서, Nuxt에서 제공하는 useCookie 컴포저블을 사용해 쿠키 값을 관리해왔다.
하지만 이전 JWT 관련 포스팅의 마지막 부분에서 언급했듯이, 나는 보안적인 이유로 access token은 전역 변수로, refresh token은 httpOnly, secure, samesite 옵션이 적용된 쿠키로 저장하는 것이 더 적절하다고 판단했다.
따라서, 기존의 토큰 관리 방식을 개선하기 위해 회사 내부 템플릿 코드를 수정한 경험을 공유하고자 한다.
useCookie
https://nuxt.com/docs/api/composables/use-cookie
우선 Nuxt3에서 제공하는 useCookie 컴포저블에 대해서 살펴볼 필요가 있다.
공식 문서에서는 useCookie를 SSR-friendly한 컴포저블로 설명하고 있다.
"SSR-friendly"라는 표현은 말 그대로, 서버 사이드 렌더링(SSR) 환경에서도 정상적으로 작동할 수 있다는 뜻이다. 일반적으로 쿠키는 브라우저 환경(클라이언트)에서 주로 사용되기 때문에, 서버 환경에서 이를 다루는 것이 조금 더 복잡할 수 있다. 하지만 useCookie는 SSR-friendly하게 설계되어 있어, 서버와 클라이언트 모두에서 안전하고 일관된 동작을 기대할 수 있다.
그렇다면 이제 핵심은 httpOnly, secure, samsite 옵션을 설정해주는 것이다. 이 옵션들은 useCookie의 옵션으로 제공된다.
위의 내용에 기반하여, 기존에 사용하던 token Store 관련 코드에서 useCookie에 다음과 같이 httpOnly, secure, samesite 값을 true로 설정해줬다.
// useTokenStore.ts
// ...
const refreshToken: Ref<string> = useCookie('refreshToken', {
httpOnly: true,
secure: true,
sameSite: true,
});
const setRefreshToken = (token: string) => {
accessToken.value = token;
};
아래 사진은, 로그인을 눌렀을 때 로그인을 성공하여 access token와 refresh token을 받아오는 데에는 성공하고 있는 상황에서 실행한 결과이다.
하지만 토큰 값이 쿠키에 추가되지 않는 것을 볼 수 있다.
이유가 무엇일까? 옵션을 추가하기 전에는 쿠키가 정상적으로 설정되었기 때문에, 추가한 옵션에 문제가 있는 것이 분명했다.
하나씩 옵션을 제거하며 테스트한 결과, httpOnly 옵션을 true로 설정했을 때 쿠키가 추가되지 않는 상황임을 확인했다. 그 외에 secure, samesite 옵션을 잘 설정되고 있었다.
왜 그럴까? 여기서 httpOnly 속성에 대해 정리해볼 필요가 있다.
httpOnly
httpOnly 속성이 설정된 쿠키는 클라이언트 측 Javascript에서 접근할 수 없다. 이는 보안상의 이유로, XSS 공격으로부터 보호하기 위한 중요 방어 수단이다.
또한, httpOnly 쿠키는 오직 서버 측에서만 설정할 수 있으며 , 클라이언트 측에서 이를 설정하려고 하면 무시된다.
따라서 내가 작성한 코드처럼 클라이언트 측에서 useCookie를 사용하여 쿠키를 설정하여 httpOnly 속성을 지정하려고 하면, 이 쿠키는 브라우저에서 설정되지 않는다. 앞서 언급했듯이, 브라우저의 클라이언트 측 코드는 httpOnly 쿠키를 생성하거나 수정할 수 없기 때문이다. 이는 브라우저에서 보안을 위해 엄격하게 관리되는 부분이다.
그렇다면 어떻게 해야할까? 여기서 useCookie의 SSR-friendly 특성을 활용해야 한다. Nuxt에서는 서버 측에서 실행되는 코드를 따로 작성할 수 있으며, 이를 통해 httpOnly 속성을 포함한 쿠키를 설정할 수 있다. server 폴더를 만들어 해당 폴더 내에서 작성된 코드들은, Nuxt에서 자동으로 스캔하여 API 및 서버 핸들러로 등록한다. 이렇게 하면 httpOnly 속성을 포함한 쿠키를 서버 측에서 안전하게 설정할 수 있다.
server 디렉토리에 대한 자세한 내용은 역시 공식문서에서 확인할 수 있다.
https://nuxt.com/docs/guide/directory-structure/server
여기서 또 한 가지 중요한 점은, server API에서 쿠키를 처리할 때는 useCookie 대신에 getCookie + setCookie 메소드를 활용해야 한다는 것이다. 관련 내용 역시 공식 문서에 언급되어 있다.
https://nuxt.com/docs/api/composables/use-cookie#cookies-in-api-routes
적용한 개선 사항
위 내용을 바탕으로 기존 로직을 다음과 같이 수정했다.
기존 로직:
- 백엔드에 로그인 API를 요청.
- 로그인에 성공하여 받은 access token과 'efresh token을 모두 useCookie로 설정. (쿠키 설정 로직이 클라이언트 단에서 처리됨)
수정된 로직:
- 백엔드에 로그인 요청을 보내는 API를 server/api/login.ts 내에 정의하여, 이를 통해 로그인 요청을 처리.
- 커스텀 API에서 백엔드 로그인 API를 호출하고, access token은 전역 변수(store)에, refresh token은 쿠키로 설정.
하지만 이 로직에서 두 가지 문제가 발생했다.
- 기존 API 호출 로직이 Nuxt 플러그인으로 커스텀 되어 있었음: 이 커스텀 메소드를 사용하려면 useNuxtApp에 접근해야 했는데, server API 핸들러에서 useNuxtApp에 접근할 수 없었다. 따라서, 해당 커스텀 Server API에서 백엔드 API 호출 로직을 적용할 수 없었다.
- 전역 변수(store)에 access token을 설정할 수 없었음: 첫 번째 문제와 유사하게, server API 핸들러에서는 store에 접근할 수 없었다.
위 문제를 해결하기 위해 로직을 다시 구성했다.
- /server/api/set-refresh-token 경로에 Server API를 생성한다.
- 기존 클라이언트 단에서 실행되던 로그인 API 호출 로직은 그대로 유지한다.
- 로그인 성공 시 반환된 access token은 클라이언트에서 store의 값을 업데이트 하도록 한다.
- 반한된 refresh token은 /api/set-refresh-token API를 호출하여, 이 API의 이벤트 핸들러 내에서 쿠키를 설정한다.
// set-refresh-token-cookie.ts
export default defineEventHandler(async (event) => {
const { refreshToken } = await readBody(event);
setCookie(event, 'refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: true,
maxAge: 60 * 60 * 24 * 30,
});
});
// login.vue
// 백엔드 단에 로그인 요청을 보냄
const data = await $api.auth.login(...)
// 응답 받은 refresh 값으로 쿠키를 설정하는 api를 요청
await $fetch('/api/set-refresh-token-cookie/', {
method: 'POST',
body: { refreshToken: data.refresh }
});
이렇게 access token을 전역 변수에 저장하고, refresh token을 httpOnly, secure, samesite 옵션이 적용된 쿠키값으로 관리할 수 있게 되었다.
이제 마지막으로 남은 것은, 쿠키를 가져와서 쓰는 로직을 수정하는 것이다.
해당 부분은 간단하다. /api/set-refresh-token과 마찬가지로, /api/get-refresh-token의 server API를 정의하고 getCookie를 통해 토큰값을 반환해주면 된다.
// get-refresh-token.ts
export default defineEventHandler(async (event) => {
const refreshToken = getCookie(event, 'refreshToken');
return { refreshToken }
});
위의 수정 내용에 맞게, 기존에 401 에러가 났을 때, refresh token 값으로 토큰 값을 갱신하는 로직도 수정을 해줬다.
// httpHandler/index.ts
export const handle401Error = async (context: FetchContext): Promise<void> =>{
const { $api } = useNuxtApp();
const tokenStore = useTokenStore();
// 기존: store에서 가져옴 -> 수정: server api로 받아옴
const { refreshToken } = await $fetch('/api/get-refresh-token-cookie');
try {
const { access: newAccessToken, refresh: newRefreshToken} = await $api.auth.refreshToken(refreshToken);
tokenStore.setAccessToken(newAccessToken)
const fetchOptions = {
...context.options,
headers: {
...context.options.headers,
Authorization: `Bearer ${newRefreshToken}`
},
};
await $fetch(context.request, fetchOptions);
} catch (error) {
navigateTo({ path: useNuxtApp().$config.public.loginPath });
}
}
이렇게 JWT (access token, refresh token) 관련 로직을 재정의하여, 회사 내부의 Nuxt3 커스텀 코드에 반영했다.
'Nuxt.js' 카테고리의 다른 글
Caching in Nuxt3 (0) | 2024.07.30 |
---|---|
[Nuxt3] 효율적인 API 호출 및 관리: Factory + Repository Pattern 적용기 (0) | 2024.07.22 |
Data fetching in Nuxt3 ($fetch, useFetch, useAsyncData) (0) | 2024.07.06 |
[Nuxt3] 렌더링 모드의 핵심과 라이프사이클 이해하기 (1) | 2024.07.06 |