2025-11-10 09:38:59 +09:00
|
|
|
import { NextResponse } from 'next/server';
|
|
|
|
|
import type { NextRequest } from 'next/server';
|
|
|
|
|
|
2025-11-11 18:55:16 +09:00
|
|
|
/**
|
|
|
|
|
* 백엔드 API 로그인 응답 타입
|
|
|
|
|
*/
|
|
|
|
|
interface BackendLoginResponse {
|
|
|
|
|
message: string;
|
|
|
|
|
access_token: string;
|
|
|
|
|
refresh_token: string;
|
|
|
|
|
token_type: string;
|
|
|
|
|
expires_in: number;
|
|
|
|
|
expires_at: string;
|
|
|
|
|
user: {
|
|
|
|
|
id: number;
|
|
|
|
|
user_id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
email: string;
|
|
|
|
|
phone: string;
|
|
|
|
|
};
|
|
|
|
|
tenant: {
|
|
|
|
|
id: number;
|
|
|
|
|
company_name: string;
|
|
|
|
|
business_num: string;
|
|
|
|
|
tenant_st_code: string;
|
|
|
|
|
other_tenants: any[];
|
|
|
|
|
};
|
|
|
|
|
menus: Array<{
|
|
|
|
|
id: number;
|
|
|
|
|
parent_id: number | null;
|
|
|
|
|
name: string;
|
|
|
|
|
url: string;
|
|
|
|
|
icon: string;
|
|
|
|
|
sort_order: number;
|
|
|
|
|
is_external: number;
|
|
|
|
|
external_url: string | null;
|
|
|
|
|
}>;
|
|
|
|
|
roles: Array<{
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
description: string;
|
|
|
|
|
}>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 프론트엔드로 전달할 응답 타입 (토큰 제외)
|
|
|
|
|
*/
|
|
|
|
|
interface FrontendLoginResponse {
|
|
|
|
|
message: string;
|
|
|
|
|
user: BackendLoginResponse['user'];
|
|
|
|
|
tenant: BackendLoginResponse['tenant'];
|
|
|
|
|
menus: BackendLoginResponse['menus'];
|
|
|
|
|
roles: BackendLoginResponse['roles'];
|
|
|
|
|
token_type: string;
|
|
|
|
|
expires_in: number;
|
|
|
|
|
expires_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-10 09:38:59 +09:00
|
|
|
/**
|
|
|
|
|
* Login Proxy Route Handler
|
|
|
|
|
*
|
|
|
|
|
* Purpose:
|
|
|
|
|
* - Proxy login requests to PHP backend
|
|
|
|
|
* - Store token in HttpOnly cookie (XSS protection)
|
|
|
|
|
* - Never expose token to client JavaScript
|
|
|
|
|
*/
|
|
|
|
|
export async function POST(request: NextRequest) {
|
|
|
|
|
try {
|
|
|
|
|
const body = await request.json();
|
|
|
|
|
const { user_id, user_pwd } = body;
|
|
|
|
|
|
|
|
|
|
// Validate input
|
|
|
|
|
if (!user_id || !user_pwd) {
|
|
|
|
|
return NextResponse.json(
|
|
|
|
|
{ error: 'User ID and password are required' },
|
|
|
|
|
{ status: 400 }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Call PHP backend API
|
2025-11-10 17:25:56 +09:00
|
|
|
const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/login`, {
|
2025-11-10 09:38:59 +09:00
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Accept': 'application/json',
|
|
|
|
|
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ user_id, user_pwd }),
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-10 17:25:56 +09:00
|
|
|
if (!backendResponse.ok) {
|
|
|
|
|
// Don't expose detailed backend error messages to client
|
|
|
|
|
// Use generic error messages based on status code
|
|
|
|
|
let errorMessage = 'Authentication failed';
|
|
|
|
|
|
|
|
|
|
if (backendResponse.status === 422) {
|
|
|
|
|
errorMessage = 'Invalid credentials provided';
|
|
|
|
|
} else if (backendResponse.status === 429) {
|
|
|
|
|
errorMessage = 'Too many login attempts. Please try again later';
|
|
|
|
|
} else if (backendResponse.status >= 500) {
|
|
|
|
|
errorMessage = 'Service temporarily unavailable';
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-10 09:38:59 +09:00
|
|
|
return NextResponse.json(
|
2025-11-10 17:25:56 +09:00
|
|
|
{ error: errorMessage },
|
|
|
|
|
{ status: backendResponse.status === 422 ? 401 : backendResponse.status }
|
2025-11-10 09:38:59 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-11 18:55:16 +09:00
|
|
|
const data: BackendLoginResponse = await backendResponse.json();
|
2025-11-10 09:38:59 +09:00
|
|
|
|
|
|
|
|
// Prepare response with user data (no token exposed)
|
2025-11-11 18:55:16 +09:00
|
|
|
const responseData: FrontendLoginResponse = {
|
2025-11-10 09:38:59 +09:00
|
|
|
message: data.message,
|
|
|
|
|
user: data.user,
|
|
|
|
|
tenant: data.tenant,
|
|
|
|
|
menus: data.menus,
|
2025-11-11 18:55:16 +09:00
|
|
|
roles: data.roles, // ✅ roles 데이터 추가
|
2025-11-10 17:25:56 +09:00
|
|
|
token_type: data.token_type,
|
|
|
|
|
expires_in: data.expires_in,
|
|
|
|
|
expires_at: data.expires_at,
|
2025-11-10 09:38:59 +09:00
|
|
|
};
|
|
|
|
|
|
2025-11-10 17:25:56 +09:00
|
|
|
// Set HttpOnly cookies for both access_token and refresh_token
|
|
|
|
|
const accessTokenCookie = [
|
|
|
|
|
`access_token=${data.access_token}`,
|
2025-11-10 09:38:59 +09:00
|
|
|
'HttpOnly', // ✅ JavaScript cannot access
|
|
|
|
|
'Secure', // ✅ HTTPS only (production)
|
|
|
|
|
'SameSite=Strict', // ✅ CSRF protection
|
|
|
|
|
'Path=/',
|
2025-11-10 17:25:56 +09:00
|
|
|
`Max-Age=${data.expires_in || 7200}`, // Use backend expiry (default 2 hours)
|
2025-11-10 09:38:59 +09:00
|
|
|
].join('; ');
|
|
|
|
|
|
2025-11-10 17:25:56 +09:00
|
|
|
const refreshTokenCookie = [
|
|
|
|
|
`refresh_token=${data.refresh_token}`,
|
|
|
|
|
'HttpOnly', // ✅ JavaScript cannot access
|
|
|
|
|
'Secure', // ✅ HTTPS only (production)
|
|
|
|
|
'SameSite=Strict', // ✅ CSRF protection
|
|
|
|
|
'Path=/',
|
|
|
|
|
'Max-Age=604800', // 7 days (longer for refresh token)
|
|
|
|
|
].join('; ');
|
2025-11-10 09:38:59 +09:00
|
|
|
|
2025-11-10 17:25:56 +09:00
|
|
|
console.log('✅ Login successful - Access & Refresh tokens stored in HttpOnly cookies');
|
|
|
|
|
|
|
|
|
|
const response = NextResponse.json(responseData, { status: 200 });
|
|
|
|
|
|
|
|
|
|
response.headers.append('Set-Cookie', accessTokenCookie);
|
|
|
|
|
response.headers.append('Set-Cookie', refreshTokenCookie);
|
|
|
|
|
|
|
|
|
|
return response;
|
2025-11-10 09:38:59 +09:00
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Login proxy error:', error);
|
|
|
|
|
return NextResponse.json(
|
|
|
|
|
{ error: 'Internal server error' },
|
|
|
|
|
{ status: 500 }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|