Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 84 additions & 56 deletions src/api/apiClient.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,72 @@
import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import Router from 'next/router';

import { RetryRequestConfig } from '@/types/AuthTypes';

import { updateAccessToken } from './auth';

// axios 인스턴스 생성
const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_BASE_URL,
timeout: 10_000,
headers: { 'Content-Type': 'application/json' },
});

/* Axios 인터셉터 설정 */
// 토큰 추가 인터셉터
apiClient.interceptors.request.use(addAccessToken);

// 에러 처리 및 리프레쉬 토큰 추가 인터셉터
apiClient.interceptors.response.use(
(res) => res.data,
async (error) => {
const status = error.response?.status;
const refreshToken = localStorage.getItem('refreshToken');

if (status !== 401 || !refreshToken) return handleCommonError(error);
try {
const result = await handleRequestRefreshToken(error, refreshToken);
if (result) return result;
} catch (refreshTokenError) {
// 토큰 삭제 처리 후 리디렉트
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
Router.replace('/signin');
return handleCommonError(refreshTokenError as AxiosError);
}
},
);

import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';

import { updateAccessToken } from '@/api/auth';
import { getCookie, setAuthCookies } from '@/lib/cookie';
import { isClient } from '@/lib/utils';
import {
ApiClientContext,
RefreshTokenRequest,
RefreshTokenResponse,
RetryRequestConfig,
} from '@/types/AuthTypes';
import { CookieHeaderParams } from '@/types/CookieTypes';

// 서버사이드 렌더링의 경우 context를 전달받음
export const createApiClient = (context?: ApiClientContext) => {
const cookieHeader = context?.req?.headers.cookie || '';

// axios 인스턴스 생성
const instance = axios.create({
baseURL: process.env.NEXT_PUBLIC_BASE_URL,
timeout: 10_000,
headers: { 'Content-Type': 'application/json' },
});

/* Axios 인터셉터 설정 */
// 토큰 추가 인터셉터
instance.interceptors.request.use(createAddAccessToken({ cookieHeader }));

// 에러 처리 및 리프레쉬 토큰 추가 인터셉터
instance.interceptors.response.use(
(res) => res.data,
async (error) => {
const status = error.response?.status;

const refreshToken = await getCookie({ name: 'refreshToken', cookieHeader });

if (status !== 401 || !refreshToken) return handleCommonError(error);

try {
const result = await handleRequestRefreshToken({
instance,
error,
refreshToken,
res: context?.res,
});
if (result) return result;
} catch (refreshTokenError) {
return handleCommonError(refreshTokenError as AxiosError);
}
},
);

return instance;
};

const apiClient = createApiClient();
export default apiClient;

