From df0a125faa0e675a9202f766f07b6b97f22a8157 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 9 Mar 2026 03:13:24 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20TKQC=20Chrome=20=EB=AC=B4=ED=95=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A3=A8=ED=94=84=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20=EB=B0=8F=20SSO=20=EB=A6=AC=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EB=A0=89=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Service Worker 제거: 캐시 간섭으로 인한 Chrome 인증 루프 방지 - sw.js를 자기 정리(캐시 삭제+해제) 버전으로 교체 - auth-manager.js에 SW 해제 코드 추가 (모든 페이지 즉시 적용) - page-preloader.js SW 등록을 해제 로직으로 전환 - Gateway 로그인 리다이렉트: isSafeRedirect() 함수로 서브도메인 절대 URL 허용 - *.technicalkorea.net만 허용하여 open redirect 방지 유지 Co-Authored-By: Claude Opus 4.6 --- gateway/html/login.html | 21 +- .../web/static/js/core/auth-manager.js | 12 + .../web/static/js/core/page-preloader.js | 22 +- system3-nonconformance/web/sw.js | 340 +----------------- 4 files changed, 50 insertions(+), 345 deletions(-) diff --git a/gateway/html/login.html b/gateway/html/login.html index 709e503..20ede4e 100644 --- a/gateway/html/login.html +++ b/gateway/html/login.html @@ -161,8 +161,7 @@ // redirect 파라미터가 있으면 해당 URL로, 없으면 포털로 var redirect = new URLSearchParams(location.search).get('redirect'); - // Open redirect 방지: 같은 origin의 상대 경로만 허용 - if (redirect && /^\/[a-zA-Z0-9]/.test(redirect) && !redirect.includes('://') && !redirect.includes('//')) { + if (redirect && isSafeRedirect(redirect)) { window.location.href = redirect; } else { window.location.href = '/'; @@ -176,6 +175,22 @@ } } + // 안전한 리다이렉트인지 확인 (같은 도메인 상대 경로 또는 *.technicalkorea.net) + function isSafeRedirect(url) { + if (!url) return false; + // 상대 경로 + if (/^\/[a-zA-Z0-9]/.test(url) && !url.includes('://') && !url.includes('//')) { + return true; + } + // technicalkorea.net 서브도메인 절대 URL + try { + var parsed = new URL(url); + return parsed.hostname.endsWith('.technicalkorea.net') || parsed.hostname === 'technicalkorea.net'; + } catch (e) { + return false; + } + } + // 토큰 만료 확인 function isTokenValid(token) { try { @@ -191,7 +206,7 @@ if (existingToken && existingToken !== 'undefined' && existingToken !== 'null') { if (isTokenValid(existingToken)) { var redirect = new URLSearchParams(location.search).get('redirect'); - window.location.href = redirect || '/'; + window.location.href = (redirect && isSafeRedirect(redirect)) ? redirect : '/'; } else { // 만료된 토큰 정리 ssoCookie.remove('sso_token'); diff --git a/system3-nonconformance/web/static/js/core/auth-manager.js b/system3-nonconformance/web/static/js/core/auth-manager.js index 7f806bf..39eafc5 100644 --- a/system3-nonconformance/web/static/js/core/auth-manager.js +++ b/system3-nonconformance/web/static/js/core/auth-manager.js @@ -1,3 +1,15 @@ +// 서비스 워커 해제 (캐시 간섭으로 인한 인증 루프 방지) +if ('serviceWorker' in navigator) { + navigator.serviceWorker.getRegistrations().then(function(registrations) { + registrations.forEach(function(registration) { registration.unregister(); }); + }); + if (typeof caches !== 'undefined') { + caches.keys().then(function(names) { + names.forEach(function(name) { caches.delete(name); }); + }); + } +} + /** * 중앙화된 인증 관리자 * 페이지 간 이동 시 불필요한 API 호출을 방지하고 인증 상태를 효율적으로 관리 diff --git a/system3-nonconformance/web/static/js/core/page-preloader.js b/system3-nonconformance/web/static/js/core/page-preloader.js index 486d02b..15c40c0 100644 --- a/system3-nonconformance/web/static/js/core/page-preloader.js +++ b/system3-nonconformance/web/static/js/core/page-preloader.js @@ -18,12 +18,12 @@ class PagePreloader { init() { // 유휴 시간에 프리로딩 시작 this.schedulePreloading(); - + // 링크 호버 시 프리로딩 this.setupHoverPreloading(); - - // 서비스 워커 등록 (캐싱용) - this.registerServiceWorker(); + + // 기존 서비스 워커 해제 (캐시 문제 방지) + this.unregisterServiceWorker(); } /** @@ -274,14 +274,20 @@ class PagePreloader { } /** - * 서비스 워커 등록 + * 기존 서비스 워커 해제 및 캐시 정리 */ - async registerServiceWorker() { + async unregisterServiceWorker() { if ('serviceWorker' in navigator) { try { - const registration = await navigator.serviceWorker.register('/sw.js'); + const registrations = await navigator.serviceWorker.getRegistrations(); + for (const registration of registrations) { + await registration.unregister(); + } + // 모든 캐시 삭제 + const cacheNames = await caches.keys(); + await Promise.all(cacheNames.map(name => caches.delete(name))); } catch (error) { - console.log('서비스 워커 등록 실패:', error); + // 무시 } } } diff --git a/system3-nonconformance/web/sw.js b/system3-nonconformance/web/sw.js index eca27e4..bd9035b 100644 --- a/system3-nonconformance/web/sw.js +++ b/system3-nonconformance/web/sw.js @@ -1,349 +1,21 @@ /** - * 서비스 워커 - 페이지 및 리소스 캐싱 - * M-Project 작업보고서 시스템 + * 서비스 워커 정리용 + * 기존 캐시를 모두 삭제하고 자신을 비활성화합니다. */ -const CACHE_NAME = 'mproject-v1.1.0'; -const STATIC_CACHE = 'mproject-static-v1.1.0'; -const DYNAMIC_CACHE = 'mproject-dynamic-v1.1.0'; - -// 캐시할 정적 리소스 -const STATIC_ASSETS = [ - '/', - '/app.html', - '/issue-view.html', - '/issues-dashboard.html', - '/issues-inbox.html', - '/issues-management.html', - '/issues-archive.html', - '/ai-assistant.html', - '/reports.html', - '/reports-daily.html', - '/reports-weekly.html', - '/reports-monthly.html', - '/static/js/api.js', - '/static/js/app.js', - '/static/js/core/permissions.js', - '/static/js/core/auth-manager.js', - '/static/js/components/common-header.js', - '/static/js/core/page-manager.js', - '/static/js/core/page-preloader.js', - '/static/js/date-utils.js', - '/static/js/image-utils.js', - 'https://cdn.tailwindcss.com', - 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css' -]; - -// 캐시 전략별 URL 패턴 -const CACHE_STRATEGIES = { - // 네트워크 우선 (API 호출) - networkFirst: [ - /\/api\//, - /\/auth\// - ], - - // 캐시 우선 (정적 리소스) - cacheFirst: [ - /\.css$/, - /\.js$/, - /\.png$/, - /\.jpg$/, - /\.jpeg$/, - /\.gif$/, - /\.svg$/, - /\.woff$/, - /\.woff2$/, - /cdn\.tailwindcss\.com/, - /cdnjs\.cloudflare\.com/ - ], - - // 스테일 허용 (HTML 페이지) - staleWhileRevalidate: [ - /\.html$/, - /\/$/ - ] -}; - -/** - * 서비스 워커 설치 - */ self.addEventListener('install', (event) => { - console.log(' 서비스 워커 설치 중...'); - - event.waitUntil( - caches.open(STATIC_CACHE) - .then((cache) => { - console.log(' 정적 리소스 캐싱 중...'); - return cache.addAll(STATIC_ASSETS); - }) - .then(() => { - console.log(' 서비스 워커 설치 완료'); - return self.skipWaiting(); - }) - .catch((error) => { - console.error(' 서비스 워커 설치 실패:', error); - }) - ); + self.skipWaiting(); }); -/** - * 서비스 워커 활성화 - */ self.addEventListener('activate', (event) => { - console.log(' 서비스 워커 활성화 중...'); - event.waitUntil( caches.keys() .then((cacheNames) => { return Promise.all( - cacheNames.map((cacheName) => { - // 이전 버전 캐시 삭제 - if (cacheName !== STATIC_CACHE && - cacheName !== DYNAMIC_CACHE && - cacheName !== CACHE_NAME) { - console.log(' 이전 캐시 삭제:', cacheName); - return caches.delete(cacheName); - } - }) + cacheNames.map((cacheName) => caches.delete(cacheName)) ); }) - .then(() => { - console.log(' 서비스 워커 활성화 완료'); - return self.clients.claim(); - }) + .then(() => self.clients.claim()) + .then(() => self.registration.unregister()) ); }); - -/** - * 네트워크 요청 가로채기 - */ -self.addEventListener('fetch', (event) => { - const { request } = event; - const url = new URL(request.url); - - // POST 등 GET 이외 요청은 캐시 불가 → 기본 처리 - if (request.method !== 'GET') { - return; - } - - // CORS 요청이나 외부 도메인은 기본 처리 - if (url.origin !== location.origin && !isCDNResource(url)) { - return; - } - - // 캐시 전략 결정 - const strategy = getCacheStrategy(request.url); - - event.respondWith( - handleRequest(request, strategy) - ); -}); - -/** - * 요청 처리 (캐시 전략별) - */ -async function handleRequest(request, strategy) { - try { - switch (strategy) { - case 'networkFirst': - return await networkFirst(request); - case 'cacheFirst': - return await cacheFirst(request); - case 'staleWhileRevalidate': - return await staleWhileRevalidate(request); - default: - return await fetch(request); - } - } catch (error) { - console.error('요청 처리 실패:', request.url, error); - return await handleOffline(request); - } -} - -/** - * 네트워크 우선 전략 - */ -async function networkFirst(request) { - try { - const networkResponse = await fetch(request); - - // 성공적인 응답만 캐시 - if (networkResponse.ok) { - const cache = await caches.open(DYNAMIC_CACHE); - cache.put(request, networkResponse.clone()); - } - - return networkResponse; - } catch (error) { - // 네트워크 실패 시 캐시에서 반환 - const cachedResponse = await caches.match(request); - if (cachedResponse) { - return cachedResponse; - } - throw error; - } -} - -/** - * 캐시 우선 전략 - */ -async function cacheFirst(request) { - const cachedResponse = await caches.match(request); - - if (cachedResponse) { - return cachedResponse; - } - - // 캐시에 없으면 네트워크에서 가져와서 캐시 - const networkResponse = await fetch(request); - - if (networkResponse.ok) { - const cache = await caches.open(STATIC_CACHE); - cache.put(request, networkResponse.clone()); - } - - return networkResponse; -} - -/** - * 스테일 허용 전략 - */ -async function staleWhileRevalidate(request) { - const cachedResponse = await caches.match(request); - - // 백그라운드에서 업데이트 - const networkResponsePromise = fetch(request) - .then(async (networkResponse) => { - if (networkResponse.ok) { - const responseToCache = networkResponse.clone(); - const cache = await caches.open(DYNAMIC_CACHE); - cache.put(request, responseToCache); - } - return networkResponse; - }) - .catch(() => null); - - // 캐시된 응답이 있으면 즉시 반환, 없으면 네트워크 대기 - return cachedResponse || await networkResponsePromise; -} - -/** - * 캐시 전략 결정 - */ -function getCacheStrategy(url) { - for (const [strategy, patterns] of Object.entries(CACHE_STRATEGIES)) { - if (patterns.some(pattern => pattern.test(url))) { - return strategy; - } - } - return 'networkFirst'; // 기본값 -} - -/** - * CDN 리소스 확인 - */ -function isCDNResource(url) { - const cdnDomains = [ - 'cdn.tailwindcss.com', - 'cdnjs.cloudflare.com', - 'fonts.googleapis.com', - 'fonts.gstatic.com' - ]; - - return cdnDomains.some(domain => url.hostname.includes(domain)); -} - -/** - * 오프라인 처리 - */ -async function handleOffline(request) { - // HTML 요청에 대한 오프라인 페이지 - if (request.destination === 'document') { - const offlinePage = await caches.match('/app.html'); - if (offlinePage) { - return offlinePage; - } - } - - // 이미지 요청에 대한 기본 이미지 - if (request.destination === 'image') { - return new Response( - '오프라인', - { headers: { 'Content-Type': 'image/svg+xml' } } - ); - } - - // 기본 오프라인 응답 - return new Response('오프라인 상태입니다.', { - status: 503, - statusText: 'Service Unavailable', - headers: { 'Content-Type': 'text/plain; charset=utf-8' } - }); -} - -/** - * 메시지 처리 (캐시 관리) - */ -self.addEventListener('message', (event) => { - const { type, payload } = event.data; - - switch (type) { - case 'CLEAR_CACHE': - clearAllCaches().then(() => { - event.ports[0].postMessage({ success: true }); - }); - break; - - case 'CACHE_PAGE': - cachePage(payload.url).then(() => { - event.ports[0].postMessage({ success: true }); - }); - break; - - case 'GET_CACHE_STATUS': - getCacheStatus().then((status) => { - event.ports[0].postMessage({ status }); - }); - break; - } -}); - -/** - * 모든 캐시 정리 - */ -async function clearAllCaches() { - const cacheNames = await caches.keys(); - await Promise.all( - cacheNames.map(cacheName => caches.delete(cacheName)) - ); - console.log(' 모든 캐시 정리 완료'); -} - -/** - * 특정 페이지 캐시 - */ -async function cachePage(url) { - try { - const cache = await caches.open(DYNAMIC_CACHE); - await cache.add(url); - console.log(' 페이지 캐시 완료:', url); - } catch (error) { - console.error('페이지 캐시 실패:', url, error); - } -} - -/** - * 캐시 상태 조회 - */ -async function getCacheStatus() { - const cacheNames = await caches.keys(); - const status = {}; - - for (const cacheName of cacheNames) { - const cache = await caches.open(cacheName); - const keys = await cache.keys(); - status[cacheName] = keys.length; - } - - return status; -}