diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 5954404..f369320 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,49 +1,46 @@ -import type { IncomingMessage, ServerResponse } from 'http'; - import { Hydrate, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { NextPageContext } from 'next'; import App from 'next/app'; import type { AppContext, AppProps } from 'next/app'; import dynamic from 'next/dynamic'; import Script from 'next/script'; import { useEffect, useState } from 'react'; -import { refreshAccessToken } from '@/shared/apis/auth/refreshAccessToken'; import ConfirmProvider from '@/shared/components/ConfirmProvider'; import CustomHead from '@/shared/components/CustomHead'; import { queryClient as sullogQueryClient } from '@/shared/configs/reactQuery'; import '@/assets/styles/index.scss'; +import { NEXT_PUBLIC_TEST_USER_TOKEN } from '@/shared/constants'; import { usePageLoading } from '@/shared/hooks/usePageLoading'; import * as gtag from '@/shared/libs/gtags'; import { getAccessToken, getRefreshToken, setAccessToken, + setRefreshToken, } from '@/shared/utils/auth'; const Loading = dynamic(() => import('@/shared/components/Loading')); type SullogAppProps = AppProps & { - tokens?: { - accessToken: string; - refreshToken: string; - }; + accessToken?: string; }; export default function SullogApp({ Component, pageProps, - tokens, + accessToken, }: SullogAppProps) { const [queryClient] = useState(() => sullogQueryClient); const { isPageLoading } = usePageLoading(); gtag.useGtag(); useEffect(() => { - if (tokens?.accessToken) { - setAccessToken(tokens.accessToken); + if (accessToken) { + setAccessToken(accessToken); } - }, [tokens]); + }, [accessToken]); return ( <> @@ -78,61 +75,32 @@ export default function SullogApp({ ); } -const goToLogin = (res: ServerResponse | undefined) => { - res?.writeHead(307, { location: `/login` }); - res?.end(); +const setTestUserTokens = ({ req, res }: NextPageContext) => { + if (process.env.NODE_ENV === 'development' && NEXT_PUBLIC_TEST_USER_TOKEN) { + setAccessToken(NEXT_PUBLIC_TEST_USER_TOKEN, { req, res }); + setRefreshToken(NEXT_PUBLIC_TEST_USER_TOKEN, { req, res }); + } }; SullogApp.getInitialProps = async (appContext: AppContext) => { const { ctx } = appContext; const { req, res, pathname } = ctx; - const refreshToken = getRefreshToken({ req, res }); + setTestUserTokens(ctx); - console.log( - '2', - res?.getHeaders(), - req?.headers.cookie, - pathname, - refreshToken - ); + const accessToken = getAccessToken({ req, res }); + const refreshToken = getRefreshToken({ req, res }); - if (pathname !== '/login' && !refreshToken) { - console.log('3'); - goToLogin(res); + if (pathname !== '/login' && !accessToken && !refreshToken) { + res?.writeHead(307, { location: `/login` }); + res?.end(); return {}; } - let tokens: SullogAppProps['tokens']; - if (refreshToken) { - console.log('4', tokens); - try { - await refreshAccessToken(ctx); - const accessToken = getAccessToken(); - const refreshToken = getRefreshToken({ req, res }); - console.log('5', accessToken, refreshToken); - - if (!accessToken || !refreshToken) { - console.log('6'); - goToLogin(res); - return {}; - } - - tokens = { - accessToken, - refreshToken, - }; - } catch (error) { - console.log('7', error); - goToLogin(res); - return {}; - } - } - const pageProps = await App.getInitialProps(appContext); return { ...pageProps, - tokens, + accessToken, }; }; diff --git a/src/pages/api/logout.ts b/src/pages/api/logout.ts index 6f732ed..72b3337 100644 --- a/src/pages/api/logout.ts +++ b/src/pages/api/logout.ts @@ -1,11 +1,12 @@ import { NextApiRequest, NextApiResponse } from 'next'; -import { deleteRefreshToken } from '@/shared/utils/auth'; +import { deleteAccessToken, deleteRefreshToken } from '@/shared/utils/auth'; export default async function handler( req: NextApiRequest, res: NextApiResponse ) { + deleteAccessToken({ req, res }); deleteRefreshToken({ req, res }); res.redirect(307, '/login'); } diff --git a/src/pages/api/refresh.ts b/src/pages/api/refresh.ts new file mode 100644 index 0000000..f2ed406 --- /dev/null +++ b/src/pages/api/refresh.ts @@ -0,0 +1,50 @@ +import axios from 'axios'; +import { NextApiRequest, NextApiResponse } from 'next'; + +import { + ACCESS_TOKEN_KEY, + NEXT_PUBLIC_API_BASE_URI, + REFRESH_TOKEN_KEY, +} from '@/shared/constants'; +import { + getRefreshToken, + setAccessToken, + setRefreshToken, +} from '@/shared/utils/auth'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + try { + const refreshToken = getRefreshToken({ req, res }); + + if (!refreshToken) { + throw new Error('refreshToken is undefined'); + } + + const response = await axios({ + baseURL: NEXT_PUBLIC_API_BASE_URI, + url: '/token/refresh', + method: 'get', + headers: { + Authorization: `Bearer ${refreshToken}`, + }, + validateStatus: null, + }); + + const newAccessToken = response.headers[ACCESS_TOKEN_KEY]; + const newRefreshToken = response.headers[REFRESH_TOKEN_KEY]; + + if (!newAccessToken || !newRefreshToken) { + throw new Error('Invalid tokens.'); + } + + setAccessToken(newAccessToken, { req, res }); + setRefreshToken(newRefreshToken, { req, res }); + + res.status(200).json({ result: true }); + } catch (err) { + res.status(200).json({ result: false }); + } +} diff --git a/src/shared/apis/auth/refreshAccessToken.ts b/src/shared/apis/auth/refreshAccessToken.ts deleted file mode 100644 index be6a093..0000000 --- a/src/shared/apis/auth/refreshAccessToken.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; - -import axios from 'axios'; - -import { - ACCESS_TOKEN_KEY, - NEXT_PUBLIC_API_BASE_URI, - REFRESH_TOKEN_KEY, -} from '@/shared/constants'; -import { - getRefreshToken, - setAccessToken, - setRefreshToken, -} from '@/shared/utils/auth'; -import { isServer } from '@/shared/utils/isServer'; - -/** - * 리프레시 토큰을 사용하여 새 액세스 토큰을 가져옴 - * @returns 토근 리프레시 성공 여부 - */ -// TODO : api로 만들어서 서버에서 토큰 설정하도록 수정 (?) -export const refreshAccessToken = async (context?: { - req?: IncomingMessage; - res?: ServerResponse; -}): Promise => { - try { - const refreshToken = getRefreshToken(context); - - if (!refreshToken) { - throw new Error('refreshToken is undefined'); - } - - const response = await axios({ - baseURL: NEXT_PUBLIC_API_BASE_URI, - url: '/token/refresh', - method: 'get', - headers: { - Authorization: `Bearer ${refreshToken}`, - }, - validateStatus: null, - }); - - setAccessToken(response.headers[ACCESS_TOKEN_KEY]); - setRefreshToken(response.headers[REFRESH_TOKEN_KEY], context); - - return true; - } catch (error) { - return false; - } -}; diff --git a/src/shared/apis/auth/refreshTokens.ts b/src/shared/apis/auth/refreshTokens.ts new file mode 100644 index 0000000..306a1bb --- /dev/null +++ b/src/shared/apis/auth/refreshTokens.ts @@ -0,0 +1,8 @@ +import axios from 'axios'; + +export const refreshTokens = async () => { + return await axios<{ result: boolean }>({ + url: '/api/refresh', + method: 'get', + }); +}; diff --git a/src/shared/configs/axios.ts b/src/shared/configs/axios.ts index 0908d91..78903c4 100644 --- a/src/shared/configs/axios.ts +++ b/src/shared/configs/axios.ts @@ -1,8 +1,8 @@ import axios, { AxiosError, AxiosResponse } from 'axios'; -import { refreshAccessToken } from '@/shared/apis/auth/refreshAccessToken'; import { NEXT_PUBLIC_API_BASE_URI } from '@/shared/constants'; +import { refreshTokens } from '../apis/auth/refreshTokens'; import { getAccessToken } from '../utils/auth'; import { isServer } from '../utils/isServer'; @@ -17,7 +17,11 @@ let subscribers: ((accessToken: string) => Promise)[] = []; const handleUnauthorizedError = async (error: AxiosError) => { if (error.response?.status === 401) { try { - throw new Error('exceed retry limit count'); + const { data } = await refreshTokens(); + + if (!data.result) { + throw new Error('Failed to refresh tokens'); + } } catch (error) { // 새 액세스 토큰을 가져 오는 동안 오류가 발생하면 로그인 페이지로 리디렉션 if (!isServer()) { diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index 895af62..59f903f 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -1,5 +1,6 @@ export const NEXT_PUBLIC_API_BASE_URI = process.env.NEXT_PUBLIC_API_BASE_URI; - +export const NEXT_PUBLIC_TEST_USER_TOKEN = + process.env.NEXT_PUBLIC_TEST_USER_TOKEN; export const NEXT_PUBLIC_KAKAO_BASE_URI = process.env.NEXT_PUBLIC_KAKAO_BASE_URI; export const NEXT_PUBLIC_KAKAO_CLIENT_ID = diff --git a/src/shared/hooks/useAuth.ts b/src/shared/hooks/useAuth.ts index 7da96e5..7c9983d 100644 --- a/src/shared/hooks/useAuth.ts +++ b/src/shared/hooks/useAuth.ts @@ -8,6 +8,7 @@ import { } from '@/shared/utils/auth'; import { logout as authLogout } from '../apis/auth/logout'; +import { refreshTokens } from '../apis/auth/refreshTokens'; import useConfirm from './useConfirm'; @@ -39,6 +40,12 @@ const useAuth = () => { const verifyLoggedIn = async (): Promise => { const accessToken = getAccessToken(); + + if (!accessToken) { + const { data } = await refreshTokens(); + return !!data.result; + } + return !!accessToken; }; diff --git a/src/shared/utils/auth.ts b/src/shared/utils/auth.ts index a65ea88..db8a879 100644 --- a/src/shared/utils/auth.ts +++ b/src/shared/utils/auth.ts @@ -9,36 +9,57 @@ import { NEXT_PUBLIC_KAKAO_SCOPE, } from '@/shared/constants'; -import { REFRESH_TOKEN_KEY } from '../constants'; +import { REFRESH_TOKEN_KEY, ACCESS_TOKEN_KEY } from '../constants'; import { generateUrl } from './generateUrl'; +import { InMemoryValue } from './inMemory'; import { isServer } from './isServer'; -let inMemoryAccessToken: string | undefined; +const ONE_DAY = 24 * 60 * 60 * 1000; -/** - * 액세스 토큰을 인메모리에 저장 - */ -export const setAccessToken = (accessToken: string) => { - inMemoryAccessToken = accessToken; +const inMemoryAccessToken = new InMemoryValue(undefined); + +export const setAccessToken = ( + accessToken: string, + context?: { req?: IncomingMessage; res?: ServerResponse } +) => { + if (isServer() && context) { + setCookie(ACCESS_TOKEN_KEY, accessToken, { + ...(context || {}), + httpOnly: true, + secure: process.env.NODE_ENV !== 'development', + maxAge: ONE_DAY, + sameSite: 'strict', + path: '/', + }); + } + inMemoryAccessToken.set(accessToken); }; -export const getAccessToken = (): string | undefined => { - return inMemoryAccessToken; +export const getAccessToken = (context?: { + req?: IncomingMessage; + res?: ServerResponse; +}): string | undefined => { + if (isServer() && context) { + return getCookie(ACCESS_TOKEN_KEY, context) as string | undefined; + } + return inMemoryAccessToken.get(); }; -export const deleteAccessToken = () => { - inMemoryAccessToken = ''; +export const deleteAccessToken = (context?: { + req?: IncomingMessage; + res?: ServerResponse; +}) => { + if (isServer() && context) { + deleteCookie(ACCESS_TOKEN_KEY, context); + } + inMemoryAccessToken.delete(); }; -/** - * 리프레시 토큰을 쿠키에 저장 (expires : 14일) - */ const FOURTEEN_DAYS = 14 * 24 * 60 * 60 * 1000; export const setRefreshToken = ( refreshToken: string, - /** required for server side cookies */ context?: { req?: IncomingMessage; res?: ServerResponse } ) => { if (isServer() && !context) { @@ -54,7 +75,7 @@ export const setRefreshToken = ( httpOnly: true, secure: process.env.NODE_ENV !== 'development', maxAge: FOURTEEN_DAYS, - sameSite: 'none', + sameSite: 'strict', path: '/', }); }; diff --git a/src/shared/utils/inMemory.ts b/src/shared/utils/inMemory.ts new file mode 100644 index 0000000..954d288 --- /dev/null +++ b/src/shared/utils/inMemory.ts @@ -0,0 +1,15 @@ +export class InMemoryValue { + constructor(private value?: T) {} + + set(value: T) { + this.value = value; + } + + get() { + return this.value; + } + + delete() { + this.value = undefined; + } +}