fix: TKQC Chrome 무한 로그인 루프 해결 및 SSO 리다이렉트 수정
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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 호출을 방지하고 인증 상태를 효율적으로 관리
|
||||
|
||||
@@ -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);
|
||||
// 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200"><rect width="200" height="200" fill="#f3f4f6"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="#9ca3af">오프라인</text></svg>',
|
||||
{ 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user