diff --git a/src/api/apiClient.ts b/src/api/apiClient.ts index af4c72e..8e98d0f 100644 --- a/src/api/apiClient.ts +++ b/src/api/apiClient.ts @@ -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; + }; } // 공통 에러 처리 메소드 @@ -64,24 +85,31 @@ function handleCommonError(error: AxiosError) { } // 리프레쉬 토큰 및 에러 처리 메소드 -async function handleRequestRefreshToken( - error: AxiosError, - refreshToken: string, -): Promise { +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); // 토큰 갱신 후 재요청 } diff --git a/src/api/auth.ts b/src/api/auth.ts index 40421b3..1ffe208 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -10,16 +10,22 @@ import { SignupResponse, } from '@/types/AuthTypes'; +import { tokenClient } from './tokenClient'; + export const createUser = (data: SignupRequest): Promise => { return apiClient.post(`/${process.env.NEXT_PUBLIC_TEAM}/auth/signUp`, data); }; export const loginUser = (data: LoginRequest): Promise => { - 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 => { - 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 => { + return tokenClient.post(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/check-token`, null); }; export const signInKakao = (data: KakakoSignInRequest): Promise => { diff --git a/src/api/tokenClient.ts b/src/api/tokenClient.ts new file mode 100644 index 0000000..7f4bb59 --- /dev/null +++ b/src/api/tokenClient.ts @@ -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); +} diff --git a/src/hooks/useAuthRedirect.tsx b/src/hooks/useAuthRedirect.tsx deleted file mode 100644 index ba7a05f..0000000 --- a/src/hooks/useAuthRedirect.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect } from 'react'; - -import { useQuery } from '@tanstack/react-query'; -import { useRouter } from 'next/router'; - -import { getUser } from '@/api/user'; - -import useTokenCheck from './useTokenCheck'; - -const useAuthRedirect = () => { - const router = useRouter(); - const hasToken = useTokenCheck(); - - const { data: userData, isLoading } = useQuery({ - queryKey: ['getUser'], - queryFn: getUser, - enabled: hasToken, - retry: false, - }); - - useEffect(() => { - if (userData) { - router.replace('/'); - } - }, [userData, router]); - - return { userData, isLoading, hasToken }; -}; - -export default useAuthRedirect; diff --git a/src/hooks/useTokenCheck.tsx b/src/hooks/useTokenCheck.tsx deleted file mode 100644 index 5f2d142..0000000 --- a/src/hooks/useTokenCheck.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useEffect, useState } from 'react'; - -const useTokenCheck = () => { - const [hasToken, setHasToken] = useState(false); - - useEffect(() => { - const token = localStorage.getItem('accessToken'); - setHasToken(!!token); - }, []); - - return hasToken; -}; - -export default useTokenCheck; diff --git a/src/hooks/useTokenCheckRedirect.tsx b/src/hooks/useTokenCheckRedirect.tsx new file mode 100644 index 0000000..561f8a0 --- /dev/null +++ b/src/hooks/useTokenCheckRedirect.tsx @@ -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; diff --git a/src/lib/cookie.ts b/src/lib/cookie.ts new file mode 100644 index 0000000..91d640f --- /dev/null +++ b/src/lib/cookie.ts @@ -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 = { + 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('='))]; + }), + ); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2819a83..681a43d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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'; +} diff --git a/src/pages/api/auth/check-token.ts b/src/pages/api/auth/check-token.ts new file mode 100644 index 0000000..b310f86 --- /dev/null +++ b/src/pages/api/auth/check-token.ts @@ -0,0 +1,21 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +import { parseCookie } from '@/lib/cookie'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const cookieHeader = req.headers.cookie; + console.log('test'); + if (cookieHeader) { + const cookies = parseCookie(cookieHeader); + + return res.status(200).json({ + accessToken: cookies.accessToken ?? null, + refreshToken: cookies.refreshToken ?? null, + }); + } + + return res.status(200).json({ + accessToken: null, + refreshToken: null, + }); +} diff --git a/src/pages/api/auth/refresh-token.ts b/src/pages/api/auth/refresh-token.ts new file mode 100644 index 0000000..4a8bb46 --- /dev/null +++ b/src/pages/api/auth/refresh-token.ts @@ -0,0 +1,30 @@ +import { AxiosError } from 'axios'; +import { NextApiRequest, NextApiResponse } from 'next'; + +import apiClient from '@/api/apiClient'; +import { clearAuthCookies, setAuthCookies } from '@/lib/cookie'; +import { AccessTokenResponse } from '@/types/AuthTypes'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const url = `/${process.env.NEXT_PUBLIC_TEAM}/auth/refresh-token`; + const data = (await apiClient.post(url, req.body)) as AccessTokenResponse; + const { accessToken } = data; + setAuthCookies(res, accessToken); + + res.status(200).json({ + accessToken, + message: '리프레쉬 토큰 갱신이 완료되었습니다.', + success: true, + }); + res.end(); + } catch (error) { + console.log(error); + const err = error as AxiosError; + const message = error instanceof AxiosError ? error.message : '서버 오류가 발생했습니다.'; + const status = err.response?.status || 500; + clearAuthCookies(res); + + res.status(status).json({ message }); + } +} diff --git a/src/pages/api/auth/signIn.ts b/src/pages/api/auth/signIn.ts new file mode 100644 index 0000000..fbe2416 --- /dev/null +++ b/src/pages/api/auth/signIn.ts @@ -0,0 +1,29 @@ +import { AxiosError } from 'axios'; +import { NextApiRequest, NextApiResponse } from 'next'; + +import apiClient from '@/api/apiClient'; +import { setAuthCookies } from '@/lib/cookie'; +import { LoginResponse } from '@/types/AuthTypes'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const url = `/${process.env.NEXT_PUBLIC_TEAM}/auth/signIn`; + const data = (await apiClient.post(url, req.body)) as LoginResponse; + const { accessToken, refreshToken } = data; + + setAuthCookies(res, accessToken, refreshToken); + + res.status(200).json({ + user: data.user, + message: '로그인이 성공했습니다', + success: true, + }); + res.end(); + } catch (error) { + const err = error as AxiosError; + const message = error instanceof AxiosError ? error.message : '서버 오류가 발생했습니다.'; + const status = err.response?.status || 500; + + res.status(status).json({ message }); + } +} diff --git a/src/pages/signin/index.tsx b/src/pages/signin/index.tsx index 1326aa6..615a384 100644 --- a/src/pages/signin/index.tsx +++ b/src/pages/signin/index.tsx @@ -14,8 +14,8 @@ import AuthLogo from '@/components/auth/AuthLogo'; import FormInput from '@/components/common/FormInput'; import ErrorModal from '@/components/common/Modal/ErrorModal'; import { Button } from '@/components/ui/button'; -import useAuthRedirect from '@/hooks/useAuthRedirect'; import useErrorModal from '@/hooks/useErrorModal'; +import useTokenCheckRedirect from '@/hooks/useTokenCheckRedirect'; import { emailSchema, passwordSchema } from '@/lib/form/schemas'; import { LoginRequest, LoginResponse } from '@/types/AuthTypes'; @@ -30,7 +30,7 @@ type LoginData = z.infer; const SignIn = () => { const { open, setOpen, handleError, errorMessage } = useErrorModal(); - const { userData, isLoading } = useAuthRedirect(); + const { isLoading } = useTokenCheckRedirect(); const router = useRouter(); const methods = useForm({ @@ -47,9 +47,7 @@ const SignIn = () => { const loginMutation = useMutation({ mutationFn: loginUser, - onSuccess: (data) => { - localStorage.setItem('accessToken', data.accessToken); - localStorage.setItem('refreshToken', data.refreshToken); + onSuccess: () => { router.push('/'); }, onError: (error) => { @@ -73,9 +71,9 @@ const SignIn = () => { window.location.href = kakaoAuthUrl; }; - /* useAuthRedirect 훅에서 유저 데이터 요청 후 리디렉트 처리 */ + /* useTokenCheckRedirect 훅에서 유저 데이터 요청 후 리디렉트 처리 */ // 로딩중이거나 데이터 없으면 화면 안보이게 처리 - if (isLoading || userData) return null; + if (isLoading) return null; return ( diff --git a/src/pages/signup/index.tsx b/src/pages/signup/index.tsx index 5f89942..3c6f467 100644 --- a/src/pages/signup/index.tsx +++ b/src/pages/signup/index.tsx @@ -14,8 +14,8 @@ import AuthLogo from '@/components/auth/AuthLogo'; import FormInput from '@/components/common/FormInput'; import ErrorModal from '@/components/common/Modal/ErrorModal'; import { Button } from '@/components/ui/button'; -import useAuthRedirect from '@/hooks/useAuthRedirect'; import useErrorModal from '@/hooks/useErrorModal'; +import useTokenCheckRedirect from '@/hooks/useTokenCheckRedirect'; import { emailSchema, nicknameSchema, @@ -40,7 +40,7 @@ type SignupData = z.infer; const Signup = () => { const { open, setOpen, handleError, errorMessage } = useErrorModal(); - const { userData, isLoading } = useAuthRedirect(); + const { isLoading } = useTokenCheckRedirect(); const router = useRouter(); const methods = useForm({ @@ -76,10 +76,7 @@ const Signup = () => { const loginMutation = useMutation({ mutationFn: loginUser, - onSuccess: (data) => { - /* 로그인 후 로컬스토리지 토큰 저장 */ - localStorage.setItem('accessToken', data.accessToken); - localStorage.setItem('refreshToken', data.refreshToken); + onSuccess: () => { router.push('/'); }, onError: (error) => { @@ -104,9 +101,9 @@ const Signup = () => { } }; - /* useAuthRedirect 훅에서 유저 데이터 요청 후 리디렉트 처리 */ + /* useTokenCheckRedirect 훅에서 유저 데이터 요청 후 리디렉트 처리 */ // 로딩중이거나 데이터 없으면 화면 안보이게 처리 - if (isLoading || userData) return null; + if (isLoading) return null; return ( diff --git a/src/types/AuthTypes.ts b/src/types/AuthTypes.ts index 9e95d13..f3b711e 100644 --- a/src/types/AuthTypes.ts +++ b/src/types/AuthTypes.ts @@ -1,4 +1,7 @@ -import { InternalAxiosRequestConfig } from 'axios'; +import { IncomingMessage, ServerResponse } from 'http'; + +import { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +import { GetServerSidePropsContext } from 'next'; export interface SignupRequest { email: string; @@ -40,6 +43,7 @@ export interface AccessTokenRequest { export interface AccessTokenResponse { accessToken: string; + refreshToken: string; } export interface KakakoSignInRequest { @@ -58,3 +62,18 @@ export interface KakakoSignInResponse { export interface RetryRequestConfig extends InternalAxiosRequestConfig { _retry?: boolean; } + +export interface ApiClientContext { + req?: IncomingMessage; + res?: ServerResponse; +} + +export interface RefreshTokenRequest { + instance: AxiosInstance; + error: AxiosError; + refreshToken: string; + res?: ServerResponse; + context?: GetServerSidePropsContext; +} + +export type RefreshTokenResponse = Promise; diff --git a/src/types/CookieTypes.ts b/src/types/CookieTypes.ts new file mode 100644 index 0000000..fe13ccf --- /dev/null +++ b/src/types/CookieTypes.ts @@ -0,0 +1,49 @@ +import { NextApiResponse } from 'next'; + +export interface GetClientCookieParams { + name: string; +} + +export interface SetCookieType { + name: string; + value: string; + maxAge: number; +} + +export interface SetCookieCallbackParams { + accessToken: string; + refreshToken: string; + callback: () => void; +} + +export type ClearAuthCookiesParams = () => void; + +export interface GetServerCookieParams { + cookieHeader: string | undefined; + name: string; +} + +export type GetServerCookieReturn = string | undefined; + +export interface SetServerCookieParams { + response: NextApiResponse; + name: string; + value: string; + maxAge: number; +} + +export interface CookieHeaderParams { + cookieHeader?: string | undefined; +} + +export interface GetCookieParams { + name: string; + cookieHeader?: string | undefined; +} + +export interface SetCookieParams { + name: string; + value: string; + maxAge: number; + response?: NextApiResponse; +}