feat: [auth] MNG→SAM 자동 로그인 페이지 구현

- /auto-login?token=xxx 페이지 추가 (기존 세션 로그아웃 후 토큰 로그인)
- /api/auth/token-login 프록시 라우트 (HttpOnly 쿠키 설정)
- publicRoutes에 /auto-login 추가 (인증 없이 접근 허용)
This commit is contained in:
2026-03-11 01:08:09 +09:00
parent d17b2a11a4
commit dc0c317d23
3 changed files with 220 additions and 1 deletions

View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-4 p-8">
<div className="text-destructive text-lg font-semibold"> </div>
<p className="text-muted-foreground">{errorMessage}</p>
<button
onClick={() => router.push('/login')}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition"
>
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-4">
<div className="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent" />
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
}

View File

@@ -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 }
);
}
}

View File

@@ -14,7 +14,7 @@ export const AUTH_CONFIG = {
// 명시적으로 여기에 추가된 경로만 비로그인 접근 가능
// 기본 정책: 모든 페이지는 인증 필요
publicRoutes: [
// 비어있음 - 필요시 추가 (예: '/about', '/terms', '/privacy')
'/auto-login', // MNG → SAM 자동 로그인 (토큰 기반)
],
// 🔒 보호된 라우트 (참고용, 실제로는 기본 정책으로 보호됨)