feat: 3-System 분리 프로젝트 초기 코드 작성

TK-FB(공장관리+신고)와 M-Project(부적합관리)를 3개 독립 시스템으로
분리하기 위한 전체 코드 구조 작성.
- SSO 인증 서비스 (bcrypt + pbkdf2 이중 해시 지원)
- System 1: 공장관리 (TK-FB 기반, 신고 코드 제거)
- System 2: 신고 (TK-FB에서 workIssue 코드 추출)
- System 3: 부적합관리 (M-Project 기반)
- Gateway 포털 (path-based 라우팅)
- 통합 docker-compose.yml 및 배포 스크립트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-09 14:40:11 +09:00
commit 550633b89d
824 changed files with 1071683 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN chown -R node:node /usr/src/app
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })"
CMD ["node", "index.js"]

View File

@@ -0,0 +1,358 @@
/**
* SSO Auth Controller
*
* 로그인, 토큰 검증/갱신, 사용자 CRUD
*/
const jwt = require('jsonwebtoken');
const userModel = require('../models/userModel');
const JWT_SECRET = process.env.SSO_JWT_SECRET;
const JWT_EXPIRES_IN = process.env.SSO_JWT_EXPIRES_IN || '7d';
const JWT_REFRESH_SECRET = process.env.SSO_JWT_REFRESH_SECRET;
const JWT_REFRESH_EXPIRES_IN = process.env.SSO_JWT_REFRESH_EXPIRES_IN || '30d';
/**
* JWT 토큰 페이로드 생성 (모든 시스템 공통 구조)
*/
function createTokenPayload(user) {
return {
user_id: user.user_id,
id: user.user_id,
username: user.username,
name: user.name,
department: user.department,
role: user.role,
access_level: user.role,
sub: user.username,
system_access: {
system1: user.system1_access,
system2: user.system2_access,
system3: user.system3_access
}
};
}
/**
* POST /api/auth/login
*/
async function login(req, res, next) {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, error: '사용자명과 비밀번호를 입력하세요' });
}
const user = await userModel.findByUsername(username);
if (!user) {
return res.status(401).json({ success: false, error: '사용자명 또는 비밀번호가 올바르지 않습니다' });
}
const valid = await userModel.verifyPassword(password, user.password_hash);
if (!valid) {
return res.status(401).json({ success: false, error: '사용자명 또는 비밀번호가 올바르지 않습니다' });
}
await userModel.updateLastLogin(user.user_id);
const payload = createTokenPayload(user);
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
const refresh_token = jwt.sign(
{ user_id: user.user_id, type: 'refresh' },
JWT_REFRESH_SECRET,
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
);
// SSO 쿠키 설정 (동일 도메인 공유)
res.cookie('sso_token', access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/'
});
res.json({
success: true,
access_token,
refresh_token,
token_type: 'bearer',
user: {
user_id: user.user_id,
username: user.username,
name: user.name,
department: user.department,
role: user.role,
system_access: payload.system_access
}
});
} catch (err) {
next(err);
}
}
/**
* POST /api/auth/login/form - OAuth2 form data login (M-Project 호환)
*/
async function loginForm(req, res, next) {
try {
const username = req.body.username;
const password = req.body.password;
if (!username || !password) {
return res.status(400).json({ detail: 'Missing username or password' });
}
const user = await userModel.findByUsername(username);
if (!user) {
return res.status(401).json({ detail: 'Incorrect username or password' });
}
const valid = await userModel.verifyPassword(password, user.password_hash);
if (!valid) {
return res.status(401).json({ detail: 'Incorrect username or password' });
}
await userModel.updateLastLogin(user.user_id);
const payload = createTokenPayload(user);
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
res.json({
access_token,
token_type: 'bearer',
user: {
id: user.user_id,
username: user.username,
full_name: user.name,
role: user.role,
department: user.department
}
});
} catch (err) {
next(err);
}
}
/**
* GET /api/auth/validate
*/
async function validate(req, res, next) {
try {
const token = extractToken(req);
if (!token) {
return res.status(401).json({ success: false, error: '토큰이 필요합니다' });
}
const decoded = jwt.verify(token, JWT_SECRET);
const user = await userModel.findById(decoded.user_id || decoded.id);
if (!user || !user.is_active) {
return res.status(401).json({ success: false, error: '유효하지 않은 사용자입니다' });
}
res.json({
success: true,
user: {
user_id: user.user_id,
username: user.username,
name: user.name,
department: user.department,
role: user.role,
system_access: {
system1: user.system1_access,
system2: user.system2_access,
system3: user.system3_access
}
}
});
} catch (err) {
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
return res.status(401).json({ success: false, error: '유효하지 않은 토큰입니다' });
}
next(err);
}
}
/**
* GET /api/auth/me - 현재 사용자 정보 (M-Project 호환)
*/
async function me(req, res, next) {
try {
const token = extractToken(req);
if (!token) {
return res.status(401).json({ detail: 'Not authenticated' });
}
const decoded = jwt.verify(token, JWT_SECRET);
const user = await userModel.findById(decoded.user_id || decoded.id);
if (!user || !user.is_active) {
return res.status(401).json({ detail: 'User not found or inactive' });
}
res.json({
id: user.user_id,
username: user.username,
full_name: user.name,
role: user.role,
department: user.department,
is_active: user.is_active
});
} catch (err) {
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
return res.status(401).json({ detail: 'Could not validate credentials' });
}
next(err);
}
}
/**
* POST /api/auth/refresh
*/
async function refresh(req, res, next) {
try {
const { refresh_token } = req.body;
if (!refresh_token) {
return res.status(400).json({ success: false, error: 'Refresh 토큰이 필요합니다' });
}
const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET);
if (decoded.type !== 'refresh') {
return res.status(401).json({ success: false, error: '유효하지 않은 Refresh 토큰입니다' });
}
const user = await userModel.findById(decoded.user_id);
if (!user || !user.is_active) {
return res.status(401).json({ success: false, error: '유효하지 않은 사용자입니다' });
}
const payload = createTokenPayload(user);
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
const new_refresh_token = jwt.sign(
{ user_id: user.user_id, type: 'refresh' },
JWT_REFRESH_SECRET,
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
);
res.cookie('sso_token', access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/'
});
res.json({
success: true,
access_token,
refresh_token: new_refresh_token,
token_type: 'bearer'
});
} catch (err) {
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
return res.status(401).json({ success: false, error: '유효하지 않은 Refresh 토큰입니다' });
}
next(err);
}
}
/**
* POST /api/auth/logout
*/
async function logout(req, res) {
res.clearCookie('sso_token', { path: '/' });
res.json({ success: true, message: '로그아웃 되었습니다' });
}
/**
* GET /api/auth/users - 모든 사용자 목록
*/
async function getUsers(req, res, next) {
try {
const users = await userModel.findAll();
res.json({ success: true, data: users });
} catch (err) {
next(err);
}
}
/**
* POST /api/auth/users - 사용자 생성
*/
async function createUser(req, res, next) {
try {
const { username, password, name, department, role } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, error: '사용자명과 비밀번호는 필수입니다' });
}
const existing = await userModel.findByUsername(username);
if (existing) {
return res.status(409).json({ success: false, error: '이미 존재하는 사용자명입니다' });
}
const user = await userModel.create({ username, password, name, department, role });
res.status(201).json({ success: true, data: user });
} catch (err) {
next(err);
}
}
/**
* PUT /api/auth/users/:id - 사용자 수정
*/
async function updateUser(req, res, next) {
try {
const userId = parseInt(req.params.id);
const user = await userModel.update(userId, req.body);
if (!user) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
}
res.json({ success: true, data: user });
} catch (err) {
next(err);
}
}
/**
* DELETE /api/auth/users/:id - 사용자 비활성화
*/
async function deleteUser(req, res, next) {
try {
const userId = parseInt(req.params.id);
await userModel.deleteUser(userId);
res.json({ success: true, message: '사용자가 비활성화되었습니다' });
} catch (err) {
next(err);
}
}
/**
* Bearer 토큰 또는 쿠키에서 토큰 추출
*/
function extractToken(req) {
// Authorization header
const authHeader = req.headers['authorization'];
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.split(' ')[1];
}
// Cookie
if (req.cookies && req.cookies.sso_token) {
return req.cookies.sso_token;
}
return null;
}
module.exports = {
login,
loginForm,
validate,
me,
refresh,
logout,
getUsers,
createUser,
updateUser,
deleteUser
};

