diff --git a/src/app/[locale]/auto-login/page.tsx b/src/app/[locale]/auto-login/page.tsx new file mode 100644 index 00000000..c185e07e --- /dev/null +++ b/src/app/[locale]/auto-login/page.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { transformApiMenusToMenuItems } from '@/lib/utils/menuTransform'; +import { performFullLogout } from '@/lib/auth/logout'; + +/** + * MNG 관리자 패널 → SAM 자동 로그인 페이지 + * + * 흐름: + * 1. MNG에서 "SAM 접속" 클릭 → /auto-login?token=xxx 로 새 창 열림 + * 2. 기존 세션 로그아웃 (쿠키 + localStorage + Zustand 초기화) + * 3. One-Time Token으로 API 호출 → 새 세션 생성 + * 4. 사용자 정보 저장 후 /dashboard로 이동 + */ +export default function AutoLoginPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [status, setStatus] = useState<'processing' | 'error'>('processing'); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + const token = searchParams.get('token'); + + if (!token) { + setStatus('error'); + setErrorMessage('로그인 토큰이 없습니다.'); + return; + } + + const performAutoLogin = async () => { + try { + // 1. 기존 세션 완전 로그아웃 (쿠키 삭제 + 스토어 초기화) + await performFullLogout({ skipServerLogout: false }); + + // 2. One-Time Token으로 로그인 + const response = await fetch('/api/auth/token-login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || '자동 로그인에 실패했습니다.'); + } + + // 3. 사용자 정보 localStorage 저장 (LoginPage와 동일 패턴) + const transformedMenus = transformApiMenusToMenuItems(data.menus || []); + + const userData = { + id: data.user?.id, + name: data.user?.name, + position: data.roles?.[0]?.description || '사용자', + userId: data.user?.user_id, + department: data.user?.department || null, + department_id: data.user?.department_id || null, + menu: transformedMenus, + roles: data.roles || [], + tenant: data.tenant || {}, + }; + localStorage.setItem('user', JSON.stringify(userData)); + + // 4. persist store rehydrate + const { useFavoritesStore } = await import('@/stores/favoritesStore'); + const { useTableColumnStore } = await import('@/stores/useTableColumnStore'); + useFavoritesStore.persist.rehydrate(); + useTableColumnStore.persist.rehydrate(); + + // 5. 로그인 플래그 설정 + sessionStorage.setItem('auth_just_logged_in', 'true'); + + // 6. 대시보드로 이동 + router.push('/dashboard'); + } catch (err) { + console.error('자동 로그인 실패:', err); + setStatus('error'); + setErrorMessage(err instanceof Error ? err.message : '자동 로그인에 실패했습니다.'); + } + }; + + performAutoLogin(); + }, [searchParams, router]); + + if (status === 'error') { + return ( +
+
+
자동 로그인 실패
+

{errorMessage}

+ +
+
+ ); + } + + return ( +
+
+
+

자동 로그인 중...

+
+
+ ); +} \ No newline at end of file diff --git a/src/app/api/auth/token-login/route.ts b/src/app/api/auth/token-login/route.ts new file mode 100644 index 00000000..eaabfa73 --- /dev/null +++ b/src/app/api/auth/token-login/route.ts @@ -0,0 +1,107 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +/** + * 🔵 Next.js 내부 API - 토큰 자동 로그인 프록시 + * + * MNG 관리자 패널에서 "SAM 접속" 버튼 클릭 시 사용 + * One-Time Token으로 사용자 인증 후 HttpOnly 쿠키 설정 + * + * 🔄 동작 흐름: + * 1. 클라이언트 → Next.js /api/auth/token-login (token) + * 2. Next.js → PHP /api/v1/token-login (토큰 검증) + * 3. PHP → Next.js (access_token, refresh_token, 사용자 정보) + * 4. Next.js: 토큰을 HttpOnly 쿠키로 설정 + * 5. Next.js → 클라이언트 (토큰 제외한 사용자 정보만 전달) + */ + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { token } = body; + + if (!token) { + return NextResponse.json( + { error: '토큰이 필요합니다.' }, + { status: 400 } + ); + } + + // PHP 백엔드 API 호출 + const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/token-login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-API-KEY': process.env.API_KEY || '', + }, + body: JSON.stringify({ token }), + }); + + if (!backendResponse.ok) { + const errorData = await backendResponse.json().catch(() => ({})); + return NextResponse.json( + { error: errorData.error || '토큰 인증에 실패했습니다.' }, + { status: backendResponse.status } + ); + } + + const data = await backendResponse.json(); + + // 클라이언트에 전달할 응답 (토큰 제외) + const responseData = { + message: data.message, + user: data.user, + tenant: data.tenant, + menus: data.menus, + roles: data.roles, + token_type: data.token_type, + expires_in: data.expires_in, + expires_at: data.expires_at, + }; + + // HttpOnly 쿠키 설정 (login/route.ts와 동일한 패턴) + const isProduction = process.env.NODE_ENV === 'production'; + + const accessTokenCookie = [ + `access_token=${data.access_token}`, + 'HttpOnly', + ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', + 'Path=/', + `Max-Age=${data.expires_in || 7200}`, + ].join('; '); + + const refreshTokenCookie = [ + `refresh_token=${data.refresh_token}`, + 'HttpOnly', + ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', + 'Path=/', + 'Max-Age=604800', + ].join('; '); + + const isAuthenticatedCookie = [ + 'is_authenticated=true', + ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', + 'Path=/', + `Max-Age=${data.expires_in || 7200}`, + ].join('; '); + + const response = NextResponse.json(responseData, { status: 200 }); + + response.headers.append('Set-Cookie', accessTokenCookie); + response.headers.append('Set-Cookie', refreshTokenCookie); + response.headers.append('Set-Cookie', isAuthenticatedCookie); + + return response; + + } catch (error) { + console.error('Token login proxy error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/lib/api/auth/auth-config.ts b/src/lib/api/auth/auth-config.ts index 6a142913..0a442041 100644 --- a/src/lib/api/auth/auth-config.ts +++ b/src/lib/api/auth/auth-config.ts @@ -14,7 +14,7 @@ export const AUTH_CONFIG = { // 명시적으로 여기에 추가된 경로만 비로그인 접근 가능 // 기본 정책: 모든 페이지는 인증 필요 publicRoutes: [ - // 비어있음 - 필요시 추가 (예: '/about', '/terms', '/privacy') + '/auto-login', // MNG → SAM 자동 로그인 (토큰 기반) ], // 🔒 보호된 라우트 (참고용, 실제로는 기본 정책으로 보호됨)