Просмотр исходного кода

M1-D5: naver/kakao social login flow

- /login/[provider]/{start,callback,complete} route handlers with one-time state cookie
- brand buttons on login page, social-notice page for EmailRequired/AccountExists
- google flow untouched (static route precedence)
- tsc 0, build green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 3 недель назад
Родитель
Сommit
887df8ad84

+ 112 - 0
app/(auth)/login/[provider]/callback/route.ts

@@ -0,0 +1,112 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { LoginResponse } from '@/types/response/auth';
+import { fetchJson } from '@/lib/utils/server';
+import { isSocialLoginProvider, SOCIAL_PROVIDER_LABELS, OAUTH_STATE_COOKIE_PREFIX } from '@/constants/social';
+
+/**
+ * 소셜 로그인 콜백 라우트 (Naver/Kakao) — /login/{provider}/callback
+ *
+ * 1. ?code&state(&error) 수신 → start 라우트가 심은 httpOnly state 쿠키와 대조 (CSRF 방어)
+ * 2. 백엔드 POST /api/auth/social-login (code 교환 + userinfo + 계정 매칭)
+ * 3. 성공: accessToken/refreshToken httpOnly 쿠키(기존 google/callback 옵션 동일) → /login/{provider}/complete
+ * 4. Auth.EmailRequired / Auth.AccountExists: 안내 화면(/login/social-notice)으로 분기
+ * 5. 기타 실패: /login?error={message} (로그인 페이지 alert 관례)
+ */
+export async function GET(request: NextRequest, { params }: { params: Promise<{ provider: string }> })
+{
+	const { provider } = await params;
+
+	if (!isSocialLoginProvider(provider)) {
+		const url = new URL('/login', request.url);
+		url.searchParams.set('error', '지원하지 않는 소셜 로그인입니다.');
+		return NextResponse.redirect(url);
+	}
+
+	const label = SOCIAL_PROVIDER_LABELS[provider];
+	const stateCookieName = `${OAUTH_STATE_COOKIE_PREFIX}${provider}`;
+	const savedState = request.cookies.get(stateCookieName)?.value ?? null;
+
+	// state 쿠키는 일회용 — 성공/실패와 무관하게 응답에서 제거
+	const finish = (response: NextResponse) => {
+		response.cookies.delete(stateCookieName);
+		return response;
+	};
+
+	const failRedirect = (message: string) => {
+		const url = new URL('/login', request.url);
+		url.searchParams.set('error', message);
+		return finish(NextResponse.redirect(url));
+	};
+
+	const noticeRedirect = (reason: 'email-required'|'account-exists') => {
+		const url = new URL('/login/social-notice', request.url);
+		url.searchParams.set('reason', reason);
+		url.searchParams.set('provider', provider);
+		return finish(NextResponse.redirect(url));
+	};
+
+	const searchParams = request.nextUrl.searchParams;
+	const code = searchParams.get('code');
+	const state = searchParams.get('state');
+	const oauthError = searchParams.get('error');
+	const oauthErrorDescription = searchParams.get('error_description');
+
+	// 사용자가 동의 화면에서 취소한 경우 등 provider 측 에러
+	if (oauthError) {
+		if (oauthError === 'access_denied') {
+			return failRedirect(`${label} 로그인이 취소되었습니다.`);
+		}
+		return failRedirect(oauthErrorDescription || `${label} 인증에 실패했습니다. 다시 시도해주세요.`);
+	}
+
+	if (!code) {
+		return failRedirect(`${label} 인증 코드가 없습니다. 다시 시도해주세요.`);
+	}
+
+	if (!state || !savedState || state !== savedState) {
+		return failRedirect('로그인 요청 검증(state)에 실패했습니다. 다시 시도해주세요.');
+	}
+
+	try {
+		const redirectUri = `${request.nextUrl.origin}/login/${provider}/callback`;
+
+		const res = await fetchJson<LoginResponse>('/api/auth/social-login', {
+			method: 'POST',
+			body: JSON.stringify({
+				Provider: provider,
+				Code: code,
+				State: state,
+				RedirectUri: redirectUri
+			})
+		});
+
+		if (res.success && res.data) {
+			const response = NextResponse.redirect(new URL(`/login/${provider}/complete`, request.url));
+			const cookieOptions = { httpOnly: true, path: '/' };
+			response.cookies.set('accessToken', res.data.accessToken, cookieOptions);
+			response.cookies.set('refreshToken', res.data.refreshToken, cookieOptions);
+			return finish(response);
+		}
+
+		const errorCode = res.errors?.[0]?.code;
+
+		// 카카오 이메일 선택동의 미동의 → 이메일 제공 동의 안내 화면
+		if (errorCode === 'Auth.EmailRequired') {
+			return noticeRedirect('email-required');
+		}
+
+		// 같은 이메일의 기존 계정 존재 (자동연결 금지) → 기존 계정 로그인 + SNS 연동 안내 화면
+		if (errorCode === 'Auth.AccountExists') {
+			return noticeRedirect('account-exists');
+		}
+
+		if (errorCode === 'Auth.SocialNotConfigured') {
+			return failRedirect(`${label} 로그인이 아직 준비되지 않았습니다. 잠시 후 다시 시도해주세요.`);
+		}
+
+		return failRedirect(res.errors?.[0]?.description || res.message || `${label} 로그인에 실패했습니다. 다시 시도해주세요.`);
+	} catch (e) {
+		console.error(`[${provider} Callback] Error:`, e);
+		return failRedirect(e instanceof Error ? e.message : `${label} 로그인 처리 중 오류가 발생했습니다.`);
+	}
+}

