- executeServerAction 공통 유틸 도입으로 actions.ts 대폭 간소화 (50+개 파일) - sanitize 유틸 추가 (XSS 방지) - middleware CSP 헤더 추가 및 Open Redirect 방지 - 프록시 라우트 로깅 개발환경 한정으로 변경 - 프로덕션 불필요 console.log 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
202 lines
6.3 KiB
TypeScript
202 lines
6.3 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import type { NextRequest } from 'next/server';
|
|
|
|
/**
|
|
* 🔵 Next.js 내부 API - 로그인 프록시 (PHP 백엔드로 전달)
|
|
*
|
|
* ⚡ 설계 목적:
|
|
* - 보안: HttpOnly 쿠키로 토큰 저장 (JavaScript 접근 불가)
|
|
* - 프록시 패턴: PHP 백엔드 API 호출 후 토큰을 안전하게 쿠키로 설정
|
|
* - 클라이언트 보호: 토큰을 절대 클라이언트 JavaScript에 노출하지 않음
|
|
*
|
|
* 🔄 동작 흐름:
|
|
* 1. 클라이언트 → Next.js /api/auth/login (user_id, user_pwd)
|
|
* 2. Next.js → PHP /api/v1/login (인증 요청)
|
|
* 3. PHP → Next.js (access_token, refresh_token, 사용자 정보)
|
|
* 4. Next.js: 토큰을 HttpOnly 쿠키로 설정
|
|
* 5. Next.js → 클라이언트 (토큰 제외한 사용자 정보만 전달)
|
|
*
|
|
* 🔐 보안 특징:
|
|
* - 토큰은 클라이언트에 절대 노출되지 않음
|
|
* - HttpOnly: XSS 공격 방지
|
|
* - Secure: HTTPS만 전송
|
|
* - SameSite=Strict: CSRF 공격 방지
|
|
*
|
|
* ⚠️ 주의:
|
|
* - 이 API는 PHP /api/v1/login의 프록시입니다
|
|
* - 실제 인증 로직은 PHP 백엔드에서 처리됩니다
|
|
*/
|
|
|
|
/**
|
|
* 백엔드 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: unknown[];
|
|
};
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/login`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-API-KEY': process.env.API_KEY || '',
|
|
},
|
|
body: JSON.stringify({ user_id, user_pwd }),
|
|
});
|
|
|
|
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';
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ error: errorMessage },
|
|
{ status: backendResponse.status === 422 ? 401 : backendResponse.status }
|
|
);
|
|
}
|
|
|
|
const data: BackendLoginResponse = await backendResponse.json();
|
|
|
|
// Prepare response with user data (no token exposed)
|
|
const responseData: FrontendLoginResponse = {
|
|
message: data.message,
|
|
user: data.user,
|
|
tenant: data.tenant,
|
|
menus: data.menus,
|
|
roles: data.roles, // ✅ roles 데이터 추가
|
|
token_type: data.token_type,
|
|
expires_in: data.expires_in,
|
|
expires_at: data.expires_at,
|
|
};
|
|
|
|
// Set HttpOnly cookies for both access_token and refresh_token
|
|
// Safari compatibility: Secure only in production (HTTPS)
|
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
|
|
const accessTokenCookie = [
|
|
`access_token=${data.access_token}`,
|
|
'HttpOnly', // ✅ JavaScript cannot access
|
|
...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix)
|
|
'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility)
|
|
'Path=/',
|
|
`Max-Age=${data.expires_in || 7200}`,
|
|
// `Max-Age=10` // 여기서만 10초 하면 최초 1회만 10 초 후에 액세스 끊어짐 구동테스트 완벽하게 하기 위해서는 refresh 쪽도 10초로 수정해야 함
|
|
].join('; ');
|
|
|
|
const refreshTokenCookie = [
|
|
`refresh_token=${data.refresh_token}`,
|
|
'HttpOnly', // ✅ JavaScript cannot access
|
|
...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix)
|
|
'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility)
|
|
'Path=/',
|
|
'Max-Age=604800', // TODO: 테스트용 10초, 원래 604800 (7 days)
|
|
].join('; ');
|
|
|
|
// ✅ FCM 등에서 인증 상태 확인용 (non-HttpOnly - JavaScript 접근 가능)
|
|
const isAuthenticatedCookie = [
|
|
'is_authenticated=true',
|
|
// HttpOnly 제외 - JavaScript에서 접근 가능해야 함
|
|
...(isProduction ? ['Secure'] : []),
|
|
'SameSite=Lax',
|
|
'Path=/',
|
|
`Max-Age=${data.expires_in || 7200}`,
|
|
].join('; ');
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.log('✅ Login successful - tokens stored in HttpOnly cookies');
|
|
}
|
|
|
|
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('Login proxy error:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Internal server error' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
} |