From b988431c9a02affcb11c43848ad177a59f07f881 Mon Sep 17 00:00:00 2001 From: LMS10 Date: Sun, 17 May 2026 01:53:01 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EB=B0=8F=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/auth-api.ts | 38 ++++++++++++++++++++++++++++++++++++++ src/types/next-auth.d.ts | 1 + 2 files changed, 39 insertions(+) diff --git a/src/lib/auth-api.ts b/src/lib/auth-api.ts index ae1771a..fe3d96f 100644 --- a/src/lib/auth-api.ts +++ b/src/lib/auth-api.ts @@ -42,3 +42,41 @@ export async function signinApi(body: SigninRequest): Promise { return data; } + +export interface SocialLoginRequest { + state: string; + redirectUri: string; + token: string; +} + +export interface SocialAuthResponse { + success: boolean; + data: { + accessToken: string; + user: { + id: number; + email: string; + nickname: string; + image: string | null; + }; + }; +} + +export async function socialLoginApi( + provider: 'google' | 'kakao', + body: SocialLoginRequest, +): Promise { + const res = await fetch(`${API_URL}/api/v1/auth/social/login/${provider}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data: SocialAuthResponse = await res.json(); + + if (!res.ok || !data.success) { + throw new ApiError(res.status, '소셜 로그인에 실패했습니다.'); + } + + return data; +} diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index c473b4e..2ec68c8 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -25,5 +25,6 @@ declare module 'next-auth/jwt' { interface JWT { accessToken: string; userId: string; + picture?: string | null; } } From 285393e01fb9e25b4f6235f1b6af877e719fed62 Mon Sep 17 00:00:00 2001 From: LMS10 Date: Sun, 17 May 2026 01:54:55 +0900 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=A8=20Feat:=20NextAuth=20social=20Cre?= =?UTF-8?q?dentialsProvider=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/auth/[...nextauth]/route.ts | 26 ++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index e9acd52..4db950c 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -5,6 +5,7 @@ import { signinApi } from '@/lib/auth-api'; export const authOptions: NextAuthOptions = { providers: [ CredentialsProvider({ + id: 'credentials', name: 'Credentials', credentials: { email: { label: 'Email', type: 'email' }, @@ -33,6 +34,29 @@ export const authOptions: NextAuthOptions = { } }, }), + + CredentialsProvider({ + id: 'social', + name: 'Social', + credentials: { + accessToken: { label: 'Access Token', type: 'text' }, + userId: { label: 'User ID', type: 'text' }, + email: { label: 'Email', type: 'text' }, + name: { label: 'Name', type: 'text' }, + picture: { label: 'Picture', type: 'text' }, + }, + async authorize(credentials) { + if (!credentials?.accessToken) return null; + + return { + id: credentials.userId ?? '', + email: credentials.email ?? '', + name: credentials.name ?? '', + picture: credentials.picture ?? null, + accessToken: credentials.accessToken, + }; + }, + }), ], callbacks: { @@ -40,7 +64,7 @@ export const authOptions: NextAuthOptions = { if (user) { token.accessToken = user.accessToken; token.userId = user.id; - token.picture = null; + token.picture = user.picture ?? null; } return token; }, From 131641837415d51c46051d1ac3b63075d497885d Mon Sep 17 00:00:00 2001 From: LMS10 Date: Sun, 17 May 2026 01:58:09 +0900 Subject: [PATCH 3/9] =?UTF-8?q?=E2=9C=A8=20Feat:=20Google,=20Kakao=20OAuth?= =?UTF-8?q?=20=EC=BD=9C=EB=B0=B1=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/oauth/google/page.tsx | 73 +++++++++++++++++++++++++++++++++++ src/app/oauth/kakao/page.tsx | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/app/oauth/google/page.tsx create mode 100644 src/app/oauth/kakao/page.tsx diff --git a/src/app/oauth/google/page.tsx b/src/app/oauth/google/page.tsx new file mode 100644 index 0000000..97b852a --- /dev/null +++ b/src/app/oauth/google/page.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { signIn } from 'next-auth/react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Icon from '@/components/Icon'; +import { socialLoginApi } from '@/lib/auth-api'; + +export default function GoogleCallbackPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const called = useRef(false); + + useEffect(() => { + if (called.current) return; + called.current = true; + + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const savedState = sessionStorage.getItem('oauth_state'); + + if (!code || !state || state !== savedState) { + router.replace('/login'); + return; + } + + sessionStorage.removeItem('oauth_state'); + + (async () => { + try { + const res = await socialLoginApi('google', { + state, + redirectUri: process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI!, + token: code, + }); + + const { accessToken, user } = res.data; + + const result = await signIn('social', { + accessToken, + userId: String(user.id), + email: user.email, + name: user.nickname, + picture: user.image ?? '', + redirect: false, + }); + + if (result?.error) { + router.replace('/login'); + return; + } + + router.replace('/workspace'); + } catch { + router.replace('/login'); + } + })(); + }, [searchParams, router]); + + return ( +
+
+
+
+ +
+ +
+

Google 로그인 중

+
+
+ ); +} diff --git a/src/app/oauth/kakao/page.tsx b/src/app/oauth/kakao/page.tsx new file mode 100644 index 0000000..56837b3 --- /dev/null +++ b/src/app/oauth/kakao/page.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { signIn } from 'next-auth/react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Icon from '@/components/Icon'; +import { socialLoginApi } from '@/lib/auth-api'; + +export default function KakaoCallbackPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const called = useRef(false); + + useEffect(() => { + if (called.current) return; + called.current = true; + + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const savedState = sessionStorage.getItem('oauth_state'); + + if (!code || !state || state !== savedState) { + router.replace('/login'); + return; + } + + sessionStorage.removeItem('oauth_state'); + + (async () => { + try { + const res = await socialLoginApi('kakao', { + state, + redirectUri: process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!, + token: code, + }); + + const { accessToken, user } = res.data; + + const result = await signIn('social', { + accessToken, + userId: String(user.id), + email: user.email, + name: user.nickname, + picture: user.image ?? '', + redirect: false, + }); + + if (result?.error) { + router.replace('/login'); + return; + } + + router.replace('/workspace'); + } catch { + router.replace('/login'); + } + })(); + }, [searchParams, router]); + + return ( +
+
+
+
+ +
+ +
+

Kakao 로그인 중

+
+
+ ); +} From 1faf61ab7f3c9718ef68d43cb887f271fa47c821 Mon Sep 17 00:00:00 2001 From: LMS10 Date: Sun, 17 May 2026 02:03:44 +0900 Subject: [PATCH 4/9] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B2=84=ED=8A=BC=20OAuth=20URL?= =?UTF-8?q?=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(before-login)/(auth)/login/page.tsx | 37 ++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/app/(before-login)/(auth)/login/page.tsx b/src/app/(before-login)/(auth)/login/page.tsx index 2314e26..f811510 100644 --- a/src/app/(before-login)/(auth)/login/page.tsx +++ b/src/app/(before-login)/(auth)/login/page.tsx @@ -20,6 +20,39 @@ interface LoginErrors { const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +function generateState() { + return Math.random().toString(36).substring(2); +} + +function handleGoogleLogin() { + const state = generateState(); + sessionStorage.setItem('oauth_state', state); + + const params = new URLSearchParams({ + client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!, + redirect_uri: process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI!, + response_type: 'code', + scope: 'openid email profile', + state, + }); + + window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`; +} + +function handleKakaoLogin() { + const state = generateState(); + sessionStorage.setItem('oauth_state', state); + + const params = new URLSearchParams({ + client_id: process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY!, + redirect_uri: process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!, + response_type: 'code', + state, + }); + + window.location.href = `https://kauth.kakao.com/oauth/authorize?${params}`; +} + export default function Page() { const [form, setForm] = useState({ email: '', password: '' }); const [errors, setErrors] = useState({}); @@ -131,7 +164,7 @@ export default function Page() {