From 256194d0ebcbc45be458fe5b298f928ee9b6f4ed Mon Sep 17 00:00:00 2001 From: Haesol Lee Date: Thu, 16 Apr 2026 22:05:14 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat(web):=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=95=A8=EC=88=98=EB=A5=BC=20?= =?UTF-8?q?=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20url=20=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=EC=97=90=EC=84=9C=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_pages/auth/use-cases/social-login-url.ts | 19 ----------- .../src/_pages/auth/use-cases/social-login.ts | 33 +++++++++++++++++++ apps/web/src/features/auth/ui/login-form.tsx | 2 +- apps/web/src/features/auth/ui/signup-form.tsx | 2 +- 4 files changed, 35 insertions(+), 21 deletions(-) delete mode 100644 apps/web/src/_pages/auth/use-cases/social-login-url.ts create mode 100644 apps/web/src/_pages/auth/use-cases/social-login.ts diff --git a/apps/web/src/_pages/auth/use-cases/social-login-url.ts b/apps/web/src/_pages/auth/use-cases/social-login-url.ts deleted file mode 100644 index 5f36b714..00000000 --- a/apps/web/src/_pages/auth/use-cases/social-login-url.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { API_BASE_URL, TEAM_ID } from "@/shared/config/env"; - -/** - * 구글 소셜 로그인 시작 URL 반환 - * 브라우저가 이 URL로 이동하면 백엔드가 구글 로그인 페이지로 302 리다이렉트 - * 구글 인증 완료 후 /oauth/callback?accessToken=...&refreshToken=... 으로 복귀 - */ -export function getGoogleLoginUrl(): string { - return `${API_BASE_URL}/${TEAM_ID}/auth/google`; -} - -/** - * 카카오 소셜 로그인 시작 URL 반환 - * 브라우저가 이 URL로 이동하면 백엔드가 카카오 로그인 페이지로 302 리다이렉트 - * 카카오 인증 완료 후 /oauth/callback?accessToken=...&refreshToken=... 으로 복귀 - */ -export function getKakaoLoginUrl(): string { - return `${API_BASE_URL}/${TEAM_ID}/auth/kakao`; -} diff --git a/apps/web/src/_pages/auth/use-cases/social-login.ts b/apps/web/src/_pages/auth/use-cases/social-login.ts new file mode 100644 index 00000000..8135a873 --- /dev/null +++ b/apps/web/src/_pages/auth/use-cases/social-login.ts @@ -0,0 +1,33 @@ +import { API_BASE_URL, TEAM_ID } from "@/shared/config/env"; + +/** + * 구글 소셜 로그인 + * Google Identity Services SDK로 받은 access_token을 백엔드로 전달 + * 백엔드가 토큰 검증 후 자체 JWT(accessToken, refreshToken) 발급 + */ +export async function loginWithGoogle(accessToken: string) { + const res = await fetch(`${API_BASE_URL}/${TEAM_ID}/oauth/google`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: accessToken }), + }); + + if (!res.ok) throw new Error("구글 로그인 실패"); + return res.json() as Promise<{ accessToken: string; refreshToken: string }>; +} + +/** + * 카카오 소셜 로그인 + * Next.js Route Handler에서 카카오 access_token으로 교환 후 백엔드로 전달 + * 백엔드가 토큰 검증 후 자체 JWT(accessToken, refreshToken) 발급 + */ +export async function loginWithKakao(accessToken: string) { + const res = await fetch(`${API_BASE_URL}/${TEAM_ID}/oauth/kakao`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: accessToken }), + }); + + if (!res.ok) throw new Error("카카오 로그인 실패"); + return res.json() as Promise<{ accessToken: string; refreshToken: string }>; +} diff --git a/apps/web/src/features/auth/ui/login-form.tsx b/apps/web/src/features/auth/ui/login-form.tsx index 6155edad..b2ae8632 100644 --- a/apps/web/src/features/auth/ui/login-form.tsx +++ b/apps/web/src/features/auth/ui/login-form.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { startTransition, useActionState } from "react"; import { useForm } from "react-hook-form"; import { loginAction } from "@/_pages/auth/actions"; -import { getGoogleLoginUrl, getKakaoLoginUrl } from "@/_pages/auth/use-cases/social-login-url"; +import { getGoogleLoginUrl, getKakaoLoginUrl } from "@/_pages/auth/use-cases/social-login"; import { ROUTES } from "@/shared/config/routes"; import { PasswordInput } from "./password-input"; diff --git a/apps/web/src/features/auth/ui/signup-form.tsx b/apps/web/src/features/auth/ui/signup-form.tsx index b9e778ac..45a60595 100644 --- a/apps/web/src/features/auth/ui/signup-form.tsx +++ b/apps/web/src/features/auth/ui/signup-form.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { startTransition, useActionState } from "react"; import { useForm } from "react-hook-form"; import { signupAction } from "@/_pages/auth/actions"; -import { getGoogleLoginUrl, getKakaoLoginUrl } from "@/_pages/auth/use-cases/social-login-url"; +import { getGoogleLoginUrl, getKakaoLoginUrl } from "@/_pages/auth/use-cases/social-login"; import { ROUTES } from "@/shared/config/routes"; import { PasswordInput } from "./password-input"; From 5531d9877c2a6fa33e2a9e86455ca8af3bdab7a1 Mon Sep 17 00:00:00 2001 From: Haesol Lee Date: Thu, 16 Apr 2026 22:41:36 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat(web):=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=20=EC=BD=9C=EB=B0=B1=EC=9D=84=20=EC=9D=B8=EA=B0=80=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B0=8F=20route=20handler=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/api/oauth/kakao/route.ts | 32 +++++++++++++++++ .../app/oauth/callback/{ => kakao}/page.tsx | 34 +++++++++++-------- 2 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/app/api/oauth/kakao/route.ts rename apps/web/src/app/oauth/callback/{ => kakao}/page.tsx (58%) diff --git a/apps/web/src/app/api/oauth/kakao/route.ts b/apps/web/src/app/api/oauth/kakao/route.ts new file mode 100644 index 00000000..c0d79d1a --- /dev/null +++ b/apps/web/src/app/api/oauth/kakao/route.ts @@ -0,0 +1,32 @@ +import { type NextRequest, NextResponse } from "next/server"; + +// 카카오 인가 코드 -> access_token 교환 +// client_secret이 필요하기 때문에 브라우저가 아닌 서버(Route Handler)에서 처리 +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const code = searchParams.get("code"); + + if (!code) { + return NextResponse.json({ message: "인가 코드가 없습니다." }, { status: 400 }); + } + + const tokenRes = await fetch("https://kauth.kakao.com/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID!, + client_secret: process.env.KAKAO_CLIENT_SECRET!, + code, + redirect_uri: process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!, + }), + }); + + if (!tokenRes.ok) { + return NextResponse.json({ message: "카카오 토큰 교환 실패" }, { status: 400 }); + } + + const { access_token } = await tokenRes.json(); + + return NextResponse.json({ accessToken: access_token }); +} diff --git a/apps/web/src/app/oauth/callback/page.tsx b/apps/web/src/app/oauth/callback/kakao/page.tsx similarity index 58% rename from apps/web/src/app/oauth/callback/page.tsx rename to apps/web/src/app/oauth/callback/kakao/page.tsx index ed79b226..31c0d374 100644 --- a/apps/web/src/app/oauth/callback/page.tsx +++ b/apps/web/src/app/oauth/callback/kakao/page.tsx @@ -3,45 +3,51 @@ import { LoadingIndicator } from "@moum-zip/ui/components"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef } from "react"; +import { loginWithKakao } from "@/_pages/auth/use-cases/social-login"; import { ROUTES } from "@/shared/config/routes"; -function OAuthCallbackContent() { +function KakaoCallbackContent() { const router = useRouter(); const searchParams = useSearchParams(); const handledRef = useRef(false); useEffect(() => { - // 토큰 처리 중복 실행 방지 + // 중복 실행 방지 if (handledRef.current) return; handledRef.current = true; - const accessToken = searchParams.get("accessToken"); - const refreshToken = searchParams.get("refreshToken"); - // 백엔드 OAuth 실패 시 ?error=... 형태로 넘어오는 케이스 대비 + const code = searchParams.get("code"); const error = searchParams.get("error"); - // 토큰이 없거나 에러가 있으면 로그인 페이지로 이동 - if (error || !accessToken || !refreshToken) { + // 카카오 로그인 실패 시 ?error=... 형태로 넘어오는 케이스 대비 + if (error || !code) { router.replace(ROUTES.login); return; } const handleCallback = async () => { try { - // 토큰을 URL에서 즉시 제거 (히스토리/Referer 헤더로 토큰 노출 방지) - window.history.replaceState({}, "", window.location.pathname); + // 1. 카카오 인가 코드 → access_token 교환 (Route Handler에서 처리) + const tokenRes = await fetch(`/api/oauth/kakao?code=${code}`); + if (!tokenRes.ok) { + router.replace(ROUTES.login); + return; + } + const { accessToken: kakaoAccessToken } = await tokenRes.json(); + + // 2. 카카오 access_token → 백엔드 JWT 발급 + const { accessToken, refreshToken } = await loginWithKakao(kakaoAccessToken); - // httpOnly 쿠키는 서버에서만 set 가능하므로 Route Handler에 위임 + // 3. httpOnly 쿠키에 저장 const res = await fetch("/api/auth/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ accessToken, refreshToken }), credentials: "include", - referrerPolicy: "no-referrer", // Referer 헤더로 토큰 노출 방지 + referrerPolicy: "no-referrer", }); if (res.ok) { - // router.replace는 서버 컴포넌트를 재실행하지 않아 네비게이션 상태가 바뀌지 않음 // 풀 리로드로 쿠키 반영 window.location.replace(ROUTES.home); } else { @@ -58,10 +64,10 @@ function OAuthCallbackContent() { return ; } -export default function OAuthCallbackPage() { +export default function KakaoCallbackPage() { return ( }> - + ); } From 879550283baeefcd76090a904b34b02d8748c762 Mon Sep 17 00:00:00 2001 From: Haesol Lee Date: Thu, 16 Apr 2026 22:55:16 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat(web):=20layout=EC=97=90=20google=20i?= =?UTF-8?q?dentity=20services=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/layout.tsx | 1 + apps/web/src/app/oauth/callback/kakao/page.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 58757a69..5f6442d6 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -21,6 +21,7 @@ export default function RootLayout({ crossOrigin="" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" /> +