Skip to content

Commit

Permalink
[#649] SSR을 위해 JWT 토큰을 쿠키에 저장하도록 수정 (#658)
Browse files Browse the repository at this point in the history
* wip: 변경사항 공유를 위한 커밋

* fix: toast provider에 use client directive 추가

* feat: 클라이언트에서 사용 가능한 getCookie 함수 구현

* feat: oauth redirect 요청 시 profile이 없는 경우 프로필 추가 페이지로 이동하는 route handler 구현

- cookie, token 관련 constants key 상수로 분리

* fix: auth 확인을 위한 localstorage 의존성을 session(server action)으로 이동

* fix: react query provider use client 추가

* refactor: layout ContextProvider 컴포넌트 제거, AuthFailedErrorBoundary 위치 수정

* feat: middleware에 profile 추가 등록 페이지로 이동하는 기능 추가

- 세션 관련 함수 디렉토리 구조 수정

* chore: 불필요한 주석 제거

* fix: getOrigin server action에서도 사용할 수 있도록 개선

* fix: cookie samesite lax로 수정

* fix: 로그아웃시 document 쿠키도 함께 제거

* fix: AuthFailedErrorBoundary 무한 reset 되지 않도록 재시도 가능한 fallback 렌더링

* chore: 불필요한 parameter 제거

* chore: /app/layout에 불필요한 async 제거

* fix: middleware 내부에서 토큰 삭제하는 로직 제거, logout 에러 핸들링 로직 추가
  • Loading branch information
gxxrxn authored Jul 10, 2024
1 parent ef5830c commit 351f4a2
Show file tree
Hide file tree
Showing 24 changed files with 496 additions and 157 deletions.
7 changes: 3 additions & 4 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ const nextConfig = {
},
async rewrites() {
return [
{
source: '/service-api/:url*',
destination: `${baseURL}/api/:url*`,
},
{
source: '/aladin-api',
has: [{ type: 'query', key: 'QueryType', value: '(?<QueryType>.*)' }],
Expand Down Expand Up @@ -70,6 +66,9 @@ const nextConfig = {
},
],
},
experimental: {
serverActions: true,
},
};

module.exports = nextConfig;
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@types/react-dom": "18.0.10",
"axios": "^1.3.4",
"colorthief": "^2.4.0",
"jose": "^5.5.0",
"next": "13.4.7",
"react": "18.2.0",
"react-dom": "18.2.0",
Expand Down
31 changes: 9 additions & 22 deletions src/apis/core/axios.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import axios, { CreateAxiosDefaults, InternalAxiosRequestConfig } from 'axios';

import { AuthRefreshIgnoredError } from '@/types/customError';
import { ACCESS_TOKEN_STORAGE_KEY, SERVICE_ERROR_MESSAGE } from '@/constants';
import { SERVICE_ERROR_MESSAGE, SESSION_COOKIES_KEYS } from '@/constants';
import {
isAuthFailedError,
isAuthRefreshError,
isAxiosErrorWithCustomCode,
} from '@/utils/helpers';
import webStorage from '@/utils/storage';
import { deleteAuthSession, setAuthSession } from '@/server/session';
import { deleteCookie } from '@/utils/cookie';

const storage = webStorage(ACCESS_TOKEN_STORAGE_KEY);
const options: CreateAxiosDefaults = {
baseURL: process.env.NEXT_HOST,
headers: {
Expand All @@ -25,11 +25,6 @@ export const publicApi = axios.create({

const requestHandler = (config: InternalAxiosRequestConfig) => {
const { data, method } = config;
const accessToken = storage.get();

if (accessToken) {
setAxiosAuthHeader(config, accessToken);
}

if (!data && (method === 'get' || method === 'delete')) {
config.data = {};
Expand All @@ -51,7 +46,7 @@ const responseHandler = async (error: unknown) => {
}

if (isAuthFailedError(code)) {
removeToken();
await removeToken();
}
} else {
console.error('예상하지 못한 오류가 발생했어요.\n', error);
Expand All @@ -63,12 +58,10 @@ const responseHandler = async (error: unknown) => {
const silentRefresh = async (originRequest: InternalAxiosRequestConfig) => {
try {
const newToken = await updateToken();
storage.set(newToken);
setAxiosAuthHeader(originRequest, newToken);

await setAuthSession(newToken);
return await publicApi(originRequest);
} catch (error) {
removeToken();
await removeToken();
return Promise.reject(error);
}
};
Expand All @@ -93,15 +86,9 @@ const updateToken = () =>
.finally(() => (isTokenRefreshing = false));
});

const removeToken = () => {
storage.remove();
};

const setAxiosAuthHeader = (
config: InternalAxiosRequestConfig,
token: string
) => {
config.headers['Authorization'] = `Bearers ${token}`;
const removeToken = async () => {
SESSION_COOKIES_KEYS.map(key => deleteCookie(key));
await deleteAuthSession();
};

publicApi.interceptors.request.use(requestHandler);
Expand Down
18 changes: 12 additions & 6 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import type { Metadata } from 'next';
import { appleSplashScreens } from '@/constants/metadata';

import GoogleAnalytics from '@/components/common/GoogleAnalytics';
import ContextProvider from '@/components/common/ContextProvider';
import AuthFailedErrorBoundary from '@/components/common/AuthFailedErrorBoundary';
import PWAServiceWorkerProvider from '@/components/common/PWAServiceWorkerProvider';
import ReactQueryProvider from '@/components/common/ReactQueryProvider';
import ToastProvider from '@/components/common/Toast/ToastProvider';
import Layout from '@/components/layout/Layout';

import { LineSeedKR } from '@/styles/font';
Expand Down Expand Up @@ -41,11 +43,15 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => {
<html lang="ko">
<body className={`${LineSeedKR.variable} app-layout font-lineseed`}>
<GoogleAnalytics />
<Layout>
<ContextProvider>
<AuthFailedErrorBoundary>{children}</AuthFailedErrorBoundary>
</ContextProvider>
</Layout>
<PWAServiceWorkerProvider>
<ToastProvider>
<ReactQueryProvider>
<AuthFailedErrorBoundary>
<Layout>{children}</Layout>
</AuthFailedErrorBoundary>
</ReactQueryProvider>
</ToastProvider>
</PWAServiceWorkerProvider>
</body>
</html>
);
Expand Down
47 changes: 0 additions & 47 deletions src/app/login/redirect/page.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,6 @@
'use client';

import { notFound, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect } from 'react';

import { setAuth } from '@/utils/helpers';
import userAPI from '@/apis/user';

import Loading from '@/components/common/Loading';

const RedirectPage = () => {
const router = useRouter();
const searchParams = useSearchParams();

const accessToken = searchParams.get('access_token');

if (!accessToken) {
notFound();
}

const checkSavedAdditionalInfo = useCallback(async () => {
try {
const isSavedAdditionalInfo = await userAPI.getMyProfile().then(
({
data: {
job: { jobName, jobGroupName },
nickname,
},
}) => !!(nickname && jobGroupName && jobName)
);

if (!isSavedAdditionalInfo) {
router.replace('/profile/me/add');
}

router.replace('/bookarchive');
} catch {
router.replace('/not-found');
}
}, [router]);

useEffect(() => {
const hasAccessToken = !!accessToken;

if (hasAccessToken) {
accessToken && setAuth(accessToken);
checkSavedAdditionalInfo();
}
}, [accessToken, checkSavedAdditionalInfo]);

return <Loading fullpage />;
};

Expand Down
18 changes: 12 additions & 6 deletions src/app/profile/me/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { useQueryClient } from '@tanstack/react-query';
import userAPI from '@/apis/user';
import userKeys from '@/queries/user/key';

import { checkAuthentication, removeAuth } from '@/utils/helpers';
import { KAKAO_LOGIN_URL } from '@/constants';
import { deleteAuthSession } from '@/server/session';
import { deleteCookie } from '@/utils/cookie';
import { checkAuthentication } from '@/utils/helpers';
import { KAKAO_LOGIN_URL, SESSION_COOKIES_KEYS } from '@/constants';
import { IconArrowRight } from '@public/icons';

import SSRSafeSuspense from '@/components/common/SSRSafeSuspense';
Expand Down Expand Up @@ -86,10 +88,14 @@ const MyProfileForAuth = () => {
const router = useRouter();

const handleLogoutButtonClick = async () => {
await userAPI.logout();
removeAuth();
queryClient.removeQueries({ queryKey: userKeys.me(), exact: true });
router.refresh();
try {
await userAPI.logout();
await deleteAuthSession();
} finally {
SESSION_COOKIES_KEYS.map(key => deleteCookie(key));
queryClient.removeQueries({ queryKey: userKeys.me(), exact: true });
router.refresh();
}
};

return (
Expand Down
102 changes: 102 additions & 0 deletions src/app/profile/redirect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

import { COOKIE_KEYS, SEARCH_PARAMS_KEYS } from '@/constants';
import { isAuthRefreshError } from '@/utils/helpers';
import { createQueryString } from '@/utils/url';
import { getOrigin } from '@/lib/request/getOrigin';
import {
getAuthSession,
setProfileSession,
deleteAuthSession,
} from '@/server/session';

const REDIRECT_SEARCH_KEY = SEARCH_PARAMS_KEYS.REDIRECT_PATHNAME;

interface RetryRequest extends Request {
retried?: boolean; // fetch 재시도 시 true로 설정
}

// GET /profile/redirect
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const destination = searchParams.get(REDIRECT_SEARCH_KEY);

const accessToken = cookies().get(COOKIE_KEYS.ACCESS_TOKEN);

if (accessToken) {
const _request: RetryRequest = request.clone();
_request.retried = false;

let response: Response;

try {
response = await fetchMyProfile(_request, accessToken.value);
} catch (error) {
console.log('Caught error, redirect to root!\n', error);
await deleteAuthSession();
return redirect('/');
}

const data = await response.json();
const hasProfile = Boolean(data?.nickname && data?.job?.jobGroupName);

await setProfileSession(hasProfile);

if (!hasProfile) {
const search = createQueryString({
...(destination && { [REDIRECT_SEARCH_KEY]: destination }),
});
redirect(`/profile/me/add${search}`);
} else if (destination) {
redirect(`${destination}`);
} else {
redirect('/bookarchive');
}
}

return redirect('/');
}

/*
* 내 프로필 조회
*/
const fetchMyProfile = async (
request: RetryRequest,
token: string
): Promise<Response> => {
const origin = getOrigin(new URL(request.url), request.headers);
const response = await fetch(`${origin}/service-api/users/me`, {
headers: {
Accept: '*/*',
'Content-Type': 'application/json',
Authorization: `Bearers ${token}`,
},
});

if (!response.ok) {
// fetch 1번만 재시도
if (!request.retried) {
const error = await response.json();
const code = error.code || '';

if (isAuthRefreshError(code)) {
// 새로운 auth 세션 쿠키 설정
const newToken = await getAuthSession();
if (!newToken) {
throw Error('Failed to get access token');
}

// 재시도 flag 설정
request.retried = true;

// 재요청
return fetchMyProfile(request, newToken);
}
}

throw Error('Failed to get my profile', { cause: response });
}

return response;
};
16 changes: 4 additions & 12 deletions src/components/common/AuthFailedErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
import { useEffect } from 'react';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';

import useToast from '@/components/common/Toast/useToast';
import { isAuthFailedError, isAxiosErrorWithCustomCode } from '@/utils/helpers';
import Loading from '@/components/common/Loading';
import QueryErrorBoundaryFallback from '@/components/common/QueryErrorBoundaryFallback';

const AuthFailedErrorBoundary = ({
children,
Expand All @@ -30,14 +28,8 @@ const AuthFailedFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
const { show: showToast } = useToast();

useEffect(() => {
if (
isAxiosErrorWithCustomCode(error) &&
isAuthFailedError(error.response.data.code)
) {
showToast({ message: '다시 로그인 해주세요' });
resetErrorBoundary();
}
}, [error, resetErrorBoundary, showToast]);
showToast({ message: '다시 로그인 해주세요' });
}, [error, showToast]);

return <Loading fullpage />;
return <QueryErrorBoundaryFallback resetErrorBoundary={resetErrorBoundary} />;
};
20 changes: 0 additions & 20 deletions src/components/common/ContextProvider.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion src/components/common/QueryErrorBoundaryFallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const QueryErrorBoundaryFallback = ({
<div className="flex h-full w-full flex-col items-center justify-center gap-[1rem] rounded-lg py-[2rem]">
<p className="font-body1-bold">데이터를 불러오는 중 문제가 발생했어요.</p>
<Button size="small" onClick={resetErrorBoundary}>
다시 불러오기
다시 시도하기
</Button>
</div>
);
Expand Down
2 changes: 2 additions & 0 deletions src/components/common/ReactQueryProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import AuthRefreshIgnoredError from '@/types/customError/AuthRefreshIgnoredError';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
Expand Down
2 changes: 2 additions & 0 deletions src/components/common/Toast/ToastProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import { createContext, ReactNode, useMemo, useState } from 'react';

import type {
Expand Down
Loading

0 comments on commit 351f4a2

Please sign in to comment.