49
sso-auth-service/index.js Normal file
View File

@@ -0,0 +1,49 @@
/**
* SSO Auth Service - 중앙 인증 서비스
*
* TK Factory Services의 통합 인증을 담당
* - JWT 발급/검증/갱신
* - 사용자 CRUD
* - bcrypt + pbkdf2 비밀번호 호환
*/
const express = require('express');
const cors = require('cors');
const authRoutes = require('./routes/authRoutes');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors({
origin: true,
credentials: true
}));
app.use(express.json());
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'sso-auth', timestamp: new Date().toISOString() });
});
// Auth routes
app.use('/api/auth', authRoutes);
// 404
app.use((req, res) => {
res.status(404).json({ success: false, error: 'Not Found' });
});
// Error handler
app.use((err, req, res, next) => {
console.error('SSO Auth Error:', err.message);
res.status(err.status || 500).json({
success: false,
error: err.message || 'Internal Server Error'
});
});
app.listen(PORT, () => {
console.log(`SSO Auth Service running on port ${PORT}`);
});
module.exports = app;

View File

@@ -0,0 +1,172 @@
/**
* SSO User Model
*
* sso_users 테이블 CRUD 및 비밀번호 검증
* bcrypt + pbkdf2_sha256 둘 다 지원
*/
const mysql = require('mysql2/promise');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
let pool;
function getPool() {
if (!pool) {
pool = mysql.createPool({
host: process.env.DB_HOST || 'mariadb',
port: parseInt(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'hyungi_user',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'hyungi',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
}
return pool;
}
/**
* pbkdf2_sha256 해시 검증 (M-Project/passlib 호환)
* passlib 형식: $pbkdf2-sha256$rounds$salt$hash
*/
function verifyPbkdf2(password, storedHash) {
try {
const parts = storedHash.split('$');
if (parts.length < 5) return false;
const rounds = parseInt(parts[2]);
// passlib uses adapted base64 (. instead of +, no padding)
const salt = parts[3].replace(/\./g, '+');
const hash = parts[4].replace(/\./g, '+');
// Pad base64 if needed
const padded = (s) => s + '='.repeat((4 - s.length % 4) % 4);
const saltBuffer = Buffer.from(padded(salt), 'base64');
const expectedHash = Buffer.from(padded(hash), 'base64');
const derivedKey = crypto.pbkdf2Sync(password, saltBuffer, rounds, expectedHash.length, 'sha256');
return crypto.timingSafeEqual(derivedKey, expectedHash);
} catch (err) {
console.error('pbkdf2 verify error:', err.message);
return false;
}
}
/**
* 비밀번호 검증 (bcrypt 또는 pbkdf2_sha256 자동 감지)
*/
async function verifyPassword(password, storedHash) {
if (!password || !storedHash) return false;
// pbkdf2-sha256 형식 (passlib)
if (storedHash.startsWith('$pbkdf2-sha256$')) {
return verifyPbkdf2(password, storedHash);
}
// bcrypt 형식
if (storedHash.startsWith('$2b$') || storedHash.startsWith('$2a$')) {
return bcrypt.compare(password, storedHash);
}
return false;
}
/**
* bcrypt로 비밀번호 해시 생성 (새 비밀번호는 항상 bcrypt)
*/
async function hashPassword(password) {
return bcrypt.hash(password, 10);
}
async function findByUsername(username) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM sso_users WHERE username = ? AND is_active = TRUE',
[username]
);
return rows[0] || null;
}
async function findById(userId) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM sso_users WHERE user_id = ?',
[userId]
);
return rows[0] || null;
}
async function findAll() {
const db = getPool();
const [rows] = await db.query(
'SELECT user_id, username, name, department, role, system1_access, system2_access, system3_access, is_active, last_login, created_at FROM sso_users ORDER BY user_id'
);
return rows;
}
async function create({ username, password, name, department, role }) {
const db = getPool();
const password_hash = await hashPassword(password);
const [result] = await db.query(
`INSERT INTO sso_users (username, password_hash, name, department, role)
VALUES (?, ?, ?, ?, ?)`,
[username, password_hash, name || null, department || null, role || 'user']
);
return findById(result.insertId);
}
async function update(userId, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
if (data.department !== undefined) { fields.push('department = ?'); values.push(data.department); }
if (data.role !== undefined) { fields.push('role = ?'); values.push(data.role); }
if (data.system1_access !== undefined) { fields.push('system1_access = ?'); values.push(data.system1_access); }
if (data.system2_access !== undefined) { fields.push('system2_access = ?'); values.push(data.system2_access); }
if (data.system3_access !== undefined) { fields.push('system3_access = ?'); values.push(data.system3_access); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
if (data.password) {
fields.push('password_hash = ?');
values.push(await hashPassword(data.password));
}
if (fields.length === 0) return findById(userId);
values.push(userId);
await db.query(
`UPDATE sso_users SET ${fields.join(', ')} WHERE user_id = ?`,
values
);
return findById(userId);
}
async function updateLastLogin(userId) {
const db = getPool();
await db.query(
'UPDATE sso_users SET last_login = NOW() WHERE user_id = ?',
[userId]
);
}
async function deleteUser(userId) {
const db = getPool();
await db.query('UPDATE sso_users SET is_active = FALSE WHERE user_id = ?', [userId]);
}
module.exports = {
verifyPassword,
hashPassword,
findByUsername,
findById,
findAll,
create,
update,
updateLastLogin,
deleteUser,
getPool
};

View File

@@ -0,0 +1,18 @@
{
"name": "sso-auth-service",
"version": "1.0.0",
"description": "TK Factory Services - 중앙 SSO 인증 서비스",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.0",
"mysql2": "^3.14.1",
"redis": "^5.9.0"
}
}

View File

@@ -0,0 +1,42 @@
/**
* SSO Auth Routes
*/
const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
const jwt = require('jsonwebtoken');
// Middleware: admin 체크
function requireAdmin(req, res, next) {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(401).json({ success: false, error: '인증이 필요합니다' });
}
try {
const decoded = jwt.verify(token, process.env.SSO_JWT_SECRET);
if (!['admin', 'system'].includes(decoded.role)) {
return res.status(403).json({ success: false, error: '관리자 권한이 필요합니다' });
}
req.user = decoded;
next();
} catch {
return res.status(401).json({ success: false, error: '유효하지 않은 토큰입니다' });
}
}
// 공개 엔드포인트
router.post('/login', authController.login);
router.post('/login/form', express.urlencoded({ extended: true }), authController.loginForm);
router.get('/validate', authController.validate);
router.get('/me', authController.me);
router.post('/refresh', authController.refresh);
router.post('/logout', authController.logout);
// 관리자 엔드포인트
router.get('/users', requireAdmin, authController.getUsers);
router.post('/users', requireAdmin, authController.createUser);
router.put('/users/:id', requireAdmin, authController.updateUser);
router.delete('/users/:id', requireAdmin, authController.deleteUser);
module.exports = router;