+ 39 - 0
app/(auth)/login/[provider]/complete/page.tsx

@@ -0,0 +1,39 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useParams, useRouter } from 'next/navigation';
+import Logo from '@/app/component/Logo';
+import useAuth from '@/hooks/useAuth';
+import { isSocialLoginProvider, SOCIAL_PROVIDER_LABELS } from '@/constants/social';
+
+/**
+ * 소셜 로그인 완료 페이지 (Naver/Kakao) — /login/{provider}/complete
+ * 콜백 라우트가 httpOnly 쿠키(accessToken/refreshToken)를 심은 뒤 도착.
+ * 기존 /login/google/complete 와 동일하게 login() 으로 프로필 조회 + 메인 이동.
+ */
+export default function Page()
+{
+	const { login } = useAuth();
+	const router = useRouter();
+	const params = useParams<{ provider: string }>();
+	const label = isSocialLoginProvider(params.provider) ? SOCIAL_PROVIDER_LABELS[params.provider] : null;
+
+	useEffect(() => {
+		if (!label) {
+			router.replace('/login');
+			return;
+		}
+		login(true);
+	// eslint-disable-next-line react-hooks/exhaustive-deps
+	}, []);
+
+	return (
+		<div className="fixed inset-0 flex flex-col items-center justify-center gap-6 bg-[var(--bg-page)] z-50">
+			<span className="auth-logo inline-block">
+				<Logo size="lg" />
+			</span>
+			<div className="w-7 h-7 border-[3px] border-gray-200 border-t-[var(--color-accent)] rounded-full animate-spin" />
+			<p className="text-sm text-[var(--text-muted)]">{label ?? '소셜'} 로그인 중...</p>
+		</div>
+	);
+}

+ 59 - 0
app/(auth)/login/[provider]/start/route.ts

@@ -0,0 +1,59 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { fetchJson } from '@/lib/utils/server';
+import { isSocialLoginProvider, SOCIAL_PROVIDER_LABELS, OAUTH_STATE_COOKIE_PREFIX } from '@/constants/social';
+
+/**
+ * 소셜 로그인 시작 라우트 (Naver/Kakao) — /login/{provider}/start
+ *
+ * 1. state 생성 (crypto.randomUUID) → httpOnly 쿠키 보관 (10분)
+ * 2. 백엔드 GET /api/auth/{provider}/authorize-url 호출
+ * 3. 각사 authorize 페이지로 302 리다이렉트
+ *
+ * Google 은 기존 GIS(id_token) 플로우(/login/google/*) 유지 — 이 라우트 미사용.
+ */
+
+interface AuthorizeUrlResponse {
+	provider: string;
+	authorizeUrl: string;
+}
+
+export async function GET(request: NextRequest, { params }: { params: Promise<{ provider: string }> })
+{
+	const { provider } = await params;
+
+	const failRedirect = (message: string) => {
+		const url = new URL('/login', request.url);
+		url.searchParams.set('error', message);
+		return NextResponse.redirect(url);
+	};
+
+	if (!isSocialLoginProvider(provider)) {
+		return failRedirect('지원하지 않는 소셜 로그인입니다.');
+	}
+
+	const label = SOCIAL_PROVIDER_LABELS[provider];
+	const state = crypto.randomUUID();
+	const redirectUri = `${request.nextUrl.origin}/login/${provider}/callback`;
+	const query = new URLSearchParams({ state, redirectUri });
+
+	const res = await fetchJson<AuthorizeUrlResponse>(`/api/auth/${provider}/authorize-url?${query.toString()}`);
+
+	if (!res.success || !res.data?.authorizeUrl) {
+		// OAuth 앱 키 미설정(Auth.SocialNotConfigured)은 사용자 친화 문구로 치환
+		if (res.errors?.some(e => e.code === 'Auth.SocialNotConfigured')) {
+			return failRedirect(`${label} 로그인이 아직 준비되지 않았습니다. 잠시 후 다시 시도해주세요.`);
+		}
+		return failRedirect(res.errors?.[0]?.description || res.message || `${label} 로그인을 시작할 수 없습니다. 잠시 후 다시 시도해주세요.`);
+	}
+
+	const response = NextResponse.redirect(res.data.authorizeUrl);
+	response.cookies.set(`${OAUTH_STATE_COOKIE_PREFIX}${provider}`, state, {
+		httpOnly: true,
+		path: '/',
+		sameSite: 'lax',
+		secure: process.env.NODE_ENV === 'production',
+		maxAge: 600
+	});
+
+	return response;
+}

