- better-sqlite3 제거, wingDb.ts (PostgreSQL wing DB Pool) 추가 - layers 라우터: 동기(better-sqlite3) → 비동기(pg) 전환 - LAYER 테이블 마이그레이션 SQL 생성 (database/migration/001_layer_table.sql) - seed 스크립트 PostgreSQL 전환 - 문서 업데이트: CLAUDE.md, README.md, docs/README.md, COMMON-GUIDE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
191 lines
6.2 KiB
TypeScript
Executable File
191 lines
6.2 KiB
TypeScript
Executable File
import 'dotenv/config'
|
|
import express from 'express'
|
|
import cors from 'cors'
|
|
import helmet from 'helmet'
|
|
import rateLimit from 'express-rate-limit'
|
|
import cookieParser from 'cookie-parser'
|
|
import { testAuthDbConnection } from './db/authDb.js'
|
|
import { testWingDbConnection } from './db/wingDb.js'
|
|
import layersRouter from './routes/layers.js'
|
|
import simulationRouter from './routes/simulation.js'
|
|
import authRouter from './auth/authRouter.js'
|
|
import userRouter from './users/userRouter.js'
|
|
import roleRouter from './roles/roleRouter.js'
|
|
import settingsRouter from './settings/settingsRouter.js'
|
|
import menuRouter from './menus/menuRouter.js'
|
|
import auditRouter from './audit/auditRouter.js'
|
|
import {
|
|
sanitizeBody,
|
|
sanitizeQuery,
|
|
removeServerInfo,
|
|
BODY_SIZE_LIMIT
|
|
} from './middleware/security.js'
|
|
|
|
const app = express()
|
|
const PORT = process.env.PORT || 3001
|
|
|
|
// ============================================================
|
|
// 보안 미들웨어
|
|
// ============================================================
|
|
|
|
// 1. Helmet: HTTP 보안 헤더 설정
|
|
// - X-Content-Type-Options: nosniff (MIME 스니핑 방지)
|
|
// - X-Frame-Options: DENY (클릭재킹 방지)
|
|
// - X-XSS-Protection: 1 (브라우저 XSS 필터)
|
|
// - Strict-Transport-Security (HTTPS 강제)
|
|
// - Content-Security-Policy (컨텐츠 보안 정책)
|
|
app.use(helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
imgSrc: ["'self'", "data:", "blob:"],
|
|
connectSrc: ["'self'", "http://localhost:*", "https://*.gc-si.dev", "https://*.data.go.kr", "https://*.khoa.go.kr"],
|
|
fontSrc: ["'self'"],
|
|
objectSrc: ["'none'"],
|
|
frameSrc: ["'none'"],
|
|
}
|
|
},
|
|
crossOriginEmbedderPolicy: false, // API 서버이므로 비활성
|
|
}))
|
|
|
|
// 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지)
|
|
app.use(removeServerInfo)
|
|
app.disable('x-powered-by')
|
|
|
|
// 3. CORS: 허용된 출처만 접근 가능
|
|
const allowedOrigins = [
|
|
'http://localhost:5173', // Vite dev server
|
|
'http://localhost:5174',
|
|
'http://localhost:3000',
|
|
'https://wing-demo.gc-si.dev',
|
|
process.env.FRONTEND_URL, // 운영 환경 프론트엔드 URL (추가 도메인)
|
|
].filter(Boolean) as string[]
|
|
|
|
app.use(cors({
|
|
origin: (origin, callback) => {
|
|
// 서버-to-서버 요청 (origin 없음) 또는 허용된 출처
|
|
if (!origin || allowedOrigins.includes(origin)) {
|
|
callback(null, true)
|
|
} else {
|
|
callback(new Error('CORS 정책에 의해 차단되었습니다.'))
|
|
}
|
|
},
|
|
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
credentials: true,
|
|
maxAge: 86400, // preflight 캐시 24시간
|
|
}))
|
|
|
|
// 4. 요청 속도 제한 (Rate Limiting) - DDoS/브루트포스 방지
|
|
const generalLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15분
|
|
max: 200, // IP당 최대 200요청
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: {
|
|
error: '요청 횟수 초과',
|
|
message: '너무 많은 요청을 보냈습니다. 15분 후 다시 시도하세요.'
|
|
}
|
|
})
|
|
|
|
const simulationLimiter = rateLimit({
|
|
windowMs: 60 * 1000, // 1분
|
|
max: 10, // IP당 최대 10요청 (시뮬레이션은 비용이 큰 작업)
|
|
message: {
|
|
error: '시뮬레이션 요청 횟수 초과',
|
|
message: '시뮬레이션 요청은 1분당 10회로 제한됩니다.'
|
|
}
|
|
})
|
|
|
|
app.use(generalLimiter)
|
|
|
|
// 5. 쿠키 파서 (JWT 인증 쿠키 처리)
|
|
app.use(cookieParser())
|
|
|
|
// 6. JSON 본문 파서 (크기 제한 적용)
|
|
app.use(express.json({ limit: BODY_SIZE_LIMIT }))
|
|
app.use(express.text({ limit: BODY_SIZE_LIMIT }))
|
|
app.use(express.urlencoded({ extended: false, limit: BODY_SIZE_LIMIT }))
|
|
|
|
// 7. 입력값 살균 미들웨어
|
|
app.use(sanitizeBody)
|
|
app.use(sanitizeQuery)
|
|
|
|
// ============================================================
|
|
// 라우트
|
|
// ============================================================
|
|
|
|
// 루트 경로
|
|
app.get('/', (_req, res) => {
|
|
res.json({
|
|
name: 'WING Backend API',
|
|
version: '1.0.0',
|
|
status: 'running',
|
|
})
|
|
})
|
|
|
|
// API 라우트 — 인증
|
|
app.use('/api/auth', authRouter)
|
|
app.use('/api/users', userRouter)
|
|
app.use('/api/roles', roleRouter)
|
|
app.use('/api/settings', settingsRouter)
|
|
app.use('/api/menus', menuRouter)
|
|
app.use('/api/audit', auditRouter)
|
|
|
|
// API 라우트 — 업무
|
|
app.use('/api/layers', layersRouter)
|
|
app.use('/api/simulation', simulationLimiter, simulationRouter)
|
|
|
|
// 헬스 체크
|
|
app.get('/health', (_req, res) => {
|
|
res.json({ status: 'ok' })
|
|
})
|
|
|
|
// ============================================================
|
|
// 404 처리
|
|
// ============================================================
|
|
app.use((_req, res) => {
|
|
res.status(404).json({ error: '요청한 리소스를 찾을 수 없습니다.' })
|
|
})
|
|
|
|
// ============================================================
|
|
// 전역 에러 핸들러 (내부 오류 메시지 노출 방지)
|
|
// ============================================================
|
|
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
// 운영 환경에서는 내부 오류 메시지를 노출하지 않음
|
|
const isDev = process.env.NODE_ENV !== 'production'
|
|
if (isDev) {
|
|
console.error('서버 오류:', err.message)
|
|
}
|
|
|
|
res.status(500).json({
|
|
error: '서버 내부 오류가 발생했습니다.',
|
|
...(isDev && { detail: err.message })
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// 서버 시작
|
|
// ============================================================
|
|
app.listen(PORT, async () => {
|
|
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
|
|
|
|
// wing DB (운영 데이터) 연결 확인
|
|
await testWingDbConnection()
|
|
|
|
// wing_auth DB (인증 데이터) 연결 확인
|
|
const connected = await testAuthDbConnection()
|
|
if (connected) {
|
|
// SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응)
|
|
try {
|
|
const { authPool } = await import('./db/authDb.js')
|
|
await authPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`)
|
|
console.log('[migration] SETTING_VAL → TEXT 변환 완료')
|
|
} catch {
|
|
// 이미 TEXT이거나 권한 없으면 무시
|
|
}
|
|
}
|
|
})
|