From d371aa3cec3c8cc0507615fb326c06712a01355b Mon Sep 17 00:00:00 2001 From: ukkodeveloper Date: Thu, 21 Sep 2023 20:55:29 +0900 Subject: [PATCH] =?UTF-8?q?Feat/#406=20access=20token=20=EC=9E=AC=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#446)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: accessToken 을 관리하는 localstorage 객체 생성 * feat: token storage에 setToken 메서드 추가 * feat: fetcher를 통해 요청하기 전에 access token 만료 검사 * fix: 테스트를 위해 토큰 재발급 시 요청 'GET'으로 변경 * fix: 요청 시 headers 스프레드 연산자 제거 * fix: 만료일 계산 로직 다시 설정 * fix: accessToken 만료시 요청 header 비워주도록 변경 * refactor: 만료일 계산 로직 util로 분리 * refactor: accessTokenStorage 활용하여 토큰관리 * feat: LoginPopUp 컴포넌트 구현 * refactor: AuthLayout이 필요한 page에 추가 * feat: 로그인 인증 에러 및 바운더리 추가 * feat: response ok가 아닐 경우 에러 객체 생성 * fix: auth error boundary 삭제 * feat: loginPopup 컨텍스트 구현 및 적용 * fix: 사용하지 않는 이유로 loginPopup Modal 삭제 * fix: 401 인증 실패시에 logout처리 * refactor: localstorage 로직을 accessTokenStorage에서 하도록 수정 * fix: mutate, query 시에 error 던지지 않도록 수정 * refactor: login 인증 코드가 undefined일 경우 처리 * fix: error 발생 시 불필요한 사이드 이펙트 발생 부분 제거 * feat: kakao Oauth 추가로 인한 router 및 경로 수정 * fix: 킬링파트 구간 등록시 모달 열리도록 수정 * fix: google oauth url 변경 * refactor: loginmodal message 배열에서 문자열로 받도록 수정 * feat: access token cache 기능 추가 * refactor: access token 만료 확인 로직 분리 * comment: 추후 error boundary 적용을 위한 주석 추가 * feat: login 팝업 모달 에러 코드별 처리 * style: 필요없는 주석 제거 * refactor: 불필요한 console.log 제거 * fix: 토큰 refresh 시에 GET에서 POST로 요청 메서드 변경 * feat: refresh 요청 401 응답 시 바로 error 발생 --- .../features/auth/components/AuthProvider.tsx | 8 +-- .../features/auth/components/LoginModal.tsx | 12 ++-- .../src/features/auth/constants/authUrls.ts | 6 ++ .../features/auth/constants/googleAuthUrl.ts | 7 -- .../features/auth/hooks/LoginPopUpContext.tsx | 46 ++++++++++++ .../comments/components/CommentForm.tsx | 2 +- .../songs/components/KillingPartTrack.tsx | 7 +- .../songs/components/VoteInterface.tsx | 19 ++--- frontend/src/index.tsx | 1 - frontend/src/pages/AuthPage.tsx | 21 ++++-- frontend/src/pages/LoginPage.tsx | 43 ++++++++---- frontend/src/router.tsx | 19 +++-- .../shared/components/Layout/AuthLayout.tsx | 17 ++--- frontend/src/shared/constants/path.ts | 3 +- frontend/src/shared/hooks/useFetch.ts | 20 +++++- frontend/src/shared/hooks/useMutation.ts | 20 +++++- frontend/src/shared/remotes/AuthError.ts | 11 +++ frontend/src/shared/remotes/index.ts | 29 ++++---- .../src/shared/remotes/preCheckAccessToken.ts | 46 ++++++++++++ .../src/shared/utils/accessTokenStorage.ts | 70 +++++++++++++++++++ 20 files changed, 311 insertions(+), 96 deletions(-) create mode 100644 frontend/src/features/auth/constants/authUrls.ts delete mode 100644 frontend/src/features/auth/constants/googleAuthUrl.ts create mode 100644 frontend/src/features/auth/hooks/LoginPopUpContext.tsx create mode 100644 frontend/src/shared/remotes/AuthError.ts create mode 100644 frontend/src/shared/remotes/preCheckAccessToken.ts create mode 100644 frontend/src/shared/utils/accessTokenStorage.ts diff --git a/frontend/src/features/auth/components/AuthProvider.tsx b/frontend/src/features/auth/components/AuthProvider.tsx index 6d8644965..005e4befd 100644 --- a/frontend/src/features/auth/components/AuthProvider.tsx +++ b/frontend/src/features/auth/components/AuthProvider.tsx @@ -1,4 +1,5 @@ import { createContext, useContext, useMemo, useState } from 'react'; +import accessTokenStorage from '@/shared/utils/accessTokenStorage'; import parseJWT from '../utils/parseJWT'; interface User { @@ -23,9 +24,8 @@ export const useAuthContext = () => { const AuthContext = createContext(null); const AuthProvider = ({ children }: { children: React.ReactElement[] }) => { - const [accessToken, setAccessToken] = useState(localStorage.getItem('userToken') || ''); + const [accessToken, setAccessToken] = useState(accessTokenStorage.getToken() || ''); - // TODO: 예외처리? const user: User | null = useMemo(() => { if (!accessToken) { return null; @@ -40,12 +40,12 @@ const AuthProvider = ({ children }: { children: React.ReactElement[] }) => { }, [accessToken]); const login = (userToken: string) => { - localStorage.setItem('userToken', userToken); + accessTokenStorage.setToken(userToken); setAccessToken(userToken); }; const logout = () => { - localStorage.removeItem('userToken'); + accessTokenStorage.removeToken(); setAccessToken(''); }; diff --git a/frontend/src/features/auth/components/LoginModal.tsx b/frontend/src/features/auth/components/LoginModal.tsx index 259daf090..8eda812cc 100644 --- a/frontend/src/features/auth/components/LoginModal.tsx +++ b/frontend/src/features/auth/components/LoginModal.tsx @@ -6,18 +6,14 @@ import ROUTE_PATH from '@/shared/constants/path'; interface LoginModalProps { isOpen: boolean; closeModal: () => void; - messageList: string[]; + message: string; } -const LoginModal = ({ isOpen, closeModal, messageList }: LoginModalProps) => { +const LoginModal = ({ isOpen, closeModal, message }: LoginModalProps) => { return ( 로그인이 필요합니다 - <> - {messageList.map((message) => { - return {message}; - })} - + {message} 닫기 @@ -38,8 +34,8 @@ const ModalContent = styled.div` margin: 16px 0; font-size: 16px; + line-height: 1.7; color: #b5b3bc; - text-align: center; white-space: pre-line; `; diff --git a/frontend/src/features/auth/constants/authUrls.ts b/frontend/src/features/auth/constants/authUrls.ts new file mode 100644 index 000000000..be9374b99 --- /dev/null +++ b/frontend/src/features/auth/constants/authUrls.ts @@ -0,0 +1,6 @@ +import path from '@/shared/constants/path'; + +const DOMAIN_URL = `${process.env.BASE_URL}`?.replace(/api\/?/, ''); + +export const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?scope=email&response_type=code&redirect_uri=${DOMAIN_URL}${path.GOOGLE_REDIRECT}&client_id=405219607197-qfpt1e3v1bm25ebvadt5bvttskse5vpg.apps.googleusercontent.com`; +export const kakaoAuthUrl = `https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=1808b8a2e3f29fbae5b54adc357a0692&redirect_uri=${DOMAIN_URL}${path.KAKAO_REDIRECT}`; diff --git a/frontend/src/features/auth/constants/googleAuthUrl.ts b/frontend/src/features/auth/constants/googleAuthUrl.ts deleted file mode 100644 index 16c5162dd..000000000 --- a/frontend/src/features/auth/constants/googleAuthUrl.ts +++ /dev/null @@ -1,7 +0,0 @@ -import ROUTE_PATH from '@/shared/constants/path'; - -const redirectUrl = `${process.env.BASE_URL}${ROUTE_PATH.LOGIN_REDIRECT}`?.replace(/api\/?/, ''); - -const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?scope=email&response_type=code&redirect_uri=${redirectUrl}&client_id=405219607197-qfpt1e3v1bm25ebvadt5bvttskse5vpg.apps.googleusercontent.com`; - -export default googleAuthUrl; diff --git a/frontend/src/features/auth/hooks/LoginPopUpContext.tsx b/frontend/src/features/auth/hooks/LoginPopUpContext.tsx new file mode 100644 index 000000000..3bec92351 --- /dev/null +++ b/frontend/src/features/auth/hooks/LoginPopUpContext.tsx @@ -0,0 +1,46 @@ +import { createContext, useContext, useState } from 'react'; +import LoginModal from '@/features/auth/components/LoginModal'; +import useModal from '@/shared/components/Modal/hooks/useModal'; +import type { PropsWithChildren } from 'react'; + +interface LoginPopUpContextProps { + popupLoginModal: (errorCode: number) => void; +} + +const LoginPopUpContext = createContext(null); + +export const useLoginPopup = () => { + const contextValue = useContext(LoginPopUpContext); + + if (contextValue === null) throw new Error('AuthContext가 null입니다.'); + + return contextValue; +}; + +const LoginPopupProvider = ({ children }: PropsWithChildren) => { + const { isOpen, closeModal, openModal } = useModal(false); + const [message, setMessage] = useState('로그인 하시겠습니까?'); + + const popupLoginModal = (errorCode: number) => { + // accessToken 관련 에러 코드 + if ([1000, 1003, 1004].includes(errorCode)) { + setMessage('로그인 정보가 부정확하여 재로그인이 필요합니다.\n다시 로그인하시겠습니까?'); + } + // refreshToken 관련 에러 코드 + if ([1011, 1012, 1007].includes(errorCode)) { + setMessage('로그인 정보가 만료되었습니다.\n다시 로그인하시겠습니까?'); + } + + setMessage('로그인 하시겠습니까?'); + openModal(); + }; + + return ( + + {children} + + + ); +}; + +export default LoginPopupProvider; diff --git a/frontend/src/features/comments/components/CommentForm.tsx b/frontend/src/features/comments/components/CommentForm.tsx index a1f99e03b..414f47a6d 100644 --- a/frontend/src/features/comments/components/CommentForm.tsx +++ b/frontend/src/features/comments/components/CommentForm.tsx @@ -70,7 +70,7 @@ const CommentForm = ({ getComment, songId, partId }: CommentFormProps) => { /> diff --git a/frontend/src/features/songs/components/KillingPartTrack.tsx b/frontend/src/features/songs/components/KillingPartTrack.tsx index 8fad2d047..7d4a79819 100644 --- a/frontend/src/features/songs/components/KillingPartTrack.tsx +++ b/frontend/src/features/songs/components/KillingPartTrack.tsx @@ -129,10 +129,9 @@ const KillingPartTrack = ({ )} diff --git a/frontend/src/features/songs/components/VoteInterface.tsx b/frontend/src/features/songs/components/VoteInterface.tsx index be1908b98..ad2e29855 100644 --- a/frontend/src/features/songs/components/VoteInterface.tsx +++ b/frontend/src/features/songs/components/VoteInterface.tsx @@ -1,4 +1,3 @@ -import { useNavigate } from 'react-router-dom'; import { styled } from 'styled-components'; import { useAuthContext } from '@/features/auth/components/AuthProvider'; import LoginModal from '@/features/auth/components/LoginModal'; @@ -9,7 +8,6 @@ import useModal from '@/shared/components/Modal/hooks/useModal'; import Modal from '@/shared/components/Modal/Modal'; import Spacing from '@/shared/components/Spacing'; import useToastContext from '@/shared/components/Toast/hooks/useToastContext'; -import ROUTE_PATH from '@/shared/constants/path'; import { toPlayingTimeText } from '@/shared/utils/convertTime'; import copyClipboard from '@/shared/utils/copyClipBoard'; import { usePostKillingPart } from '../remotes/usePostKillingPart'; @@ -19,23 +17,17 @@ const VoteInterface = () => { const { showToast } = useToastContext(); const { interval, partStartTime, songId, songVideoId } = useVoteInterfaceContext(); const { videoPlayer } = useVideoPlayerContext(); - const navigate = useNavigate(); - const { error, createKillingPart } = usePostKillingPart(); + + const { createKillingPart } = usePostKillingPart(); const { user } = useAuthContext(); const { isOpen, openModal, closeModal } = useModal(); const isLoggedIn = !!user; - const voteTimeText = toPlayingTimeText(partStartTime, partStartTime + interval); const submitKillingPart = async () => { videoPlayer.current?.pauseVideo(); - await createKillingPart(songId, { startSecond: partStartTime, length: interval }); - if (error) { - navigate(ROUTE_PATH.LOGIN); - return; - } openModal(); }; @@ -74,10 +66,9 @@ const VoteInterface = () => { ) : ( diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index be07a2c66..67e866ffc 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -22,7 +22,6 @@ async function main() { } await loadIFrameApi(); - // TODO: 웹 사이트 진입 시에 자동 로그인 (token 확인) const root = createRoot(document.getElementById('root') as HTMLElement); diff --git a/frontend/src/pages/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx index 34af4a3a0..700c451a9 100644 --- a/frontend/src/pages/AuthPage.tsx +++ b/frontend/src/pages/AuthPage.tsx @@ -1,7 +1,8 @@ import { useEffect } from 'react'; -import { Navigate, useSearchParams } from 'react-router-dom'; +import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useAuthContext } from '@/features/auth/components/AuthProvider'; -import googleAuthUrl from '@/features/auth/constants/googleAuthUrl'; +import path from '@/shared/constants/path'; +import accessTokenStorage from '@/shared/utils/accessTokenStorage'; interface AccessTokenResponse { accessToken: string; @@ -9,18 +10,22 @@ interface AccessTokenResponse { const AuthPage = () => { const [searchParams] = useSearchParams(); + const { platform } = useParams(); + const { login } = useAuthContext(); + const navigate = useNavigate(); - // TODO: 예외처리 const getAccessToken = async () => { const code = searchParams.get('code'); + if (!code) { - localStorage.removeItem('userToken'); - window.location.href = googleAuthUrl; + accessTokenStorage.removeToken(); + navigate(path.LOGIN); + alert('비정상적인 로그인입니다. 다시 로그인해주세요.'); return; } - const response = await fetch(`${process.env.BASE_URL}/login/google?code=${code}`, { + const response = await fetch(`${process.env.BASE_URL}/login/${platform}?code=${code}`, { method: 'get', credentials: 'include', }); @@ -28,7 +33,9 @@ const AuthPage = () => { const data = (await response.json()) as AccessTokenResponse; const { accessToken } = data; - login(accessToken); + if (accessToken) { + login(accessToken); + } }; useEffect(() => { diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index ce6360317..68e9aefe8 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -3,11 +3,19 @@ import { styled } from 'styled-components'; import googleLogo from '@/assets/icon/google-logo.svg'; import kakaoLogo from '@/assets/icon/kakao-logo.svg'; import slogan from '@/assets/image/shook-slogan.jpg'; -import googleAuthUrl from '@/features/auth/constants/googleAuthUrl'; +import { googleAuthUrl, kakaoAuthUrl } from '@/features/auth/constants/authUrls'; import Spacing from '@/shared/components/Spacing'; import ROUTE_PATH from '@/shared/constants/path'; const LoginPage = () => { + const goToGoogleAuth = () => { + if (window.navigator.userAgent.match(/kakaotalk/i)) { + alert('카카오톡에서 구글 로그인은 불가능합니다.'); + return; + } + window.location.href = googleAuthUrl; + }; + return (
@@ -17,20 +25,22 @@ const LoginPage = () => {
- - + + 구글로 로그인하기 + + + + + + 카카오로 로그인하기 + + - alert('카카오 로그인은 준비중입니다.')}> - - - 카카오로 로그인하기 - -
color.oauth.kakao}; `; -const GoogleLogin = styled(LoginButton)` +const GoogleLogin = styled(PlatformName)` background-color: ${({ theme: { color } }) => color.oauth.google}; `; +const GoogleLoginButton = styled.button` + flex: 1; + + @media (max-width: ${({ theme }) => theme.breakPoints.xs}) { + width: 100%; + } +`; + const LoginLink = styled.a` flex: 1; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 2140daff0..33b267ea3 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -1,4 +1,5 @@ import { createBrowserRouter } from 'react-router-dom'; +import LoginPopupProvider from '@/features/auth/hooks/LoginPopUpContext'; import EditProfilePage from '@/pages/EditProfilePage'; import AuthPage from './pages/AuthPage'; import LoginPage from './pages/LoginPage'; @@ -13,7 +14,11 @@ import ROUTE_PATH from './shared/constants/path'; const router = createBrowserRouter([ { path: ROUTE_PATH.ROOT, - element: , + element: ( + + + + ), children: [ { index: true, @@ -35,13 +40,13 @@ const router = createBrowserRouter([ ), }, - { - path: `${ROUTE_PATH.MY_PAGE}`, - element: , - }, { path: `${ROUTE_PATH.EDIT_PROFILE}`, - element: , + element: ( + + + + ), }, ], }, @@ -50,7 +55,7 @@ const router = createBrowserRouter([ element: , }, { - path: `${ROUTE_PATH.LOGIN_REDIRECT}`, + path: `/:platform/redirect`, element: , }, ]); diff --git a/frontend/src/shared/components/Layout/AuthLayout.tsx b/frontend/src/shared/components/Layout/AuthLayout.tsx index 62f6f26d1..3b7f163c1 100644 --- a/frontend/src/shared/components/Layout/AuthLayout.tsx +++ b/frontend/src/shared/components/Layout/AuthLayout.tsx @@ -1,13 +1,15 @@ -import { useNavigate } from 'react-router-dom'; +import { Navigate } from 'react-router-dom'; +import { useAuthContext } from '@/features/auth/components/AuthProvider'; import parseJWT from '@/features/auth/utils/parseJWT'; -import ROUTE_PATH from '@/shared/constants/path'; +import path from '@/shared/constants/path'; +import accessTokenStorage from '@/shared/utils/accessTokenStorage'; +import type React from 'react'; const isValidToken = (accessToken: string) => { if (!accessToken) return false; const { memberId, nickname } = parseJWT(accessToken); - // TODO: memberId와 url param Id 가 다를 때 다르게 처리해줄 것. if (!memberId || !nickname || typeof memberId !== 'number' || typeof nickname !== 'string') { return false; } @@ -16,13 +18,12 @@ const isValidToken = (accessToken: string) => { }; const AuthLayout = ({ children }: { children: React.ReactElement }) => { - const accessToken = localStorage.getItem('userToken'); - const navigator = useNavigate(); + const accessToken = accessTokenStorage.getToken(); + const { logout } = useAuthContext(); if (!accessToken || !isValidToken(accessToken)) { - localStorage.removeItem('userToken'); - navigator(ROUTE_PATH.LOGIN); - return; + logout(); + return ; } return <>{children}; diff --git a/frontend/src/shared/constants/path.ts b/frontend/src/shared/constants/path.ts index ade59f750..b885585c0 100644 --- a/frontend/src/shared/constants/path.ts +++ b/frontend/src/shared/constants/path.ts @@ -3,7 +3,8 @@ const ROUTE_PATH = { COLLECT: 'collect', SONG_DETAILS: 'songs', LOGIN: '/login', - LOGIN_REDIRECT: '/login/redirect', + GOOGLE_REDIRECT: 'google/redirect', + KAKAO_REDIRECT: 'kakao/redirect', MY_PAGE: 'my-page', EDIT_PROFILE: 'my-page/edit', } as const; diff --git a/frontend/src/shared/hooks/useFetch.ts b/frontend/src/shared/hooks/useFetch.ts index 7b8635e02..410d22835 100644 --- a/frontend/src/shared/hooks/useFetch.ts +++ b/frontend/src/shared/hooks/useFetch.ts @@ -1,10 +1,19 @@ import { useCallback, useEffect, useState } from 'react'; -import type { ErrorResponse } from '@/shared/remotes'; +import { useAuthContext } from '@/features/auth/components/AuthProvider'; +import { useLoginPopup } from '@/features/auth/hooks/LoginPopUpContext'; +import AuthError from '@/shared/remotes/AuthError'; const useFetch = (fetcher: () => Promise, defaultFetch: boolean = true) => { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); + const { popupLoginModal } = useLoginPopup(); + const { logout } = useAuthContext(); + + // TODO: Error Boudary 적용 시에 주석을 사용해주세요. + // if (error) { + // throw error; + // } const fetchData = useCallback(async () => { setError(null); @@ -14,7 +23,12 @@ const useFetch = (fetcher: () => Promise, defaultFetch: boolean = true) => const data = await fetcher(); setData(data); } catch (error) { - setError(error as ErrorResponse); + if (error instanceof AuthError) { + logout(); + popupLoginModal(error.code); + return; + } + setError(error as Error); } finally { setIsLoading(false); } diff --git a/frontend/src/shared/hooks/useMutation.ts b/frontend/src/shared/hooks/useMutation.ts index 1a59e6061..a7289a371 100644 --- a/frontend/src/shared/hooks/useMutation.ts +++ b/frontend/src/shared/hooks/useMutation.ts @@ -1,11 +1,20 @@ import { useCallback, useState } from 'react'; -import type { ErrorResponse } from '@/shared/remotes'; +import { useAuthContext } from '@/features/auth/components/AuthProvider'; +import { useLoginPopup } from '@/features/auth/hooks/LoginPopUpContext'; +import AuthError from '@/shared/remotes/AuthError'; // eslint-disable-next-line export const useMutation = (mutateFn: (...params: P) => Promise) => { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); + const { popupLoginModal } = useLoginPopup(); + const { logout } = useAuthContext(); + + // TODO: Error Boudary 적용 시에 주석을 사용해주세요. + // if (error) { + // throw error; + // } const mutateData = useCallback( async (...params: P) => { @@ -16,7 +25,12 @@ export const useMutation = (mutateFn: (...params: P) => Prom const responseBody = await mutateFn(...params); setData(responseBody); } catch (error) { - setError(error as ErrorResponse); + if (error instanceof AuthError) { + logout(); + popupLoginModal(error.code); + return; + } + setError(error as Error); } finally { setIsLoading(false); } diff --git a/frontend/src/shared/remotes/AuthError.ts b/frontend/src/shared/remotes/AuthError.ts new file mode 100644 index 000000000..2816e7412 --- /dev/null +++ b/frontend/src/shared/remotes/AuthError.ts @@ -0,0 +1,11 @@ +class AuthError extends Error { + code: number; + name: string; + constructor({ code, message }: { code: number; message: string }) { + super(message); + this.code = code; + this.name = 'AuthError'; + } +} + +export default AuthError; diff --git a/frontend/src/shared/remotes/index.ts b/frontend/src/shared/remotes/index.ts index 7472945a1..45f26d4d9 100644 --- a/frontend/src/shared/remotes/index.ts +++ b/frontend/src/shared/remotes/index.ts @@ -1,4 +1,5 @@ -import ROUTE_PATH from '@/shared/constants/path'; +import AuthError from '@/shared/remotes/AuthError'; +import preCheckAccessToken from '@/shared/remotes/preCheckAccessToken'; export interface ErrorResponse { code: number; @@ -8,14 +9,12 @@ export interface ErrorResponse { const { BASE_URL } = process.env; const fetcher = async (url: string, method: string, body?: unknown) => { - const loginRedirectUrl = `${process.env.BASE_URL}${ROUTE_PATH.LOGIN}`?.replace(/api\/?/, ''); - - const accessToken = localStorage.getItem('userToken'); - const headers: Record = { 'Content-type': 'application/json', }; + const accessToken = await preCheckAccessToken(); + if (accessToken) { headers['Authorization'] = `Bearer ${accessToken}`; } @@ -31,20 +30,18 @@ const fetcher = async (url: string, method: string, body?: unknown) => { const response = await fetch(`${BASE_URL}${url}`, options); - if (response.status >= 500) { - throw new Error(`서버문제로 HTTP 통신에 실패했습니다.`); - } - - if (response.status === 401) { - localStorage.removeItem('userToken'); - //TODO: 해당 부분 router-dom으로 해결 가능한지 확인해야함. - window.location.href = loginRedirectUrl; - } - if (!response.ok) { const errorResponse: ErrorResponse = await response.json(); - throw errorResponse; + if (response.status >= 500) { + throw new Error(errorResponse.message); + } + + if (response.status === 401) { + throw new AuthError(errorResponse); + } + + throw new Error(errorResponse.message); } const contentType = response.headers.get('content-type'); diff --git a/frontend/src/shared/remotes/preCheckAccessToken.ts b/frontend/src/shared/remotes/preCheckAccessToken.ts new file mode 100644 index 000000000..2a1d8b126 --- /dev/null +++ b/frontend/src/shared/remotes/preCheckAccessToken.ts @@ -0,0 +1,46 @@ +import AuthError from '@/shared/remotes/AuthError'; +import accessTokenStorage from '@/shared/utils/accessTokenStorage'; +import type { ErrorResponse } from '@/shared/remotes/index'; + +const isTokenExpiredAfter60seconds = (tokenExp: number) => { + return tokenExp * 1000 - 30 * 1000 < Date.now(); +}; + +const preCheckAccessToken = async () => { + const accessTokenWithPayload = accessTokenStorage.getTokenWithPayload(); + + if (accessTokenWithPayload) { + const { + accessToken, + payload: { exp }, + } = accessTokenWithPayload; + + if (!isTokenExpiredAfter60seconds(exp)) { + return accessToken; + } + + const response = await fetch(`${process.env.BASE_URL}/reissue`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (response.ok) { + const { accessToken } = await response.json(); + accessTokenStorage.setToken(accessToken); + return accessToken; + } + + const errorResponse: ErrorResponse = await response.json(); + + if (response.status === 401) { + throw new AuthError(errorResponse); + } + // 기타 상태코드 처리 + } + + return null; +}; + +export default preCheckAccessToken; diff --git a/frontend/src/shared/utils/accessTokenStorage.ts b/frontend/src/shared/utils/accessTokenStorage.ts new file mode 100644 index 000000000..e2699f542 --- /dev/null +++ b/frontend/src/shared/utils/accessTokenStorage.ts @@ -0,0 +1,70 @@ +import parseJWT from '@/features/auth/utils/parseJWT'; + +const ACCESS_TOKEN_KEY = 'userToken'; +interface AccessTokenPayload { + sub: string; + memberId: number; + nickname: string; + iat: number; + exp: number; +} + +interface AccessCache { + accessToken: string; + payload: AccessTokenPayload; +} + +// 은닉하기 위해 객체 밖으로 빼어냈습니다. +let cache: AccessCache | null; + +const accessTokenStorage = { + getTokenWithPayload() { + const accessToken = this.getToken(); + + try { + if (!accessToken) throw new Error(); + + if (this.isCacheMissed(accessToken)) { + this.setCache(accessToken); + } + + if (cache) { + return { + ...cache, + }; + } + } catch { + this.removeToken(); + return null; + } + }, + + getToken() { + return localStorage.getItem(ACCESS_TOKEN_KEY); + }, + + setToken(token: string) { + localStorage.setItem(ACCESS_TOKEN_KEY, token); + }, + + removeToken() { + localStorage.removeItem(ACCESS_TOKEN_KEY); + }, + + parseToken(token: string) { + return parseJWT(token); + }, + + isCacheMissed(accessToken: string) { + return !cache || cache.accessToken !== accessToken; + }, + + setCache(accessToken: string) { + cache = { + accessToken: accessToken, + payload: this.parseToken(accessToken) as AccessTokenPayload, + }; + }, +}; + +export default accessTokenStorage;