+ 15 - 0
app/(auth)/login/page.tsx

@@ -144,6 +144,21 @@ export default function Page()
                                 />
                             )}
                         </div>
+
+                        {/* Naver/Kakao 소셜 로그인 — start 라우트가 state 생성 후 각사 authorize 로 리다이렉트.
+                            route handler 라 Link prefetch 시 OAuth 시작이 실행되므로 일반 <a> 사용 */}
+                        <div className="social-login" role="group" aria-label="소셜 로그인">
+                            <a href="/login/naver/start" className="social-login__button social-login__button--naver">
+                                <span className="social-login__icon social-login__icon--naver" aria-hidden="true">N</span>
+                                <span>네이버 로그인</span>
+                            </a>
+                            <a href="/login/kakao/start" className="social-login__button social-login__button--kakao">
+                                <svg className="social-login__icon social-login__icon--kakao" viewBox="0 0 24 24" aria-hidden="true">
+                                    <path fill="currentColor" d="M12 3C6.48 3 2 6.54 2 10.9c0 2.8 1.86 5.25 4.64 6.65-.2.75-.74 2.72-.85 3.14-.13.53.2.52.41.38.17-.11 2.65-1.8 3.72-2.53.67.1 1.37.15 2.08.15 5.52 0 10-3.53 10-7.89S17.52 3 12 3z"/>
+                                </svg>
+                                <span>카카오 로그인</span>
+                            </a>
+                        </div>
 					</form>
                     <hr hidden/>
 				</fieldset>

+ 78 - 0
app/(auth)/login/social-notice/page.tsx

@@ -0,0 +1,78 @@
+import './style.scss';
+import Link from 'next/link';
+import { isSocialLoginProvider, SOCIAL_PROVIDER_LABELS } from '@/constants/social';
+
+/**
+ * 소셜 로그인 안내 화면 — /login/social-notice?reason=...&provider=...
+ *
+ * - email-required: 카카오 이메일 선택동의 미동의 (Auth.EmailRequired) → 재동의 유도
+ * - account-exists: 같은 이메일의 기존 계정 존재 (Auth.AccountExists) → 기존 계정 로그인 + SNS 연동 안내
+ */
+export default async function Page({ searchParams }: { searchParams: Promise<{ reason?: string; provider?: string }> })
+{
+	const { reason, provider } = await searchParams;
+	const label = isSocialLoginProvider(provider) ? SOCIAL_PROVIDER_LABELS[provider] : '소셜';
+
+	if (reason === 'email-required') {
+		return (
+			<div className="social-notice">
+				<h1 className="social-notice__title">이메일 제공 동의가 필요합니다</h1>
+				<p className="social-notice__desc">
+					{label} 계정으로 가입하려면 이메일 제공에 동의해야 합니다.<br />
+					다시 로그인하면서 <strong>이메일</strong> 항목에 동의해주세요.
+				</p>
+				{provider === 'kakao' && (
+					<p className="social-notice__hint">
+						동의 항목이 보이지 않는다면 카카오 <strong>내 계정 &gt; 연결된 서비스 관리</strong> 에서
+						개미투자 연결을 해제한 뒤 다시 시도해주세요.
+					</p>
+				)}
+				<div className="social-notice__actions">
+					{isSocialLoginProvider(provider) && (
+						<a href={`/login/${provider}/start`} className="social-notice__button social-notice__button--primary">
+							{label} 로그인 다시 시도
+						</a>
+					)}
+					<Link href="/login" className="social-notice__button">
+						로그인으로 돌아가기
+					</Link>
+				</div>
+			</div>
+		);
+	}
+
+	if (reason === 'account-exists') {
+		return (
+			<div className="social-notice">
+				<h1 className="social-notice__title">이미 가입된 이메일입니다</h1>
+				<p className="social-notice__desc">
+					{label} 계정과 같은 이메일로 가입된 계정이 이미 있습니다.<br />
+					안전을 위해 자동으로 연결하지 않아요.
+				</p>
+				<p className="social-notice__hint">
+					기존 계정으로 로그인한 뒤 <strong>계정 관리 &gt; SNS 연결 관리</strong> 에서 {label} 계정을 연동하면,
+					다음부터 {label} 로그인을 사용할 수 있습니다.
+				</p>
+				<div className="social-notice__actions">
+					<Link href="/login" className="social-notice__button social-notice__button--primary">
+						기존 계정으로 로그인
+					</Link>
+				</div>
+			</div>
+		);
+	}
+
+	return (
+		<div className="social-notice">
+			<h1 className="social-notice__title">로그인을 완료할 수 없습니다</h1>
+			<p className="social-notice__desc">
+				{label} 로그인 처리 중 문제가 발생했습니다. 잠시 후 다시 시도해주세요.
+			</p>
+			<div className="social-notice__actions">
+				<Link href="/login" className="social-notice__button social-notice__button--primary">
+					로그인으로 돌아가기
+				</Link>
+			</div>
+		</div>
+	);
+}

