feat: SSO 쿠키 인증 통합 + 서브도메인 라우팅 아키텍처

- Path-based 라우팅을 서브도메인 기반으로 전환
  (tkfb/tkreport/tkqc.technicalkorea.net)
- 3개 시스템 프론트엔드에 SSO 쿠키 인증 통합
  (domain=.technicalkorea.net, localStorage 폴백)
- Gateway: 포털+로그인+System1 프록시, 쿠키 SSO 설정
- System 1: 토큰키 통일, nginx.conf 생성, 신고페이지 리다이렉트
- System 2: api-base.js/app-init.js 생성, getSSOToken() 통합
- System 3: TokenManager 쿠키 지원, 중앙 로그인 리다이렉트
- docker-compose.yml에 cloudflared 서비스 추가
- DEPLOY-GUIDE.md 배포 가이드 작성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-09 18:41:44 +09:00
parent 550633b89d
commit 6495b8af32
114 changed files with 1729 additions and 4335 deletions

View File

@@ -1,9 +1,9 @@
// ✅ /js/admin.js (수정됨 - 중복 로딩 제거)
async function initDashboard() {
// 로그인 토큰 확인
const token = localStorage.getItem('token');
const token = localStorage.getItem('sso_token');
if (!token) {
location.href = '/index.html';
location.href = '/login';
return;
}

View File

@@ -30,10 +30,10 @@ function getApiBaseUrl() {
export const API = getApiBaseUrl();
export function ensureAuthenticated() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('sso_token');
if (!token || token === 'undefined') {
alert('로그인이 필요합니다');
localStorage.removeItem('token');
localStorage.removeItem('sso_token');
window.location.href = '/';
return null;
}
@@ -41,7 +41,7 @@ export function ensureAuthenticated() {
}
export function getAuthHeaders() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('sso_token');
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
@@ -70,7 +70,7 @@ export async function apiCall(url, options = {}) {
// 인증 만료 처리
if (response.status === 401) {
console.error('❌ 인증 만료');
localStorage.removeItem('token');
localStorage.removeItem('sso_token');
alert('인증이 만료되었습니다. 다시 로그인해주세요.');
window.location.href = '/';
return;

View File

@@ -37,7 +37,7 @@ async function authFetch(endpoint, options = {}) {
if (!token) {
console.error('토큰이 없습니다. 로그인이 필요합니다.');
clearAuthData(); // 인증 정보 정리
window.location.href = '/index.html'; // 로그인 페이지로 리디렉션
window.location.href = '/login'; // 로그인 페이지로 리디렉션
// 에러를 던져서 후속 실행을 중단
throw new Error('인증 토큰이 없습니다.');
}
@@ -59,7 +59,7 @@ async function authFetch(endpoint, options = {}) {
if (response.status === 401) {
console.error('인증 실패. 토큰이 만료되었거나 유효하지 않습니다.');
clearAuthData(); // 만료된 인증 정보 정리
window.location.href = '/index.html';
window.location.href = '/login';
throw new Error('인증에 실패했습니다.');
}

View File

@@ -105,7 +105,7 @@ function getKoreaDateString(date = new Date()) {
*/
function getCurrentUser() {
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('sso_token');
if (!token) return null;
const payloadBase64 = token.split('.')[1];
@@ -118,7 +118,7 @@ function getCurrentUser() {
}
try {
const userInfo = localStorage.getItem('user') || localStorage.getItem('userInfo');
const userInfo = localStorage.getItem('sso_user') || localStorage.getItem('userInfo');
if (userInfo) {
return JSON.parse(userInfo);
}
@@ -190,7 +190,7 @@ async function makeRateLimitedRequest(url, options = {}, retryCount = 0) {
if (response.status === 401) {
showMessage('인증이 만료되었습니다. 다시 로그인해주세요.', 'error');
localStorage.removeItem('token');
localStorage.removeItem('sso_token');
setTimeout(() => {
window.location.href = '/';
}, 2000);
@@ -972,10 +972,10 @@ function renderWorkersList(workers) {
async function init() {
try {
// 인증 확인 (api-config.js의 ensureAuthenticated 대신 직접 확인)
const token = localStorage.getItem('token');
const token = localStorage.getItem('sso_token');
if (!token || token === 'undefined') {
showMessage('로그인이 필요합니다.', 'error');
localStorage.removeItem('token');
localStorage.removeItem('sso_token');
setTimeout(() => {
window.location.href = '/';
}, 2000);

View File

@@ -6,7 +6,7 @@ import { isLoggedIn, getUser, clearAuthData } from './auth.js';
if (!isLoggedIn()) {
console.log('🚨 인증되지 않은 사용자. 로그인 페이지로 이동합니다.');
clearAuthData(); // 만약을 위해 한번 더 정리
window.location.href = '/index.html';
window.location.href = '/login';
return; // 이후 코드 실행 방지
}
@@ -16,7 +16,7 @@ import { isLoggedIn, getUser, clearAuthData } from './auth.js';
if (!currentUser || !currentUser.username || !currentUser.role) {
console.error('🚨 사용자 정보가 유효하지 않습니다. 강제 로그아웃 처리합니다.');
clearAuthData();
window.location.href = '/index.html';
window.location.href = '/login';
return;
}

View File

@@ -20,7 +20,7 @@ export function parseJwt(token) {
* @returns {string|null} - 저장된 토큰 또는 토큰이 없을 경우 null
*/
export function getToken() {
return localStorage.getItem('token');
return localStorage.getItem('sso_token');
}
/**
@@ -28,7 +28,7 @@ export function getToken() {
* @returns {object|null} - 저장된 사용자 객체 또는 정보가 없을 경우 null
*/
export function getUser() {
const user = localStorage.getItem('user');
const user = localStorage.getItem('sso_user');
try {
return user ? JSON.parse(user) : null;
} catch(e) {
@@ -43,16 +43,16 @@ export function getUser() {
* @param {object} user - 서버에서 받은 사용자 정보 객체
*/
export function saveAuthData(token, user) {
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
localStorage.setItem('sso_token', token);
localStorage.setItem('sso_user', JSON.stringify(user));
}
/**
* 로그아웃 시 localStorage에서 인증 정보를 제거합니다.
*/
export function clearAuthData() {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
}
/**

View File

@@ -184,9 +184,9 @@ form?.addEventListener('submit', async (e) => {
if (countdown < 0) {
clearInterval(countdownInterval);
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/index.html';
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
window.location.href = '/login';
}
}, 1000);
@@ -205,7 +205,7 @@ form?.addEventListener('submit', async (e) => {
// 페이지 로드 시 현재 사용자 정보 표시
document.addEventListener('DOMContentLoaded', () => {
const user = JSON.parse(localStorage.getItem('user') || '{}');
const user = JSON.parse(localStorage.getItem('sso_user') || '{}');
console.log('🔐 비밀번호 변경 페이지 로드됨');
console.log('👤 현재 사용자:', user.username || 'Unknown');
});

View File

@@ -65,7 +65,7 @@ function initializePage() {
const user = getUser();
if (!user) {
showError('로그인이 필요합니다. 2초 후 로그인 페이지로 이동합니다.');
setTimeout(() => window.location.href = '/index.html', 2000);
setTimeout(() => window.location.href = '/login', 2000);
return;
}

View File

@@ -28,7 +28,7 @@ function getKoreaToday() {
// 현재 로그인한 사용자 정보 가져오기
function getCurrentUser() {
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('sso_token');
if (!token) return null;
const payloadBase64 = token.split('.')[1];
@@ -42,7 +42,7 @@ function getCurrentUser() {
}
try {
const userInfo = localStorage.getItem('user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
const userInfo = localStorage.getItem('sso_user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
if (userInfo) {
const parsed = JSON.parse(userInfo);
console.log('localStorage에서 가져온 사용자 정보:', parsed);
@@ -861,10 +861,10 @@ function setupEventListeners() {
// 초기화
async function init() {
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('sso_token');
if (!token || token === 'undefined') {
showMessage('로그인이 필요합니다.', 'error');
localStorage.removeItem('token');
localStorage.removeItem('sso_token');
setTimeout(() => {
window.location.href = '/';
}, 2000);

View File

@@ -6,7 +6,7 @@ document.getElementById('uploadForm').addEventListener('submit', async (e) => {
try {
// FormData를 사용할 때는 Content-Type을 설정하지 않음 (자동 설정됨)
const token = localStorage.getItem('token');
const token = localStorage.getItem('sso_token');
const res = await fetch(`${API}/factoryinfo`, {
method: 'POST',
headers: {

View File

@@ -69,7 +69,7 @@ function updateTeamStatusUI() {
// 환영 메시지 개인화
function personalizeWelcome() {
const user = JSON.parse(localStorage.getItem('user') || '{}');
const user = JSON.parse(localStorage.getItem('sso_user') || '{}');
const welcomeMsg = document.getElementById('welcome-message');
if (user && user.name && welcomeMsg) {
@@ -83,7 +83,7 @@ document.addEventListener('DOMContentLoaded', function() {
console.log('🚀 그룹장 대시보드 초기화 시작');
// 사용자 정보 확인
const user = JSON.parse(localStorage.getItem('user') || '{}');
const user = JSON.parse(localStorage.getItem('sso_user') || '{}');
console.log('👤 현재 사용자:', user);
// 권한 확인

View File

@@ -79,7 +79,7 @@ function setupNavbarEvents() {
logoutButton.addEventListener('click', () => {
if (confirm('로그아웃 하시겠습니까?')) {
clearAuthData();
window.location.href = '/index.html';
window.location.href = '/login';
}
});
}

View File

@@ -12,7 +12,7 @@ const accessLabels = {
};
// 현재 사용자 정보 가져오기
const currentUser = JSON.parse(localStorage.getItem('user') || '{}');
const currentUser = JSON.parse(localStorage.getItem('sso_user') || '{}');
const isSystemUser = currentUser.access_level === 'system';
function createRow(item, cols, delHandler) {
@@ -72,9 +72,9 @@ myPasswordForm?.addEventListener('submit', async e => {
// 3초 후 로그인 페이지로 이동
setTimeout(() => {
alert('비밀번호가 변경되어 다시 로그인해주세요.');
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/index.html';
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
window.location.href = '/login';
}, 2000);
} else {
alert('❌ 비밀번호 변경 실패: ' + (result.error || '현재 비밀번호가 올바르지 않습니다.'));

View File

@@ -33,7 +33,7 @@ function getKoreaToday() {
// 현재 로그인한 사용자 정보 가져오기
function getCurrentUser() {
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('sso_token');
if (!token) return null;
const payloadBase64 = token.split('.')[1];
@@ -47,7 +47,7 @@ function getCurrentUser() {
}
try {
const userInfo = localStorage.getItem('user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
const userInfo = localStorage.getItem('sso_user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
if (userInfo) {
const parsed = JSON.parse(userInfo);
console.log('localStorage에서 가져온 사용자 정보:', parsed);
@@ -929,10 +929,10 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('permission-check-message').style.display = 'block';
// 토큰 확인
const token = localStorage.getItem('token');
const token = localStorage.getItem('sso_token');
if (!token || token === 'undefined') {
showMessage('로그인이 필요합니다.', 'error');
localStorage.removeItem('token');
localStorage.removeItem('sso_token');
setTimeout(() => {
window.location.href = '/';
}, 2000);

View File

@@ -19,7 +19,7 @@ const accessLevelMap = {
async function loadProfile() {
try {
// 먼저 로컬 스토리지에서 기본 정보 표시
const storedUser = JSON.parse(localStorage.getItem('user') || '{}');
const storedUser = JSON.parse(localStorage.getItem('sso_user') || '{}');
if (storedUser) {
updateProfileUI(storedUser);
}
@@ -40,7 +40,7 @@ async function loadProfile() {
...storedUser,
...userData
};
localStorage.setItem('user', JSON.stringify(updatedUser));
localStorage.setItem('sso_user', JSON.stringify(updatedUser));
// UI 업데이트
updateProfileUI(userData);

View File

@@ -19,7 +19,7 @@ let basicData = {
// 현재 사용자 정보 가져오기
function getCurrentUser() {
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('sso_token');
if (!token) return null;
const payloadBase64 = token.split('.')[1];
@@ -747,7 +747,7 @@ window.saveEditedWork = saveEditedWork;
// 초기화
async function init() {
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('sso_token');
if (!token || token === 'undefined') {
showMessage('로그인이 필요합니다.', 'error');
setTimeout(() => {

View File

@@ -40,7 +40,7 @@ const AttendanceValidationPage = () => {
try {
const response = await fetch(`/api/workreports/date/${date}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('sso_token')}`
}
});
return await response.json();
@@ -54,7 +54,7 @@ const AttendanceValidationPage = () => {
try {
const response = await fetch(`/api/daily-work-reports/date/${date}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('sso_token')}`
}
});
return await response.json();
@@ -72,10 +72,10 @@ const AttendanceValidationPage = () => {
try {
const [workReports, dailyReports] = await Promise.all([
fetch(`/api/workreports?start=${start}&end=${end}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
headers: { 'Authorization': `Bearer ${localStorage.getItem('sso_token')}` }
}).then(res => res.json()),
fetch(`/api/daily-work-reports/search?start_date=${start}&end_date=${end}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
headers: { 'Authorization': `Bearer ${localStorage.getItem('sso_token')}` }
}).then(res => res.json())
]);

View File

@@ -623,7 +623,7 @@
// 토큰 확인 함수
function checkToken() {
const token = localStorage.getItem('token');
const token = localStorage.getItem('sso_token');
if (!token) {
showError('로그인이 필요합니다.');
setTimeout(() => {

View File

@@ -324,7 +324,7 @@
// 토큰 가져오기
function getToken() {
return localStorage.getItem('token') || sessionStorage.getItem('token');
return localStorage.getItem('sso_token') || sessionStorage.getItem('token');
}
// 로딩 상태 설정

View File

@@ -541,7 +541,7 @@
<script>
// 디버깅용 콘솔 로그
console.log('📊 그룹장 대시보드 로딩됨');
console.log('👤 현재 사용자:', JSON.parse(localStorage.getItem('user') || '{}'));
console.log('👤 현재 사용자:', JSON.parse(localStorage.getItem('sso_user') || '{}'));
// 팀 현황 새로고침
function refreshTeamStatus() {
@@ -551,7 +551,7 @@
// 환영 메시지 개인화
document.addEventListener('DOMContentLoaded', function() {
const user = JSON.parse(localStorage.getItem('user') || '{}');
const user = JSON.parse(localStorage.getItem('sso_user') || '{}');
if (user && user.name) {
const welcomeMsg = document.getElementById('welcome-message');
if (welcomeMsg) {