Files
TK-FB-Project/api.hyungi.net/routes/authRoutes.js

916 lines
25 KiB
JavaScript

// routes/authRoutes.js - 비밀번호 변경 및 보안 기능 포함 완전판
const express = require('express');
const bcrypt = require('bcryptjs');
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 = {
host: process.env.DB_HOST || 'db_hyungi_net',
user: process.env.DB_USER || 'hyungi',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'hyungi'
};
// 로그인 시도 추적 (메모리 기반 - 실제로는 Redis 권장)
const loginAttempts = new Map();
// 로그인 시도 체크 미들웨어
const checkLoginAttempts = (req, res, next) => {
const { username } = req.body;
const key = `login_${username}`;
const attempts = loginAttempts.get(key) || { count: 0, blockedUntil: null };
// 차단 확인
if (attempts.blockedUntil && attempts.blockedUntil > Date.now()) {
const remainingTime = Math.ceil((attempts.blockedUntil - Date.now()) / 1000 / 60);
return res.status(429).json({
error: `너무 많은 로그인 시도입니다. ${remainingTime}분 후에 다시 시도하세요.`
});
}
req.loginAttempts = attempts;
next();
};
// 로그인 시도 기록
const recordLoginAttempt = (username, success = false) => {
const key = `login_${username}`;
const attempts = loginAttempts.get(key) || { count: 0, blockedUntil: null };
if (success) {
loginAttempts.delete(key);
} else {
attempts.count += 1;
// 5회 실패 시 15분 차단
if (attempts.count >= 5) {
attempts.blockedUntil = Date.now() + (15 * 60 * 1000);
console.log(`[로그인 차단] 사용자: ${username} - 15분간 차단`);
}
loginAttempts.set(key, attempts);
}
};
// 로그인 이력 기록
const recordLoginHistory = async (connection, userId, success, ipAddress, userAgent, failureReason = null) => {
try {
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);
// 로그 테이블이 없어도 계속 진행
}
};
/**
* 로그인 - DB 연동 (보안 강화)
*/
router.post('/login', authController.login);
/**
* 토큰 갱신
*/
router.post('/refresh-token', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: '리프레시 토큰이 필요합니다.' });
}
// 리프레시 토큰 검증
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET);
if (decoded.type !== 'refresh') {
return res.status(401).json({ error: '유효하지 않은 토큰입니다.' });
}
const connection = await mysql.createConnection(dbConfig);
// 사용자 정보 조회
const [users] = await connection.execute(
'SELECT * FROM users WHERE user_id = ? AND is_active = TRUE',
[decoded.user_id]
);
await connection.end();
if (users.length === 0) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const user = users[0];
// 새 토큰 발급
const newToken = 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 newRefreshToken = jwt.sign(
{
user_id: user.user_id,
type: 'refresh'
},
process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d' }
);
res.json({
success: true,
token: newToken,
refreshToken: newRefreshToken
});
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: '리프레시 토큰이 만료되었습니다.' });
}
console.error('Token refresh error:', error);
res.status(500).json({ error: '서버 오류가 발생했습니다.' });
}
});
/**
* 본인 비밀번호 변경
*/
router.post('/change-password', verifyToken, async (req, res) => {
let connection;
try {
const { currentPassword, newPassword } = req.body;
const userId = req.user.user_id;
// 입력값 검증
if (!currentPassword || !newPassword) {
return res.status(400).json({
success: false,
error: '현재 비밀번호와 새 비밀번호를 입력해주세요.'
});
}
// 비밀번호 강도 검증
if (newPassword.length < 6) {
return res.status(400).json({
success: false,
error: '비밀번호는 최소 6자 이상이어야 합니다.'
});
}
connection = await mysql.createConnection(dbConfig);
// 현재 사용자의 비밀번호 조회
const [users] = await connection.execute(
'SELECT password FROM users WHERE user_id = ?',
[userId]
);
if (users.length === 0) {
return res.status(404).json({
success: false,
error: '사용자를 찾을 수 없습니다.'
});
}
// 현재 비밀번호 확인
const isValidPassword = await bcrypt.compare(currentPassword, users[0].password);
if (!isValidPassword) {
console.log(`[비밀번호 변경 실패] 현재 비밀번호 불일치 - 사용자: ${req.user.username}`);
return res.status(401).json({
success: false,
error: '현재 비밀번호가 올바르지 않습니다.'
});
}
// 새 비밀번호와 현재 비밀번호 동일 여부 확인
const isSamePassword = await bcrypt.compare(newPassword, users[0].password);
if (isSamePassword) {
return res.status(400).json({
success: false,
error: '새 비밀번호는 현재 비밀번호와 달라야 합니다.'
});
}
// 새 비밀번호 해시화
const hashedPassword = await bcrypt.hash(newPassword, 10);
// 비밀번호 업데이트
await connection.execute(
'UPDATE Users SET password = ?, password_changed_at = NOW(), updated_at = NOW() WHERE user_id = ?',
[hashedPassword, userId]
);
// 비밀번호 변경 로그 기록
try {
await connection.execute(
'INSERT INTO password_change_logs (user_id, changed_by_user_id, changed_at, change_type) VALUES (?, ?, NOW(), ?)',
[userId, userId, 'self']
);
} catch (logError) {
console.log('비밀번호 변경 로그 기록 실패 (테이블 없음)');
}
console.log(`[비밀번호 변경 성공] 사용자: ${req.user.username}`);
res.json({
success: true,
message: '비밀번호가 성공적으로 변경되었습니다.'
});
} catch (error) {
console.error('Change password error:', error);
res.status(500).json({
success: false,
error: '서버 오류가 발생했습니다.'
});
} finally {
if (connection) {
await connection.end();
}
}
});
/**
* 시스템 관리자의 타인 비밀번호 변경
*/
router.post('/admin/change-password', verifyToken, async (req, res) => {
let connection;
try {
const { userId, newPassword } = req.body;
const adminId = req.user.user_id;
// 권한 확인 (system 레벨만 허용)
if (req.user.access_level !== 'system') {
return res.status(403).json({
success: false,
error: '시스템 관리자만 사용할 수 있는 기능입니다.'
});
}
// 입력값 검증
if (!userId || !newPassword) {
return res.status(400).json({
success: false,
error: '사용자 ID와 새 비밀번호를 입력해주세요.'
});
}
// 비밀번호 강도 검증
if (newPassword.length < 6) {
return res.status(400).json({
success: false,
error: '비밀번호는 최소 6자 이상이어야 합니다.'
});
}
connection = await mysql.createConnection(dbConfig);
// 대상 사용자 확인
const [users] = await connection.execute(
'SELECT username, name FROM users WHERE user_id = ?',
[userId]
);
if (users.length === 0) {
return res.status(404).json({
success: false,
error: '대상 사용자를 찾을 수 없습니다.'
});
}
const targetUser = users[0];
// 자기 자신의 비밀번호는 이 API로 변경 불가
if (parseInt(userId) === adminId) {
return res.status(400).json({
success: false,
error: '본인의 비밀번호는 일반 비밀번호 변경 기능을 사용하세요.'
});
}
// 새 비밀번호 해시화
const hashedPassword = await bcrypt.hash(newPassword, 10);
// 비밀번호 업데이트
await connection.execute(
'UPDATE Users SET password = ?, password_changed_at = NOW(), updated_at = NOW(), failed_login_attempts = 0, locked_until = NULL WHERE user_id = ?',
[hashedPassword, userId]
);
// 비밀번호 변경 로그 기록
try {
await connection.execute(
'INSERT INTO password_change_logs (user_id, changed_by_user_id, changed_at, change_type) VALUES (?, ?, NOW(), ?)',
[userId, adminId, 'admin']
);
} catch (logError) {
console.log('비밀번호 변경 로그 기록 실패 (테이블 없음)');
}
console.log(`[관리자 비밀번호 변경] 대상: ${targetUser.username} - 관리자: ${req.user.username}`);
res.json({
success: true,
message: `${targetUser.name}(${targetUser.username})님의 비밀번호가 변경되었습니다.`
});
} catch (error) {
console.error('Admin change password error:', error);
res.status(500).json({
success: false,
error: '서버 오류가 발생했습니다.'
});
} finally {
if (connection) {
await connection.end();
}
}
});
/**
* 비밀번호 강도 체크
*/
router.post('/check-password-strength', (req, res) => {
const { password } = req.body;
if (!password) {
return res.json({ strength: 0, message: '비밀번호를 입력하세요.' });
}
let strength = 0;
const feedback = [];
// 길이 체크
if (password.length >= 8) strength += 1;
if (password.length >= 12) strength += 1;
else feedback.push('12자 이상 사용을 권장합니다.');
// 대문자 포함
if (/[A-Z]/.test(password)) strength += 1;
else feedback.push('대문자를 포함하세요.');
// 소문자 포함
if (/[a-z]/.test(password)) strength += 1;
else feedback.push('소문자를 포함하세요.');
// 숫자 포함
if (/\d/.test(password)) strength += 1;
else feedback.push('숫자를 포함하세요.');
// 특수문자 포함
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1;
else feedback.push('특수문자를 포함하세요.');
const strengthLevels = ['매우 약함', '약함', '보통', '강함', '매우 강함'];
const level = Math.min(Math.floor((strength / 6) * 5), 4);
res.json({
strength: level,
strengthText: strengthLevels[level],
score: strength,
maxScore: 6,
feedback: feedback
});
});
/**
* 현재 사용자 정보 조회
*/
router.get('/me', verifyToken, async (req, res) => {
let connection;
try {
const userId = req.user.user_id;
connection = await mysql.createConnection(dbConfig);
const [rows] = await connection.execute(
'SELECT user_id, username, name, email, access_level, worker_id, last_login_at, created_at FROM users WHERE user_id = ?',
[userId]
);
if (rows.length === 0) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const user = rows[0];
res.json({
user_id: user.user_id,
username: user.username,
name: user.name || user.username,
email: user.email,
access_level: user.access_level,
worker_id: user.worker_id,
last_login_at: user.last_login_at,
created_at: user.created_at
});
} catch (error) {
console.error('Get current user error:', error);
res.status(500).json({ error: '서버 오류가 발생했습니다.' });
} finally {
if (connection) {
await connection.end();
}
}
});
/**
* 사용자 등록
*/
router.post('/register', verifyToken, async (req, res) => {
let connection;
try {
const { username, password, name, email, access_level, worker_id } = req.body;
// 권한 확인 (admin 이상만 사용자 등록 가능)
if (!['admin', 'system'].includes(req.user.access_level)) {
return res.status(403).json({
success: false,
error: '사용자 등록 권한이 없습니다.'
});
}
if (!username || !password || !name || !access_level) {
return res.status(400).json({
success: false,
error: '필수 항목을 모두 입력해주세요.'
});
}
// 비밀번호 강도 검증
if (password.length < 6) {
return res.status(400).json({
success: false,
error: '비밀번호는 최소 6자 이상이어야 합니다.'
});
}
connection = await mysql.createConnection(dbConfig);
// 사용자명 중복 체크
const [existing] = await connection.execute(
'SELECT user_id FROM users WHERE username = ?',
[username]
);
if (existing.length > 0) {
return res.status(409).json({
success: false,
error: '이미 존재하는 사용자명입니다.'
});
}
// 이메일 중복 체크 (이메일이 제공된 경우)
if (email) {
const [existingEmail] = await connection.execute(
'SELECT user_id FROM users WHERE email = ?',
[email]
);
if (existingEmail.length > 0) {
return res.status(409).json({
success: false,
error: '이미 사용 중인 이메일입니다.'
});
}
}
// 비밀번호 해시
const hashedPassword = await bcrypt.hash(password, 10);
// 사용자 추가
const [result] = await connection.execute(
`INSERT INTO Users (username, password, name, email, access_level, worker_id, is_active, created_at, password_changed_at)
VALUES (?, ?, ?, ?, ?, ?, TRUE, NOW(), NOW())`,
[username, hashedPassword, name, email || null, access_level, worker_id || null]
);
// 비밀번호 변경 로그 기록 (초기 설정)
try {
await connection.execute(
'INSERT INTO password_change_logs (user_id, changed_by_user_id, changed_at, change_type) VALUES (?, ?, NOW(), ?)',
[result.insertId, req.user.user_id, 'initial']
);
} catch (logError) {
console.log('비밀번호 변경 로그 기록 실패 (테이블 없음)');
}
console.log(`[사용자 등록] 새 사용자: ${username} (${access_level}) - 등록자: ${req.user.username}`);
res.json({
success: true,
message: '사용자가 성공적으로 등록되었습니다.',
user: {
user_id: result.insertId,
username,
name,
email: email || null,
access_level,
worker_id: worker_id || null
}
});
} catch (error) {
console.error('Register error:', error);
res.status(500).json({
success: false,
error: '서버 오류가 발생했습니다.'
});
} finally {
if (connection) {
await connection.end();
}
}
});
/**
* 사용자 목록 조회
*/
router.get('/users', verifyToken, async (req, res) => {
let connection;
try {
connection = await mysql.createConnection(dbConfig);
// 기본 쿼리
let query = `
SELECT
user_id,
username,
name,
email,
access_level,
worker_id,
is_active,
last_login_at,
created_at
FROM users
WHERE 1=1
`;
const params = [];
// 필터링 옵션
if (req.query.active !== undefined) {
query += ' AND is_active = ?';
params.push(req.query.active === 'true');
}
if (req.query.access_level) {
query += ' AND access_level = ?';
params.push(req.query.access_level);
}
query += ' ORDER BY created_at DESC';
const [rows] = await connection.execute(query, params);
const userList = rows.map(user => ({
user_id: user.user_id,
username: user.username,
name: user.name || user.username,
email: user.email,
access_level: user.access_level,
worker_id: user.worker_id,
is_active: user.is_active,
last_login_at: user.last_login_at,
created_at: user.created_at
}));
res.json(userList);
} catch (error) {
console.error('Get users error:', error);
res.status(500).json({ error: '서버 오류가 발생했습니다.' });
} finally {
if (connection) {
await connection.end();
}
}
});
/**
* 사용자 정보 수정
*/
router.put('/users/:id', verifyToken, async (req, res) => {
let connection;
try {
const userId = parseInt(req.params.id);
const { name, email, access_level, worker_id, password, is_active } = req.body;
// 권한 확인
if (!['admin', 'system'].includes(req.user.access_level)) {
// 일반 사용자는 자신의 정보만 수정 가능 (이름, 이메일만)
if (userId !== req.user.user_id) {
return res.status(403).json({
success: false,
error: '다른 사용자의 정보를 수정할 권한이 없습니다.'
});
}
if (access_level || worker_id || is_active !== undefined) {
return res.status(403).json({
success: false,
error: '권한, 작업자 ID, 활성화 상태는 관리자만 수정할 수 있습니다.'
});
}
}
connection = await mysql.createConnection(dbConfig);
// 사용자 존재 확인
const [existing] = await connection.execute(
'SELECT user_id, username FROM users WHERE user_id = ?',
[userId]
);
if (existing.length === 0) {
return res.status(404).json({
success: false,
error: '사용자를 찾을 수 없습니다.'
});
}
// 업데이트할 필드들 준비
let updateFields = [];
let updateValues = [];
if (name !== undefined) {
updateFields.push('name = ?');
updateValues.push(name);
}
if (email !== undefined) {
// 이메일 중복 체크
if (email) {
const [emailCheck] = await connection.execute(
'SELECT user_id FROM users WHERE email = ? AND user_id != ?',
[email, userId]
);
if (emailCheck.length > 0) {
return res.status(409).json({
success: false,
error: '이미 사용 중인 이메일입니다.'
});
}
}
updateFields.push('email = ?');
updateValues.push(email || null);
}
if (access_level !== undefined && ['admin', 'system'].includes(req.user.access_level)) {
updateFields.push('access_level = ?');
updateValues.push(access_level);
}
if (worker_id !== undefined && ['admin', 'system'].includes(req.user.access_level)) {
updateFields.push('worker_id = ?');
updateValues.push(worker_id || null);
}
if (is_active !== undefined && ['admin', 'system'].includes(req.user.access_level)) {
updateFields.push('is_active = ?');
updateValues.push(is_active);
}
if (password) {
if (password.length < 6) {
return res.status(400).json({
success: false,
error: '비밀번호는 최소 6자 이상이어야 합니다.'
});
}
updateFields.push('password = ?');
updateValues.push(await bcrypt.hash(password, 10));
updateFields.push('password_changed_at = NOW()');
// 비밀번호 변경 로그
try {
await connection.execute(
'INSERT INTO password_change_logs (user_id, changed_by_user_id, changed_at, change_type) VALUES (?, ?, NOW(), ?)',
[userId, req.user.user_id, userId === req.user.user_id ? 'self' : 'admin']
);
} catch (logError) {
console.log('비밀번호 변경 로그 기록 실패');
}
}
if (updateFields.length === 0) {
return res.status(400).json({
success: false,
error: '수정할 내용이 없습니다.'
});
}
updateFields.push('updated_at = NOW()');
updateValues.push(userId);
// 업데이트 실행
await connection.execute(
`UPDATE Users SET ${updateFields.join(', ')} WHERE user_id = ?`,
updateValues
);
// 업데이트된 사용자 정보 조회
const [updated] = await connection.execute(
'SELECT user_id, username, name, email, access_level, worker_id, is_active FROM users WHERE user_id = ?',
[userId]
);
console.log(`[사용자 수정] 대상: ${existing[0].username} - 수정자: ${req.user.username}`);
res.json({
success: true,
message: '사용자 정보가 성공적으로 수정되었습니다.',
user: updated[0]
});
} catch (error) {
console.error('Update user error:', error);
res.status(500).json({
success: false,
error: '서버 오류가 발생했습니다.'
});
} finally {
if (connection) {
await connection.end();
}
}
});
/**
* 사용자 삭제
*/
router.delete('/users/:id', verifyToken, async (req, res) => {
let connection;
try {
const userId = parseInt(req.params.id);
// 권한 확인 (system만 삭제 가능)
if (req.user.access_level !== 'system') {
return res.status(403).json({
success: false,
error: '사용자 삭제 권한이 없습니다.'
});
}
// 자기 자신 삭제 방지
if (userId === req.user.user_id) {
return res.status(400).json({
success: false,
error: '자기 자신은 삭제할 수 없습니다.'
});
}
connection = await mysql.createConnection(dbConfig);
// 사용자 존재 확인
const [existing] = await connection.execute(
'SELECT username FROM users WHERE user_id = ?',
[userId]
);
if (existing.length === 0) {
return res.status(404).json({
success: false,
error: '사용자를 찾을 수 없습니다.'
});
}
// 소프트 삭제 (실제로는 비활성화)
await connection.execute(
'UPDATE Users SET is_active = FALSE, updated_at = NOW() WHERE user_id = ?',
[userId]
);
// 또는 하드 삭제 (실제로 삭제)
// await connection.execute('DELETE FROM Users WHERE user_id = ?', [userId]);
console.log(`[사용자 삭제] 대상: ${existing[0].username} - 삭제자: ${req.user.username}`);
res.json({
success: true,
message: '사용자가 성공적으로 삭제되었습니다.'
});
} catch (error) {
console.error('Delete user error:', error);
res.status(500).json({
success: false,
error: '서버 오류가 발생했습니다.'
});
} finally {
if (connection) {
await connection.end();
}
}
});
/**
* 로그아웃
*/
router.post('/logout', verifyToken, async (req, res) => {
console.log(`[로그아웃] 사용자: ${req.user.username}`);
// 로그아웃 시간 기록 (선택사항)
let connection;
try {
connection = await mysql.createConnection(dbConfig);
await connection.execute(
'UPDATE login_logs SET logout_time = NOW() WHERE user_id = ? AND logout_time IS NULL ORDER BY login_time DESC LIMIT 1',
[req.user.user_id]
);
} catch (error) {
console.error('로그아웃 기록 실패:', error);
} finally {
if (connection) {
await connection.end();
}
}
res.json({
success: true,
message: '성공적으로 로그아웃되었습니다.'
});
});
/**
* 로그인 이력 조회
*/
router.get('/login-history', verifyToken, async (req, res) => {
let connection;
try {
const { limit = 50, offset = 0 } = req.query;
const userId = req.user.user_id;
connection = await mysql.createConnection(dbConfig);
// 본인의 로그인 이력만 조회 (관리자는 전체 조회 가능)
let query = `
SELECT
log_id,
user_id,
login_time,
logout_time,
ip_address,
user_agent,
login_status
FROM login_logs
`;
const params = [];
if (!['admin', 'system'].includes(req.user.access_level)) {
query += ' WHERE user_id = ?';
params.push(userId);
} else if (req.query.user_id) {
query += ' WHERE user_id = ?';
params.push(req.query.user_id);
}
query += ' ORDER BY login_time DESC LIMIT ? OFFSET ?';
params.push(parseInt(limit), parseInt(offset));
const [rows] = await connection.execute(query, params);
res.json({
success: true,
data: rows,
pagination: {
limit: parseInt(limit),
offset: parseInt(offset)
}
});
} catch (error) {
console.error('Get login history error:', error);
res.status(500).json({ error: '서버 오류가 발생했습니다.' });
} finally {
if (connection) {
await connection.end();
}
}
});
module.exports = router;