+ 58 - 0
app/(auth)/login/social-notice/style.scss

@@ -0,0 +1,58 @@
+// 소셜 로그인 안내 화면 (email-required / account-exists)
+.social-notice {
+	max-width: 480px;
+	margin: 3rem auto;
+	padding: 2rem 1.5rem;
+	border: 1px solid var(--border-strong);
+	border-radius: 6px;
+	text-align: center;
+
+	&__title {
+		font-size: 1.25rem;
+		font-weight: bold;
+		margin-bottom: 1rem;
+	}
+
+	&__desc {
+		font-size: 0.938rem;
+		line-height: 1.6;
+		margin-bottom: 0.75rem;
+	}
+
+	&__hint {
+		font-size: 0.875rem;
+		line-height: 1.6;
+		color: var(--text-muted);
+		margin-bottom: 0.75rem;
+	}
+
+	&__actions {
+		display: flex;
+		flex-direction: column;
+		gap: 8px;
+		margin-top: 1.5rem;
+	}
+
+	&__button {
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		width: 100%;
+		height: 40px;
+		border: 1px solid var(--border-strong);
+		border-radius: 4px;
+		font-size: 0.938rem;
+		font-weight: 600;
+		text-decoration: none;
+
+		&:hover {
+			filter: brightness(0.96);
+		}
+
+		&--primary {
+			border-color: transparent;
+			background: var(--color-accent);
+			color: #fff;
+		}
+	}
+}

+ 49 - 0
app/(auth)/login/style.scss

@@ -86,6 +86,55 @@
     }
 }
 
+// 소셜 로그인 버튼 (네이버 #03C75A / 카카오 #FEE500 — 각사 브랜드 컬러 가이드)
+.social-login {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    margin-top: 8px;
+
+    &__button {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        gap: 8px;
+        width: 100%;
+        height: 40px;
+        border-radius: 4px;
+        font-size: 0.938rem;
+        font-weight: 600;
+        text-decoration: none;
+        transition: filter 0.15s ease;
+
+        &:hover {
+            filter: brightness(0.96);
+        }
+
+        &--naver {
+            background: #03c75a;
+            color: #fff;
+        }
+
+        &--kakao {
+            background: #fee500;
+            color: rgba(0, 0, 0, 0.85); // 카카오 가이드: 검정 85%
+        }
+    }
+
+    &__icon {
+        &--naver {
+            font-weight: 800;
+            font-size: 1.063rem;
+            line-height: 1;
+        }
+
+        &--kakao {
+            width: 18px;
+            height: 18px;
+        }
+    }
+}
+
 @media (max-width: 420px) {
     #loginForm {
         fieldset {

+ 16 - 0
constants/social.ts

@@ -0,0 +1,16 @@
+// 소셜 로그인 (Naver/Kakao) 공통 상수 — Google 은 기존 GIS(id_token) 플로우 유지 (d5 §⑤)
+export const SOCIAL_LOGIN_PROVIDERS = ['naver', 'kakao'] as const;
+
+export type SocialLoginProvider = typeof SOCIAL_LOGIN_PROVIDERS[number];
+
+export const SOCIAL_PROVIDER_LABELS: Record<SocialLoginProvider, string> = {
+	naver: '네이버',
+	kakao: '카카오'
+};
+
+// OAuth CSRF 방어용 state 쿠키 이름 prefix — start 라우트가 발급하고 callback 라우트가 검증
+export const OAUTH_STATE_COOKIE_PREFIX = 'oauth_state_';
+
+export function isSocialLoginProvider(value: string|null|undefined): value is SocialLoginProvider {
+	return value === 'naver' || value === 'kakao';
+}