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:
@@ -24,7 +24,8 @@ export type AuditAction =
|
||||
| "STATUS_CHANGE"
|
||||
| "BATCH_CREATE"
|
||||
| "BATCH_UPDATE"
|
||||
| "BATCH_DELETE";
|
||||
| "BATCH_DELETE"
|
||||
| "DEPT_CHANGE_WARNING";
|
||||
|
||||
export type AuditResourceType =
|
||||
| "MENU"
|
||||
|
||||
@@ -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 !== "*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
75
backend-node/src/services/tokenInvalidationService.ts
Normal file
75
backend-node/src/services/tokenInvalidationService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user