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:
Hyungi Ahn
2026-03-09 03:13:24 +09:00
parent 81478dc6ac
commit df0a125faa
4 changed files with 50 additions and 345 deletions

View File

@@ -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');

View File

@@ -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 호출을 방지하고 인증 상태를 효율적으로 관리

View File

@@ -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);
// 무시
}
}
}

View File

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