Enhance user management and token invalidation features
- Added comprehensive validation for user data during registration and updates, including email format, company code existence, user type validation, and password length checks. - Implemented JWT token invalidation for users when their status changes or when roles are updated, ensuring security and compliance with the latest policies. - Introduced a new TokenInvalidationService to manage token versioning and invalidation processes efficiently. - Updated the admin controller to provide detailed error messages and success responses for user status changes and validations. - Enhanced the authentication middleware to check token versions against the database, ensuring that invalidated tokens cannot be used. This commit improves the overall security and user management experience within the application.
This commit is contained in:
@@ -2504,7 +2504,9 @@ export const changeUserStatus = async (
|
||||
// 필수 파라미터 검증
|
||||
if (!userId || !status) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 ID와 상태는 필수입니다.",
|
||||
msg: "사용자 ID와 상태는 필수입니다.",
|
||||
});
|
||||
return;
|
||||
@@ -2513,7 +2515,9 @@ export const changeUserStatus = async (
|
||||
// 상태 값 검증
|
||||
if (!["active", "inactive"].includes(status)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
|
||||
msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
|
||||
});
|
||||
return;
|
||||
@@ -2528,7 +2532,9 @@ export const changeUserStatus = async (
|
||||
|
||||
if (!currentUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
msg: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
@@ -2549,6 +2555,12 @@ export const changeUserStatus = async (
|
||||
if (updateResult.length > 0) {
|
||||
// 사용자 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
|
||||
|
||||
// inactive로 변경 시 기존 JWT 토큰 무효화
|
||||
if (status === "inactive") {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||
}
|
||||
|
||||
logger.info("사용자 상태 변경 성공", {
|
||||
userId,
|
||||
oldStatus: currentUser.status,
|
||||
@@ -2571,12 +2583,16 @@ export const changeUserStatus = async (
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: true,
|
||||
message: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
||||
msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 상태 변경에 실패했습니다.",
|
||||
msg: "사용자 상태 변경에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
@@ -2587,7 +2603,9 @@ export const changeUserStatus = async (
|
||||
status: req.body.status,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "시스템 오류가 발생했습니다.",
|
||||
msg: "시스템 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
@@ -2627,12 +2645,214 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 추가 유효성 검증
|
||||
|
||||
// 1. email 형식 검증 (값이 있는 경우만)
|
||||
if (userData.email && userData.email.trim() !== "") {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(userData.email.trim())) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "이메일 형식이 올바르지 않습니다.",
|
||||
error: {
|
||||
code: "INVALID_EMAIL_FORMAT",
|
||||
details: `Invalid email format: ${userData.email}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. companyCode 존재 확인 (값이 있는 경우만)
|
||||
if (userData.companyCode && userData.companyCode.trim() !== "") {
|
||||
const companyExists = await queryOne<{ company_code: string }>(
|
||||
`SELECT company_code FROM company_mng WHERE company_code = $1`,
|
||||
[userData.companyCode.trim()]
|
||||
);
|
||||
if (!companyExists) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `존재하지 않는 회사 코드입니다: ${userData.companyCode}`,
|
||||
error: {
|
||||
code: "INVALID_COMPANY_CODE",
|
||||
details: `Company code not found: ${userData.companyCode}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. userType 유효값 검증 (값이 있는 경우만)
|
||||
if (userData.userType && userData.userType.trim() !== "") {
|
||||
const validUserTypes = ["SUPER_ADMIN", "COMPANY_ADMIN", "USER", "GUEST", "PARTNER"];
|
||||
if (!validUserTypes.includes(userData.userType.trim())) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `유효하지 않은 사용자 유형입니다: ${userData.userType}. 허용값: ${validUserTypes.join(", ")}`,
|
||||
error: {
|
||||
code: "INVALID_USER_TYPE",
|
||||
details: `Invalid userType: ${userData.userType}. Allowed: ${validUserTypes.join(", ")}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 비밀번호 최소 길이 검증 (신규 등록 시)
|
||||
if (!isUpdate && userData.userPassword && userData.userPassword.length < 4) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "비밀번호는 최소 4자 이상이어야 합니다.",
|
||||
error: {
|
||||
code: "PASSWORD_TOO_SHORT",
|
||||
details: "Password must be at least 4 characters long",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 비밀번호 암호화 (비밀번호가 제공된 경우에만)
|
||||
let encryptedPassword = null;
|
||||
if (userData.userPassword) {
|
||||
encryptedPassword = await EncryptUtil.encrypt(userData.userPassword);
|
||||
}
|
||||
|
||||
// PUT(수정) 요청 시 company_code / dept_code 변경 감지
|
||||
if (isUpdate) {
|
||||
const existingUser = await queryOne<{ company_code: string; dept_code: string }>(
|
||||
`SELECT company_code, dept_code FROM user_info WHERE user_id = $1`,
|
||||
[userData.userId]
|
||||
);
|
||||
|
||||
// company_code 변경 감지 → 이전 회사 권한 그룹 제거
|
||||
if (
|
||||
userData.companyCode &&
|
||||
existingUser &&
|
||||
existingUser.company_code &&
|
||||
existingUser.company_code !== userData.companyCode
|
||||
) {
|
||||
const oldCompanyCode = existingUser.company_code;
|
||||
logger.info("사용자 회사 코드 변경 감지 - 이전 회사 권한 그룹 제거", {
|
||||
userId: userData.userId,
|
||||
oldCompanyCode,
|
||||
newCompanyCode: userData.companyCode,
|
||||
});
|
||||
|
||||
// 이전 회사의 권한 그룹에서 해당 사용자 제거
|
||||
await query(
|
||||
`DELETE FROM authority_sub_user
|
||||
WHERE user_id = $1
|
||||
AND master_objid IN (
|
||||
SELECT objid FROM authority_master WHERE company_code = $2
|
||||
)`,
|
||||
[userData.userId, oldCompanyCode]
|
||||
);
|
||||
}
|
||||
|
||||
// dept_code 변경 감지 → 결재 템플릿/진행중 결재라인 경고 로그
|
||||
const newDeptCode = userData.deptCode || null;
|
||||
const oldDeptCode = existingUser?.dept_code || null;
|
||||
if (existingUser && oldDeptCode && newDeptCode && oldDeptCode !== newDeptCode) {
|
||||
logger.warn("사용자 부서 변경 감지 - 결재라인 영향 확인 시작", {
|
||||
userId: userData.userId,
|
||||
userName: userData.userName,
|
||||
oldDeptCode,
|
||||
newDeptCode,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1) 결재선 템플릿 스텝에서 해당 사용자가 결재자로 등록된 건 조회
|
||||
const templateSteps = await query<{
|
||||
template_id: number;
|
||||
step_order: number;
|
||||
approver_label: string | null;
|
||||
approver_dept_code: string | null;
|
||||
}>(
|
||||
`SELECT s.template_id, s.step_order, s.approver_label, s.approver_dept_code
|
||||
FROM approval_line_template_steps s
|
||||
WHERE s.approver_user_id = $1`,
|
||||
[userData.userId]
|
||||
);
|
||||
|
||||
if (templateSteps && templateSteps.length > 0) {
|
||||
logger.warn(
|
||||
`[결재라인 경고] 부서 변경된 사용자(${userData.userId})가 결재선 템플릿 ${templateSteps.length}건에 결재자로 등록되어 있습니다. 수동 확인이 필요합니다.`,
|
||||
{
|
||||
userId: userData.userId,
|
||||
oldDeptCode,
|
||||
newDeptCode,
|
||||
affectedTemplates: templateSteps.map((s) => ({
|
||||
templateId: s.template_id,
|
||||
stepOrder: s.step_order,
|
||||
label: s.approver_label,
|
||||
currentDeptInStep: s.approver_dept_code,
|
||||
})),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 2) 진행중인 결재 요청에서 해당 사용자가 대기중 결재자인 건 조회
|
||||
const pendingLines = await query<{
|
||||
request_id: number;
|
||||
step_order: number;
|
||||
approver_dept: string | null;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT l.request_id, l.step_order, l.approver_dept, l.status
|
||||
FROM approval_lines l
|
||||
JOIN approval_requests r ON r.request_id = l.request_id
|
||||
WHERE l.approver_id = $1
|
||||
AND l.status = 'pending'
|
||||
AND r.status IN ('in_progress', 'pending')`,
|
||||
[userData.userId]
|
||||
);
|
||||
|
||||
if (pendingLines && pendingLines.length > 0) {
|
||||
logger.warn(
|
||||
`[결재라인 경고] 부서 변경된 사용자(${userData.userId})에게 대기중인 결재 ${pendingLines.length}건이 있습니다. 수동 확인이 필요합니다.`,
|
||||
{
|
||||
userId: userData.userId,
|
||||
oldDeptCode,
|
||||
newDeptCode,
|
||||
pendingApprovals: pendingLines.map((l) => ({
|
||||
requestId: l.request_id,
|
||||
stepOrder: l.step_order,
|
||||
currentDeptInLine: l.approver_dept,
|
||||
})),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 감사 로그 기록
|
||||
auditLogService.log({
|
||||
companyCode: userData.companyCode || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DEPT_CHANGE_WARNING",
|
||||
resourceType: "USER",
|
||||
resourceId: userData.userId,
|
||||
resourceName: userData.userName,
|
||||
summary: `사용자 "${userData.userName}"의 부서 변경 (${oldDeptCode} → ${newDeptCode}). 결재 템플릿 ${templateSteps?.length || 0}건, 대기중 결재 ${pendingLines?.length || 0}건 영향 가능`,
|
||||
changes: {
|
||||
before: { deptCode: oldDeptCode },
|
||||
after: {
|
||||
deptCode: newDeptCode,
|
||||
affectedTemplateCount: templateSteps?.length || 0,
|
||||
pendingApprovalCount: pendingLines?.length || 0,
|
||||
},
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
} catch (approvalCheckError) {
|
||||
// 결재 테이블이 없는 환경에서도 사용자 저장은 계속 진행
|
||||
logger.warn("결재라인 영향 확인 중 오류 (사용자 저장은 계속 진행)", {
|
||||
error: approvalCheckError instanceof Error ? approvalCheckError.message : approvalCheckError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT)
|
||||
const updatePasswordClause = encryptedPassword ? "user_password = $4," : "";
|
||||
|
||||
@@ -2688,6 +2908,12 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||
savedUser.regdate &&
|
||||
new Date(savedUser.regdate).getTime() < Date.now() - 1000;
|
||||
|
||||
// 기존 사용자의 비밀번호 변경 시 JWT 토큰 무효화
|
||||
if (encryptedPassword && isExistingUser) {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userData.userId);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료",
|
||||
{
|
||||
@@ -3534,6 +3760,10 @@ export const resetUserPassword = async (
|
||||
if (updateResult.length > 0) {
|
||||
// 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
|
||||
|
||||
// 비밀번호 변경 후 기존 JWT 토큰 무효화
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||
|
||||
logger.info("비밀번호 초기화 성공", {
|
||||
userId,
|
||||
updatedBy: req.user?.userId,
|
||||
@@ -4153,6 +4383,140 @@ export const saveUserWithDept = async (
|
||||
* GET /api/admin/users/:userId/with-dept
|
||||
* 사원 + 부서 정보 조회 API (수정 모달용)
|
||||
*/
|
||||
/**
|
||||
* DELETE /api/admin/users/:userId
|
||||
* 사용자 삭제 API (soft delete)
|
||||
* status = 'deleted', end_date = now() 설정
|
||||
* authority_sub_user 멤버십 제거, JWT 토큰 무효화
|
||||
*/
|
||||
export const deleteUser = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
// 1. userId 파라미터 검증
|
||||
if (!userId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 ID는 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 자기 자신 삭제 방지
|
||||
if (req.user?.userId === userId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "자기 자신은 삭제할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 사용자 존재 여부 확인
|
||||
const currentUser = await queryOne<any>(
|
||||
`SELECT user_id, user_name, status, company_code FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!currentUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 삭제된 사용자 체크
|
||||
if (currentUser.status === "deleted") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "이미 삭제된 사용자입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. soft delete: status = 'deleted', end_date = now()
|
||||
const updateResult = await query<any>(
|
||||
`UPDATE user_info
|
||||
SET status = 'deleted', end_date = NOW()
|
||||
WHERE user_id = $1
|
||||
RETURNING *`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (updateResult.length === 0) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 삭제에 실패했습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. authority_sub_user에서 해당 사용자 멤버십 제거
|
||||
await query(
|
||||
`DELETE FROM authority_sub_user WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// 6. JWT 토큰 무효화
|
||||
try {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||
} catch (tokenError) {
|
||||
logger.warn("토큰 무효화 중 오류 (삭제는 정상 처리됨)", { userId, error: tokenError });
|
||||
}
|
||||
|
||||
logger.info("사용자 삭제(soft delete) 성공", {
|
||||
userId,
|
||||
userName: currentUser.user_name,
|
||||
deletedBy: req.user?.userId,
|
||||
});
|
||||
|
||||
// 7. 감사 로그 기록
|
||||
auditLogService.log({
|
||||
companyCode: currentUser.company_code || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DELETE",
|
||||
resourceType: "USER",
|
||||
resourceId: userId,
|
||||
resourceName: currentUser.user_name,
|
||||
summary: `사용자 "${currentUser.user_name}" (${userId}) 삭제 처리`,
|
||||
changes: {
|
||||
before: { status: currentUser.status },
|
||||
after: { status: "deleted" },
|
||||
fields: ["status", "end_date"],
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
// 8. 응답
|
||||
res.json({
|
||||
success: true,
|
||||
result: true,
|
||||
message: `사용자 "${currentUser.user_name}" (${userId})이(가) 삭제되었습니다.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("사용자 삭제 중 오류 발생", {
|
||||
error: error.message,
|
||||
userId: req.params.userId,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "시스템 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserWithDept = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
|
||||
@@ -472,6 +472,10 @@ export const addRoleMembers = async (
|
||||
req.user?.userId || "SYSTEM"
|
||||
);
|
||||
|
||||
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(userIds);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
message: "권한 그룹 멤버 추가 성공",
|
||||
@@ -568,6 +572,13 @@ export const updateRoleMembers = async (
|
||||
);
|
||||
}
|
||||
|
||||
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||
const allAffectedUsers = [...new Set([...toAdd, ...toRemove])];
|
||||
if (allAffectedUsers.length > 0) {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(allAffectedUsers);
|
||||
}
|
||||
|
||||
logger.info("권한 그룹 멤버 일괄 업데이트 성공", {
|
||||
masterObjid,
|
||||
added: toAdd.length,
|
||||
@@ -646,6 +657,10 @@ export const removeRoleMembers = async (
|
||||
req.user?.userId || "SYSTEM"
|
||||
);
|
||||
|
||||
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(userIds);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
message: "권한 그룹 멤버 제거 성공",
|
||||
@@ -777,6 +792,18 @@ export const setMenuPermissions = async (
|
||||
req.user?.userId || "SYSTEM"
|
||||
);
|
||||
|
||||
// 해당 권한 그룹의 모든 멤버 JWT 토큰 무효화
|
||||
try {
|
||||
const members = await RoleService.getRoleMembers(authObjid);
|
||||
const memberIds = members.map((m: any) => m.userId);
|
||||
if (memberIds.length > 0) {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(memberIds);
|
||||
}
|
||||
} catch (invalidateError) {
|
||||
logger.warn("메뉴 권한 변경 후 토큰 무효화 실패 (권한 설정은 성공)", { invalidateError });
|
||||
}
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
message: "메뉴 권한 설정 성공",
|
||||
|
||||
Reference in New Issue
Block a user