- POST /api/auth/change-password: 현재 비밀번호 검증 후 변경 - POST /api/auth/check-password-strength: 비밀번호 강도 체크 - 대시보드 프로필 카드에 '비밀번호 변경' 바로가기 링크 추가 - 프론트엔드(password.html + change-password.js)는 이미 구현됨 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
454 lines
14 KiB
JavaScript
454 lines
14 KiB
JavaScript
/**
|
|
* SSO Auth Controller
|
|
*
|
|
* 로그인, 토큰 검증/갱신, 사용자 CRUD
|
|
*/
|
|
|
|
const jwt = require('jsonwebtoken');
|
|
const userModel = require('../models/userModel');
|
|
const redis = require('../utils/redis');
|
|
|
|
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';
|
|
|
|
const MAX_LOGIN_ATTEMPTS = 5;
|
|
const LOGIN_LOCKOUT_SECONDS = 300; // 5분
|
|
|
|
/**
|
|
* JWT 토큰 페이로드 생성 (모든 시스템 공통 구조)
|
|
*/
|
|
function createTokenPayload(user) {
|
|
return {
|
|
user_id: user.user_id,
|
|
id: user.user_id,
|
|
username: user.username,
|
|
name: user.name,
|
|
department_id: user.department_id || null,
|
|
department_name: user.department_name || user.department || null,
|
|
is_production: user.is_production || false,
|
|
department: user.department,
|
|
role: user.role,
|
|
access_level: user.role,
|
|
sub: user.username,
|
|
partner_company_id: user.partner_company_id || null,
|
|
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 attemptKey = `login_attempts:${username}`;
|
|
const attempts = parseInt(await redis.get(attemptKey)) || 0;
|
|
if (attempts >= MAX_LOGIN_ATTEMPTS) {
|
|
return res.status(429).json({ success: false, error: '로그인 시도 횟수를 초과했습니다. 5분 후 다시 시도하세요' });
|
|
}
|
|
|
|
const user = await userModel.findByUsername(username);
|
|
if (!user) {
|
|
await redis.incr(attemptKey);
|
|
await redis.expire(attemptKey, LOGIN_LOCKOUT_SECONDS);
|
|
return res.status(401).json({ success: false, error: '사용자명 또는 비밀번호가 올바르지 않습니다' });
|
|
}
|
|
|
|
// 협력업체 계정 만료일 체크
|
|
if (user.account_expires_at && new Date(user.account_expires_at) < new Date()) {
|
|
return res.status(401).json({ success: false, error: '계정이 만료되었습니다. 관리자에게 문의하세요.' });
|
|
}
|
|
|
|
const valid = await userModel.verifyPassword(password, user.password_hash);
|
|
if (!valid) {
|
|
await redis.incr(attemptKey);
|
|
await redis.expire(attemptKey, LOGIN_LOCKOUT_SECONDS);
|
|
return res.status(401).json({ success: false, error: '사용자명 또는 비밀번호가 올바르지 않습니다' });
|
|
}
|
|
|
|
// 로그인 성공 시 시도 횟수 초기화
|
|
await redis.del(attemptKey);
|
|
await userModel.updateLastLogin(user.user_id);
|
|
|
|
const payload = createTokenPayload(user);
|
|
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
|
const refresh_token = jwt.sign(
|
|
{ user_id: user.user_id, type: 'refresh' },
|
|
JWT_REFRESH_SECRET,
|
|
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
|
|
);
|
|
|
|
// SSO 쿠키는 클라이언트(login.html)에서 domain=.technicalkorea.net으로 설정
|
|
// 서버 httpOnly 쿠키는 서브도메인 공유 불가하므로 제거
|
|
|
|
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,
|
|
department_id: user.department_id || null,
|
|
department_name: user.department_name || user.department || null,
|
|
is_production: user.is_production || false,
|
|
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' });
|
|
}
|
|
|
|
// Rate limiting (동일 로직: /login과 공유)
|
|
const attemptKey = `login_attempts:${username}`;
|
|
const attempts = parseInt(await redis.get(attemptKey)) || 0;
|
|
if (attempts >= MAX_LOGIN_ATTEMPTS) {
|
|
return res.status(429).json({ detail: '로그인 시도 횟수를 초과했습니다. 5분 후 다시 시도하세요' });
|
|
}
|
|
|
|
const user = await userModel.findByUsername(username);
|
|
if (!user) {
|
|
await redis.incr(attemptKey);
|
|
await redis.expire(attemptKey, LOGIN_LOCKOUT_SECONDS);
|
|
return res.status(401).json({ detail: 'Incorrect username or password' });
|
|
}
|
|
|
|
// 협력업체 계정 만료일 체크
|
|
if (user.account_expires_at && new Date(user.account_expires_at) < new Date()) {
|
|
return res.status(401).json({ detail: '계정이 만료되었습니다' });
|
|
}
|
|
|
|
const valid = await userModel.verifyPassword(password, user.password_hash);
|
|
if (!valid) {
|
|
await redis.incr(attemptKey);
|
|
await redis.expire(attemptKey, LOGIN_LOCKOUT_SECONDS);
|
|
return res.status(401).json({ detail: 'Incorrect username or password' });
|
|
}
|
|
|
|
// 로그인 성공 시 시도 횟수 초기화
|
|
await redis.del(attemptKey);
|
|
|
|
await userModel.updateLastLogin(user.user_id);
|
|
|
|
const payload = createTokenPayload(user);
|
|
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
|
|
|
|
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: '토큰이 필요합니다' });
|
|
}
|
|
|
|
// TODO: issuer/audience 클레임 검증 추가 검토
|
|
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
|
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,
|
|
department_id: user.department_id || null,
|
|
department_name: user.department_name || user.department || null,
|
|
is_production: user.is_production || false,
|
|
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, { algorithms: ['HS256'] });
|
|
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, { algorithms: ['HS256'] });
|
|
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, algorithm: 'HS256' });
|
|
const new_refresh_token = jwt.sign(
|
|
{ user_id: user.user_id, type: 'refresh' },
|
|
JWT_REFRESH_SECRET,
|
|
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
|
|
);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/auth/change-password — 본인 비밀번호 변경
|
|
*/
|
|
async function changePassword(req, res, next) {
|
|
try {
|
|
const token = extractToken(req);
|
|
if (!token) return res.status(401).json({ success: false, message: '인증이 필요합니다' });
|
|
|
|
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
|
const userId = decoded.user_id || decoded.id;
|
|
const user = await userModel.findById(userId);
|
|
if (!user || !user.is_active) {
|
|
return res.status(401).json({ success: false, message: '유효하지 않은 사용자입니다' });
|
|
}
|
|
|
|
const { currentPassword, newPassword } = req.body;
|
|
if (!currentPassword || !newPassword) {
|
|
return res.status(400).json({ success: false, message: '현재 비밀번호와 새 비밀번호를 모두 입력해주세요' });
|
|
}
|
|
if (newPassword.length < 6) {
|
|
return res.status(400).json({ success: false, message: '새 비밀번호는 6자 이상이어야 합니다' });
|
|
}
|
|
if (currentPassword === newPassword) {
|
|
return res.status(400).json({ success: false, message: '새 비밀번호는 현재 비밀번호와 달라야 합니다' });
|
|
}
|
|
|
|
const isValid = await userModel.verifyPassword(currentPassword, user.password_hash);
|
|
if (!isValid) {
|
|
return res.status(400).json({ success: false, message: '현재 비밀번호가 올바르지 않습니다' });
|
|
}
|
|
|
|
await userModel.update(userId, { password: newPassword });
|
|
res.json({ success: true, message: '비밀번호가 변경되었습니다. 다시 로그인해주세요.' });
|
|
} catch (err) {
|
|
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
|
|
return res.status(401).json({ success: false, message: '인증이 만료되었습니다' });
|
|
}
|
|
next(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/auth/check-password-strength — 비밀번호 강도 체크
|
|
*/
|
|
async function checkPasswordStrength(req, res) {
|
|
const { password } = req.body;
|
|
if (!password) return res.json({ success: true, data: { score: 0, level: 'weak' } });
|
|
|
|
let score = 0;
|
|
if (password.length >= 6) score++;
|
|
if (password.length >= 8) score++;
|
|
if (/[A-Z]/.test(password)) score++;
|
|
if (/[0-9]/.test(password)) score++;
|
|
if (/[^A-Za-z0-9]/.test(password)) score++;
|
|
|
|
const level = score <= 1 ? 'weak' : score <= 3 ? 'medium' : 'strong';
|
|
res.json({ success: true, data: { score, level } });
|
|
}
|
|
|
|
/**
|
|
* Bearer 토큰 또는 쿠키에서 토큰 추출
|
|
*/
|
|
function extractToken(req) {
|
|
// Authorization header (SSO 토큰은 항상 Bearer로 전달)
|
|
const authHeader = req.headers['authorization'];
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
return authHeader.split(' ')[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
module.exports = {
|
|
login,
|
|
loginForm,
|
|
validate,
|
|
me,
|
|
refresh,
|
|
logout,
|
|
getUsers,
|
|
createUser,
|
|
updateUser,
|
|
deleteUser,
|
|
changePassword,
|
|
checkPasswordStrength
|
|
};
|