Skip to content

Commit 351f4a2

Browse files
authored
[#649] SSR을 위해 JWT 토큰을 쿠키에 저장하도록 수정 (#658)
* 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 에러 핸들링 로직 추가
1 parent ef5830c commit 351f4a2

File tree

24 files changed

+496
-157
lines changed

24 files changed

+496
-157
lines changed

next.config.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,6 @@ const nextConfig = {
3131
},
3232
async rewrites() {
3333
return [
34-
{
35-
source: '/service-api/:url*',
36-
destination: `${baseURL}/api/:url*`,
37-
},
3834
{
3935
source: '/aladin-api',
4036
has: [{ type: 'query', key: 'QueryType', value: '(?<QueryType>.*)' }],
@@ -70,6 +66,9 @@ const nextConfig = {
7066
},
7167
],
7268
},
69+
experimental: {
70+
serverActions: true,
71+
},
7372
};
7473

7574
module.exports = nextConfig;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@types/react-dom": "18.0.10",
2727
"axios": "^1.3.4",
2828
"colorthief": "^2.4.0",
29+
"jose": "^5.5.0",
2930
"next": "13.4.7",
3031
"react": "18.2.0",
3132
"react-dom": "18.2.0",

src/apis/core/axios.ts

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import axios, { CreateAxiosDefaults, InternalAxiosRequestConfig } from 'axios';
22

33
import { AuthRefreshIgnoredError } from '@/types/customError';
4-
import { ACCESS_TOKEN_STORAGE_KEY, SERVICE_ERROR_MESSAGE } from '@/constants';
4+
import { SERVICE_ERROR_MESSAGE, SESSION_COOKIES_KEYS } from '@/constants';
55
import {
66
isAuthFailedError,
77
isAuthRefreshError,
88
isAxiosErrorWithCustomCode,
99
} from '@/utils/helpers';
10-
import webStorage from '@/utils/storage';
10+
import { deleteAuthSession, setAuthSession } from '@/server/session';
11+
import { deleteCookie } from '@/utils/cookie';
1112

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

2626
const requestHandler = (config: InternalAxiosRequestConfig) => {
2727
const { data, method } = config;
28-
const accessToken = storage.get();
29-
30-
if (accessToken) {
31-
setAxiosAuthHeader(config, accessToken);
32-
}
3328

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

5348
if (isAuthFailedError(code)) {
54-
removeToken();
49+
await removeToken();
5550
}
5651
} else {
5752
console.error('예상하지 못한 오류가 발생했어요.\n', error);
@@ -63,12 +58,10 @@ const responseHandler = async (error: unknown) => {
6358
const silentRefresh = async (originRequest: InternalAxiosRequestConfig) => {
6459
try {
6560
const newToken = await updateToken();
66-
storage.set(newToken);
67-
setAxiosAuthHeader(originRequest, newToken);
68-
61+
await setAuthSession(newToken);
6962
return await publicApi(originRequest);
7063
} catch (error) {
71-
removeToken();
64+
await removeToken();
7265
return Promise.reject(error);
7366
}
7467
};
@@ -93,15 +86,9 @@ const updateToken = () =>
9386
.finally(() => (isTokenRefreshing = false));
9487
});
9588

96-
const removeToken = () => {
97-
storage.remove();
98-
};
99-
100-
const setAxiosAuthHeader = (
101-
config: InternalAxiosRequestConfig,
102-
token: string
103-
) => {
104-
config.headers['Authorization'] = `Bearers ${token}`;
89+
const removeToken = async () => {
90+
SESSION_COOKIES_KEYS.map(key => deleteCookie(key));
91+
await deleteAuthSession();
10592
};
10693

10794
publicApi.interceptors.request.use(requestHandler);

src/app/layout.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import type { Metadata } from 'next';
33
import { appleSplashScreens } from '@/constants/metadata';
44

55
import GoogleAnalytics from '@/components/common/GoogleAnalytics';
6-
import ContextProvider from '@/components/common/ContextProvider';
76
import AuthFailedErrorBoundary from '@/components/common/AuthFailedErrorBoundary';
7+
import PWAServiceWorkerProvider from '@/components/common/PWAServiceWorkerProvider';
8+
import ReactQueryProvider from '@/components/common/ReactQueryProvider';
9+
import ToastProvider from '@/components/common/Toast/ToastProvider';
810
import Layout from '@/components/layout/Layout';
911

1012
import { LineSeedKR } from '@/styles/font';
@@ -41,11 +43,15 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => {
4143
<html lang="ko">
4244
<body className={`${LineSeedKR.variable} app-layout font-lineseed`}>
4345
<GoogleAnalytics />
44-
<Layout>
45-
<ContextProvider>
46-
<AuthFailedErrorBoundary>{children}</AuthFailedErrorBoundary>
47-
</ContextProvider>
48-
</Layout>
46+
<PWAServiceWorkerProvider>
47+
<ToastProvider>
48+
<ReactQueryProvider>
49+
<AuthFailedErrorBoundary>
50+
<Layout>{children}</Layout>
51+
</AuthFailedErrorBoundary>
52+
</ReactQueryProvider>
53+
</ToastProvider>
54+
</PWAServiceWorkerProvider>
4955
</body>
5056
</html>
5157
);

src/app/login/redirect/page.tsx

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,6 @@
1-
'use client';
2-
3-
import { notFound, useRouter, useSearchParams } from 'next/navigation';
4-
import { useCallback, useEffect } from 'react';
5-
6-
import { setAuth } from '@/utils/helpers';
7-
import userAPI from '@/apis/user';
8-
91
import Loading from '@/components/common/Loading';
102

113
const RedirectPage = () => {
12-
const router = useRouter();
13-
const searchParams = useSearchParams();
14-
15-
const accessToken = searchParams.get('access_token');
16-
17-
if (!accessToken) {
18-
notFound();
19-
}
20-
21-
const checkSavedAdditionalInfo = useCallback(async () => {
22-
try {
23-
const isSavedAdditionalInfo = await userAPI.getMyProfile().then(
24-
({
25-
data: {
26-
job: { jobName, jobGroupName },
27-
nickname,
28-
},
29-
}) => !!(nickname && jobGroupName && jobName)
30-
);
31-
32-
if (!isSavedAdditionalInfo) {
33-
router.replace('/profile/me/add');
34-
}
35-
36-
router.replace('/bookarchive');
37-
} catch {
38-
router.replace('/not-found');
39-
}
40-
}, [router]);
41-
42-
useEffect(() => {
43-
const hasAccessToken = !!accessToken;
44-
45-
if (hasAccessToken) {
46-
accessToken && setAuth(accessToken);
47-
checkSavedAdditionalInfo();
48-
}
49-
}, [accessToken, checkSavedAdditionalInfo]);
50-
514
return <Loading fullpage />;
525
};
536

src/app/profile/me/page.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import { useQueryClient } from '@tanstack/react-query';
77
import userAPI from '@/apis/user';
88
import userKeys from '@/queries/user/key';
99

10-
import { checkAuthentication, removeAuth } from '@/utils/helpers';
11-
import { KAKAO_LOGIN_URL } from '@/constants';
10+
import { deleteAuthSession } from '@/server/session';
11+
import { deleteCookie } from '@/utils/cookie';
12+
import { checkAuthentication } from '@/utils/helpers';
13+
import { KAKAO_LOGIN_URL, SESSION_COOKIES_KEYS } from '@/constants';
1214
import { IconArrowRight } from '@public/icons';
1315

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

8890
const handleLogoutButtonClick = async () => {
89-
await userAPI.logout();
90-
removeAuth();
91-
queryClient.removeQueries({ queryKey: userKeys.me(), exact: true });
92-
router.refresh();
91+
try {
92+
await userAPI.logout();
93+
await deleteAuthSession();
94+
} finally {
95+
SESSION_COOKIES_KEYS.map(key => deleteCookie(key));
96+
queryClient.removeQueries({ queryKey: userKeys.me(), exact: true });
97+
router.refresh();
98+
}
9399
};
94100

95101
return (

src/app/profile/redirect/route.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { cookies } from 'next/headers';
2+
import { redirect } from 'next/navigation';
3+
4+
import { COOKIE_KEYS, SEARCH_PARAMS_KEYS } from '@/constants';
5+
import { isAuthRefreshError } from '@/utils/helpers';
6+
import { createQueryString } from '@/utils/url';
7+
import { getOrigin } from '@/lib/request/getOrigin';
8+
import {
9+
getAuthSession,
10+
setProfileSession,
11+
deleteAuthSession,
12+
} from '@/server/session';
13+
14+
const REDIRECT_SEARCH_KEY = SEARCH_PARAMS_KEYS.REDIRECT_PATHNAME;
15+
16+
interface RetryRequest extends Request {
17+
retried?: boolean; // fetch 재시도 시 true로 설정
18+
}
19+
20+
// GET /profile/redirect
21+
export async function GET(request: Request) {
22+
const { searchParams } = new URL(request.url);
23+
const destination = searchParams.get(REDIRECT_SEARCH_KEY);
24+
25+
const accessToken = cookies().get(COOKIE_KEYS.ACCESS_TOKEN);
26+
27+
if (accessToken) {
28+
const _request: RetryRequest = request.clone();
29+
_request.retried = false;
30+
31+
let response: Response;
32+
33+
try {
34+
response = await fetchMyProfile(_request, accessToken.value);
35+
} catch (error) {
36+
console.log('Caught error, redirect to root!\n', error);
37+
await deleteAuthSession();
38+
return redirect('/');
39+
}
40+
41+
const data = await response.json();
42+
const hasProfile = Boolean(data?.nickname && data?.job?.jobGroupName);
43+
44+
await setProfileSession(hasProfile);
45+
46+
if (!hasProfile) {
47+
const search = createQueryString({
48+
...(destination && { [REDIRECT_SEARCH_KEY]: destination }),
49+
});
50+
redirect(`/profile/me/add${search}`);
51+
} else if (destination) {
52+
redirect(`${destination}`);
53+
} else {
54+
redirect('/bookarchive');
55+
}
56+
}
57+
58+
return redirect('/');
59+
}
60+
61+
/*
62+
* 내 프로필 조회
63+
*/
64+
const fetchMyProfile = async (
65+
request: RetryRequest,
66+
token: string
67+
): Promise<Response> => {
68+
const origin = getOrigin(new URL(request.url), request.headers);
69+
const response = await fetch(`${origin}/service-api/users/me`, {
70+
headers: {
71+
Accept: '*/*',
72+
'Content-Type': 'application/json',
73+
Authorization: `Bearers ${token}`,
74+
},
75+
});
76+
77+
if (!response.ok) {
78+
// fetch 1번만 재시도
79+
if (!request.retried) {
80+
const error = await response.json();
81+
const code = error.code || '';
82+
83+
if (isAuthRefreshError(code)) {
84+
// 새로운 auth 세션 쿠키 설정
85+
const newToken = await getAuthSession();
86+
if (!newToken) {
87+
throw Error('Failed to get access token');
88+
}
89+
90+
// 재시도 flag 설정
91+
request.retried = true;
92+
93+
// 재요청
94+
return fetchMyProfile(request, newToken);
95+
}
96+
}
97+
98+
throw Error('Failed to get my profile', { cause: response });
99+
}
100+
101+
return response;
102+
};

src/components/common/AuthFailedErrorBoundary.tsx

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@
33
import { useEffect } from 'react';
44
import { QueryErrorResetBoundary } from '@tanstack/react-query';
55
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
6-
76
import useToast from '@/components/common/Toast/useToast';
8-
import { isAuthFailedError, isAxiosErrorWithCustomCode } from '@/utils/helpers';
9-
import Loading from '@/components/common/Loading';
7+
import QueryErrorBoundaryFallback from '@/components/common/QueryErrorBoundaryFallback';
108

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

3230
useEffect(() => {
33-
if (
34-
isAxiosErrorWithCustomCode(error) &&
35-
isAuthFailedError(error.response.data.code)
36-
) {
37-
showToast({ message: '다시 로그인 해주세요' });
38-
resetErrorBoundary();
39-
}
40-
}, [error, resetErrorBoundary, showToast]);
31+
showToast({ message: '다시 로그인 해주세요' });
32+
}, [error, showToast]);
4133

42-
return <Loading fullpage />;
34+
return <QueryErrorBoundaryFallback resetErrorBoundary={resetErrorBoundary} />;
4335
};

src/components/common/ContextProvider.tsx

Lines changed: 0 additions & 20 deletions
This file was deleted.

src/components/common/QueryErrorBoundaryFallback.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const QueryErrorBoundaryFallback = ({
99
<div className="flex h-full w-full flex-col items-center justify-center gap-[1rem] rounded-lg py-[2rem]">
1010
<p className="font-body1-bold">데이터를 불러오는 중 문제가 발생했어요.</p>
1111
<Button size="small" onClick={resetErrorBoundary}>
12-
다시 불러오기
12+
다시 시도하기
1313
</Button>
1414
</div>
1515
);

0 commit comments

Comments
 (0)