Skip to content

Commit

Permalink
Feat/#406 access token 재발급 처리 및 카카오 로그인 추가 (#446)
Browse files Browse the repository at this point in the history
* 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 발생
  • Loading branch information
ukkodeveloper authored Sep 21, 2023
1 parent 6d9d1a5 commit d371aa3
Show file tree
Hide file tree
Showing 20 changed files with 311 additions and 96 deletions.
8 changes: 4 additions & 4 deletions frontend/src/features/auth/components/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createContext, useContext, useMemo, useState } from 'react';
import accessTokenStorage from '@/shared/utils/accessTokenStorage';
import parseJWT from '../utils/parseJWT';

interface User {
Expand All @@ -23,9 +24,8 @@ export const useAuthContext = () => {
const AuthContext = createContext<AuthContextProps | null>(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;
Expand All @@ -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('');
};

Expand Down
12 changes: 4 additions & 8 deletions frontend/src/features/auth/components/LoginModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Modal isOpen={isOpen} closeModal={closeModal}>
<ModalTitle>로그인이 필요합니다</ModalTitle>
<>
{messageList.map((message) => {
return <ModalContent key={message}>{message}</ModalContent>;
})}
</>
<ModalContent>{message}</ModalContent>
<ButtonContainer>
<ConfirmButton type="button" onClick={closeModal}>
닫기
Expand All @@ -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;
`;

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/features/auth/constants/authUrls.ts
Original file line number Diff line number Diff line change
@@ -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}`;
7 changes: 0 additions & 7 deletions frontend/src/features/auth/constants/googleAuthUrl.ts

This file was deleted.

46 changes: 46 additions & 0 deletions frontend/src/features/auth/hooks/LoginPopUpContext.tsx
Original file line number Diff line number Diff line change
@@ -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<LoginPopUpContextProps | null>(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 (
<LoginPopUpContext.Provider value={{ popupLoginModal }}>
{children}
<LoginModal isOpen={isOpen} closeModal={closeModal} message={message} />
</LoginPopUpContext.Provider>
);
};

export default LoginPopupProvider;
2 changes: 1 addition & 1 deletion frontend/src/features/comments/components/CommentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const CommentForm = ({ getComment, songId, partId }: CommentFormProps) => {
/>
<LoginModal
isOpen={isOpen}
messageList={['로그인하고 댓글을 작성해 보세요!']}
message={'로그인하고 댓글을 작성해 보세요!'}
closeModal={closeLoginModal}
/>
</>
Expand Down
7 changes: 3 additions & 4 deletions frontend/src/features/songs/components/KillingPartTrack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,9 @@ const KillingPartTrack = ({
)}
<LoginModal
isOpen={isOpen}
messageList={[
'로그인하여 킬링파트에 "좋아요!"를 눌러주세요!',
'"좋아요!"한 노래는 마이페이지에 저장됩니다!',
]}
message={
'로그인하여 킬링파트에 "좋아요!"를 눌러주세요!\n"좋아요!"한 노래는 마이페이지에 저장됩니다!'
}
closeModal={closeModal}
/>
</Container>
Expand Down
19 changes: 5 additions & 14 deletions frontend/src/features/songs/components/VoteInterface.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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();
};

Expand Down Expand Up @@ -74,10 +66,9 @@ const VoteInterface = () => {
</Modal>
) : (
<LoginModal
messageList={[
'슉에서 당신만의 킬링파트를 등록해보세요!',
'당신이 등록한 구간이 대표 킬링파트가 될 수 있어요!',
]}
message={
'슉에서 당신만의 킬링파트를 등록해보세요!\n당신이 등록한 구간이 대표 킬링파트가 될 수 있어요!'
}
isOpen={isOpen}
closeModal={closeModal}
/>
Expand Down
1 change: 0 additions & 1 deletion frontend/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ async function main() {
}

await loadIFrameApi();
// TODO: 웹 사이트 진입 시에 자동 로그인 (token 확인)

const root = createRoot(document.getElementById('root') as HTMLElement);

Expand Down
21 changes: 14 additions & 7 deletions frontend/src/pages/AuthPage.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,41 @@
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;
}

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',
});

const data = (await response.json()) as AccessTokenResponse;
const { accessToken } = data;

login(accessToken);
if (accessToken) {
login(accessToken);
}
};

useEffect(() => {
Expand Down
43 changes: 31 additions & 12 deletions frontend/src/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<LayoutContainer>
<div>
Expand All @@ -17,20 +25,22 @@ const LoginPage = () => {
</Link>
</div>
<LoginButtonContainer>
<LoginLink href={googleAuthUrl}>
<GoogleLogin type="button">
<GoogleLoginButton onClick={goToGoogleAuth}>
<GoogleLogin>
<Spacing direction="horizontal" size={10} />
<LoginLogo src={googleLogo} alt="google logo" />
<LoginText>구글로 로그인하기</LoginText>
<Spacing direction="horizontal" size={10} />
</GoogleLogin>
</GoogleLoginButton>
<LoginLink href={kakaoAuthUrl}>
<KakaoLogin>
<Spacing direction="horizontal" size={10} />
<LoginLogo src={kakaoLogo} alt="google logo" />
<LoginText>카카오로 로그인하기</LoginText>
<Spacing direction="horizontal" size={10} />
</KakaoLogin>
</LoginLink>
<KakaoLogin type="button" onClick={() => alert('카카오 로그인은 준비중입니다.')}>
<Spacing direction="horizontal" size={10} />
<LoginLogo src={kakaoLogo} alt="google logo" />
<LoginText>카카오로 로그인하기</LoginText>
<Spacing direction="horizontal" size={10} />
</KakaoLogin>
</LoginButtonContainer>
<div>
<UnderLineAnchor
Expand Down Expand Up @@ -95,10 +105,11 @@ const MainLogo = styled.img`
width: 500px;
`;

const LoginButton = styled.button`
const PlatformName = styled.div`
display: flex;
align-items: center;
justify-content: center;
text-align: center;
width: 400px;
height: 60px;
Expand All @@ -110,14 +121,22 @@ const LoginButton = styled.button`
}
`;

const KakaoLogin = styled(LoginButton)`
const KakaoLogin = styled(PlatformName)`
background-color: ${({ theme: { color } }) => 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;
Expand Down
19 changes: 12 additions & 7 deletions frontend/src/router.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,7 +14,11 @@ import ROUTE_PATH from './shared/constants/path';
const router = createBrowserRouter([
{
path: ROUTE_PATH.ROOT,
element: <Layout />,
element: (
<LoginPopupProvider>
<Layout />
</LoginPopupProvider>
),
children: [
{
index: true,
Expand All @@ -35,13 +40,13 @@ const router = createBrowserRouter([
</AuthLayout>
),
},
{
path: `${ROUTE_PATH.MY_PAGE}`,
element: <MyPage />,
},
{
path: `${ROUTE_PATH.EDIT_PROFILE}`,
element: <EditProfilePage />,
element: (
<AuthLayout>
<EditProfilePage />
</AuthLayout>
),
},
],
},
Expand All @@ -50,7 +55,7 @@ const router = createBrowserRouter([
element: <LoginPage />,
},
{
path: `${ROUTE_PATH.LOGIN_REDIRECT}`,
path: `/:platform/redirect`,
element: <AuthPage />,
},
]);
Expand Down
Loading

0 comments on commit d371aa3

Please sign in to comment.