diff --git a/next.config.ts b/next.config.ts index a78c040b..0bb40b33 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,7 +14,7 @@ const nextConfig: NextConfig = { reactStrictMode: true, // 빌드 환경에서 console.log 제거 compiler: { - removeConsole: process.env.NODE_ENV === 'production', + // removeConsole: process.env.NODE_ENV === 'production', }, images: { remotePatterns: [ diff --git a/src/app/(app)/error.tsx b/src/app/(app)/error.tsx index 55352da1..ff92ba16 100644 --- a/src/app/(app)/error.tsx +++ b/src/app/(app)/error.tsx @@ -47,7 +47,7 @@ export default function SegmentError({ error, reset }: ErrorPageProps) { }, [error]); return ( -
+

콘텐츠를 불러오지 못했습니다. diff --git a/src/app/(auth)/kakao/transition/page.tsx b/src/app/(auth)/kakao/transition/page.tsx new file mode 100644 index 00000000..185afed1 --- /dev/null +++ b/src/app/(auth)/kakao/transition/page.tsx @@ -0,0 +1,16 @@ +'use client'; +import dynamic from 'next/dynamic'; +import { Suspense } from 'react'; + +const KakaoTransition = dynamic( + () => import('@/domain/Auth/components/KakaoTransition'), + { ssr: false }, // 클라이언트 전용 렌더링 +); + +export default function Page() { + return ( + + + + ); +} diff --git a/src/app/(auth)/signin/page.tsx b/src/app/(auth)/signin/page.tsx index b69fa2ea..61ce508d 100644 --- a/src/app/(auth)/signin/page.tsx +++ b/src/app/(auth)/signin/page.tsx @@ -45,7 +45,7 @@ export default function SignInPage() {

- +
아직 계정이 없으신가요? diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index a66d45ba..5763559f 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -44,7 +44,7 @@ export default function SignUpPage() {
- +
이미 회원이신가요? diff --git a/src/app/api/auth/kakao/signin/route.ts b/src/app/api/auth/kakao/signin/route.ts new file mode 100644 index 00000000..40cd0afa --- /dev/null +++ b/src/app/api/auth/kakao/signin/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { + OAuthResponse, + oauthResponseSchema, +} from '@/domain/Auth/schemas/response'; +import setAuthCookies from '@/domain/Auth/utils/setAuthCookies'; +import { API_ENDPOINTS } from '@/shared/constants/endpoints'; +import { ERROR_CODES, ROUTES } from '@/shared/constants/routes'; + +/** + * @function handleOauthSignIn + * @description + * 백엔드에 카카오 인가 코드를 전달하여 로그인 시도 후 사용자 정보를 반환합니다. + * 실패 시 에러 객체에 HTTP 상태 코드를 포함하여 던집니다. + * + * @param kakaoAuthCode - 카카오에서 발급받은 인가 코드 + * @returns 유효성 검증된 OAuth 로그인 응답 데이터 + * @throws 로그인 실패 또는 응답 형식이 올바르지 않을 경우 오류 + */ +async function handleOauthSignIn( + kakaoAuthCode: string, +): Promise { + const redirectUri = process.env.NEXT_PUBLIC_KAKAO_SIGNIN_REDIRECT_URI; + if (!redirectUri) { + throw new Error( + 'NEXT_PUBLIC_KAKAO_SIGNIN_REDIRECT_URI가 설정되지 않았습니다.', + ); + } + + const signInRes = await fetch( + `${process.env.API_BASE_URL}${API_ENDPOINTS.OAUTH.SIGNIN_PROVIDER('kakao')}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: kakaoAuthCode, + redirectUri, + }), + }, + ); + + const responseData = await signInRes.json(); + if (!signInRes.ok) { + throw Object.assign( + new Error(responseData.message || '로그인에 실패했습니다.'), + { + status: signInRes.status, + }, + ); + } + return oauthResponseSchema.parse(responseData); +} + +/** + * @function GET + * @description + * 카카오 OAuth 로그인 콜백을 처리하는 라우트입니다. + * + * 1. 인가 코드로 백엔드에 로그인 요청 + * 2. 성공 시 토큰 쿠키 저장 및 메인 페이지로 이동 + * 3. 실패 시 에러 코드에 따라 적절한 리디렉션 처리 + * + * ### 에러별 리디렉션 전략: + * - 403 또는 404 → `/kakao/transition?status=need-signup` + * - 그 외 → `/signin?error=...&message=...` + * + * @param request - Next.js 서버 요청 객체 + * @returns 리디렉션 응답 + */ +export async function GET(request: NextRequest) { + try { + const code = request.nextUrl.searchParams.get('code'); + + if (!code) { + throw Object.assign(new Error('카카오 인증 코드가 없습니다.'), { + status: 400, + }); + } + + const responseData = await handleOauthSignIn(code); + const response = NextResponse.redirect( + new URL(ROUTES.ACTIVITIES.ROOT, request.url), + ); + setAuthCookies(response, { + accessToken: responseData.accessToken, + refreshToken: responseData.refreshToken, + }); + return response; + } catch (error: unknown) { + console.error('[Kakao Signin Error]:', error); + + const errorStatus = + error instanceof Error && 'status' in error + ? (error as Error & { status: number }).status + : undefined; + + if (errorStatus === 404 || errorStatus === 403) { + const redirectToTransition = new URL( + '/kakao/transition', + request.nextUrl.origin, + ); + redirectToTransition.searchParams.set('status', 'need-signup'); + redirectToTransition.searchParams.set( + 'message', + error instanceof Error ? error.message : '회원가입 먼저 해주세요.', + ); + return NextResponse.redirect(redirectToTransition); + } + + const defaultErrorUrl = new URL( + `${ROUTES.SIGNIN}?error=${ERROR_CODES.OAUTH_KAKAO_FAILED}`, + request.url, + ); + if (error instanceof Error) { + defaultErrorUrl.searchParams.append('message', error.message); + } + return NextResponse.redirect(defaultErrorUrl); + } +} diff --git a/src/app/api/auth/kakao/signup/route.ts b/src/app/api/auth/kakao/signup/route.ts new file mode 100644 index 00000000..ad044b05 --- /dev/null +++ b/src/app/api/auth/kakao/signup/route.ts @@ -0,0 +1,130 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { + OAuthResponse, + oauthResponseSchema, +} from '@/domain/Auth/schemas/response'; +import setAuthCookies from '@/domain/Auth/utils/setAuthCookies'; +import { API_ENDPOINTS } from '@/shared/constants/endpoints'; +import { ERROR_CODES, ROUTES } from '@/shared/constants/routes'; + +/** + * @function handleOauthSignUp + * @description + * 백엔드에 카카오 인가 코드와 랜덤 닉네임을 전달하여 회원가입을 시도합니다. + * 실패 시 HTTP 상태 코드와 함께 오류를 던집니다. + * + * @param kakaoAuthCode - 카카오로부터 받은 인가 코드 + * @param nickname - 자동 생성된 임의 닉네임 + * @returns 백엔드 응답 (accessToken, refreshToken 등 포함) + * @throws 백엔드 API 실패 또는 응답 스키마 불일치 시 에러 발생 + */ +async function handleOauthSignUp( + kakaoAuthCode: string, + nickname: string, +): Promise { + const redirectUri = process.env.NEXT_PUBLIC_KAKAO_SIGNUP_REDIRECT_URI; + if (!redirectUri) { + throw new Error( + 'NEXT_PUBLIC_KAKAO_SIGNUP_REDIRECT_URI가 설정되지 않았습니다.', + ); + } + + const signUpResponse = await fetch( + `${process.env.API_BASE_URL}${API_ENDPOINTS.OAUTH.SIGNUP_PROVIDER('kakao')}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: kakaoAuthCode, + nickname: nickname, + redirectUri, + }), + }, + ); + + const responseData = await signUpResponse.json(); + if (!signUpResponse.ok) { + throw Object.assign( + new Error(responseData.message || '회원가입에 실패했습니다.'), + { + status: signUpResponse.status, + }, + ); + } + return oauthResponseSchema.parse(responseData); +} + +/** + * @function GET + * @description + * 카카오 OAuth 회원가입 콜백을 처리하는 라우트입니다. + * + * 1. 인가 코드(code)를 쿼리에서 추출 + * 2. 자동 생성된 랜덤 닉네임으로 회원가입 시도 + * 3. 성공 시 토큰을 쿠키에 저장하고 `/activities`로 이동 + * 4. 실패 시 상태에 따라 리디렉션 분기 + * + * ### 리디렉션 분기 + * - 409 또는 400: 이미 가입된 사용자 → `/kakao/transition?status=already-exists` + * - 기타 오류: `/signup?error=...&message=...` 으로 리디렉션 (토스트 처리용) + * + * @param request - Next.js GET 요청 객체 + * @returns 리디렉션 응답 + */ +export async function GET(request: NextRequest) { + try { + const code = request.nextUrl.searchParams.get('code'); + + if (!code) { + throw Object.assign(new Error('카카오 인증 코드가 없습니다.'), { + status: 400, + }); + } + + const arbitraryNickname = `K_${crypto.randomUUID().replace(/-/g, '').slice(0, 7)}`; + const responseData = await handleOauthSignUp(code, arbitraryNickname); + + const response = NextResponse.redirect( + new URL(ROUTES.ACTIVITIES.ROOT, request.url), + ); + + setAuthCookies(response, { + accessToken: responseData.accessToken, + refreshToken: responseData.refreshToken, + }); + + return response; + } catch (error: unknown) { + console.error('[Kakao Signup Error]:', error); + + // const errorStatus = + // error instanceof Error && 'status' in error + // ? (error as Error & { status: number }).status + // : undefined; + + // if (errorStatus === 409 || errorStatus === 400) { + // const redirectToTransition = new URL( + // '/kakao/transition', + // request.nextUrl.origin, + // ); + // redirectToTransition.searchParams.set('status', 'already-exists'); + // redirectToTransition.searchParams.set( + // 'message', + // error instanceof Error + // ? error.message + // : '이미 가입된 회원입니다. 로그인해주세요.', + // ); + // return NextResponse.redirect(redirectToTransition); + // } + + const defaultErrorUrl = new URL( + `${ROUTES.SIGNUP}?error=${ERROR_CODES.OAUTH_KAKAO_FAILED}`, + request.url, + ); + if (error instanceof Error) { + defaultErrorUrl.searchParams.append('message', error.message); + } + return NextResponse.redirect(defaultErrorUrl); + } +} diff --git a/src/app/api/auth/signin/route.ts b/src/app/api/auth/signin/route.ts index a3055df4..4d8ea28f 100644 --- a/src/app/api/auth/signin/route.ts +++ b/src/app/api/auth/signin/route.ts @@ -4,6 +4,8 @@ import { signinRequestSchema } from '@/domain/Auth/schemas/request'; import handleSignin from '@/domain/Auth/utils/handleSignin'; import { handleApiError } from '@/shared/utils/errors/handleApiError'; +export const dynamic = 'force-dynamic'; + /** * @file /api/auth/signin/route.ts * @description 클라이언트의 로그인 요청을 BFF(Backend for Frontend) 서버에서 처리하고, 성공 시 인증 쿠키를 발급하는 API 라우트 핸들러입니다. @@ -37,6 +39,7 @@ import { handleApiError } from '@/shared/utils/errors/handleApiError'; * @see /src/app/(auth)/signin/page.tsx - 이 API를 호출하는 클라이언트 페이지 * @see /src/shared/constants/endpoints.ts - `API_ENDPOINTS` 상수 정의 */ + export async function POST(request: NextRequest) { try { const body = await request.json(); diff --git a/src/app/api/test/error/route.ts b/src/app/api/test/error/route.ts index 149287c7..833ba556 100644 --- a/src/app/api/test/error/route.ts +++ b/src/app/api/test/error/route.ts @@ -50,7 +50,7 @@ export async function GET(request: NextRequest) { ); case '409': return NextResponse.json( - { message: '이미 사용 중인 이름입니다.' }, + { message: '이미 사용 중인 이메일입니다.' }, { status: 409 }, ); case '500': diff --git a/src/app/error.tsx b/src/app/error.tsx index 07327729..ce065863 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -48,7 +48,7 @@ export default function GlobalError({ error, reset }: ErrorPageProps) { }, [error]); return ( -
+

문제가 발생했습니다. diff --git a/src/domain/Auth/components/KakaoTransition.tsx b/src/domain/Auth/components/KakaoTransition.tsx new file mode 100644 index 00000000..4cf09ef5 --- /dev/null +++ b/src/domain/Auth/components/KakaoTransition.tsx @@ -0,0 +1,78 @@ +'use client'; + +import Image from 'next/image'; +import { useSearchParams } from 'next/navigation'; + +import LogoSymbol from '@/shared/assets/logos/LogoSymbol'; +import Button from '@/shared/components/Button'; + +const KAKAO_REDIRECT_URI = process.env.NEXT_PUBLIC_KAKAO_SIGNUP_REDIRECT_URI!; +const KAKAO_CLIENT_ID = process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY!; + +/** + * @component KakaoTransitionPage + * @description + * 카카오 OAuth 인증 흐름 중, 사용자가 로그인 또는 회원가입 중간에 분기되는 전환 페이지입니다. + * + * - 백엔드 응답에서 `403`, `404`, `409`, `400` 등의 상태 코드가 발생할 경우, + * 서버는 클라이언트를 `/kakao/transition?status=...` 주소로 리디렉션합니다. + * + * - 해당 페이지는 쿼리 파라미터로 전달된 `status`, `message`를 읽고, + * 상황에 따라 안내 메시지와 함께 다시 카카오 인증 페이지로 유도합니다. + * + * @example + * ``` + * /kakao/transition?status=need-signup&message=회원가입 먼저 해주세요. + * + * ``` + * + * ### 쿼리 파라미터 + * - `status`: 분기 상태 (`need-signup`) + * - `message`: 사용자에게 표시할 메시지 + * + * ### 동작 흐름 + * 1. `status`가 `need-signup`이면 "회원 가입 먼저 진행해주세요."라는 안내 문구가 표시됩니다. + * 2. 버튼을 누르면 카카오 인증 페이지로 다시 리디렉션되어 새로운 인가 코드를 받게 됩니다. + */ +export default function KakaoTransition() { + const searchParams = useSearchParams(); + + const status = searchParams.get('status'); + const message = searchParams.get('message'); + + const handleRedirectToKakao = () => { + const kakaoAuthUrl = `https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${KAKAO_CLIENT_ID}&redirect_uri=${KAKAO_REDIRECT_URI}`; + window.location.href = kakaoAuthUrl; + }; + + const notAMember = status === 'need-signup'; + + const title = notAMember + ? '회원 가입 먼저 진행해주세요.' + : '처리 중 문제가 발생했습니다.'; + + return ( +
+ +

{title}

+

{message ?? '아래 버튼을 눌러 회원가입하세요.'}

+ + +
+ ); +} diff --git a/src/domain/Auth/components/OAuth.tsx b/src/domain/Auth/components/OAuth.tsx index 95fd80fa..53b2a3c7 100644 --- a/src/domain/Auth/components/OAuth.tsx +++ b/src/domain/Auth/components/OAuth.tsx @@ -4,69 +4,48 @@ import Image from 'next/image'; import Button from '@/shared/components/Button'; -/** - * @property {'signin' | 'signup'} pageType - 현재 페이지의 종류를 나타냅니다. - * 'signin'은 로그인 페이지를, 'signup'은 회원가입 페이지를 의미하며, - * 이에 따라 OAuth 버튼의 텍스트 및 관련 로직이 변경됩니다. - */ -interface OAuthProps { - pageType: 'signin' | 'signup'; -} - /** * @component OAuth * @description - * 소셜 로그인(OAuth) 버튼 UI와 관련 로직을 담당하는 재사용 가능한 클라이언트 컴포넌트입니다. - * 이 컴포넌트는 `pageType` prop에 따라 버튼의 텍스트가 '카카오 로그인' 또는 '카카오 회원가입'으로 변경되며, 카카오 OAuth 인증 흐름을 시작합니다. + * 카카오 소셜 로그인을 시작하는 버튼 컴포넌트입니다. * - * @param {object} props - 컴포넌트의 props입니다. - * @param {'signin' | 'signup'} props.pageType - 현재 페이지의 종류를 나타냅니다. - * - `'signin'`은 로그인 페이지를, `'signup'`은 회원가입 페이지를 의미합니다. - * - 이 값은 카카오 인증 URL의 `state` 파라미터로 전달되어, 인증 완료 후 리디렉션될 때 어떤 종류의 인증 요청이었는지(로그인 또는 회원가입) 식별하는 데 사용됩니다. + * 이 컴포넌트는 클릭 시 로그인 전용 redirect URI로 이동하여 카카오 인가 코드를 요청합니다. + * 이후 백엔드는 해당 인가 코드를 사용해 로그인 시도를 하며, + * 회원가입이 필요할 경우 자동으로 `/kakao/transition` 페이지로 리디렉션합니다. * - * @example - * 로그인 페이지에서 사용 시: - * + * ⚠️ 인가 코드는 한 번만 사용할 수 있으므로, + * 로그인 실패 후 회원가입 시도는 반드시 새로운 인가 코드를 통해 이루어집니다. * - * 회원가입 페이지에서 사용 시: - * + * @remarks + * 환경 변수: + * - `NEXT_PUBLIC_KAKAO_REST_API_KEY`: 카카오 REST API 키 + * - `NEXT_PUBLIC_KAKAO_SIGNIN_REDIRECT_URI`: 로그인 전용 redirect URI * - * @function generateRandomState - * CSRF 공격을 방지하기 위해 OAuth 요청에 사용되는 고유한 `state` 값을 생성합니다. - * 이 `state`는 현재 페이지 타입(`signin` 또는 `signup`)과 난수로 구성되며, (예: 'signin:xyz123') - * OAuth 인증 후 콜백에서 해당 요청의 정당성을 검증하는 데 사용됩니다. - * 현재는 CSRF 방어 목적이 아닌, 인증 흐름 구분용으로만(회원가입인지 로그인인지) 사용되고 있습니다. - * 실제 CSRF 방어가 필요하다면, state에 포함된 랜덤 ID를 클라이언트에 저장하고, - * 콜백에서 이를 검증하는 추가 로직이 필요합니다. * * @function handleKakaoAuth - * 카카오 인증 프로세스를 시작하는 내부 핸들러 함수입니다. - * 1. `.env` 파일에 저장된 `NEXT_PUBLIC_KAKAO_REST_API_KEY`와 `NEXT_PUBLIC_KAKAO_REDIRECT_URI` 환경 변수를 가져옵니다. - * 2. 카카오 인가 코드를 요청할 URL을 동적으로 생성합니다. - * 3. 생성된 URL로 사용자를 리디렉션하여 카카오 인증 페이지로 이동시킵니다. + * 클릭 시 카카오 인증 페이지로 이동합니다. + * 인증 완료 후 로그인용 redirect URI로 돌아옵니다. */ -export default function OAuth({ pageType }: OAuthProps) { - const buttonText = - pageType === 'signin' ? '카카오 로그인' : '카카오 회원가입'; - - const generateRandomState = (pageType: 'signin' | 'signup') => { - const randomId = crypto.randomUUID(); - const state = `${pageType}:${randomId}`; - return state; - }; - +export default function OAuth() { const handleKakaoAuth = () => { const KAKAO_CLIENT_ID = process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY; - const KAKAO_REDIRECT_URI = process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI; + const KAKAO_SIGNIN_REDIRECT_URI = + process.env.NEXT_PUBLIC_KAKAO_SIGNIN_REDIRECT_URI; + const KAKAO_SIGNUP_REDIRECT_URI = + process.env.NEXT_PUBLIC_KAKAO_SIGNUP_REDIRECT_URI; - if (!KAKAO_CLIENT_ID || !KAKAO_REDIRECT_URI) { + if ( + !KAKAO_CLIENT_ID || + !KAKAO_SIGNIN_REDIRECT_URI || + !KAKAO_SIGNUP_REDIRECT_URI + ) { console.error('카카오 OAuth 환경 변수가 설정되지 않았습니다.'); return; } - const state = generateRandomState(pageType); + const redirectUri = KAKAO_SIGNIN_REDIRECT_URI; - const kakaoAuthUrl = `https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${KAKAO_CLIENT_ID}&redirect_uri=${KAKAO_REDIRECT_URI}&state=${state}`; + const kakaoAuthUrl = `https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${KAKAO_CLIENT_ID}&redirect_uri=${redirectUri}`; window.location.href = kakaoAuthUrl; }; @@ -85,7 +64,7 @@ export default function OAuth({ pageType }: OAuthProps) { width={24} height={24} /> - {buttonText} + Kakao로 시작하기

); diff --git a/src/middleware.ts b/src/middleware.ts index c1ff70f1..cb113455 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; +import setAuthCookies from '@/domain/Auth/utils/setAuthCookies'; import { ERROR_CODES, ROUTES } from '@/shared/constants/routes'; import { BRIDGE_API } from './shared/constants/bridgeEndpoints'; @@ -135,21 +136,14 @@ export async function middleware(request: NextRequest) { signal: AbortSignal.timeout(30000), }); - const finalResponse = new NextResponse(response.body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - - finalResponse.cookies.set('accessToken', newAccessToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - path: '/', - maxAge: 60 * 60, - sameSite: 'lax', - }); - - return finalResponse; + return setAuthCookies( + new NextResponse(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }), + tokens, + ); } else { console.log( '[Middleware] Refresh Token 만료 또는 갱신 실패. 로그인 페이지로 리다이렉트합니다.',