Skip to content
Merged
7 changes: 6 additions & 1 deletion src/app/routes/components/auth.routes.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LoginPage, RealtorCertificationPage, RealtorLoginPage } from '@/pages';
import { LoginPage, OAuthRedirectPage, RealtorCertificationPage, RealtorLoginPage } from '@/pages';
import { ROUTER_PATH } from '@/shared';
import { Layout } from '@/widgets';

Expand All @@ -14,6 +14,11 @@ export const authRoutes = [
element: <LoginPage />,
handle: { layout: ROUTE_CONFIG.LOGIN.layout },
},
{
path: ROUTE_CONFIG.OAUTH_REDIRECT.path,
element: <OAuthRedirectPage />,
handle: { layout: ROUTE_CONFIG.OAUTH_REDIRECT.layout },
},
],
},
{
Expand Down
4 changes: 4 additions & 0 deletions src/app/routes/config/route-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export const ROUTE_CONFIG: Record<string, RouteConfig> = {
path: ROUTER_PATH.REALTOR_CERTIFICATION,
layout: 'Auth',
},
OAUTH_REDIRECT: {
path: ROUTER_PATH.OAUTH_REDIRECT,
layout: 'Auth',
},
REALTOR: {
path: ROUTER_PATH.REALTOR,
requiresRealtor: true, // 공인중개사만 접근 가능
Expand Down
16 changes: 16 additions & 0 deletions src/entities/auth/apis/get-ticket.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { fetchInstance } from '@/shared';

export const GET_TICKET_API_PATH = '/api/auth/ticket';

interface GetTicketApiResponse {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

GetTicketApiResponse 타입을 export하여 다른 파일(특히 custom hook)에서 재사용할 수 있도록 하는 것이 좋습니다. 이렇게 하면 타입 안정성을 높일 수 있습니다.

Suggested change
interface GetTicketApiResponse {
export interface GetTicketApiResponse {

refreshToken: string;
}

export const getTicketApi = async (ticket: string): Promise<GetTicketApiResponse> => {
const response = await fetchInstance.get(GET_TICKET_API_PATH, {
params: {
ticket,
},
});
return response.data;
};
1 change: 1 addition & 0 deletions src/entities/auth/apis/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './get-ticket.api';
14 changes: 14 additions & 0 deletions src/entities/auth/hooks/getAuthTicket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';

import { getTicketApi } from '../apis';

export const GetAuthTicketQueryKey = {
ticket: (ticket: string) => ['ticket', ticket],
};

export const useGetAuthTicket = (ticket: string) => {
return useQuery({
queryKey: GetAuthTicketQueryKey.ticket(ticket),
queryFn: () => getTicketApi(ticket),
});
};
Comment on lines +9 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

useQueryenabled 옵션을 사용하여 조건부로 쿼리를 실행할 수 있도록 useGetAuthTicket 훅을 수정하는 것을 권장합니다. 이렇게 하면 불필요한 API 호출을 방지하고 코드를 더 명확하게 만들 수 있습니다. useQuery의 옵션을 받을 수 있도록 훅의 인자를 확장해주세요. 이 변경은 get-ticket.api.ts에서 GetTicketApiResponse 타입을 export한 후에 적용해야 합니다.

Suggested change
export const useGetAuthTicket = (ticket: string) => {
return useQuery({
queryKey: GetAuthTicketQueryKey.ticket(ticket),
queryFn: () => getTicketApi(ticket),
});
};
export const useGetAuthTicket = (
ticket: string,
options?: Omit<UseQueryOptions<GetTicketApiResponse>, 'queryKey' | 'queryFn'>,
) => {
return useQuery({
queryKey: GetAuthTicketQueryKey.ticket(ticket),
queryFn: () => getTicketApi(ticket),
...options,
});
};

1 change: 1 addition & 0 deletions src/entities/auth/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './getAuthTicket';
2 changes: 2 additions & 0 deletions src/entities/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './apis';
export * from './hooks';
13 changes: 11 additions & 2 deletions src/features/login/components/common/buttons/KakaoLoginButton.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { Button } from '@/shared';
import { BASE_URL, Button } from '@/shared';

import KakaoSymbol from '../../../_assets/kakao-symbol.webp';

export const KakaoLoginButton = () => {
const kakaoLogin = () => {
// OAuth 서버로 리다이렉트하되, 리다이렉트 URL을 명시적으로 지정
const redirectUri = encodeURIComponent(`${window.location.origin}/oauth/redirect`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

리다이렉트 URL 경로가 하드코딩되어 있습니다. ROUTER_PATH 상수를 사용하면 코드의 일관성과 유지보수성을 높일 수 있습니다. import { ROUTER_PATH } from '@/shared'를 추가하고 ROUTER_PATH.OAUTH_REDIRECT를 사용하세요.

Suggested change
const redirectUri = encodeURIComponent(`${window.location.origin}/oauth/redirect`);
const redirectUri = encodeURIComponent(`${window.location.origin}${ROUTER_PATH.OAUTH_REDIRECT}`);

window.location.href = `${BASE_URL}/oauth2/authorization/kakao?redirect_uri=${redirectUri}`;
};

return (
<Button className='flex h-11 w-90 items-center gap-3 rounded-lg bg-kakao text-black hover:bg-kakao/80 active:bg-kakao/80'>
<Button
onClick={kakaoLogin}
className='flex h-11 w-90 items-center gap-3 rounded-lg bg-kakao text-black hover:bg-kakao/80 active:bg-kakao/80'
>
<img src={KakaoSymbol} className='size-4' width={16} height={16} alt='kakao' />
<span className='text-sm font-semibold'>카카오 계정으로 계속하기</span>
</Button>
Expand Down
1 change: 1 addition & 0 deletions src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './main';
export * from './realtor';
export * from './login';
export * from './diagnostics';
export * from './oauth';
104 changes: 104 additions & 0 deletions src/pages/oauth/OAuthRedirectPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';

import { useGetAuthTicket } from '@/entities/auth';

import { ROUTER_PATH } from '@/shared';

export default function OAuthRedirectPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);

// URL에서 ticket 파라미터 추출
const ticket = searchParams.get('ticket');
const oauthError = searchParams.get('error');
const errorDescription = searchParams.get('error_description');

useEffect(() => {
if (oauthError) {
setError(errorDescription || `OAuth 인증 오류: ${oauthError}`);
} else if (!ticket) {
setError('인증 토큰이 없습니다. 다시 로그인해주세요.');
}
}, [oauthError, errorDescription, ticket]);

// ticket이 있고 OAuth 에러가 없을 때만 API 호출
const shouldCallApi = !!ticket && !oauthError;
const {
data: authData,
error: apiError,
isLoading,
} = useGetAuthTicket(shouldCallApi ? ticket : '');
Comment on lines +27 to +32
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

useGetAuthTicket 훅을 호출할 때 삼항 연산자를 사용하여 빈 문자열을 전달하는 대신, React Query의 enabled 옵션을 사용하는 것이 더 효율적이고 명확한 방법입니다. 이렇게 하면 조건이 충족되지 않았을 때 불필요한 API 요청이 발생하는 것을 막을 수 있습니다. 이 변경을 적용하려면 useGetAuthTicket 훅의 수정이 필요합니다.

  const { data: authData, error: apiError, isLoading } = useGetAuthTicket(ticket || '', {
    enabled: !!ticket && !oauthError,
  });


// API 에러 처리
useEffect(() => {
if (apiError) {
console.error('API 호출 중 오류:', apiError);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

console.error는 개발 중에는 유용하지만, 프로덕션 코드에는 적절한 로깅 라이브러리(예: Sentry, LogRocket)를 사용한 에러 리포팅으로 대체하는 것이 좋습니다. 실제 사용자에게서 발생하는 에러를 추적하고 디버깅하는 데 도움이 됩니다.

setError('인증 검증에 실패했습니다. 다시 시도해주세요.');
}
}, [apiError]);

// 성공 시 처리
useEffect(() => {
if (authData && !isLoading && !apiError && shouldCallApi) {
console.log('OAuth 인증 성공!', authData);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

console.log는 개발 단계에서 디버깅 목적으로 유용하지만, 프로덕션 빌드에서는 제거하는 것이 좋습니다. 불필요한 정보가 브라우저 콘솔에 노출되는 것을 방지할 수 있습니다.


// refreshToken을 localStorage에 저장
localStorage.setItem('refreshToken', authData.refreshToken);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

refreshTokenlocalStorage에 저장하는 것은 XSS(Cross-Site Scripting) 공격에 취약할 수 있습니다. 악의적인 스크립트가 localStorage에 접근하여 토큰을 탈취할 수 있기 때문입니다. 보안을 강화하기 위해 httpOnly 속성을 가진 쿠키에 토큰을 저장하는 것을 강력히 권장합니다. httpOnly 쿠키는 JavaScript로 접근할 수 없어 XSS 공격으로부터 토큰을 보호할 수 있습니다.


// 메인 페이지로 이동
navigate(ROUTER_PATH.MAIN, { replace: true });
}
}, [authData, isLoading, apiError, navigate, shouldCallApi]);
Comment on lines +43 to +53
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

성공 처리 useEffect의 조건문(if (authData && !isLoading && !apiError && shouldCallApi))이 다소 복잡하고, 의존성 배열에 불필요한 항목이 포함되어 있습니다. useQuerydata가 존재하면 isLoadingfalse이고 errornull인 상태이므로, if (authData)로 조건을 단순화할 수 있습니다. 의존성 배열도 [authData, navigate]로 줄일 수 있습니다.

  // 성공 시 처리
  useEffect(() => {
    if (authData) {
      console.log('OAuth 인증 성공!', authData);

      // refreshToken을 localStorage에 저장
      localStorage.setItem('refreshToken', authData.refreshToken);

      // 메인 페이지로 이동
      navigate(ROUTER_PATH.MAIN, { replace: true });
    }
  }, [authData, navigate]);


if (isLoading && shouldCallApi) {
return (
<div className='flex min-h-screen items-center justify-center'>
<div className='text-center'>
<div className='mb-4'>
<div className='mx-auto h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent'></div>
</div>
<p className='text-lg font-medium text-gray-700'>인증 처리 중...</p>
<p className='mt-2 text-sm text-gray-500'>잠시만 기다려주세요.</p>
</div>
</div>
);
}

if (error) {
return (
<div className='flex min-h-screen items-center justify-center'>
<div className='text-center'>
<div className='mb-4'>
<div className='mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100'>
<svg
className='h-6 w-6 text-red-600'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</div>
</div>
<h2 className='mb-2 text-xl font-semibold text-gray-900'>인증 실패</h2>
<p className='mb-4 text-gray-600'>{error}</p>
<button
onClick={() => navigate(ROUTER_PATH.LOGIN)}
className='rounded-lg bg-blue-500 px-4 py-2 text-white transition-colors hover:bg-blue-600'
>
로그인 페이지로 돌아가기
</button>
</div>
</div>
);
}

return null;
}
1 change: 1 addition & 0 deletions src/pages/oauth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as OAuthRedirectPage } from './OAuthRedirectPage';
1 change: 1 addition & 0 deletions src/shared/constants/router-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const ROUTER_PATH = {
MAIN: '/',
REALTOR: '/realtor',
LOGIN: '/login',
OAUTH_REDIRECT: '/oauth/redirect',
RISK_DIAGNOSTICS: '/diagnostics/risk',
RELIABILITY_DIAGNOSTICS: '/diagnostics/reliability',
REALTOR_LOGIN: '/realtor/login',
Expand Down
3 changes: 1 addition & 2 deletions src/shared/libs/fetch-instance.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { initInstance } from './axios-instance';

export const BASE_URL = 'http://localhost:8080';
export const BASE_URL = 'http://43.200.101.253';

export const fetchInstance = initInstance({
baseURL: BASE_URL,
withCredentials: true,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

withCredentials: true 옵션이 제거되었습니다. 만약 애플리케이션의 다른 부분에서 인증을 위해 쿠키를 사용하고 있다면, 이 변경으로 인해 인증이 제대로 동작하지 않을 수 있습니다. 이 변경이 의도된 것인지, 그리고 다른 기능에 미치는 영향이 없는지 확인이 필요합니다. 토큰 기반 인증으로 완전히 전환하는 과정이라면 괜찮지만, 중요한 변경이므로 명확히 하는 것이 좋습니다.

headers: {
'Content-Type': 'application/json',
},
Expand Down
Loading