feat: [auth] MNG→SAM 자동 로그인 페이지 구현
- /auto-login?token=xxx 페이지 추가 (기존 세션 로그아웃 후 토큰 로그인) - /api/auth/token-login 프록시 라우트 (HttpOnly 쿠키 설정) - publicRoutes에 /auto-login 추가 (인증 없이 접근 허용)
This commit is contained in:
112
src/app/[locale]/auto-login/page.tsx
Normal file
112
src/app/[locale]/auto-login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/app/api/auth/token-login/route.ts
Normal file
107
src/app/api/auth/token-login/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export const AUTH_CONFIG = {
|
||||
// 명시적으로 여기에 추가된 경로만 비로그인 접근 가능
|
||||
// 기본 정책: 모든 페이지는 인증 필요
|
||||
publicRoutes: [
|
||||
// 비어있음 - 필요시 추가 (예: '/about', '/terms', '/privacy')
|
||||
'/auto-login', // MNG → SAM 자동 로그인 (토큰 기반)
|
||||
],
|
||||
|
||||
// 🔒 보호된 라우트 (참고용, 실제로는 기본 정책으로 보호됨)
|
||||
|
||||
Reference in New Issue
Block a user