현재 프로젝트에서 access token만을 활용해서 사용자를 확인하고 권한을 부여하고 있다.
하지만 access token은 탈취당할 위험이 있기 때문에 이에 대한 대비책이 필요하다.
그래서 항상 같은 토큰을 사용하게 하는 것보다 토큰에 유효시간을 부여하여 주기적으로 새로운 토큰을 사용하도록해서 탈취당하는 상황에서도 피해를 최소화하도록 해야한다.
하지만 이 유효기간을 길게 두면 유효기간을 사용하는 의미가 없고, 유효기간을 짧게 두면 로그인을 계속해야하기 때문에 사용자 경험적으로도 부정적일 수 있다.
그래서 유효기간이 서로 다른 access token과 refresh token을 함께 사용한다.
로그인 인증 성공시 클라이언트는 Access Token(3시간) 과 Refresh Token(1주일)을 서버에서 받는다.
인증이 필요한 API 요청인 경우, axios interceptor를 통해 Access Token을 싣어 요청을 보낸다.
사용자 응답에 따라 케이스를 구분한다.
3.1 200 (OK)
3.2 401 (Unauthorized)
해당 Access Token에 대해서 유효하지 않은 Token임을 의미한다고 판단
Access Token을 관리하던 Cookie 관련 로직을 localStorage에서 관리할 수 있도록 수정
로그인시 해당 Access Token, Refresh Token이 잘 들어오는지 확인하고, 각 토큰이 각 저장소에 잘 저장되고 있는지 확인.
위 와 같이 서버에서 Authorization에 access token을 전달해주고, refresh token은 Set-Cookie를 통해 쿠키에 저장한다.
Access Token 만료시 전달되는 401 Status에 대해서 axios interceptor를 활용하여, 인증이 필요한 API 요청에 대해서 일괄적으로 처리
// src/apis/index.ts
import axios from 'axios';
import { tokenRefresh } from './auth/token';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
@@ -16,7 +16,40 @@ export const authorizedApi = axios.create({
});
authorizedApi.interceptors.request.use((config) => {
const token = `Bearer ${localStorage.getItem('accessKey')}`;
if (token) {
config.headers.Authorization = token;
}
return config;
});
authorizedApi.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401) {
const errorMessage = error.response?.data?.message || '';
if (errorMessage.includes('Access token is invalid or missing')) {
try {
await tokenRefresh();
const newToken = localStorage.getItem('accessKey');
if (newToken) {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return authorizedApi(originalRequest); // 재요청
}
} catch (refreshError) {
console.error('Token refresh failed:', refreshError);
return Promise.reject(refreshError);
}
}
if (errorMessage.includes('Invalid refresh token')) {
window.location.href = '/login';
}
}
return Promise.reject(error);
},
);
서버가 클라이언트에게 쿠키를 설정할때 사용하는 HTTP 응답 헤더인데, 주로 사용자 정보를 저장할때 주로 사용한다고 한다.
쿠키 구조에 따라 원하는 도메인에만 해당 쿠키를 실어서 보낼 수 있고, 접근 권한 을 설정하여 XSS나 CSRF와 같은 공격을 방지할 수 있다.
예를 들어, 아래와 같은 토큰을 발급 받았다고 하면,
Authorization-refresh=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJSZWZyZXNoVG9rZW4iLCJleHAiOjE3MjQ0MDA0NDAsImlhdCI6MTcyMzc5NTY0MH0.RMHDs4rqoWjs40ZrHA2DI2pLtezJ51jGxUPSToI8KigcwLtLuDHe8_LRgRU53tDVLukoft6m3J2ztaJjFqgY9A; Max-Age=604800; Expires=Fri, 23 Aug 2024 08:07:20 GMT; Path=/; Secure; HttpOnly
Authorization-refresh
라는 이름의 쿠키에 =
이하의 키가 저장된다.Expires
로 만료 날짜를 지정할 수 있는데, 해당 쿠키는 2024년 8월 23일 8시쯤 제거 된다.Path
필드를 통해 원하는 도메인에만 실어 보낼 수 있는데 해당 쿠키는 모든 도메인에 대해서 쿠키를 사용하게 된다.Secure
옵션이 활성화 되어있기 때문에 HTTPS 연결에만 쿠키를 전송하게 된다.HttpOnly
옵션이 활성화 되어 있기 때문에 JS에서 해당 쿠키에 접근할 수 없고, 이를 통해서 XSS 공격으로 쿠키를 보호할 수 있다.이 응답 헤더를 통해서 클라이언트는 refresh 토큰을 저장하는 별도의 로직이 필요없고, 이후 refresh token가 만료된 경우를 처리하는 로직에 대해서만 핸들링을 해주면 된다.
위에서 Set-Cookie로 전달받은 Refresh Token이 제대로 브라우저 쿠키에 저장되지 않는 현상이 있었다.
원인은 Cookie 옵션 중에 Secure라는 옵션 때문이었고, 현재 로컬 브라우저의 url이 http://localhost~
였기 때문에 브라우저에서 쿠키를 제대로 사용하지 못하고 있었다.
따라서 아래 단계를 따라 로컬 환경에서도 https를 사용할 수 있도록 세팅하고 이어서 진행할 수 있었다.
mkcert 설치 ( window의 경우 아래 Reference 문서 참조 )
brew install mkcert
인증서 생성
# CA 인증서 생성
mkcert -install
# 로컬 호스트 관련 키 생성
mkcert localhost
키파일 이동
키파일을 프로젝트 파일로 이동하고, 로컬 환경에서만 쓸 파일이기 때문에 버전 관리에 포함되지 않게 하기 위해 gitignore 수정
vite.config.ts 수정
import { defineConfig } from 'vite';
import fs from 'fs';
import path from 'path';
export default defineConfig({
server: {
https: {
key: fs.readFileSync(path.resolve(__dirname, 'localhost-key.pem')),
cert: fs.readFileSync(path.resolve(__dirname, 'localhost-cert.pem')),
},
},
});
결과 확인
withCredentials 옵션을 true로 설정하여 요청을 보내면서 해결
→ withCredential 옵션은 Axios 요청에서 쿠키, 인증 헤더 등 자격 증명과 관련된 옵션을 추가해서 보내겠다는 것을 의미한다. default 값이 false이기 때문에 쿠키를 사용하기 위해서는 반드시 withCredential 옵션을 추가해야했다.
→ 이번 상황에서는 내가 서버에 보낼 요청에 쿠키를 포함하는게 아니라 서버 응답에 Set-Cookie로 전달된 값이 브라우저 쿠키에 저장되는 과정이었는데 그럴때도 필요하다. 왜냐하면 브라우저 보안상 자격 증명을 포함하지않은 요청에 대한 응답일 경우 Set-Cookie를 무시하기 때문이다.
⇒ 쿠키를 포함하기 위해 withCredential을 사용하는 것보다, 해당 요청/응답에 대해서 자격 증명에 대한 처리가 필요할때 사용한다라고 생각하는게 더 바람직하다고 생각한다.
백엔드 팀원에게 SameSite 옵션을 SameSite=None
으로 변경해달라고 요청하여 문제 해결.
→ 현재 로컬 도메인이나, 배포 도메인이 서버 도메인과 달라 발생한 이슈였다. SameSite 옵션이 설정되어 있지 않은 경우 Lax
라는 값으로 설정되어 있는데, Lax는 Get 요청이나 링크를 통해 페이지를 이동하는 요청에 대해서만 쿠키를 포함시킨다.
SameSite 옵션 | 특징 |
---|---|
Lax | 사용자가 다른 도메인에서 링크를 클릭하거나 GET 요청을 할 때 쿠키가 포함 |
Strict | 동일한 사이트에서 발생한 요청에 대해서만 전송 |
None | 크로스 사이트 요청에서도 쿠키를 전송할 수 있다. None으로 설정하는 경우 Secure 옵션도 함께 설정해주어야 한다. |
→ 현재 프로젝트 상황은 서로 다른 도메인에 리소스를 요청하는 크로스 사이트 요청이기 때문에 SameSite=None으로 설정해주면서 문제를 해결할 수 있었다.