security: 보안 강제 시스템 구축 + 하드코딩 비밀번호 제거
보안 감사 결과 CRITICAL 2건, HIGH 5건 발견 → 수정 완료 + 자동화 구축. [보안 수정] - issue-view.js: 하드코딩 비밀번호 → crypto.getRandomValues() 랜덤 생성 - pushSubscriptionController.js: ntfy 비밀번호 → process.env.NTFY_SUB_PASSWORD - DEPLOY-GUIDE.md/PROGRESS.md/migration SQL: 평문 비밀번호 → placeholder - docker-compose.yml/.env.example: NTFY_SUB_PASSWORD 환경변수 추가 [보안 강제 시스템 - 신규] - scripts/security-scan.sh: 8개 규칙 (CRITICAL 2, HIGH 4, MEDIUM 2) 3모드(staged/all/diff), severity, .securityignore, MEDIUM 임계값 - .githooks/pre-commit: 로컬 빠른 피드백 - .githooks/pre-receive-server.sh: Gitea 서버 최종 차단 bypass 거버넌스([SECURITY-BYPASS: 사유] + 사용자 제한 + 로그) - SECURITY-CHECKLIST.md: 10개 카테고리 자동/수동 구분 - docs/SECURITY-GUIDE.md: 운영자 가이드 (워크플로우, bypass, FAQ) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
464
system3-nonconformance/web/public/static/js/api.js
Normal file
464
system3-nonconformance/web/public/static/js/api.js
Normal file
@@ -0,0 +1,464 @@
|
||||
// SSO 쿠키 헬퍼
|
||||
function _cookieGet(name) {
|
||||
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
function _cookieRemove(name) {
|
||||
let cookie = name + '=; path=/; max-age=0';
|
||||
if (window.location.hostname.includes('technicalkorea.net')) {
|
||||
cookie += '; domain=.technicalkorea.net';
|
||||
}
|
||||
document.cookie = cookie;
|
||||
}
|
||||
|
||||
// 중앙 로그인 URL (캐시 버스팅 포함)
|
||||
function _getLoginUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
const t = Date.now();
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
return window.location.protocol + '//tkfb.technicalkorea.net/dashboard?redirect=' + encodeURIComponent(window.location.href) + '&_t=' + t;
|
||||
}
|
||||
return window.location.protocol + '//' + hostname + ':30000/dashboard?redirect=' + encodeURIComponent(window.location.href) + '&_t=' + t;
|
||||
}
|
||||
|
||||
// API 기본 설정 (통합 환경 지원)
|
||||
const API_BASE_URL = (() => {
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
const port = window.location.port;
|
||||
|
||||
// 프로덕션 (technicalkorea.net) - 같은 도메인 /api
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
return protocol + '//' + hostname + '/api';
|
||||
}
|
||||
|
||||
// 통합 개발 환경 (포트 30280)
|
||||
if (port === '30280' || port === '30000') {
|
||||
return protocol + '//' + hostname + ':30200/api';
|
||||
}
|
||||
|
||||
// 기존 TKQC 로컬 환경 (포트 16080)
|
||||
if (port === '16080') {
|
||||
return protocol + '//' + hostname + ':16080/api';
|
||||
}
|
||||
|
||||
// 통합 Docker 환경에서 직접 접근 (포트 30280)
|
||||
if (port === '30280') {
|
||||
return protocol + '//' + hostname + ':30200/api';
|
||||
}
|
||||
|
||||
// 기타 환경
|
||||
return '/api';
|
||||
})();
|
||||
|
||||
// 토큰 관리 (SSO 쿠키 + localStorage 이중 지원)
|
||||
const TokenManager = {
|
||||
getToken: () => {
|
||||
// SSO 쿠키 우선, localStorage 폴백
|
||||
return _cookieGet('sso_token') || localStorage.getItem('sso_token');
|
||||
},
|
||||
setToken: (token) => localStorage.setItem('sso_token', token),
|
||||
removeToken: () => {
|
||||
_cookieRemove('sso_token');
|
||||
_cookieRemove('sso_user');
|
||||
_cookieRemove('sso_refresh_token');
|
||||
localStorage.removeItem('sso_token');
|
||||
localStorage.removeItem('sso_user');
|
||||
},
|
||||
|
||||
getUser: () => {
|
||||
const ssoUser = _cookieGet('sso_user') || localStorage.getItem('sso_user');
|
||||
if (ssoUser) {
|
||||
try { return JSON.parse(ssoUser); } catch(e) {}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
setUser: (user) => localStorage.setItem('sso_user', JSON.stringify(user)),
|
||||
removeUser: () => {
|
||||
localStorage.removeItem('sso_user');
|
||||
}
|
||||
};
|
||||
|
||||
// 전역 노출 (permissions.js 등 다른 스크립트에서 접근)
|
||||
window.TokenManager = TokenManager;
|
||||
window.API_BASE_URL = API_BASE_URL;
|
||||
|
||||
// API 요청 헬퍼
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const token = TokenManager.getToken();
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const config = {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, config);
|
||||
|
||||
if (response.status === 401) {
|
||||
// 인증 실패 — 토큰만 정리하고 에러 throw (리다이렉트는 auth-manager가 처리)
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
throw new Error('인증이 만료되었습니다.');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
console.error('API Error Response:', error);
|
||||
console.error('Error details:', JSON.stringify(error, null, 2));
|
||||
|
||||
// 422 에러의 경우 validation 에러 메시지 추출
|
||||
if (response.status === 422 && error.detail && Array.isArray(error.detail)) {
|
||||
const validationErrors = error.detail.map(err =>
|
||||
`${err.loc ? err.loc.join('.') : 'field'}: ${err.msg}`
|
||||
).join(', ');
|
||||
throw new Error(`입력값 검증 오류: ${validationErrors}`);
|
||||
}
|
||||
|
||||
throw new Error(error.detail || 'API 요청 실패');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API 요청 에러:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Auth API
|
||||
const AuthAPI = {
|
||||
login: async (username, password) => {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: formData.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
console.error('로그인 에러:', error);
|
||||
throw new Error(error.detail || '로그인 실패');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
TokenManager.setToken(data.access_token);
|
||||
TokenManager.setUser(data.user);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('로그인 요청 에러:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
window.location.href = _getLoginUrl();
|
||||
},
|
||||
|
||||
getMe: () => apiRequest('/auth/me'),
|
||||
|
||||
getCurrentUser: () => apiRequest('/auth/me'),
|
||||
|
||||
getUsers: () => {
|
||||
return apiRequest('/auth/users');
|
||||
},
|
||||
|
||||
changePassword: (currentPassword, newPassword) => apiRequest('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
}),
|
||||
|
||||
// 부서 목록 가져오기
|
||||
getDepartments: () => [
|
||||
{ value: 'production', label: '생산' },
|
||||
{ value: 'quality', label: '품질' },
|
||||
{ value: 'purchasing', label: '구매' },
|
||||
{ value: 'design', label: '설계' },
|
||||
{ value: 'sales', label: '영업' }
|
||||
],
|
||||
|
||||
// 부서명 변환
|
||||
getDepartmentLabel: (departmentValue) => {
|
||||
const departments = AuthAPI.getDepartments();
|
||||
const dept = departments.find(d => d.value === departmentValue);
|
||||
return dept ? dept.label : departmentValue || '미지정';
|
||||
}
|
||||
};
|
||||
|
||||
// Issues API
|
||||
const IssuesAPI = {
|
||||
create: async (issueData) => {
|
||||
// photos 배열 처리 (최대 5장)
|
||||
const dataToSend = {
|
||||
category: issueData.category,
|
||||
description: issueData.description,
|
||||
project_id: issueData.project_id,
|
||||
photo: issueData.photos && issueData.photos.length > 0 ? issueData.photos[0] : null,
|
||||
photo2: issueData.photos && issueData.photos.length > 1 ? issueData.photos[1] : null,
|
||||
photo3: issueData.photos && issueData.photos.length > 2 ? issueData.photos[2] : null,
|
||||
photo4: issueData.photos && issueData.photos.length > 3 ? issueData.photos[3] : null,
|
||||
photo5: issueData.photos && issueData.photos.length > 4 ? issueData.photos[4] : null
|
||||
};
|
||||
|
||||
return apiRequest('/issues/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dataToSend)
|
||||
});
|
||||
},
|
||||
|
||||
getAll: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
return apiRequest(`/issues/${queryString ? '?' + queryString : ''}`);
|
||||
},
|
||||
|
||||
get: (id) => apiRequest(`/issues/${id}`),
|
||||
|
||||
update: (id, issueData) => apiRequest(`/issues/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(issueData)
|
||||
}),
|
||||
|
||||
delete: (id) => apiRequest(`/issues/${id}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
|
||||
getStats: () => apiRequest('/issues/stats/summary')
|
||||
};
|
||||
|
||||
// Reports API
|
||||
const ReportsAPI = {
|
||||
getSummary: (startDate, endDate) => apiRequest('/reports/summary', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
})
|
||||
}),
|
||||
|
||||
getIssues: (startDate, endDate) => {
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
}).toString();
|
||||
return apiRequest(`/reports/issues?${params}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 권한 체크 — authManager.checkAuth()로 통일 권장
|
||||
// 레거시 호환용으로 유지 (localStorage만 체크, API 호출 없음)
|
||||
function checkAuth() {
|
||||
const user = TokenManager.getUser();
|
||||
if (!user) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
function checkPageAccess(pageName) {
|
||||
const user = checkAuth();
|
||||
if (!user) return null;
|
||||
if (user.role === 'admin') return user;
|
||||
if (window.pagePermissionManager && !window.pagePermissionManager.canAccessPage(pageName)) {
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// AI API
|
||||
const AiAPI = {
|
||||
getSimilarIssues: async (issueId, limit = 5) => {
|
||||
try {
|
||||
const res = await fetch(`/ai-api/similar/${issueId}?n_results=${limit}`, {
|
||||
headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` }
|
||||
});
|
||||
if (!res.ok) return { available: false, results: [] };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 유사 검색 실패:', e);
|
||||
return { available: false, results: [] };
|
||||
}
|
||||
},
|
||||
searchSimilar: async (query, limit = 5, filters = {}) => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/similar/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ query, n_results: limit, ...filters })
|
||||
});
|
||||
if (!res.ok) return { available: false, results: [] };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 검색 실패:', e);
|
||||
return { available: false, results: [] };
|
||||
}
|
||||
},
|
||||
classifyIssue: async (description, detailNotes = '') => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/classify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ description, detail_notes: detailNotes })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 분류 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
generateDailyReport: async (date, projectId) => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/report/daily', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ date, project_id: projectId })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 보고서 생성 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
syncSingleIssue: async (issueId) => {
|
||||
try {
|
||||
await fetch('/ai-api/embeddings/sync-single', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ issue_id: issueId })
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('AI 임베딩 동기화 실패 (무시):', e.message);
|
||||
}
|
||||
},
|
||||
syncEmbeddings: async () => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/embeddings/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` }
|
||||
});
|
||||
if (!res.ok) return { status: 'error' };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
return { status: 'error' };
|
||||
}
|
||||
},
|
||||
checkHealth: async () => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/health');
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
return { status: 'disconnected' };
|
||||
}
|
||||
},
|
||||
// RAG: 해결방안 제안
|
||||
suggestSolution: async (issueId) => {
|
||||
try {
|
||||
const res = await fetch(`/ai-api/rag/suggest-solution/${issueId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` }
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 해결방안 제안 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
// RAG: 자연어 질의
|
||||
askQuestion: async (question, projectId = null) => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/rag/ask', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ question, project_id: projectId })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 질의 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
// RAG: 패턴 분석
|
||||
analyzePattern: async (description) => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/rag/pattern', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ description })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 패턴 분석 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
// RAG: 강화 분류 (과거 사례 참고)
|
||||
classifyWithRAG: async (description, detailNotes = '') => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/rag/classify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ description, detail_notes: detailNotes })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI RAG 분류 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 프로젝트 API
|
||||
const ProjectsAPI = {
|
||||
getAll: (activeOnly = false) => {
|
||||
const params = `?active_only=${activeOnly}`;
|
||||
return apiRequest(`/projects/${params}`);
|
||||
},
|
||||
|
||||
get: (id) => apiRequest(`/projects/${id}`)
|
||||
};
|
||||
Reference in New Issue
Block a user