From 5539b09fd860e4185bd0f330c20300fdfc45a29e Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 28 Jul 2025 11:11:25 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API?= =?UTF-8?q?=EC=9D=98=20DB=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새로운 DB 스키마(v2) 추가 (테이블명 snake_case, FK 적용) - 룰.md에 API 성능 관리 규칙 추가 - 로그인 관련 로직을 새로운 스키마에 맞게 수정 - Service와 Model의 역할 분리를 명확하게 리팩토링 --- api.hyungi.net/controllers/authController.js | 59 ++-- api.hyungi.net/hyungi_schema_v2.sql | 297 +++++++++++++++++++ api.hyungi.net/models/userModel.js | 77 ++++- api.hyungi.net/routes/authRoutes.js | 129 +------- api.hyungi.net/services/auth.service.js | 100 +++++++ 룰.md | 6 + 6 files changed, 495 insertions(+), 173 deletions(-) create mode 100644 api.hyungi.net/hyungi_schema_v2.sql create mode 100644 api.hyungi.net/services/auth.service.js diff --git a/api.hyungi.net/controllers/authController.js b/api.hyungi.net/controllers/authController.js index da020c3..e3869e7 100644 --- a/api.hyungi.net/controllers/authController.js +++ b/api.hyungi.net/controllers/authController.js @@ -1,56 +1,29 @@ const { getDb } = require('../dbPool'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); +const authService = require('../services/auth.service'); -exports.login = async (req, res) => { +const login = async (req, res) => { try { const { username, password } = req.body; - const db = await getDb(); + const ipAddress = req.ip || req.connection.remoteAddress; + const userAgent = req.headers['user-agent']; - const [rows] = await db.query( - 'SELECT * FROM Users WHERE username = ?', - [username] - ); - - if (rows.length === 0) { - return res.status(401).json({ error: '존재하지 않는 사용자입니다.' }); + if (!username || !password) { + return res.status(400).json({ error: '사용자명과 비밀번호를 입력해주세요.' }); } - const user = rows[0]; - const isMatch = await bcrypt.compare(password, user.password); - if (!isMatch) { - return res.status(401).json({ error: '비밀번호가 일치하지 않습니다.' }); + const result = await authService.loginService(username, password, ipAddress, userAgent); + + if (!result.success) { + return res.status(result.status || 400).json({ error: result.error }); } - // JWT 토큰 생성 - const token = jwt.sign( - { - user_id: user.user_id, - username: user.username, - name: user.name, - role: user.role, - access_level: user.access_level, - worker_id: user.worker_id - }, - process.env.JWT_SECRET, - { expiresIn: '1d' } - ); + res.json(result.data); - // 토큰 포함 응답 - return res.status(200).json({ - success: true, - token, - user_id: user.user_id, - username: user.username, - role: user.role - }); - - } catch (err) { - console.error('[로그인 오류]', err); - return res.status(500).json({ - error: '서버 내부 오류', - detail: err.message || String(err) - }); + } catch (error) { + console.error('Login controller error:', error); + res.status(500).json({ error: error.message || '서버 오류가 발생했습니다.' }); } }; @@ -175,4 +148,8 @@ exports.getAllUsers = async (req, res) => { console.error('[사용자 목록 조회 실패]', err); res.status(500).json({ error: '서버 오류' }); } +}; + +module.exports = { + login }; \ No newline at end of file diff --git a/api.hyungi.net/hyungi_schema_v2.sql b/api.hyungi.net/hyungi_schema_v2.sql new file mode 100644 index 0000000..d2ff224 --- /dev/null +++ b/api.hyungi.net/hyungi_schema_v2.sql @@ -0,0 +1,297 @@ +-- Hyungi Technical Korea - Database Schema v2 +-- 개선 사항: +-- 1. 모든 테이블 및 컬럼 이름을 snake_case로 통일 +-- 2. 데이터 무결성 강화를 위한 외래 키(Foreign Key) 제약조건 추가 +-- 3. 유사/중복 테이블 통합 및 정리 제안 (예: tasks, work_types) +-- 4. 컬럼의 기본값(Default), 코멘트 등 명확화 + +SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; +START TRANSACTION; +SET time_zone = "+09:00"; + +-- ================================================================= +-- 1. 인증 및 사용자 관련 (Auth Domain) +-- ================================================================= + +-- 사용자 계정 정보 +CREATE TABLE `users` ( + `user_id` int(11) NOT NULL AUTO_INCREMENT, + `username` varchar(100) NOT NULL, + `password` varchar(255) NOT NULL, + `name` varchar(50) DEFAULT NULL, + `email` varchar(255) DEFAULT NULL, + `role` varchar(30) DEFAULT 'user' COMMENT '역할 (system, admin, leader, user)', + `access_level` varchar(30) DEFAULT NULL COMMENT '접근 레벨 (레거시 필드, role로 통합 고려)', + `worker_id` int(11) DEFAULT NULL COMMENT '연결된 작업자 ID', + `is_active` tinyint(1) DEFAULT 1 COMMENT '계정 활성화 여부', + `last_login_at` datetime DEFAULT NULL COMMENT '마지막 로그인 시간', + `password_changed_at` datetime DEFAULT NULL, + `failed_login_attempts` int(11) DEFAULT 0, + `locked_until` datetime DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`user_id`), + UNIQUE KEY `username` (`username`), + KEY `fk_users_worker_id` (`worker_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 로그인 이력 +CREATE TABLE `login_logs` ( + `log_id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) DEFAULT NULL, + `login_time` datetime DEFAULT current_timestamp(), + `ip_address` varchar(45) DEFAULT NULL, + `user_agent` text DEFAULT NULL, + `login_status` enum('success','failed','locked') DEFAULT 'success', + `failure_reason` varchar(100) DEFAULT NULL, + PRIMARY KEY (`log_id`), + KEY `fk_login_logs_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 비밀번호 변경 이력 +CREATE TABLE `password_change_logs` ( + `log_id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `changed_by_user_id` int(11) DEFAULT NULL COMMENT '누가 변경했는지 (관리자)', + `changed_at` datetime DEFAULT current_timestamp(), + `change_type` enum('self','admin','reset','initial') DEFAULT 'self', + `ip_address` varchar(45) DEFAULT NULL, + PRIMARY KEY (`log_id`), + KEY `fk_pw_change_user_id` (`user_id`), + KEY `fk_pw_change_changed_by` (`changed_by_user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +-- ================================================================= +-- 2. 기초 정보 (Master Data) +-- ================================================================= + +-- 프로젝트 정보 +CREATE TABLE `projects` ( + `project_id` int(11) NOT NULL AUTO_INCREMENT, + `job_no` varchar(50) NOT NULL, + `project_name` varchar(255) NOT NULL, + `contract_date` date DEFAULT NULL, + `due_date` date DEFAULT NULL, + `delivery_method` varchar(100) DEFAULT NULL, + `site` varchar(100) DEFAULT NULL, + `pm` varchar(100) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`project_id`), + UNIQUE KEY `job_no` (`job_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 작업자(인력) 정보 +CREATE TABLE `workers` ( + `worker_id` int(11) NOT NULL AUTO_INCREMENT, + `worker_name` varchar(100) NOT NULL, + `job_type` varchar(100) DEFAULT NULL COMMENT '직종', + `join_date` date DEFAULT NULL COMMENT '입사일', + `status` varchar(20) DEFAULT 'active' COMMENT '상태 (active, inactive)', + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`worker_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 작업자 그룹 (팀) +CREATE TABLE `worker_groups` ( + `group_id` int(11) NOT NULL AUTO_INCREMENT, + `group_name` varchar(100) DEFAULT NULL, + `group_leader_id` int(11) NOT NULL COMMENT '그룹장 user_id', + `worker_id` int(11) NOT NULL COMMENT '소속 작업자 worker_id', + `is_active` tinyint(1) DEFAULT 1, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`group_id`), + KEY `fk_w_groups_leader_id` (`group_leader_id`), + KEY `fk_w_groups_worker_id` (`worker_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 표준 작업(공수) 종류 (기존 Tasks 와 work_types 통합 제안) +CREATE TABLE `tasks` ( + `task_id` int(11) NOT NULL AUTO_INCREMENT, + `task_category` varchar(255) NOT NULL COMMENT '작업 대분류 (예: PKG, Vessel)', + `task_subcategory` varchar(255) DEFAULT NULL COMMENT '작업 중분류 (예: Pipe Pre-Fabrication)', + `task_name` varchar(255) NOT NULL COMMENT '실제 작업명 (예: 취부&용접)', + `description` text DEFAULT NULL, + `is_active` tinyint(1) DEFAULT 1, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`task_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 파이프 사양(Spec) 정보 +CREATE TABLE `pipe_specs` ( + `spec_id` int(11) NOT NULL AUTO_INCREMENT, + `material` varchar(50) NOT NULL COMMENT '재질 (예: SS400, STS304)', + `diameter_in` varchar(10) NOT NULL COMMENT '직경 (inch, 예: 2")', + `schedule` varchar(50) NOT NULL COMMENT '스케줄 (예: STD, SCH10, SCH40)', + PRIMARY KEY (`spec_id`), + UNIQUE KEY `uk_pipe_specs` (`material`,`diameter_in`,`schedule`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- 공장 설비/장비 목록 +CREATE TABLE `equipment_list` ( + `equipment_id` int(11) NOT NULL AUTO_INCREMENT, + `factory_id` int(11) DEFAULT NULL COMMENT '소속 공장 ID', + `equipment_name` varchar(255) NOT NULL, + `model` varchar(100) DEFAULT NULL, + `status` varchar(50) DEFAULT 'operational', + `purchase_date` date DEFAULT NULL, + `description` text DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`equipment_id`), + KEY `fk_equip_factory_id` (`factory_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 공장 구역 정보 +CREATE TABLE `factory_info` ( + `factory_id` int(11) NOT NULL AUTO_INCREMENT, + `factory_name` varchar(255) NOT NULL, + `address` varchar(255) DEFAULT NULL, + `description` text DEFAULT NULL, + `map_image_url` varchar(255) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`factory_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 코드 정의 (기존 IssueTypes, error_types 등을 통합 관리) +CREATE TABLE `code_types` ( + `code_type_id` VARCHAR(50) NOT NULL COMMENT '코드 타입 ID (예: ISSUE_TYPE, ERROR_TYPE)', + `code_type_name` VARCHAR(100) NOT NULL COMMENT '코드 타입명 (예: 이슈 유형, 에러 유형)', + `description` TEXT, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`code_type_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `codes` ( + `code_id` INT NOT NULL AUTO_INCREMENT, + `code_type_id` VARCHAR(50) NOT NULL COMMENT '참조하는 코드 타입 ID', + `code_value` VARCHAR(100) NOT NULL COMMENT '코드 값 (예: design_miss)', + `code_name` VARCHAR(100) NOT NULL COMMENT '코드 표시명 (예: 설계 미스)', + `code_order` INT DEFAULT 0 COMMENT '정렬 순서', + `is_active` TINYINT(1) DEFAULT 1, + `description` TEXT, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`code_id`), + UNIQUE KEY `uk_code` (`code_type_id`, `code_value`), + KEY `fk_codes_code_type_id` (`code_type_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +-- ================================================================= +-- 3. 업무 데이터 (Transactional Data) +-- ================================================================= + +-- 일일 작업 보고 +CREATE TABLE `daily_work_reports` ( + `report_id` int(11) NOT NULL AUTO_INCREMENT, + `report_date` date NOT NULL COMMENT '작업 날짜', + `worker_id` int(11) NOT NULL COMMENT '작업자 ID', + `project_id` int(11) NOT NULL COMMENT '프로젝트 ID', + `task_id` int(11) NOT NULL COMMENT '작업 ID', + `work_hours` decimal(4,2) NOT NULL COMMENT '작업 시간', + `is_error` tinyint(1) NOT NULL DEFAULT 0 COMMENT '에러 여부 (0: 정상, 1: 에러)', + `error_type_code_id` int(11) DEFAULT NULL COMMENT '에러 유형 코드 ID (codes 테이블 참조)', + `created_by_user_id` int(11) NOT NULL COMMENT '작성자 user_id', + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`report_id`), + KEY `fk_dwr_worker_id` (`worker_id`), + KEY `fk_dwr_project_id` (`project_id`), + KEY `fk_dwr_task_id` (`task_id`), + KEY `fk_dwr_error_type` (`error_type_code_id`), + KEY `fk_dwr_created_by` (`created_by_user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 일일 이슈 보고 +CREATE TABLE `daily_issue_reports` ( + `issue_report_id` int(11) NOT NULL AUTO_INCREMENT, + `report_date` date NOT NULL, + `worker_id` int(11) NOT NULL, + `project_id` int(11) NOT NULL, + `issue_type_code_id` int(11) DEFAULT NULL COMMENT '이슈 유형 코드 ID (codes 테이블 참조)', + `description` text DEFAULT NULL, + `start_time` time NOT NULL, + `end_time` time NOT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`issue_report_id`), + KEY `fk_dir_worker_id` (`worker_id`), + KEY `fk_dir_project_id` (`project_id`), + KEY `fk_dir_issue_type` (`issue_type_code_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- 절단 계획 +CREATE TABLE `cutting_plans` ( + `cutting_plan_id` int(11) NOT NULL AUTO_INCREMENT, + `project_id` int(11) NOT NULL, + `spec_id` int(11) NOT NULL COMMENT '파이프 사양 ID', + `drawing_name` varchar(255) NOT NULL, + `area_number` varchar(100) DEFAULT NULL, + `spool_number` varchar(255) DEFAULT NULL, + `length` decimal(10,2) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`cutting_plan_id`), + KEY `fk_cp_project_id` (`project_id`), + KEY `fk_cp_spec_id` (`spec_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ================================================================= +-- 외래 키(Foreign Key) 제약조건 설정 +-- ================================================================= + +-- Auth Domain +ALTER TABLE `users` + ADD CONSTRAINT `fk_users_worker_id` FOREIGN KEY (`worker_id`) REFERENCES `workers` (`worker_id`) ON DELETE SET NULL; +ALTER TABLE `login_logs` + ADD CONSTRAINT `fk_login_logs_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE SET NULL; +ALTER TABLE `password_change_logs` + ADD CONSTRAINT `fk_pw_change_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE, + ADD CONSTRAINT `fk_pw_change_changed_by` FOREIGN KEY (`changed_by_user_id`) REFERENCES `users` (`user_id`) ON DELETE SET NULL; + +-- Master Data +ALTER TABLE `worker_groups` + ADD CONSTRAINT `fk_w_groups_leader_id` FOREIGN KEY (`group_leader_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE, + ADD CONSTRAINT `fk_w_groups_worker_id` FOREIGN KEY (`worker_id`) REFERENCES `workers` (`worker_id`) ON DELETE CASCADE; +ALTER TABLE `equipment_list` + ADD CONSTRAINT `fk_equip_factory_id` FOREIGN KEY (`factory_id`) REFERENCES `factory_info` (`factory_id`) ON DELETE SET NULL; +ALTER TABLE `codes` + ADD CONSTRAINT `fk_codes_code_type_id` FOREIGN KEY (`code_type_id`) REFERENCES `code_types` (`code_type_id`) ON DELETE CASCADE; + +-- Transactional Data +ALTER TABLE `daily_work_reports` + ADD CONSTRAINT `fk_dwr_worker_id` FOREIGN KEY (`worker_id`) REFERENCES `workers` (`worker_id`) ON DELETE RESTRICT, + ADD CONSTRAINT `fk_dwr_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`project_id`) ON DELETE RESTRICT, + ADD CONSTRAINT `fk_dwr_task_id` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`task_id`) ON DELETE RESTRICT, + ADD CONSTRAINT `fk_dwr_error_type` FOREIGN KEY (`error_type_code_id`) REFERENCES `codes` (`code_id`) ON DELETE SET NULL, + ADD CONSTRAINT `fk_dwr_created_by` FOREIGN KEY (`created_by_user_id`) REFERENCES `users` (`user_id`) ON DELETE RESTRICT; +ALTER TABLE `daily_issue_reports` + ADD CONSTRAINT `fk_dir_worker_id` FOREIGN KEY (`worker_id`) REFERENCES `workers` (`worker_id`) ON DELETE RESTRICT, + ADD CONSTRAINT `fk_dir_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`project_id`) ON DELETE RESTRICT, + ADD CONSTRAINT `fk_dir_issue_type` FOREIGN KEY (`issue_type_code_id`) REFERENCES `codes` (`code_id`) ON DELETE SET NULL; +ALTER TABLE `cutting_plans` + ADD CONSTRAINT `fk_cp_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`project_id`) ON DELETE CASCADE, + ADD CONSTRAINT `fk_cp_spec_id` FOREIGN KEY (`spec_id`) REFERENCES `pipe_specs` (`spec_id`) ON DELETE RESTRICT; + + +COMMIT; + +-- ================================================================= +-- 기존 테이블 (Legacy) - 검토 후 마이그레이션 및 삭제 필요 +-- ================================================================= +/* +-- 기존 Tasks, IssueTypes, error_types 등은 `codes` 와 `code_types`로 통합 제안 +-- 아래 테이블들은 `codes` 테이블로 데이터 마이그레이션 후 삭제 고려 +CREATE TABLE `IssueTypes` ( ... ); +CREATE TABLE `error_types` ( ... ); +CREATE TABLE `work_status_types` ( ... ); +CREATE TABLE `work_types` ( ... ); + +-- daily_work_reports 로 통합된 것으로 추정되는 레거시 테이블 +CREATE TABLE `WorkReports` ( ... ); +CREATE TABLE `daily_worker_summary` ( ... ); +*/ \ No newline at end of file diff --git a/api.hyungi.net/models/userModel.js b/api.hyungi.net/models/userModel.js index f12260b..893c386 100644 --- a/api.hyungi.net/models/userModel.js +++ b/api.hyungi.net/models/userModel.js @@ -1,20 +1,87 @@ -const { getDb } = require('../dbPool'); +const dbPool = require('../dbPool'); // 사용자 조회 const findByUsername = async (username) => { + let connection; try { - const db = await getDb(); - const [rows] = await db.query( - 'SELECT * FROM Users WHERE username = ?', [username] + connection = await dbPool.getConnection(); + const [rows] = await connection.query( + 'SELECT * FROM users WHERE username = ?', [username] ); return rows[0]; } catch (err) { console.error('DB 오류 - 사용자 조회 실패:', err); throw err; + } finally { + if (connection) connection.release(); } }; +/** + * 로그인 실패 횟수를 1 증가시킵니다. + * @param {number} userId - 사용자 ID + */ +const incrementFailedLoginAttempts = async (userId) => { + let connection; + try { + connection = await dbPool.getConnection(); + await connection.execute( + 'UPDATE users SET failed_login_attempts = failed_login_attempts + 1 WHERE user_id = ?', + [userId] + ); + } catch (err) { + console.error('DB 오류 - 로그인 실패 횟수 증가 실패:', err); + throw err; + } finally { + if (connection) connection.release(); + } +}; + +/** + * 특정 사용자의 계정을 잠급니다. + * @param {number} userId - 사용자 ID + */ +const lockUserAccount = async (userId) => { + let connection; + try { + connection = await dbPool.getConnection(); + await connection.execute( + 'UPDATE users SET locked_until = DATE_ADD(NOW(), INTERVAL 15 MINUTE) WHERE user_id = ?', + [userId] + ); + } catch (err) { + console.error('DB 오류 - 계정 잠금 실패:', err); + throw err; + } finally { + if (connection) connection.release(); + } +}; + +/** + * 로그인 성공 시, 마지막 로그인 시간을 업데이트하고 실패 횟수와 잠금 상태를 초기화합니다. + * @param {number} userId - 사용자 ID + */ +const resetLoginAttempts = async (userId) => { + let connection; + try { + connection = await dbPool.getConnection(); + await connection.execute( + 'UPDATE users SET last_login_at = NOW(), failed_login_attempts = 0, locked_until = NULL WHERE user_id = ?', + [userId] + ); + } catch (err) { + console.error('DB 오류 - 로그인 상태 초기화 실패:', err); + throw err; + } finally { + if (connection) connection.release(); + } +}; + + // 명확한 내보내기 module.exports = { - findByUsername + findByUsername, + incrementFailedLoginAttempts, + lockUserAccount, + resetLoginAttempts }; \ No newline at end of file diff --git a/api.hyungi.net/routes/authRoutes.js b/api.hyungi.net/routes/authRoutes.js index d5b3d62..14928ba 100644 --- a/api.hyungi.net/routes/authRoutes.js +++ b/api.hyungi.net/routes/authRoutes.js @@ -5,6 +5,7 @@ const jwt = require('jsonwebtoken'); const mysql = require('mysql2/promise'); const { verifyToken } = require('../middlewares/authMiddleware'); const router = express.Router(); +const authController = require('../controllers/authController'); // DB 연결 설정 const dbConfig = { @@ -72,133 +73,7 @@ const recordLoginHistory = async (connection, userId, success, ipAddress, userAg /** * 로그인 - DB 연동 (보안 강화) */ -router.post('/login', checkLoginAttempts, async (req, res) => { - let connection; - - try { - const { username, password } = req.body; - const ipAddress = req.ip || req.connection.remoteAddress; - const userAgent = req.headers['user-agent']; - - console.log(`[로그인 시도] 사용자: ${username}, IP: ${ipAddress}`); - - if (!username || !password) { - return res.status(400).json({ error: '사용자명과 비밀번호를 입력해주세요.' }); - } - - // DB 연결 - connection = await mysql.createConnection(dbConfig); - - // 사용자 조회 - const [rows] = await connection.execute( - 'SELECT * FROM Users WHERE username = ?', - [username] - ); - - if (rows.length === 0) { - console.log(`[로그인 실패] 사용자를 찾을 수 없음: ${username}`); - recordLoginAttempt(username, false); - // 보안상 구체적인 오류 메시지는 피함 - return res.status(401).json({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' }); - } - - const user = rows[0]; - - // 계정 활성화 상태 확인 - if (user.is_active === false) { - await recordLoginHistory(connection, user.user_id, false, ipAddress, userAgent, 'account_disabled'); - return res.status(403).json({ error: '비활성화된 계정입니다. 관리자에게 문의하세요.' }); - } - - // 계정 잠금 확인 - if (user.locked_until && new Date(user.locked_until) > new Date()) { - const remainingTime = Math.ceil((new Date(user.locked_until) - new Date()) / 1000 / 60); - return res.status(429).json({ error: `계정이 잠겨있습니다. ${remainingTime}분 후에 다시 시도하세요.` }); - } - - // 비밀번호 확인 - const isValid = await bcrypt.compare(password, user.password); - if (!isValid) { - console.log(`[로그인 실패] 비밀번호 불일치: ${username}`); - recordLoginAttempt(username, false); - - // 실패 횟수 업데이트 - await connection.execute( - 'UPDATE Users SET failed_login_attempts = failed_login_attempts + 1 WHERE user_id = ?', - [user.user_id] - ); - - // 5회 실패 시 계정 잠금 - if (user.failed_login_attempts >= 4) { - await connection.execute( - 'UPDATE Users SET locked_until = DATE_ADD(NOW(), INTERVAL 15 MINUTE) WHERE user_id = ?', - [user.user_id] - ); - } - - await recordLoginHistory(connection, user.user_id, false, ipAddress, userAgent, 'invalid_password'); - return res.status(401).json({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' }); - } - - // 로그인 성공 - recordLoginAttempt(username, true); - - // 마지막 로그인 시간 업데이트 및 실패 횟수 초기화 - await connection.execute( - 'UPDATE Users SET last_login_at = NOW(), failed_login_attempts = 0, locked_until = NULL WHERE user_id = ?', - [user.user_id] - ); - - // JWT 토큰 생성 - const token = jwt.sign( - { - user_id: user.user_id, - username: user.username, - access_level: user.access_level, - worker_id: user.worker_id, - name: user.name || user.username - }, - process.env.JWT_SECRET || 'your-secret-key', - { expiresIn: process.env.JWT_EXPIRES_IN || '24h' } - ); - - // 리프레시 토큰 생성 - const refreshToken = jwt.sign( - { - user_id: user.user_id, - type: 'refresh' - }, - process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET || 'your-refresh-secret', - { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d' } - ); - - // 로그인 이력 기록 - await recordLoginHistory(connection, user.user_id, true, ipAddress, userAgent); - - console.log(`[로그인 성공] 사용자: ${user.username} (${user.access_level})`); - - res.json({ - success: true, - token, - refreshToken, - user: { - user_id: user.user_id, - username: user.username, - name: user.name || user.username, - access_level: user.access_level, - worker_id: user.worker_id - } - }); - - } catch (error) { - console.error('Login error:', error); - res.status(500).json({ error: '서버 오류가 발생했습니다.' }); - } finally { - if (connection) { - await connection.end(); - } - } -}); +router.post('/login', authController.login); /** * 토큰 갱신 diff --git a/api.hyungi.net/services/auth.service.js b/api.hyungi.net/services/auth.service.js new file mode 100644 index 0000000..219a914 --- /dev/null +++ b/api.hyungi.net/services/auth.service.js @@ -0,0 +1,100 @@ +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const userModel = require('../models/userModel'); +const dbPool = require('../dbPool'); + +// 로그인 이력 기록 (서비스 내부 헬퍼 함수) +const recordLoginHistory = async (userId, success, ipAddress, userAgent, failureReason = null) => { + let connection; + try { + connection = await dbPool.getConnection(); + await connection.execute( + `INSERT INTO login_logs (user_id, login_time, ip_address, user_agent, login_status, failure_reason) + VALUES (?, NOW(), ?, ?, ?, ?)`, + [userId, ipAddress || 'unknown', userAgent || 'unknown', success ? 'success' : 'failed', failureReason] + ); + } catch (error) { + console.error('로그인 이력 기록 실패:', error); + } finally { + if (connection) connection.release(); + } +}; + +const loginService = async (username, password, ipAddress, userAgent) => { + // 서비스 레이어에서는 더 이상 DB 커넥션을 직접 다루지 않음 + try { + const user = await userModel.findByUsername(username); + + if (!user) { + console.log(`[로그인 실패] 사용자를 찾을 수 없음: ${username}`); + return { success: false, status: 401, error: '아이디 또는 비밀번호가 올바르지 않습니다.' }; + } + + if (user.is_active === false) { + await recordLoginHistory(user.user_id, false, ipAddress, userAgent, 'account_disabled'); + return { success: false, status: 403, error: '비활성화된 계정입니다. 관리자에게 문의하세요.' }; + } + + if (user.locked_until && new Date(user.locked_until) > new Date()) { + const remainingTime = Math.ceil((new Date(user.locked_until) - new Date()) / 1000 / 60); + return { success: false, status: 429, error: `계정이 잠겨있습니다. ${remainingTime}분 후에 다시 시도하세요.` }; + } + + const isValid = await bcrypt.compare(password, user.password); + if (!isValid) { + console.log(`[로그인 실패] 비밀번호 불일치: ${username}`); + + // 모델 함수를 사용하여 로그인 실패 처리 + await userModel.incrementFailedLoginAttempts(user.user_id); + + if (user.failed_login_attempts >= 4) { + await userModel.lockUserAccount(user.user_id); + } + + await recordLoginHistory(user.user_id, false, ipAddress, userAgent, 'invalid_password'); + return { success: false, status: 401, error: '아이디 또는 비밀번호가 올바르지 않습니다.' }; + } + + // 성공 시 모델 함수를 사용하여 상태 초기화 + await userModel.resetLoginAttempts(user.user_id); + + + const token = jwt.sign( + { user_id: user.user_id, username: user.username, access_level: user.access_level, worker_id: user.worker_id, name: user.name || user.username }, + process.env.JWT_SECRET || 'your-secret-key', + { expiresIn: process.env.JWT_EXPIRES_IN || '24h' } + ); + + const refreshToken = jwt.sign( + { user_id: user.user_id, type: 'refresh' }, + process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET || 'your-refresh-secret', + { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d' } + ); + + await recordLoginHistory(user.user_id, true, ipAddress, userAgent); + console.log(`[로그인 성공] 사용자: ${user.username} (${user.access_level})`); + + return { + success: true, + data: { + token, + refreshToken, + user: { + user_id: user.user_id, + username: user.username, + name: user.name || user.username, + access_level: user.access_level, + worker_id: user.worker_id + } + } + }; + } catch (error) { + console.error('Login service error:', error); + throw new Error('서버 오류가 발생했습니다.'); + } + // 서비스 레이어에서는 더 이상 DB 커넥션을 직접 다루지 않음 +}; + +module.exports = { + loginService, +}; \ No newline at end of file diff --git a/룰.md b/룰.md index ffc4440..d506193 100644 --- a/룰.md +++ b/룰.md @@ -95,6 +95,12 @@ - `404 Not Found`: 리소스 없음 - `500 Internal Server Error`: 서버 내부 오류 +### 4.4. 성능 및 자원 관리 + +- **최소한의 데이터 조회:** API는 반드시 필요한 데이터만 조회하고 반환해야 합니다. `SELECT *` 사용을 지양하고, 실제 클라이언트에서 사용하는 컬럼만 명시적으로 조회합니다. +- **효율적인 쿼리 작성:** 복잡한 `JOIN`이나 비효율적인 `WHERE` 조건으로 인해 데이터베이스에 과도한 부하를 주는 쿼리가 없는지 항상 확인합니다. +- **코드 리뷰:** 새로운 API를 개발하거나 기존 API를 수정할 때, 동료 개발자는 기능의 정확성뿐만 아니라 성능 측면(쿼리 효율, 불필요한 로직 등)도 함께 검토해야 합니다. 성능 저하가 의심되는 코드는 즉시 개선하는 것을 원칙으로 합니다. + ## 5. 데이터베이스 관리 - **테이블/컬럼 네이밍:** `snake_case` 사용