유저가 로그인 시 액세스 토큰은 짧은 만료시간을 가지고, 리프레시 토큰을 길게 만료시간을 가지게 하여 사이트의 보안성을 강화하기 위해 구현한 기능입니다
현재 보투게더에서는 토큰들을 더 안전하게 보관하고자 액세스 토큰은 로컬 스토리지에 담고, 리프레시 토큰은 쿠키에 담아서 따로 저장을 하여 쿠키 혹은 로컬스토리지가 탈취되었을 때 다른 나머지도 탈취를 해야 완전히 탈취가 가능하도록 했습니다
액세스 토큰의 유효시간은 30분, 리프레시 토큰의 유효 시간은 14일로 설정을 했습니다.
그리고 리프레시 토큰을 재발급 받을 때 액세스 토큰과 리프레시 토큰을 함께 새롭게 재발급하도록 하였는데요. 그렇게 한 이유는 같이 발급했을 때 14일 이내로 꾸준히 사이트를 이용한 유저는 자동 로그인이 계속해서 유지되어 로그인을 새롭게 안해도 되도록 사용성을 높혔습니다. 그리고 마지막으로 사이트를 이용한 후 14일이 지난 뒤 사이트에 들어온다면 새롭게 로그인을 하도록 하였습니다.
리프레시 토큰을 구현하는 방법에 대해 검색을 해보면 대부분 axios의 interceptors를 이용해서 원래 api 요청을 하기 전 미리 요청을 보내고 401 에러가 난다면 만료되었다고 판단하여 토큰 재발급 api로 요청하여 새로운 토큰들을 발급 받은 후 원래의 api 요청을 하는 로직을 가지고 있습니다
저희 보투게더는 axios를 사용하지 않고 있어서 리프레시 토큰 재발급만을 위해서 axios로 fetch 방법을 바꾸기보단 현재 사용하고 있는 fetch로 해결하고자 했습니다
보투게더의 경우 프론트에서 액세스 토큰이 만료되었는 지 여부를 확인 후 만료되었다면 api 요청을 보내기 전에 먼저 리프레시, 액세스 토큰 재발급 요청을 보낸 뒤 재발급된 토큰으로 원래의 요청을 보내는 방식을 택했습니다.
이런 방식을 정한 이유는 리프레시 토큰의 존재 여부를 프론트에서 확인하면서 불필요한 서버의 요청을 줄이고 싶었기 때문입니다. 아무래도 매번 액세스 토큰이 만료되었는지 여부를 서버를 통해 확인을 하게 되면 모든 요청에 액세스 토큰 만료 여부를 확인하게 되어서 성능적으로 별로이게 될 것이라고 생각했습니다.
액세스 쿠키가 만료되었는지 여부를 반환하는 함수입니다. 현재의 시간과 액세스 토큰에 담겨있는 만료 시간을 비교하여 계산하고 있습니다
import { decodeToken } from '@utils/token/decodeToken';
import { isExpiredAccessToken } from '@utils/token/isExpiredAccessToken';
describe('액세스 토큰이 지났는 지 여부를 검증하여 true/false 값을 반환한다.', () => {
test('액세스 토큰의 만료 시간이 현재 시간 기준으로 지났다면 true를 반환한다.', () => {
const EXPIRED_TIME = 1693929083;
const CURRENT_TIME = EXPIRED_TIME + 10000;
/**
* {
"memberId": 1,
"iat": 1693837083,
"exp": 1693929083
}
*/
const ACCESS_TOKEN = decodeToken(
'eyJtZW1iZXJJZCI6NiwiaWF0IjoxNjkzODM2NTgzLCJleHAiOjE2OTM5MjI5ODMsImFsZyI6IkhTMjU2In0.eyJtZW1iZXJJZCI6MSwiaWF0IjoxNjkzODM3MDgzLCJleHAiOjE2OTM5MjkwODN9.SYzSL7N8Eo40HW9iJN1YVSWK3H-jkODbP5zX9Dvaji4'
);
const result = isExpiredAccessToken({ decodedToken: ACCESS_TOKEN, currentTime: CURRENT_TIME });
expect(result).toBe(true);
});
test('액세스 토큰의 만료 시간이 현재 시간 기준으로 지나지 않았다면 false를 반환한다.', () => {
const EXPIRED_TIME = 1693929083;
const CURRENT_TIME = EXPIRED_TIME - 10000;
/**
* {
"memberId": 1,
"iat": 1693837083,
"exp": 1693929083
}
*/
const ACCESS_TOKEN = decodeToken(
'eyJtZW1iZXJJZCI6NiwiaWF0IjoxNjkzODM2NTgzLCJleHAiOjE2OTM5MjI5ODMsImFsZyI6IkhTMjU2In0.eyJtZW1iZXJJZCI6MSwiaWF0IjoxNjkzODM3MDgzLCJleHAiOjE2OTM5MjkwODN9.SYzSL7N8Eo40HW9iJN1YVSWK3H-jkODbP5zX9Dvaji4'
);
const result = isExpiredAccessToken({ decodedToken: ACCESS_TOKEN, currentTime: CURRENT_TIME });
expect(result).toBe(false);
});
});
import { AccessToken } from '@type/token';
export const isExpiredAccessToken = ({
decodedToken,
currentTime,
}: {
decodedToken: AccessToken;
currentTime: number;
}) => {
const accessTokenExpirationPeriod = decodedToken.exp;
return currentTime >= accessTokenExpirationPeriod;
};
리프래시 토큰이 만료되었는지 여부를 반환하는 함수입니다. 현재의 시간과 (액세스 토큰에 담겨있는 생성 시간 + 2주의 시간)을 비교하여 계산하고 있습니다
import { REFRESH_EXPIRATION_TIME } from '@constants/token';
import { decodeToken } from '@utils/token/decodeToken';
import { isExpiredRefreshToken } from '@utils/token/isExpiredRefreshToken';
describe('리프레시 토큰이 지났는 지 여부를 검증하여 true/false 값을 반환한다.', () => {
test('액세스 토큰 발급 시간 기준 14일이 지났다면 리프레시 토큰이 만료되었다고 판단하여 true를 반환한다.', () => {
const ISSUED_TIME = 1693837083;
const CURRENT_TIME = ISSUED_TIME + REFRESH_EXPIRATION_TIME + 10000;
/**
* {
"memberId": 1,
"iat": 1693837083,
"exp": 1693929083
}
*/
const ACCESS_TOKEN = decodeToken(
'eyJtZW1iZXJJZCI6NiwiaWF0IjoxNjkzODM2NTgzLCJleHAiOjE2OTM5MjI5ODMsImFsZyI6IkhTMjU2In0.eyJtZW1iZXJJZCI6MSwiaWF0IjoxNjkzODM3MDgzLCJleHAiOjE2OTM5MjkwODN9.SYzSL7N8Eo40HW9iJN1YVSWK3H-jkODbP5zX9Dvaji4'
);
const result = isExpiredRefreshToken({ decodedToken: ACCESS_TOKEN, currentTime: CURRENT_TIME });
expect(result).toBe(true);
});
test('액세스 토큰 발급 시간 기준 14일이 지나지 않았다면 리프레시 토큰이 만료되지 않았다고 판단하여 false를 반환한다.', () => {
const ISSUED_TIME = 1693837083;
const CURRENT_TIME = ISSUED_TIME - 10000;
/**
* {
"memberId": 1,
"iat": 1693837083,
"exp": 1693929083
}
*/
const ACCESS_TOKEN = decodeToken(
'eyJtZW1iZXJJZCI6NiwiaWF0IjoxNjkzODM2NTgzLCJleHAiOjE2OTM5MjI5ODMsImFsZyI6IkhTMjU2In0.eyJtZW1iZXJJZCI6MSwiaWF0IjoxNjkzODM3MDgzLCJleHAiOjE2OTM5MjkwODN9.SYzSL7N8Eo40HW9iJN1YVSWK3H-jkODbP5zX9Dvaji4'
);
const result = isExpiredRefreshToken({ decodedToken: ACCESS_TOKEN, currentTime: CURRENT_TIME });
expect(result).toBe(false);
});
});
import { AccessToken } from '@type/token';
export const isExpiredAccessToken = ({
decodedToken,
currentTime,
}: {
decodedToken: AccessToken;
currentTime: number;
}) => {
const accessTokenExpirationPeriod = decodedToken.exp;
return currentTime >= accessTokenExpirationPeriod;
};
위의 isExpiredAccessToken 와 isExpiredRefreshToken 의 여부를 조건문에 따라 계산해서 재발급 요청을 해야한다면 true, 아니라면 false를 반환하고 있습니다
import { ACCESS_TOKEN_KEY } from '@constants/localStorage';
import { REFRESH_EXPIRATION_TIME } from '@constants/token';
import { getLocalStorage, setLocalStorage } from '@utils/localStorage';
import { isRefreshTokenRequested } from '@utils/token/isRefreshTokenRequested';
describe('액세스 토큰의 정보를 통해 검증하여 리프레시 토큰 재발급 요청을 보낼지 여부를 true/false 값으로 반환한다.', () => {
test('액세스 토큰이 없다면 비회원 상태라고 판단하여 리프레시 토큰 재발급 요청을 하지 않는다.', () => {
const result = isRefreshTokenRequested();
expect(result).toBe(false);
});
test('액세스 토큰 발급 시간 기준 14일이 지났다면 리프레시 토큰 재발급 요청을 하지 않고, 액세스 토큰을 삭제한다.', () => {
const ISSUED_TIME = 1693837083 * 1000;
jest.useFakeTimers();
jest.setSystemTime(new Date(ISSUED_TIME + REFRESH_EXPIRATION_TIME * 1000 + 10000));
/**
* {
"memberId": 1,
"iat": 1693837083,
"exp": 1693929083
}
*/
const ACCESS_TOKEN =
'eyJtZW1iZXJJZCI6NiwiaWF0IjoxNjkzODM2NTgzLCJleHAiOjE2OTM5MjI5ODMsImFsZyI6IkhTMjU2In0.eyJtZW1iZXJJZCI6MSwiaWF0IjoxNjkzODM3MDgzLCJleHAiOjE2OTM5MjkwODN9.SYzSL7N8Eo40HW9iJN1YVSWK3H-jkODbP5zX9Dvaji4';
setLocalStorage(ACCESS_TOKEN_KEY, ACCESS_TOKEN);
const result = isRefreshTokenRequested();
const accessToken = getLocalStorage(ACCESS_TOKEN_KEY);
expect(result).toBe(false);
expect(accessToken).toBe(null);
});
test('액세스 토큰 발급 시간 기준 14일이 지나지 않았고, 액세스 토큰이 만료되었다면 리프레시 토큰 재발급 요청을 한다.', () => {
const EXPIRED_TIME = 1693929083 * 1000;
jest.useFakeTimers();
jest.setSystemTime(new Date(EXPIRED_TIME + 10000));
/**
* {
"memberId": 1,
"iat": 1693837083,
"exp": 1693929083
}
*/
const ACCESS_TOKEN =
'eyJtZW1iZXJJZCI6NiwiaWF0IjoxNjkzODM2NTgzLCJleHAiOjE2OTM5MjI5ODMsImFsZyI6IkhTMjU2In0.eyJtZW1iZXJJZCI6MSwiaWF0IjoxNjkzODM3MDgzLCJleHAiOjE2OTM5MjkwODN9.SYzSL7N8Eo40HW9iJN1YVSWK3H-jkODbP5zX9Dvaji4';
setLocalStorage(ACCESS_TOKEN_KEY, ACCESS_TOKEN);
const result = isRefreshTokenRequested();
expect(result).toBe(true);
});
test('액세스 토큰 발급 시간 기준 14일이 지나지 않았고, 액세스 토큰이 만료되지 않았다면 리프레시 토큰 재발급 요청을 하지 않는다.', () => {
const EXPIRED_TIME = 1693929083 * 1000;
jest.useFakeTimers();
jest.setSystemTime(new Date(EXPIRED_TIME - 10000));
/**
* {
"memberId": 1,
"iat": 1693837083,
"exp": 1693929083
}
*/
const ACCESS_TOKEN =
'eyJtZW1iZXJJZCI6NiwiaWF0IjoxNjkzODM2NTgzLCJleHAiOjE2OTM5MjI5ODMsImFsZyI6IkhTMjU2In0.eyJtZW1iZXJJZCI6MSwiaWF0IjoxNjkzODM3MDgzLCJleHAiOjE2OTM5MjkwODN9.SYzSL7N8Eo40HW9iJN1YVSWK3H-jkODbP5zX9Dvaji4';
setLocalStorage(ACCESS_TOKEN_KEY, ACCESS_TOKEN);
const result = isRefreshTokenRequested();
expect(result).toBe(false);
});
});
import { ACCESS_TOKEN_KEY } from '@constants/localStorage';
import { getLocalStorage, removeLocalStorage } from '@utils/localStorage';
import { decodeToken } from './decodeToken';
import { isExpiredAccessToken } from './isExpiredAccessToken';
import { isExpiredRefreshToken } from './isExpiredRefreshToken';
export const isRefreshTokenRequested = () => {
const accessToken = getLocalStorage<string>(ACCESS_TOKEN_KEY);
const isGuest = !accessToken;
if (isGuest) return false;
const decodedToken = decodeToken(accessToken);
const currentTime = Math.floor(new Date().getTime() / 1000);
if (!isExpiredAccessToken({ decodedToken, currentTime })) {
return false;
}
if (isExpiredRefreshToken({ decodedToken, currentTime })) {
removeLocalStorage(ACCESS_TOKEN_KEY);
return false;
}
return true;
};
isRefreshTokenRequested 함수가 true라면 액세스 토큰, 리프레시 토큰을 새롭게 받습니다. 매번 요청을 할 때 silentLogin이 실행되고 원래 요청했던 api를 진행합니다.
import { ACCESS_TOKEN_KEY } from '@constants/localStorage';
import { getLocalStorage, setLocalStorage } from '@utils/localStorage';
import { silentLogin } from '@utils/token/silentLogin';
import { MOCK_TOKEN } from '@mocks/mockData/token';
describe('리프레시 토큰 존재 여부를 이용하여 액세스 토큰과 리프레시 토큰을 재발급한다.', () => {
test('액세스 토큰을 발급받은 지 14일이 지나서 리프레시 토큰이 없다면 액세스 토큰과 리프레시 토큰이 발급되지 않는다.', async () => {
await silentLogin();
const accessToken = getLocalStorage(ACCESS_TOKEN_KEY);
expect(accessToken).toBe(null);
});
test('액세스 토큰이 유효 기간을 지나지 않았다면 액세스 토큰과 리프레시 토큰이 발급되지 않는다.', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date(1693929083000 - 1000));
/**
* {
"memberId": 1,
"iat": 1693837083,
"exp": 1693929083
}
*/
const ACCESS_TOKEN =
'eyJtZW1iZXJJZCI6NiwiaWF0IjoxNjkzODM2NTgzLCJleHAiOjE2OTM5MjI5ODMsImFsZyI6IkhTMjU2In0.eyJtZW1iZXJJZCI6MSwiaWF0IjoxNjkzODM3MDgzLCJleHAiOjE2OTM5MjkwODN9.SYzSL7N8Eo40HW9iJN1YVSWK3H-jkODbP5zX9Dvaji4';
setLocalStorage(ACCESS_TOKEN_KEY, ACCESS_TOKEN);
await silentLogin();
const accessToken = getLocalStorage(ACCESS_TOKEN_KEY);
expect(accessToken).toBe(ACCESS_TOKEN);
});
test('액세스 토큰을 발급받은 지 14일이 지나지 않아서 리프레시 토큰이 있고, 액세스 토큰이 유효 기간을 지났다면 새로운 액세스 토큰과 리프레시 토큰을 발급하여 로그인을 유지한다.', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date(1693929083000 + 1000));
/**
* {
"memberId": 1,
"iat": 1693837083,
"exp": 1693929083
}
*/
const ACCESS_TOKEN =
'eyJtZW1iZXJJZCI6NiwiaWF0IjoxNjkzODM2NTgzLCJleHAiOjE2OTM5MjI5ODMsImFsZyI6IkhTMjU2In0.eyJtZW1iZXJJZCI6MSwiaWF0IjoxNjkzODM3MDgzLCJleHAiOjE2OTM5MjkwODN9.SYzSL7N8Eo40HW9iJN1YVSWK3H-jkODbP5zX9Dvaji4';
setLocalStorage(ACCESS_TOKEN_KEY, ACCESS_TOKEN);
await silentLogin();
const accessToken = getLocalStorage(ACCESS_TOKEN_KEY);
expect(accessToken).toBe(MOCK_TOKEN.accessToken);
});
});
여기에서는 isRequest
라는 변수로 이미 요청중이면 요청하지 않도록 막아주었습니다. 보투게더에서는 한 페이지에서 3개의 요청을 보낼 때도 있으니 토큰들의 재발급 요청을 단 한번만 보내도록 한 장치로 사용했습니다.
import { postTokens } from '@api/token';
import { ACCESS_TOKEN_KEY } from '@constants/localStorage';
import { getLocalStorage, removeLocalStorage, setLocalStorage } from '../localStorage';
import { isRefreshTokenRequested } from './isRefreshTokenRequested';
let isRequest = false;
export const silentLogin = async () => {
if (!isRefreshTokenRequested() || isRequest) {
return;
}
isRequest = true;
try {
const accessToken = getLocalStorage<string>(ACCESS_TOKEN_KEY);
if (!accessToken) return;
const tokenData = await postTokens(accessToken);
const updatedAccessToken = tokenData.accessToken;
setLocalStorage(ACCESS_TOKEN_KEY, updatedAccessToken);
} catch (error) {
removeLocalStorage(ACCESS_TOKEN_KEY);
window.location.href = '/login';
throw new Error('로그인에 실패했습니다. 다시 로그인 해주세요.');
} finally {
isRequest = false;
}
};
토큰을 재발급 받을 때 사용하는 api 함수인데 postFetch가 아닌 일반 fetch 메서드를 사용하였습니다. 그 이유는 postFetch에 silentLogin이 실행되어 무한 루프에 빠지기 때문입니다
또한 리프레시 토큰은 서버단에서 Secure, HttpOnly 설정은 Set-Cookie를 통해 브라우저에 저장하게 됩니다.
재발급 요청을 할 때 쿠키에 담겨 있는 리프레시 토큰을 보내줘야 하기 때문에 fetch의 credentials: 'include'
옵션을 주었습니다
interface SilentLoginToken {
accessToken: string;
}
const BASE_URL = process.env.VOTOGETHER_BASE_URL ?? '';
export const postTokens = async (accessToken: string): Promise<SilentLoginToken> => {
const response = await fetch(`${BASE_URL}/auth/silent-login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ accessToken }),
credentials: 'include', // << 서버와 쿠키를 주고 받는 옵션 설정
});
if (!response.ok) {
throw new Error('error');
}
return await response.json();
};
export const getFetch = async <T>(url: string): Promise<T> => {
try {
await silentLogin(); // << Get을 하기 전 액세스 토큰을 재발급 받아야 한다면 재발급 받은 후 아래의 로직을 실행되도록 함
const response = await fetch(url, {
method: 'GET',
headers: makeFetchHeaders(),
});
...
}
fetch의 credentials: 'include'
cookie 보안(HttpOnly, Secure)
https://developer.mozilla.org/ko/docs/Web/API/fetch
https://developer.mozilla.org/ko/docs/Web/HTTP/Cookies#secure%EA%B3%BC_httponly_%EC%BF%A0%ED%82%A4
https://gusrb3164.github.io/web/2022/08/07/refresh-with-axios-for-client/