smart_ims/server/routes/system.js
choibk 8b2589b6fa feat: 플랫폼 보안 강화, 권한 계층 시스템 도입 및 버전 관리 통합 (v0.2.5)
- 최고관리자(Supervisor) 전용 2중 보안 잠금 시스템 및 인증 UI 적용
- 데이터베이스 인프라 및 암호화 마스터 키 자가 관리 기능 구축
- 권한 계층(Supervisor > Admin > User) 기반의 메뉴 노출 및 접근 제어 로직 강화
- 시스템 버전 정보 페이지 신규 추가 및 패키지 버전 자동 연동 (v0.2.5)
- 사용자 관리 UI 디자인 개선 및 폰트/스타일 일원화
2026-01-24 17:17:33 +09:00

428 lines
16 KiB
JavaScript

const express = require('express');
const router = express.Router();
const db = require('../db');
const path = require('path');
const fs = require('fs');
const { isAuthenticated, hasRole } = require('../middleware/authMiddleware');
const { generateLicense, verifyLicense } = require('../utils/licenseManager');
const { checkRemoteKey } = require('../utils/remoteLicense');
// Load Public Key for Verification
const publicKeyPath = path.join(__dirname, '../config/public_key.pem');
let publicKey = null;
try {
if (fs.existsSync(publicKeyPath)) {
publicKey = fs.readFileSync(publicKeyPath, 'utf8');
console.log('✅ Public Key loaded successfully for license verification');
} else {
console.error('❌ WARNING: public_key.pem not found at:', publicKeyPath);
}
} catch (e) {
console.error('❌ Error loading public key:', e);
}
// Helper to check if a setting key is allowed for general get/post
// This prevents modification of sensitive keys if any
const ALLOWED_SETTING_KEYS = [
'subscriber_id',
'session_timeout',
'encryption_key',
'asset_id_rule',
'asset_categories',
'asset_locations',
'asset_statuses',
'asset_maintenance_types'
];
// --- .env File Utilities ---
const envPath = path.join(__dirname, '../.env');
const readEnv = () => {
if (!fs.existsSync(envPath)) return {};
const content = fs.readFileSync(envPath, 'utf8');
const lines = content.split('\n');
const env = {};
lines.forEach(line => {
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
if (match) {
env[match[1]] = match[2] ? match[2].trim() : '';
}
});
return env;
};
const writeEnv = (updates) => {
let content = fs.readFileSync(envPath, 'utf8');
Object.entries(updates).forEach(([key, value]) => {
const regex = new RegExp(`^${key}=.*`, 'm');
if (regex.test(content)) {
content = content.replace(regex, `${key}=${value}`);
} else {
content += `\n${key}=${value}`;
}
});
fs.writeFileSync(envPath, content, 'utf8');
};
const mysql = require('mysql2/promise');
// 0. Server Configuration (Subscriber ID & Session Timeout)
router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
try {
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout', 'encryption_key')");
const settings = {};
rows.forEach(r => settings[r.setting_key] = r.setting_value);
// Include .env DB settings
const env = readEnv();
res.json({
subscriber_id: settings.subscriber_id || '',
session_timeout: parseInt(settings.session_timeout) || 60,
encryption_key: settings.encryption_key || '',
db_config: {
host: env.DB_HOST || '',
user: env.DB_USER || '',
password: env.DB_PASSWORD || '',
database: env.DB_NAME || '',
port: env.DB_PORT || '3306'
}
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
const { subscriber_id, session_timeout, encryption_key, db_config } = req.body;
try {
if (subscriber_id !== undefined) {
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('subscriber_id', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [subscriber_id]);
}
if (session_timeout !== undefined) {
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('session_timeout', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [session_timeout.toString()]);
}
if (encryption_key !== undefined) {
const encryptedKeyForDb = cryptoUtil.encryptMasterKey(encryption_key);
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]);
}
// Handle .env DB settings
if (db_config) {
writeEnv({
DB_HOST: db_config.host,
DB_USER: db_config.user,
DB_PASSWORD: db_config.password,
DB_NAME: db_config.database,
DB_PORT: db_config.port
});
}
res.json({ message: 'Settings saved. Server may restart to apply DB changes.' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// --- Crypto & Key Rotation ---
const cryptoUtil = require('../utils/cryptoUtil');
// 0.2 Test DB Connection
router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => {
const { host, user, password, database, port } = req.body;
let conn;
try {
conn = await mysql.createConnection({
host,
user,
password,
database,
port: parseInt(port) || 3306,
connectTimeout: 5000
});
await conn.query('SELECT 1');
res.json({ success: true, message: '연결 성공: 데이터베이스에 성공적으로 접속되었습니다.' });
} catch (err) {
res.status(400).json({ success: false, error: err.message });
} finally {
if (conn) await conn.end();
}
});
// 0.3 Encryption Key Management & Rotation
router.get('/encryption/status', isAuthenticated, hasRole('admin'), async (req, res) => {
try {
const [userRows] = await db.query('SELECT COUNT(*) as count FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"');
const currentKey = await cryptoUtil.getMasterKey();
res.json({
current_key: currentKey,
affected_records: {
users: userRows[0].count
}
});
} catch (err) {
res.status(500).json({ error: 'Failed to fetch encryption status' });
}
});
router.post('/encryption/rotate', isAuthenticated, hasRole('admin'), async (req, res) => {
const { new_key } = req.body;
if (!new_key) return res.status(400).json({ error: 'New key is required' });
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const oldKey = await cryptoUtil.getMasterKey();
// 1. Migrate Users Table (phone)
const [users] = await conn.query('SELECT id, phone FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"');
for (const user of users) {
const decrypted = await cryptoUtil.decrypt(user.phone, oldKey);
const reEncrypted = await cryptoUtil.encrypt(decrypted, new_key);
await conn.query('UPDATE users SET phone = ? WHERE id = ?', [reEncrypted, user.id]);
}
// 2. Update Master Key in settings (Encrypted for DB storage)
const encryptedKeyForDb = cryptoUtil.encryptMasterKey(new_key);
await conn.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]);
await conn.commit();
cryptoUtil.clearCache(); // Force immediate reload of new key
res.json({ success: true, message: 'Encryption key rotated and data migrated successfully.' });
} catch (err) {
await conn.rollback();
console.error('Rotation failed:', err);
res.status(500).json({ error: 'Key rotation failed: ' + err.message });
} finally {
conn.release();
}
});
// 0-1. Generic Setting Get/Set
router.get('/settings/:key', isAuthenticated, async (req, res) => {
const { key } = req.params;
if (!ALLOWED_SETTING_KEYS.includes(key)) {
return res.status(400).json({ error: 'Invalid setting key' });
}
try {
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = ?", [key]);
if (rows.length === 0) return res.json({ value: null });
res.json({ value: rows[0].setting_value });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
router.post('/settings/:key', isAuthenticated, hasRole('admin'), async (req, res) => {
const { key } = req.params;
const { value } = req.body;
if (!ALLOWED_SETTING_KEYS.includes(key)) {
return res.status(400).json({ error: 'Invalid setting key' });
}
try {
let stringValue = typeof value === 'string' ? value : JSON.stringify(value);
// Special handling for encryption_key to protect it in DB
if (key === 'encryption_key') {
stringValue = cryptoUtil.encryptMasterKey(stringValue);
}
await db.query(
`INSERT INTO system_settings (setting_key, setting_value)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`,
[key, stringValue]
);
res.json({ success: true, message: 'Setting saved' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// 1. Get All Modules Status
router.get('/modules', isAuthenticated, async (req, res) => {
try {
const [rows] = await db.query('SELECT * FROM system_modules');
const modules = {};
const defaults = ['asset', 'production', 'monitoring'];
// Get stored subscriber ID
const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
const serverSubscriberId = subRows.length > 0 ? subRows[0].setting_value : null;
defaults.forEach(code => {
const found = rows.find(r => r.code === code);
if (found) {
modules[code] = {
active: !!found.is_active,
type: found.license_type,
expiry: found.expiry_date,
subscriber_id: found.subscriber_id // Return who verified it
};
} else {
modules[code] = { active: false, type: null, expiry: null, subscriber_id: null };
}
});
res.json({ modules, serverSubscriberId });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
const axios = require('axios');
// 2. Activate Module (Apply License)
// Only admin can manage system modules
router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async (req, res) => {
const { code } = req.params;
let { licenseKey } = req.body;
if (!licenseKey) {
return res.status(400).json({ error: 'License key is required' });
}
licenseKey = licenseKey.trim();
// 1. Verify Key validity
const result = verifyLicense(licenseKey, publicKey);
if (!result.isValid) {
return res.status(400).json({ error: `Invalid License: ${result.reason}` });
}
// 2. Check Module match
if (result.module !== code) {
return res.status(400).json({ error: `This license is for '${result.module}' module, not '${code}'` });
}
// 3. Check Subscriber match
try {
const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
const serverSubscriberId = subRows.length > 0 ? subRows[0].setting_value : null;
if (!serverSubscriberId) {
return res.status(400).json({ error: '서버 구독자 ID가 설정되지 않았습니다. [서버 환경 설정]에서 먼저 설정해주세요.' });
}
// Allow 'dev' type to bypass strict subscriber check, but log it
if (result.type === 'dev') {
if (result.subscriberId !== serverSubscriberId) {
console.warn(`⚠️ Dev License used: Subscriber ID mismatch allowed. Key: ${result.subscriberId}, Server: ${serverSubscriberId}`);
}
} else {
if (result.subscriberId !== serverSubscriberId) {
return res.status(403).json({
error: `구독자 ID 불일치: 라이선스 키는 [${result.subscriberId}] 전용이지만, 현재 서버는 [${serverSubscriberId}]로 설정되어 있습니다.`
});
}
}
// 4. Archive Current License if exists
const [current] = await db.query('SELECT * FROM system_modules WHERE code = ?', [code]);
if (current.length > 0 && current[0].is_active && current[0].license_key) {
const old = current[0];
const historySql = `
INSERT INTO license_history (module_code, license_key, license_type, subscriber_id, activated_at)
VALUES (?, ?, ?, ?, NOW())
`;
await db.query(historySql, [old.code, old.license_key, old.license_type, old.subscriber_id]);
}
// Upsert into system_modules
const sql = `
INSERT INTO system_modules (code, name, is_active, license_key, license_type, expiry_date, subscriber_id)
VALUES (?, ?, true, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
is_active = true,
license_key = VALUES(license_key),
license_type = VALUES(license_type),
expiry_date = VALUES(expiry_date),
subscriber_id = VALUES(subscriber_id)
`;
// Map codes to names
const names = {
'asset': '자산 관리',
'production': '생산 관리',
'monitoring': 'CCTV'
};
await db.query(sql, [code, names[code] || code, licenseKey, result.type, result.expiryDate, result.subscriberId]);
// 5. Sync status with License Manager
const licenseManagerUrl = process.env.LICENSE_MANAGER_URL || 'http://localhost:3006/api';
try {
await axios.post(`${licenseManagerUrl}/licenses/activate`, { licenseKey });
console.log(`✅ Synced activation status for key: ${licenseKey.substring(0, 20)}...`);
} catch (syncErr) {
const errorDetail = syncErr.response ?
`Status: ${syncErr.response.status}, Data: ${JSON.stringify(syncErr.response.data)}` :
syncErr.message;
console.error(`⚠️ Failed to sync status with License Manager: ${errorDetail}`);
// We don't fail the whole activation if sync fails, but we log it
}
res.json({ success: true, message: 'Module activated', type: result.type, expiry: result.expiryDate });
} catch (err) {
console.error('❌ License Activation Error:', err);
res.status(500).json({
success: false,
error: err.message || 'Database error',
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
});
}
});
// 3. Get Module History
router.get('/modules/:code/history', isAuthenticated, hasRole('admin'), async (req, res) => {
try {
const [rows] = await db.query('SELECT * FROM license_history WHERE module_code = ? ORDER BY id DESC LIMIT 10', [req.params.code]);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// 4. Deactivate Module
router.post('/modules/:code/deactivate', isAuthenticated, hasRole('admin'), async (req, res) => {
const { code } = req.params;
try {
// Archive on deactivate too?
const [current] = await db.query('SELECT * FROM system_modules WHERE code = ?', [code]);
if (current.length > 0 && current[0].is_active) {
const old = current[0];
const historySql = `
INSERT INTO license_history (module_code, license_key, license_type, subscriber_id, activated_at)
VALUES (?, ?, ?, ?, NOW())
`;
await db.query(historySql, [old.code, old.license_key, old.license_type, old.subscriber_id]);
}
await db.query('UPDATE system_modules SET is_active = false WHERE code = ?', [code]);
res.json({ success: true, message: 'Module deactivated' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
module.exports = router;