// 토큰 추가 메소드
function addAccessToken(config: InternalAxiosRequestConfig) {
const accessToken = localStorage.getItem('accessToken');
if (accessToken && config.headers) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
function createAddAccessToken({ cookieHeader }: CookieHeaderParams) {
return async function addAccessToken(config: InternalAxiosRequestConfig) {
if ((config as RetryRequestConfig)._retry) return config;

const accessToken = await getCookie({ name: 'accessToken', cookieHeader });
if (accessToken && config.headers) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
};
}

// 공통 에러 처리 메소드
Expand All @@ -64,24 +85,31 @@ function handleCommonError(error: AxiosError) {
}

// 리프레쉬 토큰 및 에러 처리 메소드
async function handleRequestRefreshToken(
error: AxiosError,
refreshToken: string,
): Promise<AxiosResponse | null> {
async function handleRequestRefreshToken({
instance,
error,
res,
refreshToken,
}: RefreshTokenRequest): RefreshTokenResponse {
const originalRequest = error.config as RetryRequestConfig;

if (originalRequest._retry) return null;
originalRequest._retry = true;

const data = await updateAccessToken({ refreshToken });
const accessToken = data.accessToken;

// 갱신받은 access 토큰 저장
localStorage.setItem('accessToken', data.accessToken);

// 새 토큰으로 헤더 수정
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
if (!isClient() && res) {
setAuthCookies(res, accessToken);
}

return apiClient(originalRequest); // 토큰 갱신 후 재요청
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;

const retryRequestConfig = {
...originalRequest,
baseURL: instance.defaults.baseURL,
headers: originalRequest.headers,
};

return await instance.request(retryRequestConfig); // 토큰 갱신 후 재요청
}
10 changes: 8 additions & 2 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,22 @@ import {
SignupResponse,
} from '@/types/AuthTypes';

import { tokenClient } from './tokenClient';

export const createUser = (data: SignupRequest): Promise<SignupResponse> => {
return apiClient.post(`/${process.env.NEXT_PUBLIC_TEAM}/auth/signUp`, data);
};

export const loginUser = (data: LoginRequest): Promise<LoginResponse> => {
return apiClient.post(`/${process.env.NEXT_PUBLIC_TEAM}/auth/signIn`, data);
return apiClient.post(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/signIn`, data);
};

export const updateAccessToken = (data: AccessTokenRequest): Promise<AccessTokenResponse> => {
return apiClient.post(`/${process.env.NEXT_PUBLIC_TEAM}/auth/refresh-token`, data);
return apiClient.post(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/refresh-token`, data);
};

export const checkToken = (): Promise<AccessTokenResponse> => {
return tokenClient.post(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/check-token`, null);
};

export const signInKakao = (data: KakakoSignInRequest): Promise<KakakoSignInResponse> => {
Expand Down
29 changes: 29 additions & 0 deletions src/api/tokenClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import axios, { AxiosError } from 'axios';

export const tokenClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_BASE_URL,
timeout: 10_000,
headers: { 'Content-Type': 'application/json' },
});

tokenClient.interceptors.response.use(
(res) => res.data,
async (error) => {
return handleCommonError(error as AxiosError);
},
);

// 공통 에러 처리 메소드
function handleCommonError(error: AxiosError) {
if (!error.response) {
return Promise.reject(new Error('네트워크 오류가 발생했습니다. 인터넷 상태를 확인해주세요.'));
}

const { status, data } = error.response;
// todo: 에러 타입 정의하여 바꾸기
let errorMessage = (data as { message?: string })?.message ?? '서버에서 오류가 발생했습니다.';

// 에러 디버깅
console.error('API 에러 발생:', { status, errorMessage, data });
return Promise.reject(error);
}
30 changes: 0 additions & 30 deletions src/hooks/useAuthRedirect.tsx

This file was deleted.

14 changes: 0 additions & 14 deletions src/hooks/useTokenCheck.tsx

This file was deleted.

26 changes: 26 additions & 0 deletions src/hooks/useTokenCheckRedirect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useEffect } from 'react';

import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router';

import { checkToken } from '@/api/auth';

const useTokenCheck = () => {
const router = useRouter();

const { data: tokenData, isLoading } = useQuery({
queryKey: ['checkToken'],
queryFn: checkToken,
retry: false,
});

useEffect(() => {
if (tokenData?.accessToken) {
router.replace('/');
}
}, [tokenData, router]);

return { isLoading, tokenData };
};

export default useTokenCheck;
90 changes: 90 additions & 0 deletions src/lib/cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { ServerResponse } from 'http';

import { NextApiResponse } from 'next';

import { checkToken } from '@/api/auth';
import {
GetClientCookieParams,
GetCookieParams,
GetServerCookieParams,
GetServerCookieReturn,
} from '@/types/CookieTypes';

import { isClient } from './utils';

export const COOKIE_NAMES = {
ACCESS_TOKEN: 'accessToken',
REFRESH_TOKEN: 'refreshToken',
} as const;

type COOKIE_TYPE = 'accessToken' | 'refreshToken';

const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';

// 쿠키 설정
export function setAuthCookies(
res: NextApiResponse | ServerResponse,
accessToken: string,
refreshToken?: string,
) {
const cookies = [
`${COOKIE_NAMES.ACCESS_TOKEN}=${accessToken}; Path=/; Max-Age=${60 * 30}; SameSite=strict; HttpOnly${secure}`,
];

if (refreshToken) {
cookies.push(
`${COOKIE_NAMES.REFRESH_TOKEN}=${refreshToken}; Path=/; Max-Age=${60 * 60 * 24 * 7}; SameSite=strict; HttpOnly${secure}`,
);
}

res.setHeader('Set-Cookie', cookies);
}

// 쿠키 삭제
export function clearAuthCookies(res: NextApiResponse) {
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
const cookies = [
`${COOKIE_NAMES.ACCESS_TOKEN}=; Path=/; Max-Age=0; HttpOnly${secure}`,
`${COOKIE_NAMES.REFRESH_TOKEN}=; Path=/; Max-Age=0; HttpOnly${secure}`,
];
res.setHeader('Set-Cookie', cookies);
}

export async function getCookie({ cookieHeader, name }: GetCookieParams) {
return isClient() ? await getClientCookie({ name }) : getServerCookie({ cookieHeader, name });
}

export async function getClientCookie({ name }: GetClientCookieParams) {
const data = await checkToken();

const cookieMap: Record<COOKIE_TYPE, string | undefined> = {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
};

return cookieMap[name as COOKIE_TYPE];
}

export function getServerCookie({
cookieHeader,
name,
}: GetServerCookieParams): GetServerCookieReturn {
if (!cookieHeader) return undefined;

const cookies = cookieHeader.split(';');
for (const cookie of cookies) {
const [key, ...val] = cookie.trim().split('=');
if (key === name) {
return decodeURIComponent(val.join('='));
}
}
}

export function parseCookie(cookieHeader: string) {
return Object.fromEntries(
cookieHeader.split('; ').map((c) => {
const [key, ...v] = c.split('=');
return [key, decodeURIComponent(v.join('='))];
}),
);
}
4 changes: 4 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

export function isClient() {
return typeof window !== 'undefined';
}
Loading