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