Files
sam-react-prod/src/app/api/auth/login/route.ts
byeongcheolryu 85e51b2e2a [feat]: Safari 쿠키 호환성 및 UI/UX 개선
주요 변경사항:
- Safari 쿠키 호환성 개선 (SameSite=Lax, 개발 환경 Secure 제외)
- Sidebar 활성 메뉴 자동 스크롤 기능 추가
- Sidebar 스크롤바 스타일링 (호버 시에만 표시)
- DashboardLayout sticky 포지셔닝 적용
- IE 브라우저 차단 및 안내 페이지 추가
- 메뉴 탐색 로직 개선 (서브메뉴 우선 매칭)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 14:32:14 +09:00

188 lines
5.7 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.NEXT_PUBLIC_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}`, // Use backend expiry (default 2 hours)
].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', // 7 days (longer for refresh token)
].join('; ');
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;
} catch (error) {
console.error('Login proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}