Files
sam-react-prod/src/app/api/auth/login/route.ts
유병철 55e0791e16 refactor(WEB): Server Action 공통화 및 보안 강화
- executeServerAction 공통 유틸 도입으로 actions.ts 대폭 간소화 (50+개 파일)
- sanitize 유틸 추가 (XSS 방지)
- middleware CSP 헤더 추가 및 Open Redirect 방지
- 프록시 라우트 로깅 개발환경 한정으로 변경
- 프로덕션 불필요 console.log 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:14:06 +09:00

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