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:
kjs
2026-03-25 18:47:50 +09:00
parent 782ebb1b33
commit 70e040db39
12 changed files with 573 additions and 36 deletions

View File

@@ -24,7 +24,8 @@ export type AuditAction =
| "STATUS_CHANGE"
| "BATCH_CREATE"
| "BATCH_UPDATE"
| "BATCH_DELETE";
| "BATCH_DELETE"
| "DEPT_CHANGE_WARNING";
export type AuditResourceType =
| "MENU"

View File

@@ -134,12 +134,14 @@ export class AuthService {
company_code: string | null;
locale: string | null;
photo: Buffer | null;
token_version: number | null;
}>(
`SELECT
sabun, user_id, user_name, user_name_eng, user_name_cn,
dept_code, dept_name, position_code, position_name,
email, tel, cell_phone, user_type, user_type_name,
partner_objid, company_code, locale, photo
partner_objid, company_code, locale, photo,
COALESCE(token_version, 0) as token_version
FROM user_info
WHERE user_id = $1`,
[userId]
@@ -210,6 +212,7 @@ export class AuthService {
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
: undefined,
locale: userInfo.locale || "KR",
tokenVersion: userInfo.token_version ?? 0,
// 권한 레벨 정보 추가 (3단계 체계)
isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN",
isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*",

View File

@@ -1,4 +1,4 @@
import { query } from "../database/db";
import { query, transaction } from "../database/db";
import { logger } from "../utils/logger";
/**
@@ -145,10 +145,19 @@ export class RoleService {
writer: string;
}): Promise<RoleGroup> {
try {
// 동일 회사 내 같은 이름의 권한 그룹 중복 체크
const dupCheck = await query<{ count: string }>(
`SELECT COUNT(*) AS count FROM authority_master WHERE company_code = $1 AND auth_name = $2`,
[data.companyCode, data.authName]
);
if (dupCheck.length > 0 && parseInt(dupCheck[0].count, 10) > 0) {
throw new Error(`동일 회사 내에 이미 같은 이름의 권한 그룹이 존재합니다: ${data.authName}`);
}
const sql = `
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate)
VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW())
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
company_code AS "companyCode", status, writer, regdate
`;
@@ -460,35 +469,37 @@ export class RoleService {
writer: string
): Promise<void> {
try {
// 기존 권한 삭제
await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
authObjid,
]);
// 새로운 권한 삽입
if (permissions.length > 0) {
const values = permissions
.map(
(_, index) =>
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
)
.join(", ");
const params = permissions.flatMap((p) => [
p.menuObjid,
p.createYn,
p.readYn,
p.updateYn,
p.deleteYn,
await transaction(async (client) => {
// 기존 권한 삭제
await client.query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
authObjid,
]);
const sql = `
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
VALUES ${values}
`;
// 새로운 권한 삽입
if (permissions.length > 0) {
const values = permissions
.map(
(_, index) =>
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
)
.join(", ");
await query(sql, [authObjid, ...params, writer]);
}
const params = permissions.flatMap((p) => [
p.menuObjid,
p.createYn,
p.readYn,
p.updateYn,
p.deleteYn,
]);
const sql = `
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
VALUES ${values}
`;
await client.query(sql, [authObjid, ...params, writer]);
}
});
logger.info("메뉴 권한 설정 성공", {
authObjid,

View File

@@ -0,0 +1,75 @@
// JWT 토큰 무효화 서비스
// user_info.token_version 기반으로 기존 JWT 토큰을 무효화
import { query } from "../database/db";
import { cache } from "../utils/cache";
import { logger } from "../utils/logger";
const TOKEN_VERSION_CACHE_TTL = 2 * 60 * 1000; // 2분 캐시
export class TokenInvalidationService {
/**
* 캐시 키 생성
*/
static cacheKey(userId: string): string {
return `token_version:${userId}`;
}
/**
* 단일 사용자의 토큰 무효화 (token_version +1)
*/
static async invalidateUserTokens(userId: string): Promise<void> {
try {
await query(
`UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id = $1`,
[userId]
);
cache.delete(this.cacheKey(userId));
logger.info(`토큰 무효화: ${userId}`);
} catch (error) {
logger.error(`토큰 무효화 실패: ${userId}`, { error });
}
}
/**
* 여러 사용자의 토큰 일괄 무효화
*/
static async invalidateMultipleUserTokens(userIds: string[]): Promise<void> {
if (userIds.length === 0) return;
try {
const placeholders = userIds.map((_, i) => `$${i + 1}`).join(", ");
await query(
`UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id IN (${placeholders})`,
userIds
);
userIds.forEach((id) => cache.delete(this.cacheKey(id)));
logger.info(`토큰 일괄 무효화: ${userIds.length}`);
} catch (error) {
logger.error(`토큰 일괄 무효화 실패`, { error, userIds });
}
}
/**
* 현재 token_version 조회 (캐시 사용)
*/
static async getUserTokenVersion(userId: string): Promise<number> {
const cacheKey = this.cacheKey(userId);
const cached = cache.get<number>(cacheKey);
if (cached !== null) {
return cached;
}
try {
const result = await query<{ token_version: number | null }>(
`SELECT token_version FROM user_info WHERE user_id = $1`,
[userId]
);
const version = result.length > 0 ? (result[0].token_version ?? 0) : 0;
cache.set(cacheKey, version, TOKEN_VERSION_CACHE_TTL);
return version;
} catch (error) {
logger.error(`token_version 조회 실패: ${userId}`, { error });
return 0;
}
}
}