From df51cf685289b089ebfdf02c9a57cd03554144de Mon Sep 17 00:00:00 2001 From: kent Date: Tue, 6 Jan 2026 21:47:57 +0900 Subject: [PATCH] =?UTF-8?q?fix(WEB):=20FCM=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EC=9D=84=20=EC=9C=84=ED=95=9C=20is=5Fauthent?= =?UTF-8?q?icated=20=EC=BF=A0=ED=82=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HttpOnly 쿠키(access_token)는 JavaScript에서 읽을 수 없어 FCM 초기화 실패 - non-HttpOnly is_authenticated 쿠키 추가로 클라이언트에서 인증 상태 확인 가능 - login/logout/refresh/proxy 라우트에서 쿠키 설정/삭제 처리 - hasAuthToken()이 is_authenticated 쿠키 확인하도록 변경 --- src/app/api/auth/login/route.ts | 11 ++++++++++ src/app/api/auth/logout/route.ts | 10 ++++++++++ src/app/api/auth/refresh/route.ts | 30 +++++++++++++++++++++------- src/app/api/proxy/[...path]/route.ts | 10 ++++++++++ src/lib/api/auth-headers.ts | 13 ++++++++---- 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index d941f281..c01eb69c 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -170,12 +170,23 @@ export async function POST(request: NextRequest) { '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('; '); + 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); + response.headers.append('Set-Cookie', isAuthenticatedCookie); return response; diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 67d71a34..76917ef9 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -70,6 +70,15 @@ export async function POST(request: NextRequest) { 'Max-Age=0', // Delete immediately ].join('; '); + // ✅ is_authenticated 쿠키도 삭제 (FCM 인증 상태 플래그) + const clearIsAuthenticated = [ + 'is_authenticated=', + ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', + 'Path=/', + 'Max-Age=0', + ].join('; '); + console.log('✅ Logout complete - Access & Refresh tokens cleared'); const response = NextResponse.json( @@ -79,6 +88,7 @@ export async function POST(request: NextRequest) { response.headers.append('Set-Cookie', clearAccessToken); response.headers.append('Set-Cookie', clearRefreshToken); + response.headers.append('Set-Cookie', clearIsAuthenticated); return response; diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index 7ff3a7c8..4cf7590c 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -60,9 +60,12 @@ export async function POST(request: NextRequest) { // Refresh token is invalid or expired console.warn('⚠️ Token refresh failed - user needs to re-login'); - // Clear both tokens - const clearAccessToken = 'access_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0'; - const clearRefreshToken = 'refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0'; + const isProduction = process.env.NODE_ENV === 'production'; + + // Clear all tokens + const clearAccessToken = `access_token=; HttpOnly; ${isProduction ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=0`; + const clearRefreshToken = `refresh_token=; HttpOnly; ${isProduction ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=0`; + const clearIsAuthenticated = `is_authenticated=; ${isProduction ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=0`; const failResponse = NextResponse.json( { error: 'Token refresh failed', needsReauth: true }, @@ -71,6 +74,7 @@ export async function POST(request: NextRequest) { failResponse.headers.append('Set-Cookie', clearAccessToken); failResponse.headers.append('Set-Cookie', clearRefreshToken); + failResponse.headers.append('Set-Cookie', clearIsAuthenticated); return failResponse; } @@ -86,11 +90,13 @@ export async function POST(request: NextRequest) { }; // Set new HttpOnly cookies + const isProduction = process.env.NODE_ENV === 'production'; + const accessTokenCookie = [ `access_token=${data.access_token}`, 'HttpOnly', - 'Secure', - 'SameSite=Strict', + ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', 'Path=/', `Max-Age=${data.expires_in || 7200}`, ].join('; '); @@ -98,18 +104,28 @@ export async function POST(request: NextRequest) { const refreshTokenCookie = [ `refresh_token=${data.refresh_token}`, 'HttpOnly', - 'Secure', - 'SameSite=Strict', + ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', 'Path=/', 'Max-Age=604800', // 7 days ].join('; '); + // ✅ FCM 등에서 인증 상태 확인용 (non-HttpOnly) + const isAuthenticatedCookie = [ + 'is_authenticated=true', + ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', + 'Path=/', + `Max-Age=${data.expires_in || 7200}`, + ].join('; '); + console.log('✅ Token refresh successful - New tokens stored'); const successResponse = NextResponse.json(responseData, { status: 200 }); successResponse.headers.append('Set-Cookie', accessTokenCookie); successResponse.headers.append('Set-Cookie', refreshTokenCookie); + successResponse.headers.append('Set-Cookie', isAuthenticatedCookie); return successResponse; diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts index 86222390..cad50e86 100644 --- a/src/app/api/proxy/[...path]/route.ts +++ b/src/app/api/proxy/[...path]/route.ts @@ -79,6 +79,15 @@ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: strin 'Path=/', `Max-Age=${tokens.expiresIn || 7200}`, ].join('; ')); + + // ✅ FCM 등에서 인증 상태 확인용 (non-HttpOnly) + cookies.push([ + 'is_authenticated=true', + ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', + 'Path=/', + `Max-Age=${tokens.expiresIn || 7200}`, + ].join('; ')); } if (tokens.refreshToken) { @@ -104,6 +113,7 @@ function createClearTokenCookies(): string[] { return [ `access_token=; HttpOnly${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`, `refresh_token=; HttpOnly${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`, + `is_authenticated=${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`, ]; } diff --git a/src/lib/api/auth-headers.ts b/src/lib/api/auth-headers.ts index ec85e961..666423ab 100644 --- a/src/lib/api/auth-headers.ts +++ b/src/lib/api/auth-headers.ts @@ -32,11 +32,16 @@ export const getMultipartHeaders = (): HeadersInit => { }; /** - * 토큰 존재 여부 확인 + * 인증 상태 확인 + * + * ⚠️ 중요: access_token은 HttpOnly 쿠키로 JavaScript에서 읽을 수 없음 + * - is_authenticated 쿠키는 non-HttpOnly로 JavaScript에서 접근 가능 + * - 로그인/토큰갱신 시 함께 설정되어 인증 상태를 나타냄 + * - FCM 토큰 등록 등 클라이언트에서 인증 상태 확인이 필요한 경우 사용 */ export const hasAuthToken = (): boolean => { if (typeof window === 'undefined') return false; - // ✅ access_token 쿠키 존재 여부 확인 - const token = document.cookie.split('; ').find(row => row.startsWith('access_token='))?.split('=')[1]; - return !!token; + // ✅ is_authenticated 쿠키로 인증 상태 확인 (non-HttpOnly) + const isAuth = document.cookie.split('; ').find(row => row.startsWith('is_authenticated='))?.split('=')[1]; + return isAuth === 'true'; };