From 94da0fe9ae042e5cae47c36b169d30dd2eee559c Mon Sep 17 00:00:00 2001 From: infraurp Date: Mon, 10 Aug 2026 13:58:58 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=9C=EC=9D=B8=EC=A0=95=EB=B3=B4=EB=B3=B4?= =?UTF-8?q?=ED=98=B8=20=EA=B8=B0=EB=8A=A5=EA=B0=9C=EC=84=A0=203=EC=B0=A8?= =?UTF-8?q?=20=E2=80=94=20=EC=A0=91=EA=B7=BC=ED=86=B5=EC=A0=9C=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=ED=99=94=EB=A9=B4=20=EC=99=B8=20=EB=AF=B8=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=20=EC=82=B0=EC=B6=9C=EB=AC=BC=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5개 세션에 걸쳐 쌓인 미커밋 변경분을 한 번에 정리한다. 주 산출물은 이번 세션의 접근통제 정책 적용(SER-001 요건 4·5)이고, 나머지는 앞선 세션들의 백로그다. ■ 접근통제 정책 적용 (SER-001 요건 4·5, 이번 세션) 「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안 반영. - ACCESS_CONTROL_POLICY(단일행) + 관리자 화면 신규 (메뉴 9100047) · 계정 잠금: 인증 실패 임계값(10 -> 5), 잠금 해제 방식(관리자화면+이력 / +사유필수) · 자동 로그아웃: 미사용 시간(60 -> 30분), 만료 임박 안내 사용여부·시점 · 세션 재발급: 2차 인증 성공 시 세션 고정(Session Fixation) 차단 여부 - SessionPolicyInterceptor 신규 — /mnt/ 요청마다 정책값으로 세션 타임아웃 적용. web.xml 재배포 없이 값이 바뀐다(2025.9 자료요청 3번 "시간 설정 기능 없음" 해소). web.xml 30 은 이제 폴백값이다. - 잠금 임계값 저장 시 인증 경로가 읽는 LOGIN_LOCK_COUNT 를 함께 동기화. 정책 테이블만 고치면 화면 숫자만 바뀌고 실제 인증은 옛 값을 계속 쓰기 때문이다. - 관리자 상단(top.jsp): 로그인 후 경과시간 + 자동 로그아웃 잔여 카운트다운, 만료 임박 안내와 연장(/mnt/session/extend.do). 세션 상태를 주기 폴링하지 않는다 — 그 요청 자체가 세션을 갱신해 '미사용 자동 로그아웃'이 영원히 오지 않기 때문이다. - 자동 로그아웃 시 로그인 화면 이동: actionLogout.do?expired=Y 로 세션 invalidate 후 로그인 화면 + 사유 안내. 수동 로그아웃(프론트 메인 이동)은 기존 동작 유지. - 계정잠금관리: 정책이 '사유 필수'면 해제 사유 강제, ACCOUNT_UNLOCK_HISTORY 적재. ■ EgovLoginLogAspect fail-safe (회귀 수정) 접속기록 적재 예외가 @Before 어드바이스 밖으로 나가 로그인·로그아웃이 통째로 500 이 되고 있었다(로컬: MySQL 8 + mysql-connector 5.1.17 의 tx_isolation 조회 실패). 클래스 Javadoc 이 이미 fail-safe 를 명세하고 있었으나 구현이 따르지 않았다. try/catch 로 인증 흐름을 살리되, 접속기록 유실은 SFR-004 직결이라 ERROR 로 남긴다. ■ 앞선 세션 백로그 - 접속기록(SFR-004): 조회·CSV 비동기 다운로드(PDAL_DOWNLOAD_JOB), 업무유형 매핑 - 로그인 접속기록(#62): COMTNLOGINLOG 접속IP·접속ID 유실 복구 + 접속위치 스냅샷 - 권한관리(SFR-001): 권한변경 이력 CSV 비동기 다운로드, 신청·승인 워크플로 화면 정리 - 미사용계정(SFR-002): 현황 화면 시안 반영(요약·역할·잠금/말소·조치 이력) - 회원정보 암호화(추가제안 Action4): AES 암복호화·검색용 해시, 이관 배치와 사전 백업 ■ 커밋 제외 font/(글꼴 설치본 22M), 제안서 문서(pptx/hwp), .claude/(하네스) 는 소스가 아니라 제외. .claude/ 버전관리 여부는 별도 판단이 필요하다. 배포 산출물: _workspace/deploy/20260810_1300/ (DELTA.md, sql/, APPLY_LOG.md) 브라우저 검증(2단계 인증 필요)은 APPLY_LOG.md B1~B7 로 남겨둔다. Co-Authored-By: Claude Sonnet 5 --- .../account/AccountActionHistoryVO.java | 62 +++ .../privacy/account/AccountMngController.java | 79 ++++ .../cmm/privacy/account/AccountMngDAO.java | 41 ++ .../account/DormantAccountStatusService.java | 22 ++ .../DormantAccountStatusServiceImpl.java | 121 +++++- .../account/DormantAccountStatusVO.java | 17 + .../account/DormantAccountSummaryVO.java | 56 +++ .../auditlog/AccessLogDownloadJobDAO.java | 84 +++++ .../auditlog/AccessLogDownloadJobService.java | 40 ++ .../AccessLogDownloadJobServiceImpl.java | 170 +++++++++ .../auditlog/AccessLogDownloadJobVO.java | 161 ++++++++ .../auditlog/AccessLogDownloadJobWorker.java | 357 ++++++++++++++++++ .../auditlog/AccessLogDownloadService.java | 17 + .../AccessLogDownloadServiceImpl.java | 12 + .../PersonalDataAccessLogController.java | 215 ++++++++++- .../PersonalDataAccessLogSearchDAO.java | 14 + .../PersonalDataAccessLogSearchVO.java | 84 ++++- .../privacy/author/AuthorChangeHistoryVO.java | 4 + .../author/AuthorHistDownloadJobDAO.java | 85 +++++ .../author/AuthorHistDownloadJobService.java | 38 ++ .../AuthorHistDownloadJobServiceImpl.java | 169 +++++++++ .../author/AuthorHistDownloadJobVO.java | 148 ++++++++ .../author/AuthorHistDownloadJobWorker.java | 338 +++++++++++++++++ .../privacy/author/AuthorHistoryService.java | 14 + .../author/AuthorHistoryServiceImpl.java | 13 + .../author/AuthorWorkflowController.java | 194 +++++++++- .../cmm/privacy/author/AuthorWorkflowDAO.java | 36 +- .../privacy/author/AuthorWorkflowService.java | 15 +- .../author/AuthorWorkflowServiceImpl.java | 35 +- .../privacy/author/PrivacyUserAuthorVO.java | 116 ++++++ .../privacy/conect/ConectLogController.java | 194 ++++++++++ .../com/cmm/privacy/conect/ConectLogDAO.java | 52 +++ .../cmm/privacy/conect/ConectLogService.java | 31 ++ .../privacy/conect/ConectLogServiceImpl.java | 122 ++++++ .../com/cmm/privacy/conect/ConectLogVO.java | 115 ++++++ .../com/cmm/privacy/conect/IpLocationDAO.java | 26 ++ .../privacy/conect/IpLocationResolver.java | 189 ++++++++++ .../com/cmm/privacy/conect/IpLocationVO.java | 64 ++++ .../cmm/privacy/crypto/MemberDataCipher.java | 181 +++++++++ .../cmm/privacy/crypto/MemberDataHasher.java | 162 ++++++++ .../cmm/privacy/crypto/MemberNmCryptoDAO.java | 139 +++++++ .../cmm/privacy/crypto/MemberNmCryptoVO.java | 105 ++++++ .../privacy/crypto/MemberNmCryptoWorker.java | 309 +++++++++++++++ .../interceptor/MenuAccessLogInterceptor.java | 150 +++++++- .../interceptor/SessionPolicyInterceptor.java | 164 ++++++++ .../policy/AccessControlPolicyController.java | 253 +++++++++++++ .../policy/AccessControlPolicyDAO.java | 45 +++ .../policy/AccessControlPolicyService.java | 23 ++ .../AccessControlPolicyServiceImpl.java | 86 +++++ .../privacy/policy/AccessControlPolicyVO.java | 92 +++++ .../privacy/security/SessionRegenerator.java | 19 + .../log/clg/service/EgovLoginLogAspect.java | 179 ++++++--- .../com/sym/log/clg/service/LoginLog.java | 38 ++ .../com/uat/uia/web/EgovLoginController.java | 36 +- .../uat/uia/web/SecondFactorController.java | 27 +- .../EgovAccountUnlockManageService.java | 19 +- .../com/uss/umt/service/MberManageVO.java | 29 ++ .../com/uss/umt/service/UserDefaultVO.java | 16 + .../EgovAccountUnlockManageServiceImpl.java | 26 +- .../uss/umt/service/impl/MberManageDAO.java | 63 ++++ .../EgovAccountUnlockManageController.java | 49 ++- .../kr/or/arko/cmm/batch/BatchScheduler.java | 38 ++ .../merge/service/impl/BizMberMergeDAO.java | 29 +- .../com/message-common_en_US.properties | 3 +- .../com/message-common_ko_KR.properties | 1 + .../sym/log/clg/EgovLoginLog_SQL_Mysql.xml | 12 +- .../sym/log/clg/EgovLoginLog_SQL_Oracle.xml | 6 + .../com/uss/umt/EgovMberManage_SQL_Mysql.xml | 31 +- .../com/uss/umt/EgovMberManage_SQL_Oracle.xml | 21 +- .../mysql/sql-map-config-mysql-0-site.xml | 15 + .../frt/biz/merge/BizLibMember_SQL_Mysql.xml | 3 + .../frt/biz/merge/BizLibMember_SQL_Oracle.xml | 3 + .../PrivacyAccessControlPolicy_SQL_Mysql.xml | 70 ++++ .../PrivacyAccessControlPolicy_SQL_Oracle.xml | 57 +++ .../privacy/PrivacyAccessLog_SQL_Mysql.xml | 55 ++- .../privacy/PrivacyAccessLog_SQL_Oracle.xml | 43 ++- .../PrivacyAccountDelete_SQL_Mysql.xml | 10 +- .../PrivacyAccountDelete_SQL_Oracle.xml | 6 + .../privacy/PrivacyAccountMng_SQL_Mysql.xml | 223 +++++++++-- .../privacy/PrivacyAccountMng_SQL_Oracle.xml | 195 ++++++++-- ...PrivacyAuthorHistDownloadJob_SQL_Mysql.xml | 186 +++++++++ ...rivacyAuthorHistDownloadJob_SQL_Oracle.xml | 177 +++++++++ .../PrivacyAuthorWorkflow_SQL_Mysql.xml | 223 ++++++++--- .../PrivacyAuthorWorkflow_SQL_Oracle.xml | 201 +++++++--- .../privacy/PrivacyConectLog_SQL_Mysql.xml | 143 +++++++ .../privacy/PrivacyConectLog_SQL_Oracle.xml | 128 +++++++ .../privacy/PrivacyDownloadJob_SQL_Mysql.xml | 189 ++++++++++ .../privacy/PrivacyDownloadJob_SQL_Oracle.xml | 190 ++++++++++ .../privacy/PrivacyMberNmCrypto_SQL_Mysql.xml | 145 +++++++ .../PrivacyMberNmCrypto_SQL_Oracle.xml | 130 +++++++ .../springmvc/egov-com-servlet.xml | 31 ++ .../com/uss/umt/EgovAccountUnlockManage.jsp | 35 ++ src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp | 16 +- .../jsp/site/mnt/sec/accessLog/list.jsp | 317 ++++++++++++++-- .../jsp/site/mnt/sec/accessPolicy/policy.jsp | 226 +++++++++++ .../jsp/site/mnt/sec/authorHistory/list.jsp | 266 +++++++++++-- .../jsp/site/mnt/sec/authorRequest/list.jsp | 166 +++++--- .../jsp/site/mnt/sec/authorRequest/regist.jsp | 6 +- .../jsp/site/mnt/sec/conectLog/list.jsp | 209 ++++++++++ .../jsp/site/mnt/sec/dormantStatus/list.jsp | 281 +++++++++++--- src/main/webapp/WEB-INF/jsp/site/mnt/top.jsp | 143 +++++++ src/main/webapp/WEB-INF/web.xml | 5 +- .../backup/privacy.mber_nm_backup_mysql.sql | 101 +++++ ...acy.access_control_policy_create_mysql.sql | 77 ++++ ...cy.account_action_history_create_mysql.sql | 35 ++ ....author_hist_download_job_create_mysql.sql | 61 +++ .../ddl/privacy.conect_log_extend_mysql.sql | 207 ++++++++++ .../ddl/privacy.download_job_create_mysql.sql | 59 +++ .../ddl/privacy.emplyr_scrty_index_mysql.sql | 36 ++ .../privacy.mber_nm_encrypt_create_mysql.sql | 121 ++++++ ...rivacy.account_policy_dormant_90_mysql.sql | 40 ++ ...rivacy.menu_access_policy_insert_mysql.sql | 78 ++++ ...ivacy.menu_account_policy_insert_mysql.sql | 2 +- ...ivacy.menu_author_history_insert_mysql.sql | 8 +- ...ivacy.menu_author_history_rename_mysql.sql | 49 +++ ...ivacy.menu_author_request_insert_mysql.sql | 14 +- ...ivacy.menu_author_request_rename_mysql.sql | 60 +++ .../privacy.menu_conect_log_insert_mysql.sql | 59 +++ ...ivacy.menu_dormant_status_insert_mysql.sql | 4 +- src/script/mysql/privacy.install_mysql.sql | 260 ++++++++++++- 개인정보보호_개발진행현황.md | 14 +- 121 files changed, 10763 insertions(+), 516 deletions(-) create mode 100644 src/main/java/egovframework/com/cmm/privacy/account/AccountActionHistoryVO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/account/DormantAccountSummaryVO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobDAO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobService.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobServiceImpl.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobVO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobWorker.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobDAO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobService.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobServiceImpl.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobVO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobWorker.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/author/PrivacyUserAuthorVO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/conect/ConectLogController.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/conect/ConectLogDAO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/conect/ConectLogService.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/conect/ConectLogServiceImpl.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/conect/ConectLogVO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/conect/IpLocationDAO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/conect/IpLocationResolver.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/conect/IpLocationVO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/crypto/MemberDataCipher.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/crypto/MemberDataHasher.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoDAO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoVO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoWorker.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyController.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyDAO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyService.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyServiceImpl.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyVO.java create mode 100644 src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessControlPolicy_SQL_Mysql.xml create mode 100644 src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessControlPolicy_SQL_Oracle.xml create mode 100644 src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistDownloadJob_SQL_Mysql.xml create mode 100644 src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistDownloadJob_SQL_Oracle.xml create mode 100644 src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyConectLog_SQL_Mysql.xml create mode 100644 src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyConectLog_SQL_Oracle.xml create mode 100644 src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Mysql.xml create mode 100644 src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Oracle.xml create mode 100644 src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberNmCrypto_SQL_Mysql.xml create mode 100644 src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberNmCrypto_SQL_Oracle.xml create mode 100644 src/main/webapp/WEB-INF/jsp/site/mnt/sec/accessPolicy/policy.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp create mode 100644 src/script/mysql/backup/privacy.mber_nm_backup_mysql.sql create mode 100644 src/script/mysql/ddl/privacy.access_control_policy_create_mysql.sql create mode 100644 src/script/mysql/ddl/privacy.account_action_history_create_mysql.sql create mode 100644 src/script/mysql/ddl/privacy.author_hist_download_job_create_mysql.sql create mode 100644 src/script/mysql/ddl/privacy.conect_log_extend_mysql.sql create mode 100644 src/script/mysql/ddl/privacy.download_job_create_mysql.sql create mode 100644 src/script/mysql/ddl/privacy.emplyr_scrty_index_mysql.sql create mode 100644 src/script/mysql/ddl/privacy.mber_nm_encrypt_create_mysql.sql create mode 100644 src/script/mysql/dml/privacy.account_policy_dormant_90_mysql.sql create mode 100644 src/script/mysql/dml/privacy.menu_access_policy_insert_mysql.sql create mode 100644 src/script/mysql/dml/privacy.menu_author_history_rename_mysql.sql create mode 100644 src/script/mysql/dml/privacy.menu_author_request_rename_mysql.sql create mode 100644 src/script/mysql/dml/privacy.menu_conect_log_insert_mysql.sql diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountActionHistoryVO.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountActionHistoryVO.java new file mode 100644 index 0000000..93d1f1b --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountActionHistoryVO.java @@ -0,0 +1,62 @@ +package egovframework.com.cmm.privacy.account; + +import java.io.Serializable; + +/** + * 장기 미사용 계정 조치 이력 VO — ACCOUNT_ACTION_HISTORY 매핑 (#11). + * + *

현황 화면에서 관리자가 수행한 잠금(휴면 확정)·말소 조치를 + * 조치자·조치일시·대상·사유·IP 와 함께 남긴다. 잠금 해제 이력은 기존 + * ACCOUNT_UNLOCK_HISTORY(SER-001 #24)가 담당하며 본 테이블과 역할이 다르다.

+ * + *
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.10    URP     최초 생성 (#11 시안 반영)
+ * 
+ */ +public class AccountActionHistoryVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 조치 유형 — 잠금(휴면 확정). */ + public static final String ACTION_LOCK = "L"; + /** 조치 유형 — 말소. */ + public static final String ACTION_EXPIRE = "D"; + + private long actionHistId; + /** 조치 대상 계정 ID. */ + private String trgetId; + /** 대상 회원구분 — 취급자는 'EMP'. */ + private String trgetSe = "EMP"; + /** 조치 유형 L:잠금 D:말소. */ + private String actionTy; + /** 조치 처리자(관리자) ID. */ + private String actionUserId; + /** 조치 사유(필수). */ + private String actionResn; + /** 조치자 접속 IP(프록시 보정값). */ + private String actionIp; + /** 조치 시점의 미접속 일수(사후 검증용 스냅샷). */ + private int noConectDay; + private String actionDt; + + public long getActionHistId() { return actionHistId; } + public void setActionHistId(long actionHistId) { this.actionHistId = actionHistId; } + public String getTrgetId() { return trgetId; } + public void setTrgetId(String trgetId) { this.trgetId = trgetId; } + public String getTrgetSe() { return trgetSe; } + public void setTrgetSe(String trgetSe) { this.trgetSe = trgetSe; } + public String getActionTy() { return actionTy; } + public void setActionTy(String actionTy) { this.actionTy = actionTy; } + public String getActionUserId() { return actionUserId; } + public void setActionUserId(String actionUserId) { this.actionUserId = actionUserId; } + public String getActionResn() { return actionResn; } + public void setActionResn(String actionResn) { this.actionResn = actionResn; } + public String getActionIp() { return actionIp; } + public void setActionIp(String actionIp) { this.actionIp = actionIp; } + public int getNoConectDay() { return noConectDay; } + public void setNoConectDay(int noConectDay) { this.noConectDay = noConectDay; } + public String getActionDt() { return actionDt; } + public void setActionDt(String actionDt) { this.actionDt = actionDt; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java index f558e36..1284949 100644 --- a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java +++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java @@ -1,5 +1,6 @@ package egovframework.com.cmm.privacy.account; +import java.net.URLEncoder; import java.util.List; import javax.annotation.Resource; @@ -9,8 +10,10 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.ModelMap; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.privacy.auditlog.ClientIpResolver; import egovframework.com.cmm.util.EgovUserDetailsHelper; import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo; @@ -23,6 +26,8 @@ import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo; public class AccountMngController { private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; + /** 현황 목록(#11). page 파라미터는 좌측메뉴 하이라이트(left.jsp 9100037 분기)용이라 유지한다. */ + private static final String DORMANT_LIST_REDIRECT = "redirect:/mnt/sec/dormantStatus/listView.do?page=9100037"; @Resource(name = "accountMngPolicyService") private AccountMngPolicyService accountMngPolicyService; @@ -200,10 +205,20 @@ public class AccountMngController { */ @RequestMapping(value = "/mnt/sec/dormantStatus/listView.do") public String dormantStatusListView(@ModelAttribute("dormantAccountStatusVO") DormantAccountStatusVO searchVO, + @RequestParam(value = "resultMsg", required = false) String resultMsg, + @RequestParam(value = "searched", required = false) String searched, ModelMap model) throws Exception { if (handlerUser() == null) { return LOGIN_REDIRECT; } + + DormantAccountSummaryVO summary = dormantAccountStatusService.selectDormantSummary(); + // 최초 진입(검색 미수행)은 관리 대상만 보이도록 '휴면예정 기준일수 이상'을 기본 조건으로 건다. + // 사용자가 한 번이라도 검색하면(searched=Y) 선택한 조건을 그대로 존중한다('전체' 포함). + if (!"Y".equals(searched) && searchVO.getMinNoConnectDays() <= 0) { + searchVO.setMinNoConnectDays(summary.getWarnDay()); + } + PaginationInfo paginationInfo = new PaginationInfo(); paginationInfo.setCurrentPageNo(searchVO.getPageIndex()); paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage()); @@ -215,6 +230,70 @@ public class AccountMngController { model.addAttribute("resultList", list); model.addAttribute("paginationInfo", paginationInfo); + model.addAttribute("summary", summary); + model.addAttribute("resultMsg", resultMsg); return "site/mnt/sec/dormantStatus/list"; } + + /** #11 선택 계정 잠금(휴면 확정). 상태를 바꾸므로 POST 한정. */ + @RequestMapping(value = "/mnt/sec/dormantStatus/lock.do") + public String dormantAccountLock(@RequestParam(value = "checkedIds", required = false) String[] checkedIds, + @RequestParam(value = "actionResn", required = false) String actionResn, + HttpServletRequest request) throws Exception { + return handleAction(checkedIds, actionResn, request, true); + } + + /** #11 선택 계정 말소(잠금 상태 계정 대상). 상태를 바꾸므로 POST 한정. */ + @RequestMapping(value = "/mnt/sec/dormantStatus/expire.do") + public String dormantAccountExpire(@RequestParam(value = "checkedIds", required = false) String[] checkedIds, + @RequestParam(value = "actionResn", required = false) String actionResn, + HttpServletRequest request) throws Exception { + return handleAction(checkedIds, actionResn, request, false); + } + + /** 잠금·말소 공통 전처리(인증·요청경로·사유 검증) 후 서비스 호출. */ + private String handleAction(String[] checkedIds, String actionResn, HttpServletRequest request, boolean lock) + throws Exception { + LoginVO loginVO = handlerUser(); + if (loginVO == null) { + return LOGIN_REDIRECT; + } + if (!"POST".equalsIgnoreCase(request.getMethod())) { + return dormantListRedirect("잘못된 요청 방식입니다."); + } + if (!isSameOrigin(request)) { + return dormantListRedirect("잘못된 요청 경로입니다."); + } + if (checkedIds == null || checkedIds.length == 0) { + return dormantListRedirect("대상 계정을 선택하세요."); + } + String resn = actionResn == null ? "" : actionResn.trim(); + if (resn.length() == 0) { + return dormantListRedirect("조치 사유는 필수입니다."); + } + + String ip = ClientIpResolver.getClientIp(request); + int done = lock + ? dormantAccountStatusService.lockAccounts(checkedIds, resn, loginVO.getId(), ip) + : dormantAccountStatusService.expireAccounts(checkedIds, resn, loginVO.getId(), ip); + + int skipped = checkedIds.length - done; + String label = lock ? "잠금" : "말소"; + StringBuilder msg = new StringBuilder(); + msg.append(done).append("건 ").append(label).append(" 처리했습니다."); + if (skipped > 0) { + // 본인 계정·이미 처리된 계정·(말소의 경우) 잠기지 않은 계정은 서버가 걸러낸다. + msg.append(" ").append(skipped).append("건은 조치 대상이 아니어서 제외했습니다"); + msg.append(lock ? "(본인 계정·이미 잠긴 계정)." : "(본인 계정·잠금되지 않은 계정·이미 말소된 계정)."); + } + return dormantListRedirect(msg.toString()); + } + + /** 처리결과 메시지를 붙여 현황 목록으로 리다이렉트. */ + private String dormantListRedirect(String message) throws Exception { + if (message == null) { + return DORMANT_LIST_REDIRECT; + } + return DORMANT_LIST_REDIRECT + "&resultMsg=" + URLEncoder.encode(message, "UTF-8"); + } } diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngDAO.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngDAO.java index f8cbd2a..7cb4173 100644 --- a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngDAO.java +++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngDAO.java @@ -46,4 +46,45 @@ public class AccountMngDAO extends EgovComAbstractDAO { param.put("alertBefore", Integer.valueOf(alertBefore)); return list("AccountMngDAO.selectDormantAlertTargets", param); } + + /** #11 현황 화면 상단 요약(전체·휴면예정·휴면잠금·말소 건수). 검색조건 무관 전체 집계. */ + public DormantAccountSummaryVO selectDormantSummary() { + return (DormantAccountSummaryVO) selectByPk("AccountMngDAO.selectDormantSummary", null); + } + + /** 단건 파생 계정상태(N/W/L/D). 없는 계정이면 null. */ + public String selectDormantAccountState(String accountId) { + return (String) selectByPk("AccountMngDAO.selectDormantAccountState", accountId); + } + + /** 단건 미접속 일수. 없는 계정이면 0. */ + public int selectDormantAccountDays(String accountId) { + Object days = selectByPk("AccountMngDAO.selectDormantAccountDays", accountId); + return days == null ? 0 : ((Integer) days).intValue(); + } + + /** 휴면행 상태(DIAPAUSE_STAT). 행이 없으면 null, 값이 비어있으면 빈 문자열. */ + public String selectDiapauseStatEmp(String accountId) { + return (String) selectByPk("AccountMngDAO.selectDiapauseStatEmp", accountId); + } + + /** #11-2 잠금(휴면 확정) — 기존 휴면행 승격. */ + public int updateDiapauseConfirmEmp(String accountId) { + return update("AccountMngDAO.updateDiapauseConfirmEmp", accountId); + } + + /** #11-2 잠금(휴면 확정) — 휴면행이 없는 계정의 신규 적재. */ + public void insertDiapauseConfirmEmp(String accountId) { + insert("AccountMngDAO.insertDiapauseConfirmEmp", accountId); + } + + /** #11-2 말소 — EMPLYR_STTUS_CODE='D'. 이미 말소면 0건. */ + public int updateEmplyrExpire(String accountId) { + return update("AccountMngDAO.updateEmplyrExpire", accountId); + } + + /** #11-2 조치 이력 적재. */ + public void insertAccountActionHistory(AccountActionHistoryVO vo) { + insert("AccountMngDAO.insertAccountActionHistory", vo); + } } diff --git a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusService.java b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusService.java index 68727b8..da5b3b9 100644 --- a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusService.java +++ b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusService.java @@ -12,4 +12,26 @@ public interface DormantAccountStatusService { /** 현황 전체 건수. */ int selectDormantStatusTotCnt(DormantAccountStatusVO searchVO) throws Exception; + + /** 화면 상단 요약(전체·휴면예정·휴면잠금·말소 건수 + 정책 기준일수). */ + DormantAccountSummaryVO selectDormantSummary() throws Exception; + + /** + * 선택 계정 잠금(휴면 확정). 자동 배치가 하는 확정을 관리자가 앞당겨 수행한다. + * + * @param accountIds 대상 계정ID 배열 + * @param resn 조치 사유(필수, 이력 적재) + * @param actorId 조치자(관리자) ID — 본인 계정은 대상에서 제외한다 + * @param actorIp 조치자 IP + * @return 실제 처리 건수 + */ + int lockAccounts(String[] accountIds, String resn, String actorId, String actorIp) throws Exception; + + /** + * 선택 계정 말소. 잠금(휴면·잠금) 상태인 계정만 대상으로 한다 + * ("잠금 후에도 미사용 시 관리자 승인으로 말소" 정책). + * + * @return 실제 처리 건수 + */ + int expireAccounts(String[] accountIds, String resn, String actorId, String actorIp) throws Exception; } diff --git a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusServiceImpl.java index 02594b0..1b07119 100644 --- a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusServiceImpl.java +++ b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusServiceImpl.java @@ -7,14 +7,31 @@ import javax.annotation.Resource; import org.springframework.stereotype.Service; /** - * 장기 미사용 계정 현황 조회 서비스 구현 (#11). + * 장기 미사용 계정 현황 조회·조치 서비스 구현 (#11). + * + *

조회(목록·요약)와 현황 화면의 조치(잠금=휴면 확정 / 말소)를 담당한다. + * 메서드 전체가 context-transaction.xml 의 {@code egovframework.com..*Impl.*} 포인트컷에 + * 걸려 트랜잭션 경계 안에서 실행되므로, 다건 조치 중 예외가 나면 그 요청 전체가 롤백된다.

+ * + *
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.07.21    URP     최초 생성 (#11 현황 조회)
+ *  2026.08.10    URP     시안 반영 — 요약 집계 + 잠금·말소 조치(이력 적재)
+ * 
*/ @Service("dormantAccountStatusService") public class DormantAccountStatusServiceImpl implements DormantAccountStatusService { + /** 조치 사유 최대 길이 — ACCOUNT_ACTION_HISTORY.ACTION_RESN VARCHAR(500). */ + private static final int MAX_RESN_LEN = 500; + @Resource(name = "accountMngDAO") private AccountMngDAO accountMngDAO; + @Resource(name = "accountMngPolicyService") + private AccountMngPolicyService accountMngPolicyService; + @Override public List selectDormantStatusList(DormantAccountStatusVO searchVO) throws Exception { int pageIndex = searchVO.getPageIndex() < 1 ? 1 : searchVO.getPageIndex(); @@ -30,4 +47,106 @@ public class DormantAccountStatusServiceImpl implements DormantAccountStatusServ public int selectDormantStatusTotCnt(DormantAccountStatusVO searchVO) throws Exception { return accountMngDAO.selectDormantStatusListCount(searchVO); } + + @Override + public DormantAccountSummaryVO selectDormantSummary() throws Exception { + DormantAccountSummaryVO summary = accountMngDAO.selectDormantSummary(); + if (summary == null) { + summary = new DormantAccountSummaryVO(); + } + // 카드 라벨(60일↑/90일↑)에 쓰이는 기준일수는 정책값에서 가져온다(하드코딩 없음, #9/#13). + AccountMngPolicyVO policy = accountMngPolicyService.selectPolicy(); + int dormantDay = policy == null ? AccountMngPolicyVO.DEFAULT_DORMANT_DAY : policy.getDormantDay(); + int alertD1 = policy == null ? 0 : policy.getAlertD1(); + summary.setDormantDay(dormantDay); + // 사전알림 1차 시점부터 '휴면예정'으로 본다. 정책이 뒤집혀도 음수가 되지 않게 하한 0. + summary.setWarnDay(Math.max(dormantDay - alertD1, 0)); + summary.setBatchCycle(policy == null ? null : policy.getBatchCycle()); + summary.setPurgeUseYn(policy == null ? null : policy.getPurgeUseYn()); + return summary; + } + + @Override + public int lockAccounts(String[] accountIds, String resn, String actorId, String actorIp) throws Exception { + return processAction(accountIds, resn, actorId, actorIp, AccountActionHistoryVO.ACTION_LOCK); + } + + @Override + public int expireAccounts(String[] accountIds, String resn, String actorId, String actorIp) throws Exception { + return processAction(accountIds, resn, actorId, actorIp, AccountActionHistoryVO.ACTION_EXPIRE); + } + + /** + * 잠금·말소 공통 처리. 대상 계정별로 상태를 다시 읽어 조치 가능 여부를 서버에서 판정한다 + * (화면 목록이 오래됐거나 요청이 위조된 경우를 걸러낸다). + */ + private int processAction(String[] accountIds, String resn, String actorId, String actorIp, String actionTy) + throws Exception { + if (accountIds == null || accountIds.length == 0) { + return 0; + } + String reason = resn == null ? "" : resn.trim(); + if (reason.length() > MAX_RESN_LEN) { + reason = reason.substring(0, MAX_RESN_LEN); + } + + int done = 0; + for (int i = 0; i < accountIds.length; i++) { + String accountId = accountIds[i] == null ? "" : accountIds[i].trim(); + if (accountId.length() == 0) { + continue; + } + // 자기 계정은 조치할 수 없다(관리자 자신을 잠가 운영이 막히는 사고 방지). + if (accountId.equals(actorId)) { + continue; + } + + String state = accountMngDAO.selectDormantAccountState(accountId); + if (state == null || "D".equals(state)) { + continue; // 없는 계정이거나 이미 말소됨 + } + + boolean changed; + if (AccountActionHistoryVO.ACTION_LOCK.equals(actionTy)) { + changed = lockOne(accountId); + } else { + // 말소는 '잠금 후에도 미사용'인 계정만 — 잠기지 않은 계정은 건너뛴다. + if (!"L".equals(state)) { + continue; + } + changed = accountMngDAO.updateEmplyrExpire(accountId) > 0; + } + if (!changed) { + continue; + } + + AccountActionHistoryVO hist = new AccountActionHistoryVO(); + hist.setTrgetId(accountId); + hist.setTrgetSe("EMP"); + hist.setActionTy(actionTy); + hist.setActionUserId(actorId); + hist.setActionResn(reason); + hist.setActionIp(actorIp); + hist.setNoConectDay(accountMngDAO.selectDormantAccountDays(accountId)); + accountMngDAO.insertAccountActionHistory(hist); + done++; + } + return done; + } + + /** + * 휴면행이 있으면 'P'로 승격, 없으면 신규 적재. + * 이미 확정('P')이면 아무것도 하지 않고 false — 같은 계정을 다시 눌러도 이력이 중복되지 않는다. + */ + private boolean lockOne(String accountId) { + String stat = accountMngDAO.selectDiapauseStatEmp(accountId); + if (stat == null) { + accountMngDAO.insertDiapauseConfirmEmp(accountId); + return true; + } + if ("P".equals(stat.trim())) { + return false; + } + return accountMngDAO.updateDiapauseConfirmEmp(accountId) > 0; + } } diff --git a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusVO.java b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusVO.java index c8f1b2b..d9a4e08 100644 --- a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusVO.java +++ b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusVO.java @@ -23,9 +23,18 @@ public class DormantAccountStatusVO implements Serializable { private String sttusCode; // 상태코드(EMPLYR_STTUS_CODE) private String dormantExpectDt; // 휴면예정일(yyyy-MM-dd) private String mberTyCode; // 회원유형(EMP/GNR) + private String roleNm; // 역할명(COMTNAUTHORINFO.AUTHOR_NM, 복수는 쉼표 연결) + /** + * 파생 계정상태 — 정책값 기준으로 SQL이 계산한다. + * N:사용 W:휴면예정(기준일수-1차알림일 경과) L:휴면·잠금(기준일수 경과 또는 휴면확정) D:말소 + * EMPLYR_STTUS_CODE(sttusCode) 원본과 달리 '미접속 일수'를 함께 반영한 화면 표시용 상태다. + */ + private String acntState; // ---- 검색조건(#11) ---- private int minNoConnectDays = 0; // 미접속일수 하한 필터 + private String searchKeyword; // 취급자명 또는 계정ID 통합검색 + private String searchState; // 계정상태 필터(N/W/L/D) — 파생 acntState 대상 // ---- 페이징 ---- private int pageIndex = 1; @@ -53,8 +62,16 @@ public class DormantAccountStatusVO implements Serializable { public void setDormantExpectDt(String dormantExpectDt) { this.dormantExpectDt = dormantExpectDt; } public String getMberTyCode() { return mberTyCode; } public void setMberTyCode(String mberTyCode) { this.mberTyCode = mberTyCode; } + public String getRoleNm() { return roleNm; } + public void setRoleNm(String roleNm) { this.roleNm = roleNm; } + public String getAcntState() { return acntState; } + public void setAcntState(String acntState) { this.acntState = acntState; } public int getMinNoConnectDays() { return minNoConnectDays; } public void setMinNoConnectDays(int minNoConnectDays) { this.minNoConnectDays = minNoConnectDays; } + public String getSearchKeyword() { return searchKeyword; } + public void setSearchKeyword(String searchKeyword) { this.searchKeyword = searchKeyword; } + public String getSearchState() { return searchState; } + public void setSearchState(String searchState) { this.searchState = searchState; } public int getPageIndex() { return pageIndex; } public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; } public int getRecordCountPerPage() { return recordCountPerPage; } diff --git a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountSummaryVO.java b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountSummaryVO.java new file mode 100644 index 0000000..ced8dee --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountSummaryVO.java @@ -0,0 +1,56 @@ +package egovframework.com.cmm.privacy.account; + +import java.io.Serializable; + +/** + * 장기 미사용 계정 현황 요약 VO (#11 화면 상단 요약 카드). + * + *

취급자 전체 계정수와 휴면예정·휴면/잠금·말소 건수를 한 번의 조회로 담는다. + * 카드 라벨에 쓰이는 기준일수(dormantDay/warnDay)와 정책 적용상태는 + * ACCOUNT_MNG_POLICY 값을 서비스 계층에서 채운다(하드코딩 없음, #9/#13 연계).

+ * + *
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.10    URP     최초 생성 (#11 시안 반영)
+ * 
+ */ +public class DormantAccountSummaryVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 취급자 전체 계정수. */ + private int totCnt; + /** 휴면예정 건수(미접속 warnDay 이상). */ + private int warnCnt; + /** 휴면·잠금 건수(미접속 dormantDay 이상 또는 휴면확정). */ + private int lockCnt; + /** 말소 건수(EMPLYR_STTUS_CODE='D'). */ + private int expiredCnt; + + /** 휴면 전환(자동 잠금) 기준 미접속 일수 — ACCOUNT_MNG_POLICY.DORMANT_DAY. */ + private int dormantDay; + /** 휴면예정 진입 기준 미접속 일수 — DORMANT_DAY - ALERT_D1. */ + private int warnDay; + /** 배치 주기(DAILY/WEEKLY/MONTHLY). */ + private String batchCycle; + /** 파기 배치 사용여부(Y/N). */ + private String purgeUseYn; + + public int getTotCnt() { return totCnt; } + public void setTotCnt(int totCnt) { this.totCnt = totCnt; } + public int getWarnCnt() { return warnCnt; } + public void setWarnCnt(int warnCnt) { this.warnCnt = warnCnt; } + public int getLockCnt() { return lockCnt; } + public void setLockCnt(int lockCnt) { this.lockCnt = lockCnt; } + public int getExpiredCnt() { return expiredCnt; } + public void setExpiredCnt(int expiredCnt) { this.expiredCnt = expiredCnt; } + public int getDormantDay() { return dormantDay; } + public void setDormantDay(int dormantDay) { this.dormantDay = dormantDay; } + public int getWarnDay() { return warnDay; } + public void setWarnDay(int warnDay) { this.warnDay = warnDay; } + public String getBatchCycle() { return batchCycle; } + public void setBatchCycle(String batchCycle) { this.batchCycle = batchCycle; } + public String getPurgeUseYn() { return purgeUseYn; } + public void setPurgeUseYn(String purgeUseYn) { this.purgeUseYn = purgeUseYn; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobDAO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobDAO.java new file mode 100644 index 0000000..f7a1eef --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobDAO.java @@ -0,0 +1,84 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 접속기록 CSV 비동기 다운로드 작업(PDAL_DOWNLOAD_JOB) DAO. + * + *

개인정보보호 기능개선 사업(SFR-004-6 / SER-002). statement 는 + * {@code PrivacyDownloadJob_SQL_Mysql.xml}(namespace {@code AccessLogDownloadJobDAO}). + * 신규 sqlMap 이므로 {@code sql-map-config-mysql-0-site.xml} 등록이 필요하다.

+ * + * @since 2026-08-07 + */ +@Repository("accessLogDownloadJobDAO") +public class AccessLogDownloadJobDAO extends EgovComAbstractDAO { + + /** 요청 등록. 채번된 JOB_ID 는 selectKey 로 vo.jobId 에 채워진다. */ + public long insertJob(AccessLogDownloadJobVO vo) { + insert("AccessLogDownloadJobDAO.insertJob", vo); + return vo.getJobId(); + } + + public AccessLogDownloadJobVO selectJob(long jobId) { + return (AccessLogDownloadJobVO) selectByPk("AccessLogDownloadJobDAO.selectJob", Long.valueOf(jobId)); + } + + /** 요청자 본인의 최근 작업 목록. */ + @SuppressWarnings("unchecked") + public List selectJobListByRequester(String requesterId, int limitCnt) { + Map param = new HashMap(); + param.put("requesterId", requesterId); + param.put("limitCnt", Integer.valueOf(limitCnt)); + return list("AccessLogDownloadJobDAO.selectJobListByRequester", param); + } + + /** 대기 상태의 가장 오래된 작업 ID. 없으면 null. */ + public Long selectNextWaitingJobId() { + return (Long) selectByPk("AccessLogDownloadJobDAO.selectNextWaitingJobId", null); + } + + /** + * 대기건 선점. 영향행수 1이면 이 워커가 소유권을 얻은 것이다. + * (WHERE 에 STATUS='W' 가 걸려 있어 동시 선점 시 한쪽만 성공한다.) + */ + public boolean claimJob(long jobId) { + return update("AccessLogDownloadJobDAO.claimJob", Long.valueOf(jobId)) == 1; + } + + public void updateProgress(AccessLogDownloadJobVO vo) { + update("AccessLogDownloadJobDAO.updateProgress", vo); + } + + public void updateSuccess(AccessLogDownloadJobVO vo) { + update("AccessLogDownloadJobDAO.updateSuccess", vo); + } + + public void updateFail(AccessLogDownloadJobVO vo) { + update("AccessLogDownloadJobDAO.updateFail", vo); + } + + public void updateDownloaded(long jobId) { + update("AccessLogDownloadJobDAO.updateDownloaded", Long.valueOf(jobId)); + } + + @SuppressWarnings("unchecked") + public List selectExpiredJobs() { + return list("AccessLogDownloadJobDAO.selectExpiredJobs", null); + } + + public void updatePurged(long jobId) { + update("AccessLogDownloadJobDAO.updatePurged", Long.valueOf(jobId)); + } + + /** WAS 비정상 종료로 'R' 에 멈춘 작업을 대기로 되돌린다(기동 시 1회). */ + public int resetRunningJobs() { + return update("AccessLogDownloadJobDAO.resetRunningJobs", null); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobService.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobService.java new file mode 100644 index 0000000..c22ca7c --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobService.java @@ -0,0 +1,40 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.List; + +/** + * 접속기록 CSV 비동기 다운로드 작업 서비스 (SFR-004-6 / SER-002). + * + *

다운로드를 '요청 → 백그라운드 생성 → 완료 후 내려받기'로 나눈다. 대용량 조건에서 + * 전체를 메모리에 올린 뒤 응답에 싣던 방식의 메모리 압박·요청 타임아웃을 없애기 위함이다.

+ * + * @since 2026-08-07 + */ +public interface AccessLogDownloadJobService { + + /** + * 다운로드 작업을 등록한다(즉시 반환). 사유는 필수다. + * + * @return 등록된 작업 ID + * @throws IllegalArgumentException 사유 미입력 + */ + long requestJob(String requesterId, String requesterIp, String reason, + PersonalDataAccessLogSearchVO searchVO) throws Exception; + + /** 요청자 본인의 최근 작업 목록(화면 표시·폴링용). */ + List selectMyJobs(String requesterId, int limitCnt) throws Exception; + + /** + * 내려받기용 작업 조회 — 요청자 본인인지 재검증한다. + * 작업ID를 바꿔치기해 남의 다운로드 파일을 가져가는 것을 막는다. + * + * @return 본인의 내려받기 가능한 작업. 아니면 null + */ + AccessLogDownloadJobVO selectDownloadableJob(long jobId, String requesterId) throws Exception; + + /** 내려받기 성공 시 횟수·일시를 기록한다(다운로드 이력은 별도 적재). */ + void markDownloaded(long jobId) throws Exception; + + /** 만료된 작업 파일을 삭제하고 상태를 파기(X)로 정리한다. 파기 건수 반환. */ + int purgeExpiredJobs() throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobServiceImpl.java new file mode 100644 index 0000000..a7a4bde --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobServiceImpl.java @@ -0,0 +1,170 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.io.File; +import java.util.List; + +import javax.annotation.Resource; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * 접속기록 CSV 비동기 다운로드 작업 서비스 구현 (SFR-004-6 / SER-002). + * + *

보관 최소화: 생성된 파일은 대량 개인정보 파일이라 오래 두지 않는다. + * {@link #expireDays}(기본 3일) 경과분은 파기 배치가 파일을 지우고 상태를 X 로 바꾼다.

+ * + *

권한 재검증: 내려받기는 요청자 본인만 가능하다. 작업ID는 연속 정수라 + * 추측이 쉬우므로, 조회 단계에서 요청자ID 일치를 확인하지 않으면 남의 파일이 새어나간다.

+ * + * @since 2026-08-07 + */ +@Service("accessLogDownloadJobService") +public class AccessLogDownloadJobServiceImpl implements AccessLogDownloadJobService { + + private static final Log LOG = LogFactory.getLog(AccessLogDownloadJobServiceImpl.class); + + @Resource(name = "accessLogDownloadJobDAO") + private AccessLogDownloadJobDAO jobDAO; + + /** + * 생성 파일 보관일수. 대량 개인정보 파일이므로 짧게 잡는다(기본 3일). + * 접속기록 본체의 보관기간(730일)과는 무관한, '내려받기용 사본'의 수명이다. + */ + @Value("${Globals.privacyDownloadExpireDays:3}") + private int expireDays = 3; + + @Override + public long requestJob(String requesterId, String requesterIp, String reason, + PersonalDataAccessLogSearchVO searchVO) throws Exception { + + // 사유 필수 — 종전 동기 다운로드와 동일한 차단 기준을 유지한다. + if (reason == null || reason.trim().length() == 0) { + throw new IllegalArgumentException("다운로드 사유는 필수입니다."); + } + if (requesterId == null || requesterId.trim().length() == 0) { + throw new IllegalArgumentException("요청자를 확인할 수 없습니다."); + } + if (searchVO == null) { + searchVO = new PersonalDataAccessLogSearchVO(); + } + + AccessLogDownloadJobVO job = new AccessLogDownloadJobVO(); + job.setRequesterId(requesterId); + job.setRequesterIp(requesterIp); + job.setDlReason(cut(reason.trim(), 500)); + job.setSearchCond(cut(summarizeCond(searchVO), 1000)); + job.setCondConectId(searchVO.getConectId()); + job.setCondJobSeCode(searchVO.getJobSeCode()); + job.setCondTrgetId(searchVO.getTrgetId()); + job.setCondConectIp(searchVO.getConectIp()); + job.setCondDmlGubun(searchVO.getDmlGubun()); + job.setCondProcessResult(searchVO.getProcessResult()); + job.setCondFromDt(searchVO.getFromDt()); + job.setCondToDt(searchVO.getToDt()); + job.setExpireDays(expireDays < 1 ? 3 : expireDays); + + long jobId = jobDAO.insertJob(job); + LOG.info("[개인정보/SFR-004-6] 다운로드 작업 등록 — jobId=" + jobId + ", 요청자=" + requesterId + + ", 조건=" + job.getSearchCond()); + return jobId; + } + + @Override + public List selectMyJobs(String requesterId, int limitCnt) throws Exception { + if (requesterId == null || requesterId.trim().length() == 0) { + return java.util.Collections.emptyList(); + } + return jobDAO.selectJobListByRequester(requesterId, limitCnt < 1 ? 10 : limitCnt); + } + + @Override + public AccessLogDownloadJobVO selectDownloadableJob(long jobId, String requesterId) throws Exception { + AccessLogDownloadJobVO job = jobDAO.selectJob(jobId); + if (job == null) { + return null; + } + // 본인 확인 — 작업ID 추측으로 남의 파일을 가져가는 것을 막는다. + if (requesterId == null || !requesterId.equals(job.getRequesterId())) { + LOG.warn("[개인정보/SFR-004-6] 타인 다운로드 시도 차단 — jobId=" + jobId + + ", 소유자=" + job.getRequesterId() + ", 요청자=" + requesterId); + return null; + } + if (!job.isDownloadable()) { + return null; + } + File f = new File(job.getFilePath()); + if (!f.exists() || !f.isFile()) { + LOG.warn("[개인정보/SFR-004-6] 파일 없음(파기됐거나 유실) — jobId=" + jobId); + return null; + } + return job; + } + + @Override + public void markDownloaded(long jobId) throws Exception { + jobDAO.updateDownloaded(jobId); + } + + @Override + public int purgeExpiredJobs() throws Exception { + List expired = jobDAO.selectExpiredJobs(); + int purged = 0; + if (expired != null) { + for (int i = 0; i < expired.size(); i++) { + AccessLogDownloadJobVO job = expired.get(i); + String path = job.getFilePath(); + if (path != null && path.length() > 0) { + try { + File f = new File(path); + if (f.exists() && !f.delete()) { + // 삭제 실패는 개인정보 파일이 남는다는 뜻이라 조용히 넘기지 않는다. + LOG.error("[개인정보/SFR-004-6] 다운로드 파일 삭제 실패 — jobId=" + job.getJobId() + + ", path=" + path + " (수동 삭제 필요)"); + continue; + } + } catch (Exception e) { + LOG.error("[개인정보/SFR-004-6] 다운로드 파일 삭제 중 예외 — jobId=" + job.getJobId(), e); + continue; + } + } + jobDAO.updatePurged(job.getJobId()); + purged++; + } + } + LOG.info("[개인정보/SFR-004-6] 만료 다운로드 파일 파기 완료 — " + purged + "건(보관 " + expireDays + "일)"); + return purged; + } + + /** 검색조건을 개인정보 본문 없이 요약. */ + private String summarizeCond(PersonalDataAccessLogSearchVO vo) { + StringBuilder sb = new StringBuilder(); + appendCond(sb, "conectId", vo.getConectId()); + appendCond(sb, "jobSeCode", vo.getJobSeCode()); + appendCond(sb, "trgetId", vo.getTrgetId()); + appendCond(sb, "conectIp", vo.getConectIp()); + appendCond(sb, "dmlGubun", vo.getDmlGubun()); + appendCond(sb, "processResult", vo.getProcessResult()); + appendCond(sb, "fromDt", vo.getFromDt()); + appendCond(sb, "toDt", vo.getToDt()); + return sb.length() == 0 ? "(전체)" : sb.toString(); + } + + private void appendCond(StringBuilder sb, String key, String val) { + if (val != null && val.trim().length() > 0) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(key).append("=").append(val.trim()); + } + } + + private static String cut(String s, int max) { + if (s == null) { + return null; + } + return (s.length() > max) ? s.substring(0, max) : s; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobVO.java new file mode 100644 index 0000000..03b5e17 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobVO.java @@ -0,0 +1,161 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.io.Serializable; + +/** + * 개인정보 접속기록 CSV 비동기 다운로드 작업(PDAL_DOWNLOAD_JOB) VO. + * + *

개인정보보호 기능개선 사업(SFR-004-6 / SER-002) — 대용량 다운로드를 요청 즉시 응답하고 + * 백그라운드에서 파일을 만들기 위한 작업 한 건.

+ * + *

검색조건을 컬럼으로 보존하는 이유: 백그라운드 워커는 요청 세션이 끝난 뒤 실행되므로 + * 조건을 스스로 재현해야 한다. 담는 값은 식별자·코드·기간뿐이라 접속기록 본문 미적재 원칙에 + * 어긋나지 않는다.

+ * + * @since 2026-08-07 + */ +public class AccessLogDownloadJobVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 상태: 대기 */ + public static final String ST_WAIT = "W"; + /** 상태: 생성중 */ + public static final String ST_RUNNING = "R"; + /** 상태: 완료(내려받기 가능) */ + public static final String ST_SUCCESS = "S"; + /** 상태: 실패 */ + public static final String ST_FAIL = "F"; + /** 상태: 파기(파일 삭제됨) */ + public static final String ST_PURGED = "X"; + + private long jobId; + private String requesterId; + private String requesterIp; + private String dlReason; + private String searchCond; + + // 조건 재현용 — 프로퍼티명을 cond* 로 둔다. + // getFConectId 처럼 '소문자1자+대문자' 형태면 JavaBeans 인트로스펙션이 프로퍼티를 + // 'FConectId' 로 해석해 iBatis 바인딩(#fConectId#)이 조용히 실패한다. + private String condConectId; + private String condJobSeCode; + private String condTrgetId; + private String condConectIp; + private String condDmlGubun; + private String condProcessResult; + private String condFromDt; + private String condToDt; + + private String status; + private int totalCnt; + private int doneCnt; + private String fileNm; + private String filePath; + private long fileSize; + private String errMsg; + private int downloadCnt; + private String lastDlDt; + private String regDt; + private String startDt; + private String endDt; + private String expireDt; + + /** 만료일(일). 서비스가 EXPIRE_DT 계산에 쓰는 값을 화면에 알리기 위한 비영속 필드. */ + private int expireDays; + + public long getJobId() { return jobId; } + public void setJobId(long jobId) { this.jobId = jobId; } + + public String getRequesterId() { return requesterId; } + public void setRequesterId(String requesterId) { this.requesterId = requesterId; } + + public String getRequesterIp() { return requesterIp; } + public void setRequesterIp(String requesterIp) { this.requesterIp = requesterIp; } + + public String getDlReason() { return dlReason; } + public void setDlReason(String dlReason) { this.dlReason = dlReason; } + + public String getSearchCond() { return searchCond; } + public void setSearchCond(String searchCond) { this.searchCond = searchCond; } + + public String getCondConectId() { return condConectId; } + public void setCondConectId(String condConectId) { this.condConectId = condConectId; } + + public String getCondJobSeCode() { return condJobSeCode; } + public void setCondJobSeCode(String condJobSeCode) { this.condJobSeCode = condJobSeCode; } + + public String getCondTrgetId() { return condTrgetId; } + public void setCondTrgetId(String condTrgetId) { this.condTrgetId = condTrgetId; } + + public String getCondConectIp() { return condConectIp; } + public void setCondConectIp(String condConectIp) { this.condConectIp = condConectIp; } + + public String getCondDmlGubun() { return condDmlGubun; } + public void setCondDmlGubun(String condDmlGubun) { this.condDmlGubun = condDmlGubun; } + + public String getCondProcessResult() { return condProcessResult; } + public void setCondProcessResult(String condProcessResult) { this.condProcessResult = condProcessResult; } + + public String getCondFromDt() { return condFromDt; } + public void setCondFromDt(String condFromDt) { this.condFromDt = condFromDt; } + + public String getCondToDt() { return condToDt; } + public void setCondToDt(String condToDt) { this.condToDt = condToDt; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public int getTotalCnt() { return totalCnt; } + public void setTotalCnt(int totalCnt) { this.totalCnt = totalCnt; } + + public int getDoneCnt() { return doneCnt; } + public void setDoneCnt(int doneCnt) { this.doneCnt = doneCnt; } + + public String getFileNm() { return fileNm; } + public void setFileNm(String fileNm) { this.fileNm = fileNm; } + + public String getFilePath() { return filePath; } + public void setFilePath(String filePath) { this.filePath = filePath; } + + public long getFileSize() { return fileSize; } + public void setFileSize(long fileSize) { this.fileSize = fileSize; } + + public String getErrMsg() { return errMsg; } + public void setErrMsg(String errMsg) { this.errMsg = errMsg; } + + public int getDownloadCnt() { return downloadCnt; } + public void setDownloadCnt(int downloadCnt) { this.downloadCnt = downloadCnt; } + + public String getLastDlDt() { return lastDlDt; } + public void setLastDlDt(String lastDlDt) { this.lastDlDt = lastDlDt; } + + public String getRegDt() { return regDt; } + public void setRegDt(String regDt) { this.regDt = regDt; } + + public String getStartDt() { return startDt; } + public void setStartDt(String startDt) { this.startDt = startDt; } + + public String getEndDt() { return endDt; } + public void setEndDt(String endDt) { this.endDt = endDt; } + + public String getExpireDt() { return expireDt; } + public void setExpireDt(String expireDt) { this.expireDt = expireDt; } + + public int getExpireDays() { return expireDays; } + public void setExpireDays(int expireDays) { this.expireDays = expireDays; } + + /** 진행률(%). 총건수 미확정이면 0. */ + public int getProgressPct() { + if (totalCnt <= 0) { + return ST_SUCCESS.equals(status) ? 100 : 0; + } + long pct = (long) doneCnt * 100L / totalCnt; + return (pct > 100L) ? 100 : (int) pct; + } + + /** 내려받기 가능 여부(완료 + 파일 보유). */ + public boolean isDownloadable() { + return ST_SUCCESS.equals(status) && filePath != null && filePath.length() > 0; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobWorker.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobWorker.java new file mode 100644 index 0000000..216769c --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobWorker.java @@ -0,0 +1,357 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStreamWriter; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.annotation.Resource; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import egovframework.com.utl.sim.service.EgovFileScrty; + +/** + * 접속기록 CSV 비동기 다운로드 워커 (SFR-004-6 / SER-002). + * + *

대기(W) 작업을 하나씩 집어 CSV 파일을 만든다. 단일 워커 스레드다 — + * 대량 조회가 동시에 여러 건 돌면 DB 부하가 그대로 서비스 지연이 되기 때문이다.

+ * + *

메모리를 쓰지 않는 방식: 전체를 List 로 올리지 않고 {@link #CHUNK_ROWS}건씩 + * 끊어 조회하며 곧바로 파일에 쓴다. 100만 건이어도 상주 메모리는 한 청크뿐이다. + * 이것이 이 작업의 핵심이며, 종전 동기 다운로드의 OOM 위험을 없앤다.

+ * + *

파일은 암호화해 둔다: 생성 결과는 '대량 개인정보 파일'이다. 평문 CSV 를 디스크에 + * 남기지 않기 위해 {@link EgovFileScrty#encryptFile}로 암호화한 뒤 평문 임시파일을 지운다. + * 내려받을 때 복호화해 스트리밍한다. 저장 위치는 반드시 웹 루트 밖이어야 한다 — + * webapps 하위에 두면 URL 직접 접근으로 무인증 유출이 된다.

+ * + *

기동 시 고아 복구: 파일 생성 중 WAS 가 죽으면 작업이 R 로 굳는다. 기동 시 + * R → W 로 되돌려 재시도한다(화면에 '생성중'이 영원히 멈춰 보이는 것 방지).

+ * + * @since 2026-08-07 + */ +@Component("accessLogDownloadJobWorker") +public class AccessLogDownloadJobWorker { + + private static final Log LOG = LogFactory.getLog(AccessLogDownloadJobWorker.class); + + /** 한 번에 조회·기록할 행 수. 메모리 상주량을 결정한다. */ + private static final int CHUNK_ROWS = 2000; + /** 대기 작업이 없을 때 다음 확인까지 쉬는 시간(ms). */ + private static final long IDLE_SLEEP_MS = 3000L; + /** 예외 발생 후 폭주 방지 대기(ms). */ + private static final long ERROR_SLEEP_MS = 10000L; + + private static final String CSV_HEADER = + "로그번호,접속일시,접속자ID,처리자구분,접속IP,수행업무코드,수행업무명,처리구분,처리대상ID,처리대상범위,처리결과,무결성해시"; + + @Resource(name = "accessLogDownloadJobDAO") + private AccessLogDownloadJobDAO jobDAO; + + @Resource(name = "personalDataAccessLogSearchService") + private PersonalDataAccessLogSearchService searchService; + + /** + * 생성 파일 저장 디렉터리. 반드시 웹 루트(webapps) 밖이어야 한다. + * 미설정 시 기동 로그에 경고를 남기고 워커를 띄우지 않는다 — 임의 경로에 + * 개인정보 파일을 만드는 것보다 기능을 멈추는 편이 안전하다. + */ + @Value("${Globals.privacyDownloadPath:}") + private String downloadPath; + + private volatile boolean running = false; + private volatile Thread worker; + + @PostConstruct + public void start() { + // 저장경로가 없거나 만들 수 없어도 워커는 띄운다. + // 종전에는 여기서 return 해 워커를 아예 기동하지 않았고, 그 결과 요청은 W 로 정상 등록되지만 + // 집어갈 주체가 없어 화면이 '대기'에서 영원히 멈췄다(사용자에겐 아무 단서도 보이지 않았다). + // 게다가 화면 폴링이 그 상태를 5초마다 새로고침하며 접속기록을 자가증식시켰다. + // 지금은 작업마다 경로를 확인해 확보 불가면 그 작업을 '실패'로 확정한다 — + // 사유가 화면에 뜨고, 경로가 나중에 생기면 재기동 없이 다음 작업부터 정상 처리된다. + try { + ensureDir(); + LOG.info("[개인정보/SFR-004-6] 저장경로 확인 완료 — " + downloadPath); + } catch (Exception e) { + LOG.error("[개인정보/SFR-004-6] 저장경로를 확보하지 못했다 — " + e.getMessage() + + " 워커는 기동하되, 경로가 확보될 때까지 접수된 작업은 '실패'로 처리한다. " + + "웹 루트 밖 경로를 지정·생성할 것(예: /app/egovframework/privacy-download/)."); + } + try { + int reset = jobDAO.resetRunningJobs(); + if (reset > 0) { + LOG.warn("[개인정보/SFR-004-6] 이전 기동에서 생성 중이던 작업 " + reset + "건을 대기로 되돌려 재시도한다."); + } + } catch (Exception e) { + LOG.error("[개인정보/SFR-004-6] 고아 작업 복구 실패(계속 진행)", e); + } + + running = true; + Thread t = new Thread(new Runnable() { + public void run() { + loop(); + } + }, "pdal-download-worker"); + t.setDaemon(true); + worker = t; + t.start(); + LOG.info("[개인정보/SFR-004-6] 비동기 다운로드 워커 기동 — 저장경로=" + downloadPath + + ", 청크=" + CHUNK_ROWS + "행"); + } + + @PreDestroy + public void stop() { + running = false; + Thread t = worker; + if (t != null) { + t.interrupt(); + } + } + + /** 워커 루프 — 대기건을 하나씩 선점해 처리한다. Throwable 까지 흡수해 스스로 죽지 않는다. */ + private void loop() { + while (running) { + try { + Long jobId = jobDAO.selectNextWaitingJobId(); + if (jobId == null) { + Thread.sleep(IDLE_SLEEP_MS); + continue; + } + if (!jobDAO.claimJob(jobId.longValue())) { + // 다른 워커/재시도와 경합해 선점 실패 — 즉시 다음 것을 본다. + continue; + } + process(jobId.longValue()); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } catch (Throwable t) { + LOG.error("[개인정보/SFR-004-6] 워커 루프 예외(계속)", t); + try { + Thread.sleep(ERROR_SLEEP_MS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + LOG.info("[개인정보/SFR-004-6] 비동기 다운로드 워커 종료."); + } + + /** 작업 1건 처리 — 청크 조회 → CSV 기록 → 암호화 → 완료 확정. */ + private void process(long jobId) { + AccessLogDownloadJobVO job = null; + File plain = null; + File enc = null; + try { + job = jobDAO.selectJob(jobId); + if (job == null) { + return; + } + + PersonalDataAccessLogSearchVO searchVO = toSearchVO(job); + int total = searchService.selectAccessLogListTotCnt(searchVO); + job.setTotalCnt(total); + job.setDoneCnt(0); + jobDAO.updateProgress(job); + + // 작업마다 저장경로를 확인한다 — 기동 후 경로가 생겼다면 재기동 없이 여기서 살아난다. + // 확보 불가면 예외가 나 아래 catch 에서 이 작업이 '실패'로 확정된다(무한 대기 방지). + File dir = ensureDir(); + + String stamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()); + String baseNm = "개인정보접속기록_" + stamp + "_" + jobId; + plain = new File(dir, baseNm + ".csv.tmp"); + enc = new File(dir, baseNm + ".csv.enc"); + + int written = writeCsv(plain, searchVO, total, job); + + // 평문 CSV 를 디스크에 남기지 않는다 — 암호화 후 즉시 삭제. + if (!EgovFileScrty.encryptFile(plain.getAbsolutePath(), enc.getAbsolutePath())) { + throw new IllegalStateException("파일 암호화 실패"); + } + deleteQuietly(plain); + plain = null; + + job.setDoneCnt(written); + job.setTotalCnt(total); + job.setFileNm(baseNm + ".csv"); + job.setFilePath(enc.getAbsolutePath()); + job.setFileSize(enc.length()); + jobDAO.updateSuccess(job); + LOG.info("[개인정보/SFR-004-6] 다운로드 파일 생성 완료 — jobId=" + jobId + ", " + written + "행, " + + enc.length() + "byte"); + + } catch (Throwable t) { + LOG.error("[개인정보/SFR-004-6] 다운로드 파일 생성 실패 — jobId=" + jobId, t); + deleteQuietly(plain); + deleteQuietly(enc); + try { + AccessLogDownloadJobVO fail = (job != null) ? job : new AccessLogDownloadJobVO(); + fail.setJobId(jobId); + fail.setErrMsg(cut(String.valueOf(t.getMessage()), 500)); + jobDAO.updateFail(fail); + } catch (Exception e) { + LOG.error("[개인정보/SFR-004-6] 실패 상태 기록마저 실패 — jobId=" + jobId, e); + } + } + } + + /** + * 청크 단위로 조회하며 곧바로 파일에 기록한다(전체를 메모리에 올리지 않는다). + * + * @return 기록한 행 수 + */ + private int writeCsv(File target, PersonalDataAccessLogSearchVO searchVO, int total, + AccessLogDownloadJobVO job) throws Exception { + BufferedWriter w = null; + int written = 0; + try { + FileOutputStream fos = new FileOutputStream(target); + // UTF-8 BOM — 엑셀에서 한글이 깨지지 않게 한다(기존 동기 다운로드와 동일). + fos.write(0xEF); fos.write(0xBB); fos.write(0xBF); + w = new BufferedWriter(new OutputStreamWriter(fos, "UTF-8")); + w.write(CSV_HEADER); + w.newLine(); + + int page = 1; + while (written < total) { + searchVO.setPageIndex(page); + searchVO.setRecordCountPerPage(CHUNK_ROWS); + List chunk = searchService.selectAccessLogList(searchVO); + if (chunk == null || chunk.isEmpty()) { + break; + } + for (int i = 0; i < chunk.size(); i++) { + w.write(toCsvLine(chunk.get(i))); + w.newLine(); + written++; + } + w.flush(); + + // 진행률 갱신 — 화면 폴링이 실제로 움직이는 값을 보게 한다. + job.setDoneCnt(written); + job.setTotalCnt(total); + try { + jobDAO.updateProgress(job); + } catch (Exception e) { + // 진행률은 부가정보다 — 실패해도 생성은 계속한다. + LOG.warn("[개인정보/SFR-004-6] 진행률 갱신 실패(무시) — jobId=" + job.getJobId()); + } + page++; + } + w.flush(); + } finally { + if (w != null) { + try { w.close(); } catch (Exception ignore) { /* 닫기 실패는 무시 */ } + } + } + return written; + } + + private String toCsvLine(PersonalDataAccessLogVO v) { + StringBuilder sb = new StringBuilder(256); + sb.append(v.getLogId()).append(',') + .append(csv(v.getConectDt())).append(',') + .append(csv(v.getConectId())).append(',') + .append(csv(v.getUserSe())).append(',') + .append(csv(v.getConectIp())).append(',') + .append(csv(v.getJobSeCode())).append(',') + .append(csv(v.getJobSeNm())).append(',') + .append(csv(dmlLabel(v.getDmlGubun()))).append(',') + .append(csv(v.getTrgetId())).append(',') + .append(csv(v.getTrgetCn())).append(',') + .append(csv(resultLabel(v.getProcessResult()))).append(',') + .append(csv(v.getHashVal())); + return sb.toString(); + } + + private PersonalDataAccessLogSearchVO toSearchVO(AccessLogDownloadJobVO job) { + PersonalDataAccessLogSearchVO vo = new PersonalDataAccessLogSearchVO(); + vo.setConectId(job.getCondConectId()); + vo.setJobSeCode(job.getCondJobSeCode()); + vo.setTrgetId(job.getCondTrgetId()); + vo.setConectIp(job.getCondConectIp()); + vo.setDmlGubun(job.getCondDmlGubun()); + vo.setProcessResult(job.getCondProcessResult()); + vo.setFromDt(job.getCondFromDt()); + vo.setToDt(job.getCondToDt()); + return vo; + } + + /** + * 저장 디렉터리를 확보한다(없으면 생성). + * + *

실패는 예외로 알린다. 조용히 null 을 돌려주면 호출측이 그냥 넘어가고, + * 그 결과가 '영원한 대기'였다. 메시지는 그대로 작업의 실패사유(ERR_MSG)로 화면에 노출되므로 + * 운영자가 원인을 바로 알 수 있게 경로를 포함해 적는다.

+ */ + private File ensureDir() { + if (downloadPath == null || downloadPath.trim().length() == 0) { + throw new IllegalStateException("다운로드 저장경로 미설정(Globals.privacyDownloadPath)"); + } + File dir = new File(downloadPath.trim()); + if (!dir.exists() && !dir.mkdirs()) { + throw new IllegalStateException("다운로드 저장경로 생성 실패 — " + dir.getAbsolutePath()); + } + if (!dir.isDirectory()) { + throw new IllegalStateException("다운로드 저장경로가 디렉터리가 아님 — " + dir.getAbsolutePath()); + } + if (!dir.canWrite()) { + throw new IllegalStateException("다운로드 저장경로에 쓸 수 없음(WAS 계정 권한 확인) — " + dir.getAbsolutePath()); + } + return dir; + } + + private void deleteQuietly(File f) { + if (f != null && f.exists()) { + try { + if (!f.delete()) { + LOG.warn("[개인정보/SFR-004-6] 임시파일 삭제 실패(수동 확인 필요) — " + f.getAbsolutePath()); + } + } catch (Exception ignore) { + LOG.warn("[개인정보/SFR-004-6] 임시파일 삭제 중 예외 — " + f.getAbsolutePath()); + } + } + } + + /** CSV 셀 이스케이프(구분자·따옴표·개행 방어). */ + private String csv(String v) { + if (v == null) { + return ""; + } + String s = v.replace("\"", "\"\""); + return "\"" + s.replace("\r", " ").replace("\n", " ") + "\""; + } + + private String dmlLabel(String code) { + if ("S".equals(code)) return "조회"; + if ("I".equals(code)) return "등록"; + if ("U".equals(code)) return "수정"; + if ("D".equals(code)) return "삭제"; + return code; + } + + private String resultLabel(String code) { + if ("S".equals(code)) return "성공"; + if ("F".equals(code)) return "실패/차단"; + return code; + } + + private static String cut(String s, int max) { + if (s == null) { + return null; + } + return (s.length() > max) ? s.substring(0, max) : s; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadService.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadService.java index ec0013f..7bc5e4c 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadService.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadService.java @@ -25,4 +25,21 @@ public interface AccessLogDownloadService { */ List downloadAccessLog(String downloaderId, String downloaderIp, String reason, PersonalDataAccessLogSearchVO searchVO) throws Exception; + + /** + * 다운로드 이력만 적재한다(조회 없이). + * + *

비동기 다운로드(SFR-004-6)는 파일을 백그라운드에서 미리 만들어 두므로, 실제로 + * 내려받은 시점에 이력을 남겨야 증빙이 맞다. 조회는 이미 끝나 있어 건수·조건을 + * 작업 정보에서 그대로 받는다.

+ * + * @param downloaderId 다운로드자 계정ID + * @param downloaderIp 다운로드자 접속 IP + * @param reason 다운로드 사유(요청 시 입력한 값) + * @param searchCond 검색조건 요약(개인정보 본문 미포함) + * @param resultCnt 내려받은 건수 + * @throws Exception 적재 오류 + */ + void recordDownloadHistory(String downloaderId, String downloaderIp, String reason, + String searchCond, int resultCnt) throws Exception; } diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadServiceImpl.java index e94e080..e431591 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadServiceImpl.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadServiceImpl.java @@ -58,6 +58,18 @@ public class AccessLogDownloadServiceImpl implements AccessLogDownloadService { return result; } + @Override + public void recordDownloadHistory(String downloaderId, String downloaderIp, String reason, + String searchCond, int resultCnt) throws Exception { + AccessLogDownloadHistoryVO hist = new AccessLogDownloadHistoryVO(); + hist.setDownloaderId(downloaderId); + hist.setDownloaderIp(downloaderIp); + hist.setDlReason(reason == null ? "" : reason.trim()); + hist.setSearchCond(searchCond); + hist.setResultCnt(resultCnt); + downloadHistoryDAO.insertDownloadHistory(hist); + } + /** 검색조건을 개인정보 본문 없이 요약 문자열로. */ private String summarizeCond(PersonalDataAccessLogSearchVO vo) { StringBuilder sb = new StringBuilder(); diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java index ab8dfe6..2b4de76 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java @@ -1,5 +1,7 @@ package egovframework.com.cmm.privacy.auditlog; +import java.io.File; +import java.io.FileInputStream; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.PrintWriter; @@ -7,7 +9,9 @@ import java.net.URLEncoder; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; @@ -21,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestParam; import egovframework.com.cmm.LoginVO; import egovframework.com.cmm.util.EgovUserDetailsHelper; +import egovframework.com.utl.sim.service.EgovFileScrty; import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo; /** @@ -59,6 +64,9 @@ public class PersonalDataAccessLogController { @Resource(name = "personalDataAccessLogSearchDAO") private PersonalDataAccessLogSearchDAO personalDataAccessLogSearchDAO; + @Resource(name = "accessLogDownloadJobService") + private AccessLogDownloadJobService accessLogDownloadJobService; + /** * 개인정보 접속기록 조회 화면(서버 렌더링). * 뷰: /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp @@ -82,12 +90,178 @@ public class PersonalDataAccessLogController { model.addAttribute("resultList", list); model.addAttribute("paginationInfo", paginationInfo); model.addAttribute("jobSeList", selectJobSeCodeList()); + // 현재 검색조건 기준 조회/등록/수정/삭제 건수 — 목록을 넘겨보지 않고 처리행위를 판별하기 위함 + model.addAttribute("dmlSummary", selectDmlSummary(searchVO)); + // 내 다운로드 작업 목록(비동기) — 상태·진행률 확인 및 내려받기 + model.addAttribute("dlJobList", selectMyDownloadJobs()); return "site/mnt/sec/accessLog/list"; } /** - * 접속기록 다운로드(CSV) — 사유 필수 + 다운로드 이력 적재(#42). - * 사유 미입력·차단 시 파일을 열지 않고 400 으로 응답한다. + * 다운로드 요청(비동기) — 작업만 등록하고 즉시 목록으로 돌아온다. + * + *

대용량 조건에서 전체를 메모리에 올려 응답에 싣던 방식(아래 {@link #download})은 + * OOM·요청 타임아웃 위험이 있어, 파일 생성은 백그라운드 워커가 맡는다. + * 사유는 여기서도 필수이며, 미입력이면 작업을 만들지 않는다.

+ */ + @RequestMapping(value = "/mnt/sec/accessLog/downloadRequest.do") + public String downloadRequest(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO, + @RequestParam(value = "dlReason", required = false) String dlReason, + HttpServletRequest request, ModelMap model) throws Exception { + LoginVO user = handlerUser(); + if (user == null) { + return LOGIN_REDIRECT; + } + try { + accessLogDownloadJobService.requestJob(user.getId(), + ClientIpResolver.getClientIp(request), dlReason, searchVO); + } catch (IllegalArgumentException e) { + model.addAttribute("dlMessage", e.getMessage()); + } catch (Exception e) { + model.addAttribute("dlMessage", "다운로드 요청 중 오류가 발생했습니다."); + } + // 검색조건을 유지한 채 목록으로 — 요청 직후 작업 목록에서 진행상태를 볼 수 있다. + return listView(searchVO, model); + } + + /** + * 완료된 다운로드 파일 내려받기 — 요청자 본인만 가능하다. + * + *

저장 파일은 암호화돼 있어 복호화 후 스트리밍한다. 임시 복호화본은 전송 직후 지운다. + * 실제 내려받은 시점의 사유·건수 이력은 기존 {@code PDAL_DOWNLOAD_HISTORY} 에 적재한다.

+ */ + @RequestMapping(value = "/mnt/sec/accessLog/downloadFile.do") + public void downloadFile(@RequestParam("jobId") long jobId, + HttpServletRequest request, HttpServletResponse response) throws Exception { + LoginVO user = handlerUser(); + if (user == null) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다."); + return; + } + AccessLogDownloadJobVO job = accessLogDownloadJobService.selectDownloadableJob(jobId, user.getId()); + if (job == null) { + // 존재/소유/완료 여부를 구분해 알리지 않는다(작업ID 탐색 방지). + response.sendError(HttpServletResponse.SC_NOT_FOUND, "내려받을 수 있는 파일이 없습니다."); + return; + } + + File plain = File.createTempFile("pdal-dl-", ".csv"); + try { + if (!EgovFileScrty.decryptFile(job.getFilePath(), plain.getAbsolutePath())) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "파일을 열 수 없습니다."); + return; + } + + String fileName = URLEncoder.encode(job.getFileNm(), "UTF-8"); + response.setContentType("text/csv; charset=UTF-8"); + response.setHeader("Content-Disposition", + "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + fileName); + response.setHeader("Content-Transfer-Encoding", "binary"); + response.setHeader("Content-Length", String.valueOf(plain.length())); + + FileInputStream in = null; + OutputStream os = response.getOutputStream(); + try { + in = new FileInputStream(plain); + byte[] buf = new byte[8192]; + int len; + while ((len = in.read(buf)) > 0) { + os.write(buf, 0, len); + } + os.flush(); + } finally { + if (in != null) { + try { in.close(); } catch (Exception ignore) { /* 무시 */ } + } + } + + accessLogDownloadJobService.markDownloaded(jobId); + // 내려받은 사실을 기존 다운로드 이력에도 남긴다(사유·건수 증빙 일원화). + accessLogDownloadService.recordDownloadHistory(user.getId(), ClientIpResolver.getClientIp(request), + job.getDlReason(), job.getSearchCond(), job.getDoneCnt()); + } finally { + // 복호화 임시본을 남기지 않는다. + if (plain.exists() && !plain.delete()) { + plain.deleteOnExit(); + } + } + } + + /** + * 내 다운로드 작업의 상태만 돌려주는 폴링 전용 엔드포인트(#47 후속). + * + *

왜 따로 뒀나: 종전 화면은 진행률을 보려고 목록 화면 전체를 5초마다 다시 열었다. + * 그런데 이 화면은 접속기록 수집 화이트리스트({@code /mnt/sec/**}) 안이라 새로고침 한 번이 + * 접속기록 1행이 된다. 즉 폴링이 도는 동안 감사로그가 분당 12행씩 자기 자신을 기록했고, + * 이는 정기점검·비정상접근 탐지(SER-002)의 신호를 그대로 오염시킨다. 상태 조회를 별도 경로로 + * 빼고 그 경로를 {@code excludePatterns} 에 넣어 수집 대상에서 제외한다.

+ * + *

본문 원칙: 개인정보는커녕 검색조건도 싣지 않는다. 작업ID·상태·진행률·건수와 + * 실패사유뿐이며, 모두 본인 작업에 한정된다({@code selectMyJobs(본인ID)}).

+ * + *

형식은 한 줄 1작업의 {@code |} 구분 텍스트다 — 이 스택(Spring 3.0.5)에 JSON 메시지 + * 컨버터를 새로 물리지 않고도 확실히 동작하며, 파싱이 단순해 화면 쪽 실패 여지가 적다.

+ */ + @RequestMapping(value = "/mnt/sec/accessLog/jobStatus.do") + public void jobStatus(HttpServletResponse response) throws Exception { + response.setContentType("text/plain; charset=UTF-8"); + response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + response.setHeader("Pragma", "no-cache"); + + LoginVO user = handlerUser(); + if (user == null) { + // 화면이 재로그인으로 유도하도록 상태코드만 알린다(본문 없음). + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return; + } + + StringBuilder sb = new StringBuilder(256); + List list = accessLogDownloadJobService.selectMyJobs(user.getId(), 10); + if (list != null) { + for (AccessLogDownloadJobVO j : list) { + sb.append(j.getJobId()).append('|') + .append(nvl(j.getStatus())).append('|') + .append(j.getProgressPct()).append('|') + .append(j.getDoneCnt()).append('|') + .append(j.getTotalCnt()).append('|') + .append(oneLine(j.getErrMsg())).append('\n'); + } + } + response.getWriter().write(sb.toString()); + response.getWriter().flush(); + } + + /** 구분자·개행이 섞이면 화면 파싱이 깨지므로 한 줄로 눌러 담는다. */ + private String oneLine(String v) { + if (v == null) { + return ""; + } + return v.replace('|', '/').replace('\r', ' ').replace('\n', ' '); + } + + private String nvl(String v) { + return (v == null) ? "" : v; + } + + /** 내 다운로드 작업 목록(최근 10건). 조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe. */ + private List selectMyDownloadJobs() { + try { + LoginVO user = handlerUser(); + if (user == null) { + return new ArrayList(); + } + List list = accessLogDownloadJobService.selectMyJobs(user.getId(), 10); + return (list == null) ? new ArrayList() : list; + } catch (Exception e) { + return new ArrayList(); + } + } + + /** + * 접속기록 다운로드(CSV) — 동기 방식(기존 #42). 사유 필수 + 다운로드 이력 적재. + * + *

주의: 조건 전체를 메모리에 올린다. 대용량은 위 {@link #downloadRequest} 비동기 경로를 + * 쓴다. 이 엔드포인트는 소량 즉시 다운로드와 기존 링크 호환을 위해 남겨둔다.

*/ @RequestMapping(value = "/mnt/sec/accessLog/download.do") public void download(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO, @@ -153,6 +327,43 @@ public class PersonalDataAccessLogController { } } + /** + * 현재 검색조건 기준 처리구분별 건수 요약. + * + *

"이 취급자가 개인정보를 조회만 했는지, 수정·삭제까지 했는지"를 목록을 넘겨보지 않고 + * 판별하기 위한 값이다. 화면이 {@code ${dmlSummary.S}} 처럼 바로 꺼내 쓰도록 + * S/I/U/D 네 키를 항상 채워 반환한다(해당 기록이 없으면 0).

+ * + *

집계 실패가 화면 전체를 막지 않도록 0으로 채운 맵을 돌려준다(fail-safe).

+ */ + private Map selectDmlSummary(PersonalDataAccessLogSearchVO searchVO) { + Map summary = new LinkedHashMap(); + summary.put("S", Long.valueOf(0L)); + summary.put("I", Long.valueOf(0L)); + summary.put("U", Long.valueOf(0L)); + summary.put("D", Long.valueOf(0L)); + try { + List> rows = personalDataAccessLogSearchDAO.selectAccessLogDmlSummary(searchVO); + if (rows == null) { + return summary; + } + for (Map row : rows) { + Object code = row.get("dmlGubun"); + Object cnt = row.get("cnt"); + if (code == null || !summary.containsKey(String.valueOf(code))) { + // S/I/U/D 외 값(레거시 적재분 등)은 요약에 넣지 않는다 — 목록에서 확인한다. + continue; + } + long v = (cnt instanceof Number) ? ((Number) cnt).longValue() : 0L; + summary.put(String.valueOf(code), Long.valueOf(v)); + } + } catch (Exception e) { + // 요약은 보조 정보다 — 실패해도 목록은 떠야 한다. + return summary; + } + return summary; + } + /** CSV 셀 이스케이프(구분자·따옴표·개행 방어). */ private String csv(String v) { if (v == null) { diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchDAO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchDAO.java index b537203..c866593 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchDAO.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchDAO.java @@ -1,6 +1,7 @@ package egovframework.com.cmm.privacy.auditlog; import java.util.List; +import java.util.Map; import org.springframework.stereotype.Repository; @@ -42,4 +43,17 @@ public class PersonalDataAccessLogSearchDAO extends EgovComAbstractDAO { public List selectJobSeCodeList() { return list("PersonalDataAccessLogDAO.selectJobSeCodeList", null); } + + /** + * 현재 검색조건 기준 처리구분(S/I/U/D)별 건수. + * + *

"이 취급자가 개인정보를 조회만 했는지, 수정·삭제까지 했는지"를 목록을 넘겨보지 않고 + * 확인하기 위한 집계다. 검색조건은 목록 조회와 동일하다.

+ * + * @return {dmlGubun, cnt} 맵 목록. 해당 구분의 기록이 없으면 그 행 자체가 없다. + */ + @SuppressWarnings("unchecked") + public List> selectAccessLogDmlSummary(PersonalDataAccessLogSearchVO searchVO) { + return list("PersonalDataAccessLogDAO.selectAccessLogDmlSummary", searchVO); + } } diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchVO.java index 14bd81f..721f76f 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchVO.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchVO.java @@ -10,7 +10,19 @@ import java.io.Serializable; * 본 VO는 접속자 ID·업무유형·처리대상·IP·처리구분·처리결과·기간을 복합 검색조건으로 제공하며, * {@code IDX_PDAL_01(CONECT_ID, CONECT_DT)} 인덱스로 접속자 ID 검색 결함을 해소한다.

* - *

화면(JSP)은 본 작업 범위 밖이다 — 백엔드까지 구현. (TODO: 화면 #47 연계)

+ *

화면: {@code /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp} (#47 연계 완료)

+ * + *

검색조건 정규화(2026-08-07). 원본 입력값은 화면 재표시를 위해 그대로 두고, + * SQL 바인딩에는 아래 파생 getter를 쓴다. 이렇게 하면 MySQL/Oracle 두 sqlMap에 + * 방언 차이({@code CONCAT} vs {@code ||})를 새로 만들지 않아도 된다.

+ *
    + *
  • {@link #getToDtParam()} — 종료일 경계 결함 수정. 종전에는 {@code CONECT_DT <= '2026-08-07'}이 + * {@code 2026-08-07 00:00:00}으로 해석돼 종료일 하루가 통째로 누락됐다(실측: 8/6~8/7 검색이 + * 39건 중 38건만 반환). 날짜만 입력하면 {@code 23:59:59}를 붙여 당일을 포함시킨다.
  • + *
  • {@link #getConectIdLike()} 등 — 전방일치 검색. 종전 완전일치는 ID 전체를 정확히 알아야 해서 + * SFR-004-6(조회·검색)의 실효가 없었다. 전방일치는 {@code IDX_PDAL_01(CONECT_ID, CONECT_DT)} + * 인덱스를 그대로 탄다(중간일치 {@code %x%}는 인덱스를 못 써서 채택하지 않았다).
  • + *
* * @author arko-privacy-auditlog (Fork B - 5차) * @since 2026-07-21 @@ -81,4 +93,74 @@ public class PersonalDataAccessLogSearchVO implements Serializable { public int getTotalRecordCount() { return totalRecordCount; } public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; } + + // ===================================================================== + // SQL 바인딩용 파생값 (2026-08-07) — setter 없음(읽기 전용). + // 원본 필드는 화면 재표시용으로 건드리지 않는다. + // ===================================================================== + + /** 날짜만 입력된 값인지(yyyy-MM-dd). */ + private boolean isDateOnly(String v) { + return v != null && v.trim().length() == 10; + } + + /** + * 조회 시작일시 바인딩값. 날짜만 입력하면 그날 {@code 00:00:00}부터. + * (시작 경계는 문자열 비교로도 동일하지만, 종료와 대칭을 맞춰 의도를 드러낸다.) + */ + public String getFromDtParam() { + if (fromDt == null) { + return null; + } + String v = fromDt.trim(); + return isDateOnly(v) ? v + " 00:00:00" : v; + } + + /** + * 조회 종료일시 바인딩값. 날짜만 입력하면 그날 {@code 23:59:59}까지 포함한다. + * + *

이 보정이 없으면 종료일 당일 기록이 전부 빠진다 — 화면 안내가 {@code yyyy-MM-dd} 입력을 + * 권장하므로 반드시 밟게 되는 함정이었다.

+ */ + public String getToDtParam() { + if (toDt == null) { + return null; + } + String v = toDt.trim(); + return isDateOnly(v) ? v + " 23:59:59" : v; + } + + /** LIKE 이스케이프 문자. SQL 의 {@code ESCAPE '!'} 와 반드시 짝을 이룬다. */ + private static final String LIKE_ESCAPE = "!"; + + /** + * LIKE 전방일치 패턴으로 변환한다. 사용자가 입력한 {@code %}/{@code _}는 와일드카드가 아니라 + * 문자 그대로 찾도록 이스케이프한다 — 안 하면 {@code %} 한 글자로 전건이 조회된다. + * + *

이스케이프 문자로 백슬래시가 아닌 {@code !}를 쓴다. MySQL 문자열 리터럴에서 {@code '\'}는 + * 뒤따르는 따옴표를 이스케이프해 SQL 자체가 깨지므로 {@code ESCAPE '\'}를 쓸 수 없고, + * MySQL만 {@code '\\'}로 적으면 Oracle 쌍과 표현이 갈린다. {@code !}는 두 방언 모두 동일하다.

+ */ + private String toPrefixLike(String v) { + if (v == null) { + return null; + } + String s = v.trim(); + if (s.length() == 0) { + return null; + } + s = s.replace(LIKE_ESCAPE, LIKE_ESCAPE + LIKE_ESCAPE) + .replace("%", LIKE_ESCAPE + "%") + .replace("_", LIKE_ESCAPE + "_"); + return s + "%"; + } + + /** 접속자ID 전방일치 패턴. */ + public String getConectIdLike() { return toPrefixLike(conectId); } + + /** 처리대상 식별자 전방일치 패턴. */ + public String getTrgetIdLike() { return toPrefixLike(trgetId); } + + /** 접속IP 전방일치 패턴 — 대역 검색용(예: {@code 192.168.} 입력 시 해당 대역 전체). */ + public String getConectIpLike() { return toPrefixLike(conectIp); } } diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorChangeHistoryVO.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorChangeHistoryVO.java index fd7c1d4..9352cf4 100644 --- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorChangeHistoryVO.java +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorChangeHistoryVO.java @@ -20,6 +20,8 @@ public class AuthorChangeHistoryVO implements Serializable { private String trgetUniqId; /** 변경 대상자 계정ID */ private String trgetId; + /** 변경 대상자 성명 — COMTNEMPLYRINFO 조인 표기용(비영속) */ + private String trgetNm; /** 변경구분 G:부여 R:회수 M:변경 */ private String chgSeCode; private String beforeAuthor; @@ -54,6 +56,8 @@ public class AuthorChangeHistoryVO implements Serializable { public void setTrgetUniqId(String trgetUniqId) { this.trgetUniqId = trgetUniqId; } public String getTrgetId() { return trgetId; } public void setTrgetId(String trgetId) { this.trgetId = trgetId; } + public String getTrgetNm() { return trgetNm; } + public void setTrgetNm(String trgetNm) { this.trgetNm = trgetNm; } public String getChgSeCode() { return chgSeCode; } public void setChgSeCode(String chgSeCode) { this.chgSeCode = chgSeCode; } public String getBeforeAuthor() { return beforeAuthor; } diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobDAO.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobDAO.java new file mode 100644 index 0000000..649dd71 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobDAO.java @@ -0,0 +1,85 @@ +package egovframework.com.cmm.privacy.author; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 권한변경 이력 CSV 비동기 다운로드 작업(AUTHOR_HIST_DOWNLOAD_JOB) DAO. + * + *

개인정보보호 기능개선 사업(SFR-001 / 개발항목 #6). statement 는 + * {@code PrivacyAuthorHistDownloadJob_SQL_Mysql.xml} + * (namespace {@code AuthorHistDownloadJobDAO}). 신규 sqlMap 이므로 + * {@code sql-map-config-mysql-0-site.xml} 등록이 필요하다.

+ * + * @since 2026-08-10 + */ +@Repository("authorHistDownloadJobDAO") +public class AuthorHistDownloadJobDAO extends EgovComAbstractDAO { + + /** 요청 등록. 채번된 JOB_ID 는 selectKey 로 vo.jobId 에 채워진다. */ + public long insertJob(AuthorHistDownloadJobVO vo) { + insert("AuthorHistDownloadJobDAO.insertJob", vo); + return vo.getJobId(); + } + + public AuthorHistDownloadJobVO selectJob(long jobId) { + return (AuthorHistDownloadJobVO) selectByPk("AuthorHistDownloadJobDAO.selectJob", Long.valueOf(jobId)); + } + + /** 요청자 본인의 최근 작업 목록. */ + @SuppressWarnings("unchecked") + public List selectJobListByRequester(String requesterId, int limitCnt) { + Map param = new HashMap(); + param.put("requesterId", requesterId); + param.put("limitCnt", Integer.valueOf(limitCnt)); + return list("AuthorHistDownloadJobDAO.selectJobListByRequester", param); + } + + /** 대기 상태의 가장 오래된 작업 ID. 없으면 null. */ + public Long selectNextWaitingJobId() { + return (Long) selectByPk("AuthorHistDownloadJobDAO.selectNextWaitingJobId", null); + } + + /** + * 대기건 선점. 영향행수 1이면 이 워커가 소유권을 얻은 것이다. + * (WHERE 에 STATUS='W' 가 걸려 있어 동시 선점 시 한쪽만 성공한다.) + */ + public boolean claimJob(long jobId) { + return update("AuthorHistDownloadJobDAO.claimJob", Long.valueOf(jobId)) == 1; + } + + public void updateProgress(AuthorHistDownloadJobVO vo) { + update("AuthorHistDownloadJobDAO.updateProgress", vo); + } + + public void updateSuccess(AuthorHistDownloadJobVO vo) { + update("AuthorHistDownloadJobDAO.updateSuccess", vo); + } + + public void updateFail(AuthorHistDownloadJobVO vo) { + update("AuthorHistDownloadJobDAO.updateFail", vo); + } + + public void updateDownloaded(long jobId) { + update("AuthorHistDownloadJobDAO.updateDownloaded", Long.valueOf(jobId)); + } + + @SuppressWarnings("unchecked") + public List selectExpiredJobs() { + return list("AuthorHistDownloadJobDAO.selectExpiredJobs", null); + } + + public void updatePurged(long jobId) { + update("AuthorHistDownloadJobDAO.updatePurged", Long.valueOf(jobId)); + } + + /** WAS 비정상 종료로 'R' 에 멈춘 작업을 대기로 되돌린다(기동 시 1회). */ + public int resetRunningJobs() { + return update("AuthorHistDownloadJobDAO.resetRunningJobs", null); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobService.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobService.java new file mode 100644 index 0000000..045d8a2 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobService.java @@ -0,0 +1,38 @@ +package egovframework.com.cmm.privacy.author; + +import java.util.List; + +/** + * 권한변경 이력 CSV 비동기 다운로드 작업 서비스 (SFR-001 / 개발항목 #6). + * + *

요청은 작업만 등록하고 즉시 반환한다. 파일 생성은 + * {@link AuthorHistDownloadJobWorker} 가 백그라운드에서 맡는다.

+ * + * @since 2026-08-10 + */ +public interface AuthorHistDownloadJobService { + + /** + * 다운로드 요청 등록. + * + * @param requesterId 요청자 계정ID + * @param requesterIp 요청 IP + * @param reason 다운로드 사유(필수 — 없으면 IllegalArgumentException) + * @param searchVO 다운로드 대상 검색조건 + * @return 채번된 작업 ID + */ + long requestJob(String requesterId, String requesterIp, String reason, + AuthorChangeHistoryVO searchVO) throws Exception; + + /** 요청자 본인의 최근 작업 목록. */ + List selectMyJobs(String requesterId, int limitCnt) throws Exception; + + /** 내려받기 가능한 작업 조회. 본인 것이 아니거나 미완료/파일 없음이면 null. */ + AuthorHistDownloadJobVO selectDownloadableJob(long jobId, String requesterId) throws Exception; + + /** 내려받은 횟수·일시 기록. */ + void markDownloaded(long jobId) throws Exception; + + /** 만료 파일 파기(배치). 파일 삭제 + 상태 X. */ + int purgeExpiredJobs() throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobServiceImpl.java new file mode 100644 index 0000000..0f2dc71 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobServiceImpl.java @@ -0,0 +1,169 @@ +package egovframework.com.cmm.privacy.author; + +import java.io.File; +import java.util.List; + +import javax.annotation.Resource; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * 권한변경 이력 CSV 비동기 다운로드 작업 서비스 구현 (SFR-001 / 개발항목 #6). + * + *

보관 최소화: 생성 파일은 취급자 권한 변동 내역이 담긴 개인정보 파일이라 오래 두지 않는다. + * {@link #expireDays}(기본 3일) 경과분은 파기 배치가 파일을 지우고 상태를 X 로 바꾼다. + * 권한이력 본체의 보관기간(기본 1,095일)과는 무관한 '내려받기용 사본'의 수명이다.

+ * + *

권한 재검증: 내려받기는 요청자 본인만 가능하다. 작업ID는 연속 정수라 + * 추측이 쉬우므로, 조회 단계에서 요청자ID 일치를 확인하지 않으면 남의 파일이 새어나간다.

+ * + * @since 2026-08-10 + */ +@Service("authorHistDownloadJobService") +public class AuthorHistDownloadJobServiceImpl implements AuthorHistDownloadJobService { + + private static final Log LOG = LogFactory.getLog(AuthorHistDownloadJobServiceImpl.class); + + @Resource(name = "authorHistDownloadJobDAO") + private AuthorHistDownloadJobDAO jobDAO; + + /** 생성 파일 보관일수. 접속기록 비동기 다운로드와 같은 설정값을 공유한다(기본 3일). */ + @Value("${Globals.privacyDownloadExpireDays:3}") + private int expireDays = 3; + + @Override + public long requestJob(String requesterId, String requesterIp, String reason, + AuthorChangeHistoryVO searchVO) throws Exception { + + // 사유 필수 — 종전 동기 다운로드와 동일한 차단 기준을 유지한다. + if (reason == null || reason.trim().length() == 0) { + throw new IllegalArgumentException("다운로드 사유는 필수입니다."); + } + if (requesterId == null || requesterId.trim().length() == 0) { + throw new IllegalArgumentException("요청자를 확인할 수 없습니다."); + } + if (searchVO == null) { + searchVO = new AuthorChangeHistoryVO(); + } + + AuthorHistDownloadJobVO job = new AuthorHistDownloadJobVO(); + job.setRequesterId(requesterId); + job.setRequesterIp(requesterIp); + job.setDlReason(cut(reason.trim(), 500)); + job.setSearchCond(cut(summarizeCond(searchVO), 1000)); + job.setCondTrgetId(searchVO.getSearchTrgetId()); + job.setCondChangerId(searchVO.getSearchChangerId()); + job.setCondChgSeCode(searchVO.getSearchChgSeCode()); + job.setCondFromDt(searchVO.getFromDt()); + job.setCondToDt(searchVO.getToDt()); + job.setExpireDays(expireDays < 1 ? 3 : expireDays); + + long jobId = jobDAO.insertJob(job); + LOG.info("[개인정보/SFR-001-6] 권한이력 다운로드 작업 등록 — jobId=" + jobId + ", 요청자=" + requesterId + + ", 조건=" + job.getSearchCond()); + return jobId; + } + + @Override + public List selectMyJobs(String requesterId, int limitCnt) throws Exception { + if (requesterId == null || requesterId.trim().length() == 0) { + return java.util.Collections.emptyList(); + } + return jobDAO.selectJobListByRequester(requesterId, limitCnt < 1 ? 10 : limitCnt); + } + + @Override + public AuthorHistDownloadJobVO selectDownloadableJob(long jobId, String requesterId) throws Exception { + AuthorHistDownloadJobVO job = jobDAO.selectJob(jobId); + if (job == null) { + return null; + } + // 본인 확인 — 작업ID 추측으로 남의 파일을 가져가는 것을 막는다. + if (requesterId == null || !requesterId.equals(job.getRequesterId())) { + LOG.warn("[개인정보/SFR-001-6] 타인 다운로드 시도 차단 — jobId=" + jobId + + ", 소유자=" + job.getRequesterId() + ", 요청자=" + requesterId); + return null; + } + if (!job.isDownloadable()) { + return null; + } + File f = new File(job.getFilePath()); + if (!f.exists() || !f.isFile()) { + LOG.warn("[개인정보/SFR-001-6] 파일 없음(파기됐거나 유실) — jobId=" + jobId); + return null; + } + return job; + } + + @Override + public void markDownloaded(long jobId) throws Exception { + jobDAO.updateDownloaded(jobId); + } + + @Override + public int purgeExpiredJobs() throws Exception { + List expired = jobDAO.selectExpiredJobs(); + int purged = 0; + if (expired != null) { + for (int i = 0; i < expired.size(); i++) { + AuthorHistDownloadJobVO job = expired.get(i); + String path = job.getFilePath(); + if (path != null && path.length() > 0) { + try { + File f = new File(path); + if (f.exists() && !f.delete()) { + // 삭제 실패는 개인정보 파일이 남는다는 뜻이라 조용히 넘기지 않는다. + LOG.error("[개인정보/SFR-001-6] 다운로드 파일 삭제 실패 — jobId=" + job.getJobId() + + ", path=" + path + " (수동 삭제 필요)"); + continue; + } + } catch (Exception e) { + LOG.error("[개인정보/SFR-001-6] 다운로드 파일 삭제 중 예외 — jobId=" + job.getJobId(), e); + continue; + } + } + jobDAO.updatePurged(job.getJobId()); + purged++; + } + } + LOG.info("[개인정보/SFR-001-6] 만료 권한이력 다운로드 파일 파기 완료 — " + purged + "건(보관 " + expireDays + "일)"); + return purged; + } + + /** 검색조건을 요약(표시용). */ + private String summarizeCond(AuthorChangeHistoryVO vo) { + StringBuilder sb = new StringBuilder(); + appendCond(sb, "대상자", vo.getSearchTrgetId()); + appendCond(sb, "변경자", vo.getSearchChangerId()); + appendCond(sb, "변경구분", chgSeLabel(vo.getSearchChgSeCode())); + appendCond(sb, "시작일", vo.getFromDt()); + appendCond(sb, "종료일", vo.getToDt()); + return sb.length() == 0 ? "(전체)" : sb.toString(); + } + + private void appendCond(StringBuilder sb, String key, String val) { + if (val != null && val.trim().length() > 0) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(key).append("=").append(val.trim()); + } + } + + private String chgSeLabel(String code) { + if ("G".equals(code)) return "부여"; + if ("R".equals(code)) return "회수"; + if ("M".equals(code)) return "변경"; + return code; + } + + private static String cut(String s, int max) { + if (s == null) { + return null; + } + return (s.length() > max) ? s.substring(0, max) : s; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobVO.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobVO.java new file mode 100644 index 0000000..11f56e1 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobVO.java @@ -0,0 +1,148 @@ +package egovframework.com.cmm.privacy.author; + +import java.io.Serializable; + +/** + * 권한변경 이력 CSV 비동기 다운로드 작업(AUTHOR_HIST_DOWNLOAD_JOB) VO. + * + *

개인정보보호 기능개선 사업(SFR-001 / 개발항목 #6) — 대용량 다운로드를 요청 즉시 응답하고 + * 백그라운드에서 파일을 만들기 위한 작업 한 건. 접속기록 쪽 + * {@code AccessLogDownloadJobVO} 와 같은 패턴이며 검색조건 필드만 다르다.

+ * + *

검색조건을 컬럼으로 보존하는 이유: 백그라운드 워커는 요청 세션이 끝난 뒤 실행되므로 + * 조건을 스스로 재현해야 한다. 담는 값은 식별자·코드·기간뿐이다.

+ * + * @since 2026-08-10 + */ +public class AuthorHistDownloadJobVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 상태: 대기 */ + public static final String ST_WAIT = "W"; + /** 상태: 생성중 */ + public static final String ST_RUNNING = "R"; + /** 상태: 완료(내려받기 가능) */ + public static final String ST_SUCCESS = "S"; + /** 상태: 실패 */ + public static final String ST_FAIL = "F"; + /** 상태: 파기(파일 삭제됨) */ + public static final String ST_PURGED = "X"; + + private long jobId; + private String requesterId; + private String requesterIp; + private String dlReason; + private String searchCond; + + // 조건 재현용. 프로퍼티명을 cond* 로 둔다 — getFTrgetId 처럼 '대문자1자+대문자' 형태면 + // JavaBeans 인트로스펙션이 프로퍼티를 다르게 해석해 iBatis 바인딩이 조용히 실패한다. + private String condTrgetId; + private String condChangerId; + private String condChgSeCode; + private String condFromDt; + private String condToDt; + + private String status; + private int totalCnt; + private int doneCnt; + private String fileNm; + private String filePath; + private long fileSize; + private String errMsg; + private int downloadCnt; + private String lastDlDt; + private String regDt; + private String startDt; + private String endDt; + private String expireDt; + + /** 만료일(일). 서비스가 EXPIRE_DT 계산에 쓰는 값을 전달하기 위한 비영속 필드. */ + private int expireDays; + + public long getJobId() { return jobId; } + public void setJobId(long jobId) { this.jobId = jobId; } + + public String getRequesterId() { return requesterId; } + public void setRequesterId(String requesterId) { this.requesterId = requesterId; } + + public String getRequesterIp() { return requesterIp; } + public void setRequesterIp(String requesterIp) { this.requesterIp = requesterIp; } + + public String getDlReason() { return dlReason; } + public void setDlReason(String dlReason) { this.dlReason = dlReason; } + + public String getSearchCond() { return searchCond; } + public void setSearchCond(String searchCond) { this.searchCond = searchCond; } + + public String getCondTrgetId() { return condTrgetId; } + public void setCondTrgetId(String condTrgetId) { this.condTrgetId = condTrgetId; } + + public String getCondChangerId() { return condChangerId; } + public void setCondChangerId(String condChangerId) { this.condChangerId = condChangerId; } + + public String getCondChgSeCode() { return condChgSeCode; } + public void setCondChgSeCode(String condChgSeCode) { this.condChgSeCode = condChgSeCode; } + + public String getCondFromDt() { return condFromDt; } + public void setCondFromDt(String condFromDt) { this.condFromDt = condFromDt; } + + public String getCondToDt() { return condToDt; } + public void setCondToDt(String condToDt) { this.condToDt = condToDt; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public int getTotalCnt() { return totalCnt; } + public void setTotalCnt(int totalCnt) { this.totalCnt = totalCnt; } + + public int getDoneCnt() { return doneCnt; } + public void setDoneCnt(int doneCnt) { this.doneCnt = doneCnt; } + + public String getFileNm() { return fileNm; } + public void setFileNm(String fileNm) { this.fileNm = fileNm; } + + public String getFilePath() { return filePath; } + public void setFilePath(String filePath) { this.filePath = filePath; } + + public long getFileSize() { return fileSize; } + public void setFileSize(long fileSize) { this.fileSize = fileSize; } + + public String getErrMsg() { return errMsg; } + public void setErrMsg(String errMsg) { this.errMsg = errMsg; } + + public int getDownloadCnt() { return downloadCnt; } + public void setDownloadCnt(int downloadCnt) { this.downloadCnt = downloadCnt; } + + public String getLastDlDt() { return lastDlDt; } + public void setLastDlDt(String lastDlDt) { this.lastDlDt = lastDlDt; } + + public String getRegDt() { return regDt; } + public void setRegDt(String regDt) { this.regDt = regDt; } + + public String getStartDt() { return startDt; } + public void setStartDt(String startDt) { this.startDt = startDt; } + + public String getEndDt() { return endDt; } + public void setEndDt(String endDt) { this.endDt = endDt; } + + public String getExpireDt() { return expireDt; } + public void setExpireDt(String expireDt) { this.expireDt = expireDt; } + + public int getExpireDays() { return expireDays; } + public void setExpireDays(int expireDays) { this.expireDays = expireDays; } + + /** 진행률(%). 총건수 미확정이면 0. */ + public int getProgressPct() { + if (totalCnt <= 0) { + return ST_SUCCESS.equals(status) ? 100 : 0; + } + long pct = (long) doneCnt * 100L / totalCnt; + return (pct > 100L) ? 100 : (int) pct; + } + + /** 내려받기 가능 여부(완료 + 파일 보유). */ + public boolean isDownloadable() { + return ST_SUCCESS.equals(status) && filePath != null && filePath.length() > 0; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobWorker.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobWorker.java new file mode 100644 index 0000000..087c85a --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobWorker.java @@ -0,0 +1,338 @@ +package egovframework.com.cmm.privacy.author; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStreamWriter; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.annotation.Resource; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import egovframework.com.utl.sim.service.EgovFileScrty; + +/** + * 권한변경 이력 CSV 비동기 다운로드 워커 (SFR-001 / 개발항목 #6). + * + *

대기(W) 작업을 하나씩 집어 CSV 파일을 만든다. 단일 워커 스레드다 — + * 대량 조회가 동시에 여러 건 돌면 DB 부하가 그대로 서비스 지연이 되기 때문이다. + * 접속기록 워커({@code AccessLogDownloadJobWorker})와 같은 구조이며 조회 대상만 다르다.

+ * + *

메모리를 쓰지 않는 방식: 전체를 List 로 올리지 않고 {@link #CHUNK_ROWS}건씩 + * 끊어 조회하며 곧바로 파일에 쓴다. 종전 동기 다운로드(전량 추출)의 OOM 위험을 없앤다.

+ * + *

파일은 암호화해 둔다: 취급자 권한 변동 내역이 담긴 개인정보 파일이라 평문 CSV 를 + * 디스크에 남기지 않는다. {@link EgovFileScrty#encryptFile}로 암호화한 뒤 평문 임시파일을 지우고, + * 내려받을 때 복호화해 스트리밍한다. 저장 위치는 반드시 웹 루트 밖이어야 한다.

+ * + *

기동 시 고아 복구: 파일 생성 중 WAS 가 죽으면 작업이 R 로 굳는다. 기동 시 + * R → W 로 되돌려 재시도한다(화면에 '생성중'이 영원히 멈춰 보이는 것 방지).

+ * + * @since 2026-08-10 + */ +@Component("authorHistDownloadJobWorker") +public class AuthorHistDownloadJobWorker { + + private static final Log LOG = LogFactory.getLog(AuthorHistDownloadJobWorker.class); + + /** 한 번에 조회·기록할 행 수. 메모리 상주량을 결정한다. */ + private static final int CHUNK_ROWS = 2000; + /** 대기 작업이 없을 때 다음 확인까지 쉬는 시간(ms). */ + private static final long IDLE_SLEEP_MS = 3000L; + /** 예외 발생 후 폭주 방지 대기(ms). */ + private static final long ERROR_SLEEP_MS = 10000L; + + /** 화면 열 구성과 맞춘다(대상자명·변경자IP 포함). */ + private static final String CSV_HEADER = + "변경일시,대상자ID,대상자명,변경구분,변경전권한,변경후권한,변경사유,승인자,변경자ID,변경자IP"; + + @Resource(name = "authorHistDownloadJobDAO") + private AuthorHistDownloadJobDAO jobDAO; + + @Resource(name = "authorHistoryService") + private AuthorHistoryService authorHistoryService; + + /** + * 생성 파일 저장 디렉터리. 반드시 웹 루트(webapps) 밖이어야 한다. + * 접속기록 비동기 다운로드와 같은 설정값을 공유한다. 미설정 시 기동 로그에 경고를 남기고 + * 워커를 띄우지 않는다 — 임의 경로에 개인정보 파일을 만드는 것보다 기능을 멈추는 편이 안전하다. + */ + @Value("${Globals.privacyDownloadPath:}") + private String downloadPath; + + private volatile boolean running = false; + private volatile Thread worker; + + @PostConstruct + public void start() { + // 저장경로가 없거나 만들 수 없어도 워커는 띄운다(접속기록 워커와 동일한 이유). + // 종전에는 여기서 return 해 워커가 아예 안 돌았고, 작업이 '대기'로 영원히 굳었다. + // 지금은 작업마다 경로를 확인해 확보 불가면 그 작업을 '실패'로 확정한다. + try { + ensureDir(); + LOG.info("[개인정보/SFR-001-6] 저장경로 확인 완료 — " + downloadPath); + } catch (Exception e) { + LOG.error("[개인정보/SFR-001-6] 저장경로를 확보하지 못했다 — " + e.getMessage() + + " 워커는 기동하되, 경로가 확보될 때까지 접수된 작업은 '실패'로 처리한다. " + + "웹 루트 밖 경로를 지정·생성할 것(예: /app/egovframework/privacy-download/)."); + } + try { + int reset = jobDAO.resetRunningJobs(); + if (reset > 0) { + LOG.warn("[개인정보/SFR-001-6] 이전 기동에서 생성 중이던 작업 " + reset + "건을 대기로 되돌려 재시도한다."); + } + } catch (Exception e) { + LOG.error("[개인정보/SFR-001-6] 고아 작업 복구 실패(계속 진행)", e); + } + + running = true; + Thread t = new Thread(new Runnable() { + public void run() { + loop(); + } + }, "author-hist-download-worker"); + t.setDaemon(true); + worker = t; + t.start(); + LOG.info("[개인정보/SFR-001-6] 권한이력 비동기 다운로드 워커 기동 — 저장경로=" + downloadPath + + ", 청크=" + CHUNK_ROWS + "행"); + } + + @PreDestroy + public void stop() { + running = false; + Thread t = worker; + if (t != null) { + t.interrupt(); + } + } + + /** 워커 루프 — 대기건을 하나씩 선점해 처리한다. Throwable 까지 흡수해 스스로 죽지 않는다. */ + private void loop() { + while (running) { + try { + Long jobId = jobDAO.selectNextWaitingJobId(); + if (jobId == null) { + Thread.sleep(IDLE_SLEEP_MS); + continue; + } + if (!jobDAO.claimJob(jobId.longValue())) { + // 다른 워커/재시도와 경합해 선점 실패 — 즉시 다음 것을 본다. + continue; + } + process(jobId.longValue()); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } catch (Throwable t) { + LOG.error("[개인정보/SFR-001-6] 워커 루프 예외(계속)", t); + try { + Thread.sleep(ERROR_SLEEP_MS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + LOG.info("[개인정보/SFR-001-6] 권한이력 비동기 다운로드 워커 종료."); + } + + /** 작업 1건 처리 — 청크 조회 → CSV 기록 → 암호화 → 완료 확정. */ + private void process(long jobId) { + AuthorHistDownloadJobVO job = null; + File plain = null; + File enc = null; + try { + job = jobDAO.selectJob(jobId); + if (job == null) { + return; + } + + AuthorChangeHistoryVO searchVO = toSearchVO(job); + int total = authorHistoryService.selectHistoryListCount(searchVO); + job.setTotalCnt(total); + job.setDoneCnt(0); + jobDAO.updateProgress(job); + + // 작업마다 저장경로를 확인한다 — 기동 후 경로가 생겼다면 재기동 없이 여기서 살아난다. + File dir = ensureDir(); + + String stamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()); + String baseNm = "권한변경이력_" + stamp + "_" + jobId; + plain = new File(dir, baseNm + ".csv.tmp"); + enc = new File(dir, baseNm + ".csv.enc"); + + int written = writeCsv(plain, searchVO, total, job); + + // 평문 CSV 를 디스크에 남기지 않는다 — 암호화 후 즉시 삭제. + if (!EgovFileScrty.encryptFile(plain.getAbsolutePath(), enc.getAbsolutePath())) { + throw new IllegalStateException("파일 암호화 실패"); + } + deleteQuietly(plain); + plain = null; + + job.setDoneCnt(written); + job.setTotalCnt(total); + job.setFileNm(baseNm + ".csv"); + job.setFilePath(enc.getAbsolutePath()); + job.setFileSize(enc.length()); + jobDAO.updateSuccess(job); + LOG.info("[개인정보/SFR-001-6] 권한이력 다운로드 파일 생성 완료 — jobId=" + jobId + ", " + written + "행, " + + enc.length() + "byte"); + + } catch (Throwable t) { + LOG.error("[개인정보/SFR-001-6] 권한이력 다운로드 파일 생성 실패 — jobId=" + jobId, t); + deleteQuietly(plain); + deleteQuietly(enc); + try { + AuthorHistDownloadJobVO fail = (job != null) ? job : new AuthorHistDownloadJobVO(); + fail.setJobId(jobId); + fail.setErrMsg(cut(String.valueOf(t.getMessage()), 500)); + jobDAO.updateFail(fail); + } catch (Exception e) { + LOG.error("[개인정보/SFR-001-6] 실패 상태 기록마저 실패 — jobId=" + jobId, e); + } + } + } + + /** + * 청크 단위로 조회하며 곧바로 파일에 기록한다(전체를 메모리에 올리지 않는다). + * + * @return 기록한 행 수 + */ + private int writeCsv(File target, AuthorChangeHistoryVO searchVO, int total, + AuthorHistDownloadJobVO job) throws Exception { + BufferedWriter w = null; + int written = 0; + try { + FileOutputStream fos = new FileOutputStream(target); + // UTF-8 BOM — 엑셀에서 한글이 깨지지 않게 한다(기존 동기 다운로드와 동일). + fos.write(0xEF); fos.write(0xBB); fos.write(0xBF); + w = new BufferedWriter(new OutputStreamWriter(fos, "UTF-8")); + w.write(CSV_HEADER); + w.newLine(); + + int page = 1; + while (written < total) { + searchVO.setPageIndex(page); + searchVO.setRecordCountPerPage(CHUNK_ROWS); + List chunk = authorHistoryService.selectHistoryList(searchVO); + if (chunk == null || chunk.isEmpty()) { + break; + } + for (int i = 0; i < chunk.size(); i++) { + w.write(toCsvLine(chunk.get(i))); + w.newLine(); + written++; + } + w.flush(); + + // 진행률 갱신 — 화면 폴링이 실제로 움직이는 값을 보게 한다. + job.setDoneCnt(written); + job.setTotalCnt(total); + try { + jobDAO.updateProgress(job); + } catch (Exception e) { + // 진행률은 부가정보다 — 실패해도 생성은 계속한다. + LOG.warn("[개인정보/SFR-001-6] 진행률 갱신 실패(무시) — jobId=" + job.getJobId()); + } + page++; + } + w.flush(); + } finally { + if (w != null) { + try { w.close(); } catch (Exception ignore) { /* 닫기 실패는 무시 */ } + } + } + return written; + } + + private String toCsvLine(AuthorChangeHistoryVO v) { + StringBuilder sb = new StringBuilder(256); + sb.append(csv(v.getChgDt())).append(',') + .append(csv(v.getTrgetId())).append(',') + .append(csv(v.getTrgetNm())).append(',') + .append(csv(chgSeLabel(v.getChgSeCode()))).append(',') + .append(csv(v.getBeforeAuthor())).append(',') + .append(csv(v.getAfterAuthor())).append(',') + .append(csv(v.getChgResn())).append(',') + .append(csv(v.getAprovlId())).append(',') + .append(csv(v.getChangerId())).append(',') + .append(csv(v.getChangerIp())); + return sb.toString(); + } + + private AuthorChangeHistoryVO toSearchVO(AuthorHistDownloadJobVO job) { + AuthorChangeHistoryVO vo = new AuthorChangeHistoryVO(); + vo.setSearchTrgetId(job.getCondTrgetId()); + vo.setSearchChangerId(job.getCondChangerId()); + vo.setSearchChgSeCode(job.getCondChgSeCode()); + vo.setFromDt(job.getCondFromDt()); + vo.setToDt(job.getCondToDt()); + return vo; + } + + /** + * 저장 디렉터리를 확보한다(없으면 생성). 실패는 예외로 알린다 — + * 메시지가 그대로 작업 실패사유(ERR_MSG)로 화면에 노출된다. + */ + private File ensureDir() { + if (downloadPath == null || downloadPath.trim().length() == 0) { + throw new IllegalStateException("다운로드 저장경로 미설정(Globals.privacyDownloadPath)"); + } + File dir = new File(downloadPath.trim()); + if (!dir.exists() && !dir.mkdirs()) { + throw new IllegalStateException("다운로드 저장경로 생성 실패 — " + dir.getAbsolutePath()); + } + if (!dir.isDirectory()) { + throw new IllegalStateException("다운로드 저장경로가 디렉터리가 아님 — " + dir.getAbsolutePath()); + } + if (!dir.canWrite()) { + throw new IllegalStateException("다운로드 저장경로에 쓸 수 없음(WAS 계정 권한 확인) — " + dir.getAbsolutePath()); + } + return dir; + } + + private void deleteQuietly(File f) { + if (f != null && f.exists()) { + try { + if (!f.delete()) { + LOG.warn("[개인정보/SFR-001-6] 임시파일 삭제 실패(수동 확인 필요) — " + f.getAbsolutePath()); + } + } catch (Exception ignore) { + LOG.warn("[개인정보/SFR-001-6] 임시파일 삭제 중 예외 — " + f.getAbsolutePath()); + } + } + } + + /** CSV 셀 이스케이프(구분자·따옴표·개행 방어). */ + private String csv(String v) { + if (v == null) { + return ""; + } + String s = v.replace("\"", "\"\""); + return "\"" + s.replace("\r", " ").replace("\n", " ") + "\""; + } + + private String chgSeLabel(String code) { + if ("G".equals(code)) return "부여"; + if ("R".equals(code)) return "회수"; + if ("M".equals(code)) return "변경"; + return code; + } + + private static String cut(String s, int max) { + if (s == null) { + return null; + } + return (s.length() > max) ? s.substring(0, max) : s; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryService.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryService.java index e4e1083..4993746 100644 --- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryService.java +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryService.java @@ -21,4 +21,18 @@ public interface AuthorHistoryService { * @return 추출된 이력 목록(파일 생성은 화면/컨트롤러 계층에서) */ List downloadHistory(AuthorChangeHistoryVO searchVo, AuthorHistDownloadVO download) throws Exception; + + /** + * 다운로드 증빙 이력만 적재. 비동기 다운로드는 파일 생성과 실제 내려받기 시점이 달라 + * '내려받은 시점'에 이 메서드로 증빙을 남긴다(사유·건수 증빙을 동기 경로와 일원화). + * + * @param downloaderId 다운로드자 계정ID + * @param downloaderUniqId 다운로드자 고유식별자 + * @param downloaderIp 다운로드자 IP + * @param reason 다운로드 사유(요청 시 입력값) + * @param searchCond 검색조건 요약 + * @param cnt 내려받은 건수 + */ + void recordDownloadHistory(String downloaderId, String downloaderUniqId, String downloaderIp, + String reason, String searchCond, int cnt) throws Exception; } diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryServiceImpl.java index a74f68b..3ac3618 100644 --- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryServiceImpl.java +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryServiceImpl.java @@ -48,4 +48,17 @@ public class AuthorHistoryServiceImpl implements AuthorHistoryService { return list; } + + @Override + public void recordDownloadHistory(String downloaderId, String downloaderUniqId, String downloaderIp, + String reason, String searchCond, int cnt) throws Exception { + AuthorHistDownloadVO dl = new AuthorHistDownloadVO(); + dl.setDownloaderId(downloaderId); + dl.setDownloaderUniqId(downloaderUniqId); + dl.setDownloaderIp(downloaderIp); + dl.setDwldResn(reason); + dl.setSearchCond(searchCond); + dl.setDwldCnt(cnt); + authorHistoryDAO.insertDownloadHistory(dl); + } } diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java index bfacdfc..a9b6e99 100644 --- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java @@ -38,38 +38,38 @@ public class AuthorWorkflowController { @Resource(name = "authorHistoryService") private AuthorHistoryService authorHistoryService; + /** 권한이력 CSV 비동기 다운로드 작업(#6) */ + @Resource(name = "authorHistDownloadJobService") + private AuthorHistDownloadJobService authorHistDownloadJobService; + private LoginVO loginUser(HttpServletRequest request) { Object o = request.getSession().getAttribute("loginVO"); return (o instanceof LoginVO) ? (LoginVO) o : null; } - /** 권한 변경 신청 목록 (AJAX/JSON) */ - @RequestMapping(value = "/mnt/sec/authorRequest/list.do") - public String requestList(AuthorChangeRequestVO searchVO, ModelMap model) throws Exception { - List list = authorWorkflowService.selectRequestList(searchVO); - model.addAttribute("resultList", list); - model.addAttribute("totalCount", searchVO.getTotalRecordCount()); - return JSON; - } - /** - * 권한 변경 신청 목록·승인·반려 화면(서버 렌더링). + * 개인정보취급자 권한 관리 목록 화면(서버 렌더링) — 메뉴 진입점. + * 취급자 1인 = 1행. 소속기관/변경 전 역할/변경 후 역할/접근범위/권한상태/최근변경일/관리 열을 제공하고, + * 승인대기 행에서는 이 화면에서 바로 승인·반려한다. * 뷰: /WEB-INF/jsp/site/mnt/sec/authorRequest/list.jsp */ @RequestMapping(value = "/mnt/sec/authorRequest/listView.do") - public String requestListView(@ModelAttribute("authorChangeRequestVO") AuthorChangeRequestVO searchVO, - ModelMap model) throws Exception { + public String userAuthorListView(@ModelAttribute("privacyUserAuthorVO") PrivacyUserAuthorVO searchVO, + ModelMap model) throws Exception { PaginationInfo paginationInfo = new PaginationInfo(); paginationInfo.setCurrentPageNo(searchVO.getPageIndex()); paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage()); paginationInfo.setPageSize(10); - // selectRequestList 내부에서 firstIndex/totalRecordCount 를 세팅한다. - List list = authorWorkflowService.selectRequestList(searchVO); + // selectUserAuthorList 내부에서 firstIndex/totalRecordCount 를 세팅한다. + List list = authorWorkflowService.selectUserAuthorList(searchVO); paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount()); model.addAttribute("resultList", list); model.addAttribute("paginationInfo", paginationInfo); + model.addAttribute("orgnztList", authorWorkflowService.selectOrgnztList()); + model.addAttribute("authorityList", authorWorkflowService.selectAuthorityList()); + model.addAttribute("pendingCount", Integer.valueOf(authorWorkflowService.selectPendingRequestCount())); return "site/mnt/sec/authorRequest/list"; } @@ -163,7 +163,7 @@ public class AuthorWorkflowController { */ @RequestMapping(value = "/mnt/sec/authorHistory/listView.do") public String historyListView(@ModelAttribute("authorChangeHistoryVO") AuthorChangeHistoryVO searchVO, - ModelMap model) throws Exception { + HttpServletRequest request, ModelMap model) throws Exception { PaginationInfo paginationInfo = new PaginationInfo(); paginationInfo.setCurrentPageNo(searchVO.getPageIndex()); paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage()); @@ -175,10 +175,166 @@ public class AuthorWorkflowController { model.addAttribute("resultList", list); model.addAttribute("paginationInfo", paginationInfo); + // 내 다운로드 작업 목록(비동기) — 상태·진행률 확인 및 내려받기 + model.addAttribute("dlJobList", selectMyDownloadJobs(request)); return "site/mnt/sec/authorHistory/list"; } - /** 권한 변경 이력 다운로드 — 사유 필수 + 다운로드 이력 적재 + CSV 파일 스트리밍 */ + /** + * 내 권한이력 다운로드 작업의 상태만 돌려주는 폴링 전용 엔드포인트. + * + *

접속기록 화면과 같은 이유다 — 진행률을 보려고 목록 화면을 5초마다 다시 열면 + * 이 화면이 접속기록 수집 화이트리스트({@code /mnt/sec/**}) 안이라 새로고침마다 감사로그 1행이 + * 쌓인다. 상태 조회를 별도 경로로 빼고 {@code excludePatterns} 로 수집에서 제외한다. + * 본문에는 작업ID·상태·진행률·건수·실패사유만 담으며 본인 작업으로 한정한다.

+ */ + @RequestMapping(value = "/mnt/sec/authorHistory/jobStatus.do") + public void historyJobStatus(HttpServletRequest request, HttpServletResponse response) throws Exception { + response.setContentType("text/plain; charset=UTF-8"); + response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + response.setHeader("Pragma", "no-cache"); + + LoginVO user = loginUser(request); + if (user == null) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return; + } + + StringBuilder sb = new StringBuilder(256); + List list = authorHistDownloadJobService.selectMyJobs(user.getId(), 10); + if (list != null) { + for (AuthorHistDownloadJobVO j : list) { + sb.append(j.getJobId()).append('|') + .append(j.getStatus() == null ? "" : j.getStatus()).append('|') + .append(j.getProgressPct()).append('|') + .append(j.getDoneCnt()).append('|') + .append(j.getTotalCnt()).append('|') + .append(jobStatusOneLine(j.getErrMsg())).append('\n'); + } + } + response.getWriter().write(sb.toString()); + response.getWriter().flush(); + } + + /** 구분자·개행이 섞이면 화면 파싱이 깨지므로 한 줄로 눌러 담는다. */ + private String jobStatusOneLine(String v) { + if (v == null) { + return ""; + } + return v.replace('|', '/').replace('\r', ' ').replace('\n', ' '); + } + + /** + * 권한이력 다운로드 요청(비동기) — 작업만 등록하고 즉시 목록으로 돌아온다. + * + *

종전 동기 방식({@link #historyDownload})은 조건 전체를 List 로 메모리에 올려 + * OOM·요청 타임아웃 위험이 있었다. 파일 생성은 백그라운드 워커가 맡는다. + * 사유는 여기서도 필수이며, 미입력이면 작업을 만들지 않는다.

+ */ + @RequestMapping(value = "/mnt/sec/authorHistory/downloadRequest.do") + public String historyDownloadRequest(@ModelAttribute("authorChangeHistoryVO") AuthorChangeHistoryVO searchVO, + @RequestParam(value = "dwldResn", required = false) String dwldResn, + HttpServletRequest request, ModelMap model) throws Exception { + LoginVO user = loginUser(request); + try { + if (user == null) { + throw new IllegalArgumentException("로그인 정보를 확인할 수 없습니다."); + } + authorHistDownloadJobService.requestJob(user.getId(), + ClientIpResolver.getClientIp(request), dwldResn, searchVO); + } catch (IllegalArgumentException e) { + model.addAttribute("dlMessage", e.getMessage()); + } catch (Exception e) { + model.addAttribute("dlMessage", "다운로드 요청 중 오류가 발생했습니다."); + } + // 검색조건을 유지한 채 목록으로 — 요청 직후 작업 목록에서 진행상태를 볼 수 있다. + return historyListView(searchVO, request, model); + } + + /** + * 완료된 권한이력 다운로드 파일 내려받기 — 요청자 본인만 가능하다. + * + *

저장 파일은 암호화돼 있어 복호화 후 스트리밍한다. 임시 복호화본은 전송 직후 지운다. + * 실제 내려받은 시점의 사유·건수 이력은 기존 {@code AUTHOR_HIST_DOWNLOAD_HISTORY} 에 적재한다.

+ */ + @RequestMapping(value = "/mnt/sec/authorHistory/downloadFile.do") + public void historyDownloadFile(@RequestParam("jobId") long jobId, + HttpServletRequest request, HttpServletResponse response) throws Exception { + LoginVO user = loginUser(request); + if (user == null) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다."); + return; + } + AuthorHistDownloadJobVO job = authorHistDownloadJobService.selectDownloadableJob(jobId, user.getId()); + if (job == null) { + // 존재/소유/완료 여부를 구분해 알리지 않는다(작업ID 탐색 방지). + response.sendError(HttpServletResponse.SC_NOT_FOUND, "내려받을 수 있는 파일이 없습니다."); + return; + } + + java.io.File plain = java.io.File.createTempFile("author-hist-dl-", ".csv"); + try { + if (!egovframework.com.utl.sim.service.EgovFileScrty.decryptFile( + job.getFilePath(), plain.getAbsolutePath())) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "파일을 열 수 없습니다."); + return; + } + + String fileName = URLEncoder.encode(job.getFileNm(), "UTF-8"); + response.setContentType("text/csv; charset=UTF-8"); + response.setHeader("Content-Disposition", + "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + fileName); + response.setHeader("Content-Transfer-Encoding", "binary"); + response.setHeader("Content-Length", String.valueOf(plain.length())); + + java.io.FileInputStream in = null; + OutputStream os = response.getOutputStream(); + try { + in = new java.io.FileInputStream(plain); + byte[] buf = new byte[8192]; + int len; + while ((len = in.read(buf)) > 0) { + os.write(buf, 0, len); + } + os.flush(); + } finally { + if (in != null) { + try { in.close(); } catch (Exception ignore) { /* 무시 */ } + } + } + + authorHistDownloadJobService.markDownloaded(jobId); + // 내려받은 사실을 기존 다운로드 이력에도 남긴다(사유·건수 증빙 일원화). + authorHistoryService.recordDownloadHistory(user.getId(), user.getUniqId(), + ClientIpResolver.getClientIp(request), job.getDlReason(), job.getSearchCond(), job.getDoneCnt()); + } finally { + // 복호화 임시본을 남기지 않는다. + if (plain.exists() && !plain.delete()) { + plain.deleteOnExit(); + } + } + } + + /** 내 다운로드 작업 목록(최근 10건). 조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe. */ + private List selectMyDownloadJobs(HttpServletRequest request) { + try { + LoginVO user = loginUser(request); + if (user == null) { + return new java.util.ArrayList(); + } + List list = authorHistDownloadJobService.selectMyJobs(user.getId(), 10); + return (list == null) ? new java.util.ArrayList() : list; + } catch (Exception e) { + return new java.util.ArrayList(); + } + } + + /** + * 권한 변경 이력 다운로드 — 동기 방식(기존). 사유 필수 + 다운로드 이력 적재 + CSV 파일 스트리밍. + * + *

주의: 조건 전체를 메모리에 올린다. 화면은 위 {@link #historyDownloadRequest} + * 비동기 경로를 쓴다. 이 엔드포인트는 소량 즉시 다운로드와 기존 링크 호환을 위해 남겨둔다.

+ */ @RequestMapping(value = "/mnt/sec/authorHistory/download.do") public void historyDownload(AuthorChangeHistoryVO searchVO, @RequestParam(value = "dwldResn", required = false) String dwldResn, @@ -211,10 +367,12 @@ public class AuthorWorkflowController { OutputStream os = response.getOutputStream(); os.write(0xEF); os.write(0xBB); os.write(0xBF); // UTF-8 BOM (엑셀 한글 호환) PrintWriter pw = new PrintWriter(new OutputStreamWriter(os, "UTF-8")); - pw.println("변경일시,대상자ID,변경구분,변경전권한,변경후권한,변경사유,승인자,변경자ID,변경자IP"); + // 열 구성은 비동기 워커(AuthorHistDownloadJobWorker.CSV_HEADER)와 동일하게 유지한다. + pw.println("변경일시,대상자ID,대상자명,변경구분,변경전권한,변경후권한,변경사유,승인자,변경자ID,변경자IP"); if (list != null) { for (AuthorChangeHistoryVO h : list) { - pw.println(csv(h.getChgDt()) + "," + csv(h.getTrgetId()) + "," + csv(chgSeLabel(h.getChgSeCode())) + pw.println(csv(h.getChgDt()) + "," + csv(h.getTrgetId()) + "," + csv(h.getTrgetNm()) + + "," + csv(chgSeLabel(h.getChgSeCode())) + "," + csv(h.getBeforeAuthor()) + "," + csv(h.getAfterAuthor()) + "," + csv(h.getChgResn()) + "," + csv(h.getAprovlId()) + "," + csv(h.getChangerId()) + "," + csv(h.getChangerIp())); } diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowDAO.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowDAO.java index 0d93db3..06aa1ff 100644 --- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowDAO.java +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowDAO.java @@ -30,18 +30,6 @@ public class AuthorWorkflowDAO extends EgovComAbstractDAO { return (AuthorChangeRequestVO) selectByPk("AuthorWorkflowDAO.selectRequest", Long.valueOf(requestId)); } - /** 신청 목록 조회(상태/대상 검색 + 페이징) */ - @SuppressWarnings("unchecked") - public List selectRequestList(AuthorChangeRequestVO vo) { - return list("AuthorWorkflowDAO.selectRequestList", vo); - } - - /** 신청 목록 전체 건수 */ - public int selectRequestListCount(AuthorChangeRequestVO vo) { - Object cnt = selectByPk("AuthorWorkflowDAO.selectRequestListCount", vo); - return cnt == null ? 0 : ((Integer) cnt).intValue(); - } - /** 신청 상태 갱신(승인 A / 반려 J) */ public void updateRequestStatus(AuthorChangeRequestVO vo) { update("AuthorWorkflowDAO.updateRequestStatus", vo); @@ -77,4 +65,28 @@ public class AuthorWorkflowDAO extends EgovComAbstractDAO { public egovframework.rte.psl.dataaccess.util.EgovMap selectTargetByLoginId(String trgetId) { return (egovframework.rte.psl.dataaccess.util.EgovMap) selectByPk("AuthorWorkflowDAO.selectTargetByLoginId", trgetId); } + + /** 개인정보취급자 권한 목록(소속기관·역할·접근범위·권한상태·최근변경일 + 페이징) */ + @SuppressWarnings("unchecked") + public List selectUserAuthorList(PrivacyUserAuthorVO vo) { + return list("AuthorWorkflowDAO.selectUserAuthorList", vo); + } + + /** 개인정보취급자 권한 목록 전체 건수 */ + public int selectUserAuthorListCount(PrivacyUserAuthorVO vo) { + Object cnt = selectByPk("AuthorWorkflowDAO.selectUserAuthorListCount", vo); + return cnt == null ? 0 : ((Integer) cnt).intValue(); + } + + /** 목록 상단 요약용 전체 승인대기 건수(검색조건 무관) */ + public int selectPendingRequestCount() { + Object cnt = selectByPk("AuthorWorkflowDAO.selectPendingRequestCount", null); + return cnt == null ? 0 : ((Integer) cnt).intValue(); + } + + /** 검색조건 드롭다운용 소속기관 목록 */ + @SuppressWarnings("unchecked") + public List selectOrgnztList() { + return list("AuthorWorkflowDAO.selectOrgnztList", null); + } } diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowService.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowService.java index b7952fd..8103906 100644 --- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowService.java +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowService.java @@ -10,12 +10,6 @@ public interface AuthorWorkflowService { /** 권한 변경 신청 등록(상태 R) */ void submitRequest(AuthorChangeRequestVO vo) throws Exception; - /** 신청 목록 조회 */ - List selectRequestList(AuthorChangeRequestVO vo) throws Exception; - - /** 신청 목록 건수 */ - int selectRequestListCount(AuthorChangeRequestVO vo) throws Exception; - /** * 승인(A) — 권한 반영(COMTNEMPLYRSCRTYESTBS)과 이력 적재(AUTHOR_CHANGE_HISTORY)를 * 단일 트랜잭션으로 처리한다. @@ -34,4 +28,13 @@ public interface AuthorWorkflowService { /** 대상자 계정ID → 고유식별자·성명 자동조회 (없으면 null) */ egovframework.rte.psl.dataaccess.util.EgovMap selectTargetByLoginId(String trgetId) throws Exception; + + /** 개인정보취급자 권한 목록 조회 — firstIndex/totalRecordCount 를 vo 에 세팅한다 */ + List selectUserAuthorList(PrivacyUserAuthorVO vo) throws Exception; + + /** 목록 상단 요약용 전체 승인대기 건수 */ + int selectPendingRequestCount() throws Exception; + + /** 검색조건 드롭다운용 소속기관 목록 */ + List selectOrgnztList() throws Exception; } diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowServiceImpl.java index 1b8bc40..cc8ef0b 100644 --- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowServiceImpl.java +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowServiceImpl.java @@ -31,21 +31,6 @@ public class AuthorWorkflowServiceImpl implements AuthorWorkflowService { authorWorkflowDAO.insertRequest(vo); } - @Override - public List selectRequestList(AuthorChangeRequestVO vo) throws Exception { - int pageIndex = vo.getPageIndex() < 1 ? 1 : vo.getPageIndex(); - int perPage = vo.getRecordCountPerPage() < 1 ? 10 : vo.getRecordCountPerPage(); - vo.setFirstIndex((pageIndex - 1) * perPage); - vo.setRecordCountPerPage(perPage); - vo.setTotalRecordCount(authorWorkflowDAO.selectRequestListCount(vo)); - return authorWorkflowDAO.selectRequestList(vo); - } - - @Override - public int selectRequestListCount(AuthorChangeRequestVO vo) throws Exception { - return authorWorkflowDAO.selectRequestListCount(vo); - } - @Override public void approveRequest(long requestId, String confmId, String confmUniqId, String confmIp) throws Exception { AuthorChangeRequestVO req = authorWorkflowDAO.selectRequest(requestId); @@ -119,6 +104,26 @@ public class AuthorWorkflowServiceImpl implements AuthorWorkflowService { return authorWorkflowDAO.selectTargetByLoginId(trgetId.trim()); } + @Override + public List selectUserAuthorList(PrivacyUserAuthorVO vo) throws Exception { + int pageIndex = vo.getPageIndex() < 1 ? 1 : vo.getPageIndex(); + int perPage = vo.getRecordCountPerPage() < 1 ? 10 : vo.getRecordCountPerPage(); + vo.setFirstIndex((pageIndex - 1) * perPage); + vo.setRecordCountPerPage(perPage); + vo.setTotalRecordCount(authorWorkflowDAO.selectUserAuthorListCount(vo)); + return authorWorkflowDAO.selectUserAuthorList(vo); + } + + @Override + public int selectPendingRequestCount() throws Exception { + return authorWorkflowDAO.selectPendingRequestCount(); + } + + @Override + public List selectOrgnztList() throws Exception { + return authorWorkflowDAO.selectOrgnztList(); + } + /** 신청구분(N/C/R) → 이력 변경구분(G:부여/M:변경/R:회수) */ private String mapChgSeCode(String requestSe) { if ("R".equals(requestSe)) { diff --git a/src/main/java/egovframework/com/cmm/privacy/author/PrivacyUserAuthorVO.java b/src/main/java/egovframework/com/cmm/privacy/author/PrivacyUserAuthorVO.java new file mode 100644 index 0000000..7e3be63 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/PrivacyUserAuthorVO.java @@ -0,0 +1,116 @@ +package egovframework.com.cmm.privacy.author; + +import java.io.Serializable; + +/** + * 개인정보취급자 권한 관리 목록 VO (개인정보보호 SFR-001 / #3 화면 #46). + * + *

취급자(COMTNEMPLYRINFO) 1행 = 목록 1행. 소속기관(COMTNORGNZTINFO), + * 역할·접근범위(COMTNEMPLYRSCRTYESTBS + COMTNAUTHORINFO), 권한상태(대기신청/권한유무), + * 최근변경일(AUTHOR_CHANGE_HISTORY)을 조인해 한 화면에 제공한다.

+ * + *
+ * 개정이력
+ *  2026.08.07  URP  최초 생성 — 신청목록 화면을 취급자 권한현황 화면으로 재구성
+ * 
+ */ +public class PrivacyUserAuthorVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 취급자 계정ID (COMTNEMPLYRINFO.EMPLYR_ID) */ + private String emplyrId; + /** 취급자 고유식별자 (ESNTL_ID) */ + private String uniqId; + /** 취급자명 */ + private String userNm; + /** 소속기관 ID */ + private String orgnztId; + /** 소속기관명 */ + private String orgnztNm; + /** 역할 권한코드 */ + private String authorCode; + /** 역할명(Role) */ + private String authorNm; + /** 변경 전 역할명 — 승인대기 신청이 있으면 그 신청의 변경전권한, 없으면 최근 이력의 변경전권한 */ + private String beforeAuthorNm; + /** 변경 후 역할명 — 승인대기 신청이 있으면 신청 목표권한, 없으면 현재 역할(=최근 이력의 변경후권한) */ + private String afterAuthorNm; + /** 처리대기 신청구분 N:신설 C:변경 R:회수 (대기 신청 없으면 null) */ + private String pendingRequestSe; + /** 접근범위 — 권한설명 없으면 허용 메뉴 수로 대체 */ + private String accessScope; + /** 권한상태 N:정상 W:승인대기 R:말소 */ + private String authorSttus; + /** 최근변경일 (yyyy-MM-dd) */ + private String lastChgDt; + /** 처리대기 신청 일련번호 — 승인/반려 버튼용(없으면 0) */ + private long pendingRequestId; + /** 계정상태 코드 P:사용 A:승인대기 S:사용중지 D:말소 */ + private String emplyrSttusCode; + + // --- 검색조건 --- + /** 소속기관 */ + private String searchOrgnztId; + /** 역할(권한코드) */ + private String searchAuthorCode; + /** 권한상태 N/W/R */ + private String searchAuthorSttus; + /** 취급자명 또는 계정ID */ + private String searchKeyword; + + // --- 페이징 --- + private int pageIndex = 1; + private int recordCountPerPage = 10; + private int firstIndex = 0; + private int totalRecordCount = 0; + + public String getEmplyrId() { return emplyrId; } + public void setEmplyrId(String emplyrId) { this.emplyrId = emplyrId; } + public String getUniqId() { return uniqId; } + public void setUniqId(String uniqId) { this.uniqId = uniqId; } + public String getUserNm() { return userNm; } + public void setUserNm(String userNm) { this.userNm = userNm; } + public String getOrgnztId() { return orgnztId; } + public void setOrgnztId(String orgnztId) { this.orgnztId = orgnztId; } + public String getOrgnztNm() { return orgnztNm; } + public void setOrgnztNm(String orgnztNm) { this.orgnztNm = orgnztNm; } + public String getAuthorCode() { return authorCode; } + public void setAuthorCode(String authorCode) { this.authorCode = authorCode; } + public String getAuthorNm() { return authorNm; } + public void setAuthorNm(String authorNm) { this.authorNm = authorNm; } + public String getBeforeAuthorNm() { return beforeAuthorNm; } + public void setBeforeAuthorNm(String beforeAuthorNm) { this.beforeAuthorNm = beforeAuthorNm; } + public String getAfterAuthorNm() { return afterAuthorNm; } + public void setAfterAuthorNm(String afterAuthorNm) { this.afterAuthorNm = afterAuthorNm; } + public String getPendingRequestSe() { return pendingRequestSe; } + public void setPendingRequestSe(String pendingRequestSe) { this.pendingRequestSe = pendingRequestSe; } + public String getAccessScope() { return accessScope; } + public void setAccessScope(String accessScope) { this.accessScope = accessScope; } + public String getAuthorSttus() { return authorSttus; } + public void setAuthorSttus(String authorSttus) { this.authorSttus = authorSttus; } + public String getLastChgDt() { return lastChgDt; } + public void setLastChgDt(String lastChgDt) { this.lastChgDt = lastChgDt; } + public long getPendingRequestId() { return pendingRequestId; } + public void setPendingRequestId(long pendingRequestId) { this.pendingRequestId = pendingRequestId; } + public String getEmplyrSttusCode() { return emplyrSttusCode; } + public void setEmplyrSttusCode(String emplyrSttusCode) { this.emplyrSttusCode = emplyrSttusCode; } + + public String getSearchOrgnztId() { return searchOrgnztId; } + public void setSearchOrgnztId(String searchOrgnztId) { this.searchOrgnztId = searchOrgnztId; } + public String getSearchAuthorCode() { return searchAuthorCode; } + public void setSearchAuthorCode(String searchAuthorCode) { this.searchAuthorCode = searchAuthorCode; } + public String getSearchAuthorSttus() { return searchAuthorSttus; } + public void setSearchAuthorSttus(String searchAuthorSttus) { this.searchAuthorSttus = searchAuthorSttus; } + public String getSearchKeyword() { return searchKeyword; } + public void setSearchKeyword(String searchKeyword) { this.searchKeyword = searchKeyword; } + + public int getPageIndex() { return pageIndex; } + public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; } + public int getRecordCountPerPage() { return recordCountPerPage; } + public void setRecordCountPerPage(int recordCountPerPage) { this.recordCountPerPage = recordCountPerPage; } + public int getFirstIndex() { return firstIndex; } + public void setFirstIndex(int firstIndex) { this.firstIndex = firstIndex; } + public int getTotalRecordCount() { return totalRecordCount; } + public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogController.java b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogController.java new file mode 100644 index 0000000..5e63bed --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogController.java @@ -0,0 +1,194 @@ +package egovframework.com.cmm.privacy.conect; + +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.net.URLEncoder; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.privacy.auditlog.ClientIpResolver; +import egovframework.com.cmm.util.EgovUserDetailsHelper; +import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo; + +/** + * 로그인·로그아웃 접속기록 조회·다운로드 컨트롤러 — 개인정보보호 기능개선 사업(#62). + * + *

{@code COMTNLOGINLOG} 적재분(접속ID·접속일시·접속IP·접속위치)을 조건별로 검색하고 + * CSV 로 내려받는다. 화면: {@code /WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp}

+ * + *

다운로드: 사유 입력이 필수이며 미입력 시 400 으로 차단하고 파일을 열지 않는다. + * 다운로드자·일시·IP·사유·건수는 {@code PDAL_DOWNLOAD_HISTORY}(#42 재사용)에 적재된다.

+ * + *

접근통제: 인터셉터 3단 중 ①인증판정이 비활성이고 ②③은 fail-open 이라 + * /mnt 하위라도 무인증 노출이 가능하므로, {@link #handlerUser()} 로 화면·엔드포인트 + * 단에서 직접 판정한다(PersonalDataAccessLogController 와 동일 방식).

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.07    URP     최초 생성 (개인정보보호 #62)
+ * 
+ */ +@Controller +public class ConectLogController { + + private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; + + @Resource(name = "conectLogService") + private ConectLogService conectLogService; + + /** + * 로그인·로그아웃 접속기록 조회 화면(서버 렌더링). + * 뷰: /WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp + */ + @RequestMapping(value = "/mnt/sec/conectLog/listView.do") + public String listView(@ModelAttribute("conectLogVO") ConectLogVO searchVO, + ModelMap model) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + + // selectConectLogList 내부에서 firstIndex/totalRecordCount 를 세팅한다. + List list = conectLogService.selectConectLogList(searchVO); + + PaginationInfo paginationInfo = new PaginationInfo(); + paginationInfo.setCurrentPageNo(searchVO.getPageIndex()); + paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage()); + paginationInfo.setPageSize(10); + paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount()); + + model.addAttribute("resultList", list); + model.addAttribute("paginationInfo", paginationInfo); + model.addAttribute("conectLcList", selectConectLcList()); + return "site/mnt/sec/conectLog/list"; + } + + /** 접속기록 목록(AJAX/JSON). */ + @RequestMapping(value = "/mnt/privacy/conectLogList.do") + public String selectList(@ModelAttribute("searchVO") ConectLogVO searchVO, + ModelMap model) throws Exception { + if (handlerUser() == null) { + model.addAttribute("result", "fail"); + model.addAttribute("message", "접근 권한이 없습니다. 관리자 계정으로 로그인 후 이용하세요."); + return "jsonView"; + } + model.addAttribute("resultList", conectLogService.selectConectLogList(searchVO)); + model.addAttribute("totalCount", Integer.valueOf(searchVO.getTotalRecordCount())); + return "jsonView"; + } + + /** + * 접속기록 다운로드(CSV) — 사유 필수 + 다운로드 이력 적재. + * 사유 미입력·차단 시 파일을 열지 않고 400 으로 응답한다. + */ + @RequestMapping(value = "/mnt/sec/conectLog/download.do") + public void download(@ModelAttribute("conectLogVO") ConectLogVO searchVO, + @RequestParam(value = "dlReason", required = false) String dlReason, + HttpServletRequest request, HttpServletResponse response) throws Exception { + + LoginVO user = handlerUser(); + if (user == null) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다."); + return; + } + + List list; + try { + list = conectLogService.downloadConectLog(user.getId(), + ClientIpResolver.getClientIp(request), dlReason, searchVO); + } catch (IllegalArgumentException e) { + // 사유 미입력 등 차단 — 다운로드를 열지 않고 오류만 응답 + response.sendError(HttpServletResponse.SC_BAD_REQUEST, e.getMessage()); + return; + } + + String fileName = URLEncoder.encode( + "로그인접속기록_" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + ".csv", "UTF-8"); + response.setContentType("text/csv; charset=UTF-8"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + fileName); + response.setHeader("Content-Transfer-Encoding", "binary"); + + OutputStream os = response.getOutputStream(); + os.write(0xEF); os.write(0xBB); os.write(0xBF); // UTF-8 BOM (엑셀 한글 호환) + PrintWriter pw = new PrintWriter(new OutputStreamWriter(os, "UTF-8")); + pw.println("로그번호,접속일시,접속ID,성명,사용자구분,접속구분,접속IP,접속위치"); + if (list != null) { + for (ConectLogVO log : list) { + pw.println(csv(log.getLogId()) + "," + csv(log.getCreatDt()) + + "," + csv(log.getUserId()) + "," + csv(log.getUserNm()) + + "," + csv(userSeLabel(log.getUserSe())) + + "," + csv(mthdLabel(log.getConectMthd())) + + "," + csv(log.getConectIp()) + "," + csv(log.getConectLc())); + } + } + pw.flush(); + } + + /** + * 검색조건 드롭다운용 접속위치 목록. + * 조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe. + */ + private List selectConectLcList() { + try { + List list = conectLogService.selectConectLcList(); + return (list == null) ? new ArrayList() : list; + } catch (Exception e) { + return new ArrayList(); + } + } + + /** CSV 셀 이스케이프(구분자·따옴표·개행 방어). */ + private String csv(String v) { + if (v == null) { + return ""; + } + String s = v.replace("\"", "\"\""); + return "\"" + s.replace("\r", " ").replace("\n", " ") + "\""; + } + + private String mthdLabel(String code) { + if ("I".equals(code)) return "로그인"; + if ("O".equals(code)) return "로그아웃"; + return code; + } + + private String userSeLabel(String code) { + if ("USR".equals(code)) return "업무사용자"; + if ("GNR".equals(code)) return "개인"; + if ("GRO".equals(code)) return "그룹"; + if ("ENT".equals(code)) return "활동처"; + return code; + } + + /** + * 취급자 영역(/mnt) 인증·구분 판정. + * + * @return 인증된 업무사용자(USR) LoginVO, 그 외에는 null + */ + private LoginVO handlerUser() { + if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) { + return null; + } + Object o = EgovUserDetailsHelper.getAuthenticatedUser(); + if (!(o instanceof LoginVO)) { + return null; + } + LoginVO loginVO = (LoginVO) o; + return "USR".equals(loginVO.getUserSe()) ? loginVO : null; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogDAO.java b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogDAO.java new file mode 100644 index 0000000..3b751c0 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogDAO.java @@ -0,0 +1,52 @@ +package egovframework.com.cmm.privacy.conect; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 로그인·로그아웃 접속기록 DAO — 개인정보보호 기능개선 사업(#62). + * + *

{@code COMTNLOGINLOG} 조회 전용. 이 화면은 접속기록에 대한 수정·삭제를 + * 제공하지 않는다(위·변조 방지) — UPDATE/DELETE statement 를 정의하지 않는다. + * statement 는 {@code PrivacyConectLog_SQL_Mysql.xml} + * (namespace {@code ConectLogDAO}), sql-map-config 등록.

+ * + * @author URP + * @since 2026-08-07 + */ +@Repository("conectLogDAO") +public class ConectLogDAO extends EgovComAbstractDAO { + + /** 조건별 목록(페이징). */ + @SuppressWarnings("unchecked") + public List selectConectLogList(ConectLogVO searchVO) { + return list("ConectLogDAO.selectConectLogList", searchVO); + } + + /** 조건별 총건수. */ + public int selectConectLogListCount(ConectLogVO searchVO) { + Object cnt = selectByPk("ConectLogDAO.selectConectLogListCount", searchVO); + return (cnt == null) ? 0 : ((Integer) cnt).intValue(); + } + + /** 다운로드용 목록(페이징 없음, 상한은 서비스에서 건다). */ + @SuppressWarnings("unchecked") + public List selectConectLogListForDownload(ConectLogVO searchVO) { + return list("ConectLogDAO.selectConectLogListForDownload", searchVO); + } + + /** + * 검색조건 드롭다운용 접속위치 목록. + * + *

설정 대역(CONECT_IP_LOCATION)과 실제 적재된 값(COMTNLOGINLOG DISTINCT)을 + * 합친다. #32-1 에서 겪은 함정 — 매핑 테이블만 보면 실제 적재값과 어긋난 선택지가 떠 + * 검색 결과가 항상 0건이 된다 — 을 피하기 위한 것이다.

+ */ + @SuppressWarnings("unchecked") + public List selectConectLcList() { + return list("ConectLogDAO.selectConectLcList", null); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogService.java b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogService.java new file mode 100644 index 0000000..12b4c18 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogService.java @@ -0,0 +1,31 @@ +package egovframework.com.cmm.privacy.conect; + +import java.util.List; + +/** + * 로그인·로그아웃 접속기록 조회·다운로드 서비스 — 개인정보보호 기능개선 사업(#62). + * + * @author URP + * @since 2026-08-07 + */ +public interface ConectLogService { + + /** 조건별 접속기록 목록(페이징). searchVO 에 firstIndex/totalRecordCount 를 채워 돌려준다. */ + List selectConectLogList(ConectLogVO searchVO) throws Exception; + + /** 검색조건 드롭다운용 접속위치 목록. */ + List selectConectLcList() throws Exception; + + /** + * 접속기록 다운로드 — 사유 필수. + * + * @param downloaderId 다운로드자 계정ID + * @param downloaderIp 다운로드자 접속 IP + * @param dlReason 다운로드 사유(공백이면 차단) + * @param searchVO 검색조건 + * @return 다운로드 대상 목록 + * @throws IllegalArgumentException 사유 미입력 + */ + List downloadConectLog(String downloaderId, String downloaderIp, + String dlReason, ConectLogVO searchVO) throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogServiceImpl.java new file mode 100644 index 0000000..ead4be1 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogServiceImpl.java @@ -0,0 +1,122 @@ +package egovframework.com.cmm.privacy.conect; + +import java.util.ArrayList; +import java.util.List; + +import javax.annotation.Resource; + +import org.springframework.stereotype.Service; + +import egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryDAO; +import egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryVO; + +/** + * 로그인·로그아웃 접속기록 조회·다운로드 서비스 구현 — 개인정보보호 기능개선 사업(#62). + * + *

다운로드 이력: 별도 테이블을 만들지 않고 {@code PDAL_DOWNLOAD_HISTORY}(#42)를 + * 재사용한다. 어떤 화면에서 받아간 것인지 구분되도록 검색조건 요약 앞에 + * {@code [로그인기록]} 을 붙인다. 개인정보 본문은 이력에 남기지 않는다(검색조건 요약만).

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.07    URP     최초 생성 (개인정보보호 #62)
+ * 
+ */ +@Service("conectLogService") +public class ConectLogServiceImpl implements ConectLogService { + + /** 1회 다운로드 상한(건). 무제한 반출·OOM 방지. */ + private static final int DOWNLOAD_LIMIT = 100000; + + /** 다운로드 이력 구분 표식(PDAL_DOWNLOAD_HISTORY 공용 사용). */ + private static final String DL_TAG = "[로그인기록] "; + + @Resource(name = "conectLogDAO") + private ConectLogDAO conectLogDAO; + + @Resource(name = "accessLogDownloadHistoryDAO") + private AccessLogDownloadHistoryDAO accessLogDownloadHistoryDAO; + + @Override + public List selectConectLogList(ConectLogVO searchVO) throws Exception { + normalize(searchVO); + searchVO.setFirstIndex((searchVO.getPageIndex() - 1) * searchVO.getRecordCountPerPage()); + searchVO.setTotalRecordCount(conectLogDAO.selectConectLogListCount(searchVO)); + return conectLogDAO.selectConectLogList(searchVO); + } + + @Override + public List selectConectLcList() throws Exception { + List list = conectLogDAO.selectConectLcList(); + return (list == null) ? new ArrayList() : list; + } + + @Override + public List downloadConectLog(String downloaderId, String downloaderIp, + String dlReason, ConectLogVO searchVO) throws Exception { + + // 사유 미입력이면 조회 자체를 하지 않는다 — 이력 없는 반출을 원천 차단. + if (dlReason == null || dlReason.trim().length() == 0) { + throw new IllegalArgumentException("다운로드 사유는 필수입니다."); + } + normalize(searchVO); + searchVO.setFirstIndex(0); + searchVO.setRecordCountPerPage(DOWNLOAD_LIMIT); + + List list = conectLogDAO.selectConectLogListForDownload(searchVO); + if (list == null) { + list = new ArrayList(); + } + + AccessLogDownloadHistoryVO history = new AccessLogDownloadHistoryVO(); + history.setDownloaderId(downloaderId); + history.setDownloaderIp(downloaderIp); + history.setDlReason(dlReason.trim()); + history.setSearchCond(summarize(searchVO)); + history.setResultCnt(list.size()); + accessLogDownloadHistoryDAO.insertDownloadHistory(history); + + return list; + } + + /** 페이징 파라미터 방어(음수·0·과대 요청). */ + private void normalize(ConectLogVO searchVO) { + if (searchVO.getPageIndex() < 1) { + searchVO.setPageIndex(1); + } + if (searchVO.getRecordCountPerPage() < 1 || searchVO.getRecordCountPerPage() > 100) { + searchVO.setRecordCountPerPage(10); + } + } + + /** + * 검색조건 요약(개인정보 본문 미포함). PDAL_DOWNLOAD_HISTORY.SEARCH_COND 는 + * VARCHAR(1000) 이므로 넘치면 자른다. + */ + private String summarize(ConectLogVO vo) { + StringBuilder sb = new StringBuilder(DL_TAG); + append(sb, "접속ID", vo.getSearchUserId()); + append(sb, "접속구분", vo.getSearchMthd()); + append(sb, "사용자구분", vo.getSearchUserSe()); + append(sb, "IP", vo.getSearchIp()); + append(sb, "접속위치", vo.getSearchLc()); + append(sb, "기간", nvl(vo.getSearchBgnDe()) + "~" + nvl(vo.getSearchEndDe())); + String s = sb.toString(); + return (s.length() > 1000) ? s.substring(0, 1000) : s; + } + + private void append(StringBuilder sb, String label, String value) { + if (value != null && value.trim().length() > 0 && !"~".equals(value.trim())) { + if (sb.length() > DL_TAG.length()) { + sb.append(", "); + } + sb.append(label).append('=').append(value.trim()); + } + } + + private String nvl(String s) { + return (s == null) ? "" : s.trim(); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogVO.java b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogVO.java new file mode 100644 index 0000000..109202c --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogVO.java @@ -0,0 +1,115 @@ +package egovframework.com.cmm.privacy.conect; + +import java.io.Serializable; + +/** + * 로그인·로그아웃 접속기록 VO — 개인정보보호 기능개선 사업(#62). + * + *

{@code COMTNLOGINLOG} 조회 결과 + 검색조건 + 페이징.

+ * + *

접속ID 주의: {@code COMTNLOGINLOG.CONECT_ID}({@link #conectId})는 고유식별자 + * 자리인데 실제 적재값이 16자리 hex 라 회원 테이블 어디와도 매칭되지 않는다(2026-08-07 + * 373,959행 실측). 그래서 화면의 '접속ID'({@link #userId})는 조인 결과가 아니라 적재 시점에 + * 세션 {@code LoginVO.getId()} 를 직접 남긴 {@code CONECT_ACNT_ID} 값이다. + * 2026-08-07 이전 기록은 이 값이 없어 화면이 {@link #conectId} 를 대신 보여준다.

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.07    URP     최초 생성 (개인정보보호 #62)
+ * 
+ */ +public class ConectLogVO implements Serializable { + + private static final long serialVersionUID = 1L; + + // ---- 조회 결과 ---- + /** 로그 일련번호(COMTNLOGINLOG.LOG_ID). */ + private String logId; + /** 접속일시(yyyy-MM-dd HH:mm:ss). */ + private String creatDt; + /** 접속구분 I:로그인 O:로그아웃. */ + private String conectMthd; + /** 접속 IP. */ + private String conectIp; + /** 접속위치명(적재시점 스냅샷). */ + private String conectLc; + /** 고유식별자(원본 CONECT_ID). */ + private String conectId; + /** 접속ID(계정ID — CONECT_ACNT_ID. 2026-08-07 이전 레거시 기록은 null). */ + private String userId; + /** 성명/기관명(표시용 보조 조회 — 탈퇴 등으로 대조 실패 시 null). */ + private String userNm; + /** 사용자구분 USR:업무사용자 GNR:개인 GRO:그룹 ENT:활동처. */ + private String userSe; + /** 오류발생여부 Y/N. */ + private String errOccrrAt; + + // ---- 검색조건 ---- + /** 접속ID(계정ID, 부분일치). */ + private String searchUserId; + /** 접속구분(I/O, 공백=전체). */ + private String searchMthd; + /** 사용자구분(USR/GNR/GRO/ENT, 공백=전체). */ + private String searchUserSe; + /** 접속 IP(부분일치). */ + private String searchIp; + /** 접속위치(완전일치, 공백=전체). */ + private String searchLc; + /** 조회 시작일(yyyy-MM-dd). */ + private String searchBgnDe; + /** 조회 종료일(yyyy-MM-dd). */ + private String searchEndDe; + + // ---- 페이징 ---- + private int pageIndex = 1; + private int recordCountPerPage = 10; + private int firstIndex = 0; + private int totalRecordCount = 0; + + public String getLogId() { return logId; } + public void setLogId(String logId) { this.logId = logId; } + public String getCreatDt() { return creatDt; } + public void setCreatDt(String creatDt) { this.creatDt = creatDt; } + public String getConectMthd() { return conectMthd; } + public void setConectMthd(String conectMthd) { this.conectMthd = conectMthd; } + public String getConectIp() { return conectIp; } + public void setConectIp(String conectIp) { this.conectIp = conectIp; } + public String getConectLc() { return conectLc; } + public void setConectLc(String conectLc) { this.conectLc = conectLc; } + public String getConectId() { return conectId; } + public void setConectId(String conectId) { this.conectId = conectId; } + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + public String getUserNm() { return userNm; } + public void setUserNm(String userNm) { this.userNm = userNm; } + public String getUserSe() { return userSe; } + public void setUserSe(String userSe) { this.userSe = userSe; } + public String getErrOccrrAt() { return errOccrrAt; } + public void setErrOccrrAt(String errOccrrAt) { this.errOccrrAt = errOccrrAt; } + + public String getSearchUserId() { return searchUserId; } + public void setSearchUserId(String searchUserId) { this.searchUserId = searchUserId; } + public String getSearchMthd() { return searchMthd; } + public void setSearchMthd(String searchMthd) { this.searchMthd = searchMthd; } + public String getSearchUserSe() { return searchUserSe; } + public void setSearchUserSe(String searchUserSe) { this.searchUserSe = searchUserSe; } + public String getSearchIp() { return searchIp; } + public void setSearchIp(String searchIp) { this.searchIp = searchIp; } + public String getSearchLc() { return searchLc; } + public void setSearchLc(String searchLc) { this.searchLc = searchLc; } + public String getSearchBgnDe() { return searchBgnDe; } + public void setSearchBgnDe(String searchBgnDe) { this.searchBgnDe = searchBgnDe; } + public String getSearchEndDe() { return searchEndDe; } + public void setSearchEndDe(String searchEndDe) { this.searchEndDe = searchEndDe; } + + public int getPageIndex() { return pageIndex; } + public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; } + public int getRecordCountPerPage() { return recordCountPerPage; } + public void setRecordCountPerPage(int recordCountPerPage) { this.recordCountPerPage = recordCountPerPage; } + public int getFirstIndex() { return firstIndex; } + public void setFirstIndex(int firstIndex) { this.firstIndex = firstIndex; } + public int getTotalRecordCount() { return totalRecordCount; } + public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationDAO.java b/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationDAO.java new file mode 100644 index 0000000..0641f67 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationDAO.java @@ -0,0 +1,26 @@ +package egovframework.com.cmm.privacy.conect; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 접속위치 매핑 DAO — 개인정보보호 기능개선 사업(#62). + * + *

statement 는 {@code PrivacyConectLog_SQL_Mysql.xml} + * (namespace {@code ConectLogDAO})에 함께 정의되어 있다.

+ * + * @author URP + * @since 2026-08-07 + */ +@Repository("ipLocationDAO") +public class IpLocationDAO extends EgovComAbstractDAO { + + /** 사용중(USE_AT='Y')인 IP 대역 매핑 전체. */ + @SuppressWarnings("unchecked") + public List selectUsedLocationList() { + return list("ConectLogDAO.selectUsedLocationList", null); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationResolver.java b/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationResolver.java new file mode 100644 index 0000000..88e0690 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationResolver.java @@ -0,0 +1,189 @@ +package egovframework.com.cmm.privacy.conect; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import javax.annotation.Resource; + +import org.springframework.stereotype.Component; + +/** + * IP → 접속위치명 판정기 — 개인정보보호 기능개선 사업(#62). + * + *

{@code CONECT_IP_LOCATION}(IP 대역 → 위치명)을 캐시해 두고, 접속 IP 가 속하는 + * 대역 중 가장 긴 프리픽스 1건의 위치명을 돌려준다. 매칭이 없으면 + * 사설/루프백 대역 판정에 따른 내장 폴백을 쓴다 — 테이블이 비어 있어도 동작한다.

+ * + *

왜 스냅샷인가: 판정 결과는 적재 시점에 + * {@code COMTNLOGINLOG.CONECT_LC} 로 기록된다. 나중에 대역 매핑을 바꿔도 과거 + * 기록의 접속위치는 변하지 않아야 감사증빙으로 쓸 수 있다.

+ * + *

fail-safe: 이 판정은 로그인/로그아웃 경로에서 호출된다. 어떤 예외도 + * 밖으로 던지지 않고 폴백 값을 돌려준다 — 접속위치 때문에 로그인이 막히면 안 된다.

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.07    URP     최초 생성 (개인정보보호 #62)
+ * 
+ */ +@Component("ipLocationResolver") +public class IpLocationResolver { + + /** 매칭 실패 시 기본 위치명. */ + public static final String LOC_EXTERNAL = "외부(인터넷)"; + /** 사설 대역 폴백. */ + public static final String LOC_INTERNAL = "내부망"; + /** 루프백 폴백. */ + public static final String LOC_LOCAL = "서버 로컬"; + /** IP 자체를 못 얻은 경우. */ + public static final String LOC_UNKNOWN = "확인불가"; + + /** 캐시 재적재 주기(ms). 운영 중 대역 행을 추가해도 이 주기 안에 반영된다. */ + private static final long RELOAD_INTERVAL_MS = 5L * 60L * 1000L; + + @Resource(name = "ipLocationDAO") + private IpLocationDAO ipLocationDAO; + + /** 프리픽스 길이 내림차순으로 정렬된 매핑 캐시(가장 긴 것이 먼저 매칭된다). */ + private volatile List cache = new ArrayList(); + /** 마지막 적재 시각(0 = 미적재). */ + private volatile long loadedAt = 0L; + + /** + * 접속 IP 의 위치명을 판정한다. + * + * @param ip 점 표기 IPv4(IPv6/불명이면 폴백) + * @return 위치명(항상 비어 있지 않은 문자열) + */ + public String resolve(String ip) { + if (ip == null || ip.trim().length() == 0 || "unknown".equalsIgnoreCase(ip.trim())) { + return LOC_UNKNOWN; + } + String target = ip.trim(); + + long addr = ipv4ToLong(target); + if (addr < 0L) { + // IPv6 등 — 루프백만 별도로 알아보고 나머지는 외부로 본다. + return ("::1".equals(target) || "0:0:0:0:0:0:0:1".equals(target)) ? LOC_LOCAL : LOC_EXTERNAL; + } + + for (IpLocationVO loc : currentCache()) { + if (loc.getPrefixLen() < 0) { + continue; // 파싱 불가 행은 무시(운영자가 잘못 입력해도 판정은 계속된다) + } + long mask = (loc.getPrefixLen() == 0) ? 0L : (0xFFFFFFFFL << (32 - loc.getPrefixLen())) & 0xFFFFFFFFL; + if ((addr & mask) == (loc.getNetwork() & mask)) { + return (loc.getLocNm() == null || loc.getLocNm().length() == 0) ? LOC_EXTERNAL : loc.getLocNm(); + } + } + return fallback(addr); + } + + /** 매핑 캐시를 즉시 비운다(대역 행을 고친 직후 반영용). */ + public void refresh() { + loadedAt = 0L; + } + + /** 유효기간이 지났으면 다시 읽고, 실패하면 직전 캐시를 그대로 쓴다. */ + private List currentCache() { + long now = System.currentTimeMillis(); + if (loadedAt != 0L && (now - loadedAt) < RELOAD_INTERVAL_MS) { + return cache; + } + try { + List loaded = ipLocationDAO.selectUsedLocationList(); + if (loaded == null) { + loaded = new ArrayList(); + } + for (IpLocationVO loc : loaded) { + parseCidr(loc); + } + Collections.sort(loaded, new Comparator() { + public int compare(IpLocationVO a, IpLocationVO b) { + // 프리픽스가 긴(=더 구체적인) 대역이 먼저 매칭되어야 한다. + return b.getPrefixLen() - a.getPrefixLen(); + } + }); + cache = loaded; + loadedAt = now; + } catch (Exception e) { + // 테이블 미적용·DB 일시 오류 — 직전 캐시(초기엔 빈 목록)로 계속 판정한다. + loadedAt = now; // 매 요청마다 실패 조회를 반복하지 않도록 주기는 넘긴다 + } + return cache; + } + + /** "10.0.0.0/8" → network/prefixLen. 형식 오류면 prefixLen = -1(무시 대상). */ + private void parseCidr(IpLocationVO loc) { + loc.setNetwork(-1L); + loc.setPrefixLen(-1); + String cidr = loc.getIpCidr(); + if (cidr == null) { + return; + } + String trimmed = cidr.trim(); + int slash = trimmed.indexOf('/'); + String network = (slash < 0) ? trimmed : trimmed.substring(0, slash); + int prefix; + try { + prefix = (slash < 0) ? 32 : Integer.parseInt(trimmed.substring(slash + 1).trim()); + } catch (NumberFormatException e) { + return; + } + if (prefix < 0 || prefix > 32) { + return; + } + long net = ipv4ToLong(network); + if (net < 0L) { + return; + } + loc.setNetwork(net); + loc.setPrefixLen(prefix); + } + + /** 테이블 매칭이 없을 때의 내장 판정(루프백/사설/그 외). */ + private String fallback(long addr) { + if (inCidr(addr, "127.0.0.0", 8)) { + return LOC_LOCAL; + } + if (inCidr(addr, "10.0.0.0", 8) || inCidr(addr, "172.16.0.0", 12) || inCidr(addr, "192.168.0.0", 16)) { + return LOC_INTERNAL; + } + return LOC_EXTERNAL; + } + + private boolean inCidr(long addr, String network, int prefix) { + long net = ipv4ToLong(network); + long mask = (0xFFFFFFFFL << (32 - prefix)) & 0xFFFFFFFFL; + return (addr & mask) == (net & mask); + } + + /** 점 표기 IPv4 → 부호없는 long. 형식 오류(IPv6 포함) 시 -1. */ + private static long ipv4ToLong(String ip) { + if (ip == null) { + return -1L; + } + String[] octets = ip.trim().split("\\."); + if (octets.length != 4) { + return -1L; + } + long result = 0L; + for (String octet : octets) { + int v; + try { + v = Integer.parseInt(octet.trim()); + } catch (NumberFormatException e) { + return -1L; + } + if (v < 0 || v > 255) { + return -1L; + } + result = (result << 8) | v; + } + return result & 0xFFFFFFFFL; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationVO.java b/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationVO.java new file mode 100644 index 0000000..bb88aaf --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationVO.java @@ -0,0 +1,64 @@ +package egovframework.com.cmm.privacy.conect; + +import java.io.Serializable; + +/** + * 접속위치 매핑 VO — 개인정보보호 기능개선 사업(#62, 로그인·로그아웃 접속기록). + * + *

{@code CONECT_IP_LOCATION} 1행. IP 대역(CIDR)과 그 대역에 붙일 위치명이다. + * 접속기록 적재 시점에 {@link IpLocationResolver}가 가장 긴 프리픽스 1건을 골라 + * {@code COMTNLOGINLOG.CONECT_LC}에 스냅샷으로 남긴다 — 나중에 매핑을 바꿔도 + * 과거 기록의 위치는 변하지 않는다(감사증빙).

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.07    URP     최초 생성 (개인정보보호 #62)
+ * 
+ */ +public class IpLocationVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 일련번호(PK). */ + private long locId; + /** IP 대역(CIDR 표기. 예 "10.0.0.0/8", "203.0.113.7/32"). */ + private String ipCidr; + /** 접속위치명. */ + private String locNm; + /** 내외부 구분 I:내부망 E:외부. */ + private String locSe; + /** 정렬순서. */ + private int sortOrdr; + /** 사용여부 Y/N. */ + private String useAt; + /** 비고. */ + private String remark; + + // ---- CIDR 파싱 결과(캐시 적재 시 1회 계산, DB 컬럼 아님) ---- + /** 네트워크 주소(부호없는 IPv4). 파싱 실패 시 -1. */ + private transient long network = -1L; + /** 프리픽스 길이(0~32). 파싱 실패 시 -1. */ + private transient int prefixLen = -1; + + public long getLocId() { return locId; } + public void setLocId(long locId) { this.locId = locId; } + public String getIpCidr() { return ipCidr; } + public void setIpCidr(String ipCidr) { this.ipCidr = ipCidr; } + public String getLocNm() { return locNm; } + public void setLocNm(String locNm) { this.locNm = locNm; } + public String getLocSe() { return locSe; } + public void setLocSe(String locSe) { this.locSe = locSe; } + public int getSortOrdr() { return sortOrdr; } + public void setSortOrdr(int sortOrdr) { this.sortOrdr = sortOrdr; } + public String getUseAt() { return useAt; } + public void setUseAt(String useAt) { this.useAt = useAt; } + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } + + public long getNetwork() { return network; } + public void setNetwork(long network) { this.network = network; } + public int getPrefixLen() { return prefixLen; } + public void setPrefixLen(int prefixLen) { this.prefixLen = prefixLen; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MemberDataCipher.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberDataCipher.java new file mode 100644 index 0000000..320989e --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberDataCipher.java @@ -0,0 +1,181 @@ +/** + * Class Name : MemberDataCipher.java + * Description : 회원 개인정보 항목 대칭암호(AES-GCM) 헬퍼 — 개인정보보호 기능개선 사업(추가제안 Action4) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.10 개인정보보호 최초 생성 — 회원 이름(MBER_NM) 암호화 저장 + * + * @since 2026. 08. 10 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.crypto; + +import java.security.SecureRandom; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.commons.codec.binary.Base64; + +/** + * 회원 개인정보 항목을 가역 암호화(AES-128/GCM)하여 저장하고, 표시 시점에만 복호화한다. + * + *

대상: 1단계는 일반회원 이름({@code COMTNGNRLMBER.MBER_NM}, 실측 77,053건). + * 이메일·휴대폰·주소는 동일 클래스를 재사용해 후속 단계에서 확장한다.

+ * + *

랜덤 IV를 쓰는 이유: 같은 이름이라도 매번 다른 암호문이 나온다. 결정적 암호(고정 IV)를 + * 쓰면 암호문만 보고 "이 두 회원은 동명이인"이라는 사실이 새어나가고, 빈도분석으로 흔한 이름부터 + * 역추적된다. 대신 검색이 불가능해지므로 완전일치 검색은 + * {@link MemberDataHasher}의 해시 컬럼이 전담한다.

+ * + *

저장 형식: {@code Base64( IV(12) || ciphertext || GCM tag(16) )}. + * GCM은 무결성 태그를 포함하므로 저장된 암호문이 변조되면 복호화 시 예외가 발생한다 — + * 위·변조 탐지가 덤으로 따라온다.

+ * + *

키 관리 — 이 클래스는 개발용 폴백을 두지 않는다. + * 기존 {@code OtpSecretCipher}는 키 미설정 시 소스에 박힌 개발용 키로 폴백하는데, 회원정보에 + * 그 방식을 승계하면 "암호화했으나 키가 소스에 있는 상태"가 되어 관리실태 점검 지적이 재발한다. + * 따라서 키가 없으면 {@link IllegalStateException}으로 기동을 실패시킨다. 조용히 약한 + * 암호로 돌아가는 것보다 뜨는 편이 안전하다.

+ * + *

키 설정: 시스템 프로퍼티 {@code arko.mber.master.key} 또는 환경변수 + * {@code ARKO_MBER_MASTER_KEY}에 Base64 인코딩된 16바이트 키를 넣는다. + * WAR 안에 두지 않는다(서버 {@code setenv.sh} 또는 퍼미션 600 키파일).

+ * + *
+ *   # 키 생성 예시
+ *   openssl rand -base64 16
+ * 
+ * + *

키 길이가 AES-128인 이유: AES-256은 구형 JDK에서 JCE 무제한 정책 파일을 요구한다. + * 운영 JDK가 8u161 이상이면 무제한이 기본이므로 상향 가능하나, 운영 JDK 확인 전까지는 + * {@code OtpSecretCipher}와 동일하게 128로 맞춰 배포 리스크를 없앤다.

+ * + *

스레드 안전: {@link Cipher}는 매 호출마다 새로 얻는다(재사용 불가). 이 클래스 자체는 + * 불변이므로 싱글턴 빈으로 공유해도 된다.

+ */ +public class MemberDataCipher { + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int GCM_TAG_BITS = 128; + private static final int GCM_IV_BYTES = 12; + private static final int KEY_BYTES = 16; // AES-128 + + /** 마스터 키 시스템 프로퍼티명. */ + public static final String MASTER_KEY_PROP = "arko.mber.master.key"; + /** 마스터 키 환경변수명. */ + public static final String MASTER_KEY_ENV = "ARKO_MBER_MASTER_KEY"; + + /** + * 현재 키 버전. 암호화한 행의 {@code CRYPTO_KEY_VER}에 함께 적재한다. + * 키를 교체할 때 이 값을 올리고 복호화 측에서 버전별 키를 선택하게 확장한다. + */ + public static final int CURRENT_KEY_VER = 1; + + private final byte[] masterKey; + private final SecureRandom random = new SecureRandom(); + + /** 설정된 마스터 키로 생성한다. 키가 없으면 즉시 실패한다. */ + public MemberDataCipher() { + this.masterKey = loadMasterKey(); + } + + /** 명시적 키 주입(테스트/키 교체용). */ + public MemberDataCipher(byte[] key16) { + if (key16 == null || key16.length != KEY_BYTES) { + throw new IllegalArgumentException("AES-128 master key must be " + KEY_BYTES + " bytes"); + } + this.masterKey = key16.clone(); + } + + /** + * 마스터 키를 로드한다. + * 폴백 없음 — 미설정·형식오류는 예외다. + */ + private static byte[] loadMasterKey() { + String b64 = System.getProperty(MASTER_KEY_PROP); + if (isBlank(b64)) { + b64 = System.getenv(MASTER_KEY_ENV); + } + if (isBlank(b64)) { + throw new IllegalStateException( + "[개인정보/암호화] 회원정보 마스터 키가 설정되지 않았습니다. " + + "시스템 프로퍼티 " + MASTER_KEY_PROP + " 또는 환경변수 " + MASTER_KEY_ENV + + " 에 Base64 인코딩된 16바이트 키를 설정하십시오. (생성: openssl rand -base64 16)"); + } + byte[] key = Base64.decodeBase64(b64.trim().getBytes()); + if (key.length != KEY_BYTES) { + throw new IllegalStateException( + "[개인정보/암호화] 마스터 키 길이가 올바르지 않습니다. 기대 " + KEY_BYTES + + "바이트, 실제 " + key.length + "바이트. Base64 인코딩된 16바이트 키여야 합니다."); + } + return key; + } + + /** + * 평문을 AES-GCM 암호화하여 Base64 문자열로 반환한다. + * + *

{@code null}은 {@code null}로, 빈 문자열은 빈 문자열로 그대로 돌려준다. + * arko는 회원 탈퇴 시 개인정보 컬럼을 빈 문자열로 채우는 로직이 있어 + * ({@code EgovMberManage_SQL_Mysql.xml}) NULL과 ''를 구분해 보존해야 파기 상태가 뭉개지지 않는다.

+ * + * @param plain 평문(null/빈문자 허용) + * @return 암호문 Base64. 입력이 null이면 null, 빈문자면 빈문자 + */ + public String encrypt(String plain) throws Exception { + if (plain == null) { + return null; + } + if (plain.length() == 0) { + return ""; + } + byte[] iv = new byte[GCM_IV_BYTES]; + random.nextBytes(iv); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(masterKey, "AES"), + new GCMParameterSpec(GCM_TAG_BITS, iv)); + byte[] ct = cipher.doFinal(plain.getBytes("UTF-8")); + byte[] out = new byte[iv.length + ct.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + System.arraycopy(ct, 0, out, iv.length, ct.length); + return new String(Base64.encodeBase64(out), "US-ASCII"); + } + + /** + * {@link #encrypt(String)} 결과를 복호화한다. + * + *

암호문이 변조됐거나 다른 키로 만든 것이면 GCM 태그 검증에 실패해 예외가 난다. + * 호출측은 이를 "복호화 실패"로 처리하고 평문을 화면에 대체 출력하지 않는다.

+ * + * @param encryptedBase64 암호문 Base64(null/빈문자 허용) + * @return 평문. 입력이 null이면 null, 빈문자면 빈문자 + */ + public String decrypt(String encryptedBase64) throws Exception { + if (encryptedBase64 == null) { + return null; + } + if (encryptedBase64.length() == 0) { + return ""; + } + byte[] all = Base64.decodeBase64(encryptedBase64.getBytes("US-ASCII")); + if (all.length <= GCM_IV_BYTES) { + throw new IllegalArgumentException("암호문 길이가 비정상입니다: " + all.length + "바이트"); + } + byte[] iv = new byte[GCM_IV_BYTES]; + System.arraycopy(all, 0, iv, 0, GCM_IV_BYTES); + byte[] ct = new byte[all.length - GCM_IV_BYTES]; + System.arraycopy(all, GCM_IV_BYTES, ct, 0, ct.length); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(masterKey, "AES"), + new GCMParameterSpec(GCM_TAG_BITS, iv)); + return new String(cipher.doFinal(ct), "UTF-8"); + } + + private static boolean isBlank(String s) { + return s == null || s.trim().length() == 0; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MemberDataHasher.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberDataHasher.java new file mode 100644 index 0000000..3e6b228 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberDataHasher.java @@ -0,0 +1,162 @@ +/** + * Class Name : MemberDataHasher.java + * Description : 회원 개인정보 검색용 키드 해시(HMAC-SHA256) — 개인정보보호 기능개선 사업(추가제안 Action4) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.10 개인정보보호 최초 생성 — 이름 완전일치 검색용 해시 + * + * @since 2026. 08. 10 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.crypto; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * 암호화된 개인정보 항목의 완전일치 검색을 위한 키드 해시를 만든다. + * + *

왜 필요한가: {@link MemberDataCipher}는 랜덤 IV라 같은 이름도 매번 다른 암호문이 된다. + * 따라서 암호문으로는 검색할 수 없다. 검색 전용으로 결정적인 값을 별도 컬럼 + * ({@code MBER_NM_HASH})에 두고 인덱스를 건다.

+ * + *

왜 순수 SHA-256이 아니라 HMAC인가: 제안서 문구는 "SHA-256"이지만, 이름·휴대폰번호는 + * 엔트로피가 극히 낮다. 한국인 이름은 사실상 수십만 가지, 휴대폰번호는 1억 미만이라 순수 해시는 + * 전수대입 한 번으로 원문이 전부 복원된다 — 해시를 걸어도 평문이나 마찬가지가 된다. + * 비밀 pepper를 섞는 HMAC이면 pepper를 모르는 공격자는 사전을 만들 수 없다. + * 제안 범위보다 강화되는 방향이므로 사업상 문제가 없고 점검 대응력이 올라간다.

+ * + *

정규화가 해시 일치의 전제다. 같은 값이 다른 해시로 갈리면 검색이 조용히 실패한다. + * 항목별 정규화 규칙을 이 클래스 한 곳에만 두고, 저장 시점과 검색 시점이 반드시 같은 + * 메서드를 쓰게 한다.

+ * + *

pepper 설정: 시스템 프로퍼티 {@code arko.mber.hash.pepper} 또는 환경변수 + * {@code ARKO_MBER_HASH_PEPPER}. 마스터 키와 다른 값이어야 한다(하나가 유출돼도 + * 다른 하나는 살아 있게). 미설정 시 폴백 없이 실패한다.

+ * + *

pepper는 교체할 수 없다. 바꾸면 저장된 해시 전체를 재계산해야 한다 + * (원본 복호화 → 재해시 배치). 처음에 충분히 긴 값으로 정하고 고정한다.

+ */ +public class MemberDataHasher { + + private static final String ALGORITHM = "HmacSHA256"; + + /** pepper 시스템 프로퍼티명. */ + public static final String PEPPER_PROP = "arko.mber.hash.pepper"; + /** pepper 환경변수명. */ + public static final String PEPPER_ENV = "ARKO_MBER_HASH_PEPPER"; + + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private final byte[] pepper; + + public MemberDataHasher() { + this.pepper = loadPepper(); + } + + /** 명시적 pepper 주입(테스트용). */ + public MemberDataHasher(byte[] pepper) { + if (pepper == null || pepper.length == 0) { + throw new IllegalArgumentException("pepper must not be empty"); + } + this.pepper = pepper.clone(); + } + + private static byte[] loadPepper() { + String p = System.getProperty(PEPPER_PROP); + if (p == null || p.trim().length() == 0) { + p = System.getenv(PEPPER_ENV); + } + if (p == null || p.trim().length() == 0) { + throw new IllegalStateException( + "[개인정보/암호화] 검색용 해시 pepper가 설정되지 않았습니다. " + + "시스템 프로퍼티 " + PEPPER_PROP + " 또는 환경변수 " + PEPPER_ENV + " 를 설정하십시오. " + + "(마스터 키와 다른 값이어야 합니다. 생성: openssl rand -base64 32)"); + } + try { + return p.trim().getBytes("UTF-8"); + } catch (Exception e) { + throw new IllegalStateException("pepper 인코딩 실패", e); + } + } + + /** + * 이름 검색용 해시. 정규화는 앞뒤 공백 제거만 한다. + * + *

가운데 공백은 지우지 않는다 — "남궁 민수"와 "남궁민수"는 실제로 다르게 등록된 + * 별개 표기이며, 임의로 합치면 저장 시점과 관리자 검색 시점의 정규화가 어긋날 여지가 커진다. + * 소문자화도 하지 않는다(한글에 무의미하고 영문 이름의 대소문자는 유의미할 수 있다).

+ * + * @param name 평문 이름(null/빈문자 허용) + * @return 소문자 hex 64자. 입력이 null이면 null, 빈문자면 빈문자 + */ + public String hashName(String name) { + if (name == null) { + return null; + } + String norm = name.trim(); + if (norm.length() == 0) { + return ""; + } + return hmacHex(norm); + } + + /** + * 이메일 검색용 해시. 앞뒤 공백 제거 + 소문자화. + * (후속 단계용 — 이름 단계에서는 사용하지 않는다.) + */ + public String hashEmail(String email) { + if (email == null) { + return null; + } + String norm = email.trim().toLowerCase(); + if (norm.length() == 0) { + return ""; + } + return hmacHex(norm); + } + + /** + * 휴대폰번호 검색용 해시. 숫자 이외 문자를 모두 제거한다. + * ({@code 010-1234-5678}, {@code 010 1234 5678} → {@code 01012345678}) + * (후속 단계용 — 이름 단계에서는 사용하지 않는다.) + */ + public String hashMobile(String mobile) { + if (mobile == null) { + return null; + } + StringBuilder sb = new StringBuilder(mobile.length()); + for (int i = 0; i < mobile.length(); i++) { + char c = mobile.charAt(i); + if (c >= '0' && c <= '9') { + sb.append(c); + } + } + if (sb.length() == 0) { + return ""; + } + return hmacHex(sb.toString()); + } + + /** 정규화가 끝난 문자열의 HMAC-SHA256 소문자 hex(64자). */ + private String hmacHex(String normalized) { + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(pepper, ALGORITHM)); + byte[] out = mac.doFinal(normalized.getBytes("UTF-8")); + char[] hex = new char[out.length * 2]; + for (int i = 0; i < out.length; i++) { + int v = out[i] & 0xff; + hex[i * 2] = HEX[v >>> 4]; + hex[i * 2 + 1] = HEX[v & 0x0f]; + } + return new String(hex); + } catch (Exception e) { + // 해시 실패는 검색 불가로 이어지므로 삼키지 않는다. + throw new IllegalStateException("[개인정보/암호화] 검색용 해시 생성 실패", e); + } + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoDAO.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoDAO.java new file mode 100644 index 0000000..bc703cc --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoDAO.java @@ -0,0 +1,139 @@ +/** + * Class Name : MemberNmCryptoDAO.java + * Description : 회원 이름 암호화 이관 DAO — 개인정보보호 기능개선 사업(추가제안 Action4) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.10 개인정보보호 최초 생성 + * + * @since 2026. 08. 10 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.crypto; + +import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.orm.ibatis.SqlMapClientCallback; +import org.springframework.stereotype.Repository; + +import com.ibatis.sqlmap.client.SqlMapExecutor; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 회원 이름 암호화 백필 배치의 데이터 접근. 청크 조회 → 배치 UPDATE → 진행상태 기록. + * + *

평문 컬럼은 절대 UPDATE하지 않는다. 이 DAO의 update문은 {@code MBER_NM_ENC} / + * {@code MBER_NM_HASH} / {@code CRYPTO_KEY_VER}만 건드린다. 평문이 그대로 남아 있어야 + * ① 재실행 시 미처리 판정이 가능하고 ② 읽기 전환 전까지 롤백 창구가 유지된다.

+ * + * @author 개인정보보호 + * @since 2026-08-10 + */ +@Repository("memberNmCryptoDAO") +public class MemberNmCryptoDAO extends EgovComAbstractDAO { + + /** 진행상태 테이블의 작업 식별자. */ + public static final String JOB_ID = "COMTNGNRLMBER.MBER_NM"; + + /** + * 커서 이후의 미암호화 행을 청크 크기만큼 조회한다. + * + * @param lastKey 직전 청크의 마지막 MBER_ID. 최초 실행이면 null + * @param chunkSize 청크 크기 + */ + @SuppressWarnings("unchecked") + public List selectChunk(String lastKey, int chunkSize) { + Map param = new HashMap(); + param.put("lastKey", lastKey); + param.put("chunkSize", Integer.valueOf(chunkSize)); + return list("MemberNmCryptoDAO.selectChunk", param); + } + + /** + * 청크 한 덩어리를 단일 배치로 UPDATE한다. + * + *

건별 executeUpdate를 77,053번 왕복하면 네트워크 라운드트립이 지배적이 된다. + * iBatis2 배치로 묶으면 한 번의 왕복으로 처리되어 수 분이 수십 초로 줄어든다.

+ * + * @return 실제 반영 건수 + */ + public int updateEncryptedBatch(final List rows) { + if (rows == null || rows.isEmpty()) { + return 0; + } + Object result = getSqlMapClientTemplate().execute(new SqlMapClientCallback() { + public Object doInSqlMapClient(SqlMapExecutor executor) throws SQLException { + executor.startBatch(); + for (int i = 0; i < rows.size(); i++) { + executor.update("MemberNmCryptoDAO.updateEncrypted", rows.get(i)); + } + return Integer.valueOf(executor.executeBatch()); + } + }); + return (result instanceof Integer) ? ((Integer) result).intValue() : 0; + } + + /** 아직 암호화되지 않은 잔여 건수. */ + public int selectRemainingCount() { + Object v = selectByPk("MemberNmCryptoDAO.selectRemainingCount", null); + return (v instanceof Integer) ? ((Integer) v).intValue() : 0; + } + + /** 진행상태 행을 없으면 만든다(재실행 안전). */ + public void insertProgressIfAbsent() { + Map param = new HashMap(); + param.put("jobId", JOB_ID); + insert("MemberNmCryptoDAO.insertProgressIfAbsent", param); + } + + /** 청크 완료 시 커서·누적건수를 갱신한다. */ + public void updateProgress(String lastKey, int doneDelta, int errDelta) { + Map param = new HashMap(); + param.put("jobId", JOB_ID); + param.put("lastKey", lastKey); + param.put("doneDelta", Integer.valueOf(doneDelta)); + param.put("errDelta", Integer.valueOf(errDelta)); + update("MemberNmCryptoDAO.updateProgress", param); + } + + /** 작업 상태(R/S/C/E)와 비고를 갱신한다. */ + public void updateStatus(String status, String remark) { + Map param = new HashMap(); + param.put("jobId", JOB_ID); + param.put("jobSttus", status); + param.put("rm", remark); + update("MemberNmCryptoDAO.updateStatus", param); + } + + /** 진행상태 1건 조회(화면 표시·중지요청 확인용). */ + @SuppressWarnings("unchecked") + public Map selectProgress() { + Map param = new HashMap(); + param.put("jobId", JOB_ID); + return (Map) selectByPk("MemberNmCryptoDAO.selectProgress", param); + } + + /** + * 검증용 집계 — 평문 보유 건수와 암호문 보유 건수, 그리고 누락 건수. + * 누락(평문 있는데 암호문 없음)이 0이어야 이관 완료다. + */ + @SuppressWarnings("unchecked") + public Map selectVerifyCounts() { + return (Map) selectByPk("MemberNmCryptoDAO.selectVerifyCounts", null); + } + + /** 검증용 — 암호화된 행을 커서 기준으로 청크 조회(복호화 대사에 사용). */ + @SuppressWarnings("unchecked") + public List selectVerifyChunk(String lastKey, int chunkSize) { + Map param = new HashMap(); + param.put("lastKey", lastKey); + param.put("chunkSize", Integer.valueOf(chunkSize)); + return list("MemberNmCryptoDAO.selectVerifyChunk", param); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoVO.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoVO.java new file mode 100644 index 0000000..14d4b6a --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoVO.java @@ -0,0 +1,105 @@ +/** + * Class Name : MemberNmCryptoVO.java + * Description : 회원 이름 암호화 이관 대상 행 VO — 개인정보보호 기능개선 사업(추가제안 Action4) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.10 개인정보보호 최초 생성 + * + * @since 2026. 08. 10 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.crypto; + +import java.io.Serializable; + +/** + * 백필 배치가 읽고 쓰는 한 행. {@code COMTNGNRLMBER}의 PK와 이름 관련 컬럼만 담는다. + * + *

회원 전체 컬럼(40여 개)을 끌고 오지 않는 이유는 두 가지다. 이관 배치가 다루는 개인정보를 + * 최소화해야 하고(필요 최소 수집), 77,053건을 청크로 훑을 때 불필요한 I/O를 줄여야 한다.

+ */ +public class MemberNmCryptoVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 회원ID — COMTNGNRLMBER의 PK이자 청크 커서 키. + * + *

실측 주의: 이 컬럼의 DB 타입은 {@code VARCHAR}가 아니라 {@code VARBINARY(32)}이며, + * 77,053건 중 39건(LVMS 연계 계정 {@code ...@lvms})에 비인쇄 바이트가 들어 있다. + * 다만 전건 감사 결과 UTF-8 문자열 왕복이 무손실임을 확인했으므로(왕복 실패 0건) + * String 매핑을 유지한다.

+ * + *

혹시 변환이 어긋나더라도 실패 양상은 안전하다 — UPDATE가 0건이 되어 해당 행의 + * {@code MBER_NM_ENC}가 NULL로 남고, 검증 단계의 누락 건수에 그대로 잡힌다. + * 데이터가 잘못 덮어써지는 경우는 발생하지 않는다.

+ */ + private String mberId; + + /** 평문 이름(읽기 전용. 배치는 이 컬럼을 갱신하지 않는다). */ + private String mberNm; + + /** 이름 암호문 Base64. */ + private String mberNmEnc; + + /** 이름 검색용 HMAC-SHA256 hex. */ + private String mberNmHash; + + /** 암호화 키 버전. */ + private Integer cryptoKeyVer; + + public String getMberId() { + return mberId; + } + + public void setMberId(String mberId) { + this.mberId = mberId; + } + + public String getMberNm() { + return mberNm; + } + + public void setMberNm(String mberNm) { + this.mberNm = mberNm; + } + + public String getMberNmEnc() { + return mberNmEnc; + } + + public void setMberNmEnc(String mberNmEnc) { + this.mberNmEnc = mberNmEnc; + } + + public String getMberNmHash() { + return mberNmHash; + } + + public void setMberNmHash(String mberNmHash) { + this.mberNmHash = mberNmHash; + } + + public Integer getCryptoKeyVer() { + return cryptoKeyVer; + } + + public void setCryptoKeyVer(Integer cryptoKeyVer) { + this.cryptoKeyVer = cryptoKeyVer; + } + + /** + * 로그·디버깅용. 이름 값은 절대 포함하지 않는다 — + * 이관 로그에 평문이 남으면 암호화의 의미가 사라진다. + */ + @Override + public String toString() { + return "MemberNmCryptoVO[mberId=" + mberId + + ", encSet=" + (mberNmEnc != null) + + ", hashSet=" + (mberNmHash != null) + + ", keyVer=" + cryptoKeyVer + "]"; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoWorker.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoWorker.java new file mode 100644 index 0000000..cbc2041 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoWorker.java @@ -0,0 +1,309 @@ +/** + * Class Name : MemberNmCryptoWorker.java + * Description : 회원 이름 암호화 백필/검증 배치 — 개인정보보호 기능개선 사업(추가제안 Action4) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.10 개인정보보호 최초 생성 + * + * @since 2026. 08. 10 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.crypto; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import javax.annotation.Resource; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * 이미 적재된 회원 이름을 일괄 암호화하는 백필 배치와, 그 결과를 대사하는 검증 배치. + * + *

대상 규모(2026-08-10 실측): {@code COMTNGNRLMBER} 77,053건 / 39.6MB / + * PK {@code MBER_ID} 단일. 1,000건 청크 기준 78회 순회이며 실측 소요는 수십 초~수 분이다. + * 야간 점검창구가 필요한 규모가 아니다.

+ * + *

중단·재개 설계: 청크마다 {@code MIG_CRYPTO_PROGRESS}에 커서를 남긴다. 그러나 + * 재개의 진짜 근거는 커서가 아니라 {@code MBER_NM_ENC IS NULL} 조건이다 — + * 진행상태 테이블이 통째로 날아가도 처음부터 다시 돌리면 이미 처리된 행은 건너뛴다. + * 커서는 "다시 훑는 비용"을 줄이는 최적화일 뿐 정합성의 근거가 아니다.

+ * + *

왜 상주 스케줄러에 넣지 않는가: 1회성 이관 작업이다. {@code BatchScheduler}에 + * 등록하면 이관이 끝난 뒤에도 매 주기 깨어나 빈 조회를 반복하고, 실수로 재실행될 여지가 남는다. + * 관리자가 명시적으로 시작·중지하는 형태가 맞다.

+ * + *

평문을 로그에 남기지 않는다. 이 클래스의 모든 로그는 건수·회원ID까지만 남긴다. + * 이관 로그에 이름이 찍히면 암호화를 하고도 평문이 파일로 새어 나간다.

+ * + * @author 개인정보보호 + * @since 2026-08-10 + */ +@Component("memberNmCryptoWorker") +public class MemberNmCryptoWorker { + + private static final Logger LOG = LoggerFactory.getLogger(MemberNmCryptoWorker.class); + + /** 기본 청크 크기. */ + public static final int DEFAULT_CHUNK_SIZE = 1000; + + /** 청크 간 기본 휴지(ms) — 온라인 트래픽·복제 지연 보호. */ + public static final long DEFAULT_SLEEP_MS = 50L; + + /** 진행상태 코드. */ + private static final String STTUS_RUNNING = "R"; + private static final String STTUS_STOP = "S"; + private static final String STTUS_DONE = "C"; + private static final String STTUS_ERROR = "E"; + + @Resource(name = "memberNmCryptoDAO") + private MemberNmCryptoDAO dao; + + /** 암호·해시 헬퍼는 키를 들고 있으므로 생성 실패 시 기동을 막는다(폴백 없음). */ + private MemberDataCipher cipher; + private MemberDataHasher hasher; + + /** 실행 중 스레드. null 이면 미실행. */ + private volatile Thread runner; + + /** 중지 요청 플래그. */ + private volatile boolean stopRequested = false; + + /** 지연 초기화 — 키 미설정 서버에서 컨텍스트 전체가 죽지 않도록 실행 시점에 만든다. */ + private synchronized void ensureCrypto() { + if (cipher == null) { + cipher = new MemberDataCipher(); + } + if (hasher == null) { + hasher = new MemberDataHasher(); + } + } + + /** 현재 실행 중인가. */ + public boolean isRunning() { + Thread t = runner; + return t != null && t.isAlive(); + } + + /** 진행상태 조회(관리자 화면용). */ + public Map getProgress() { + return dao.selectProgress(); + } + + /** 중지 요청. 진행 중인 청크는 마치고 멈춘다(커서는 보존). */ + public void requestStop() { + this.stopRequested = true; + LOG.info("[개인정보/암호화] 이름 백필 중지 요청 접수"); + } + + /** + * 백필을 백그라운드로 시작한다. + * + * @param chunkSize 청크 크기(1 미만이면 기본값) + * @param sleepMs 청크 간 휴지(음수면 기본값) + * @return 시작했으면 true, 이미 실행 중이면 false + */ + public synchronized boolean start(int chunkSize, long sleepMs) { + if (isRunning()) { + LOG.warn("[개인정보/암호화] 이름 백필이 이미 실행 중입니다 — 중복 시작 무시"); + return false; + } + final int chunk = (chunkSize < 1) ? DEFAULT_CHUNK_SIZE : chunkSize; + final long sleep = (sleepMs < 0) ? DEFAULT_SLEEP_MS : sleepMs; + + this.stopRequested = false; + Thread t = new Thread(new Runnable() { + public void run() { + runBackfill(chunk, sleep); + } + }, "mber-nm-crypto-backfill"); + t.setDaemon(true); + this.runner = t; + t.start(); + return true; + } + + /** + * 백필 본체. 커서 페이징으로 미암호화 행을 청크 단위로 암호화한다. + * + *

한 건이 실패해도 청크 전체를 버리지 않는다 — 실패 행만 건너뛰고 계속한다. + * 실패 건은 {@code MBER_NM_ENC}가 NULL로 남으므로 다음 실행에서 자연히 재시도된다.

+ */ + void runBackfill(int chunkSize, long sleepMs) { + long started = System.currentTimeMillis(); + int totalDone = 0; + int totalErr = 0; + try { + ensureCrypto(); + dao.insertProgressIfAbsent(); + dao.updateStatus(STTUS_RUNNING, "백필 시작 chunk=" + chunkSize); + + int remaining = dao.selectRemainingCount(); + LOG.info("[개인정보/암호화] 이름 백필 시작 — 대상 {}건, chunk={}, sleep={}ms", + new Object[] { Integer.valueOf(remaining), Integer.valueOf(chunkSize), Long.valueOf(sleepMs) }); + + String lastKey = null; + while (true) { + if (stopRequested) { + dao.updateStatus(STTUS_STOP, "관리자 중지 요청 — 처리 " + totalDone + "건"); + LOG.info("[개인정보/암호화] 이름 백필 중지 — 누적 {}건", Integer.valueOf(totalDone)); + return; + } + + List rows = dao.selectChunk(lastKey, chunkSize); + if (rows == null || rows.isEmpty()) { + break; + } + + List ready = new ArrayList(rows.size()); + for (int i = 0; i < rows.size(); i++) { + MemberNmCryptoVO row = rows.get(i); + lastKey = row.getMberId(); + try { + // NULL/빈문자 구분 보존은 cipher/hasher가 담당한다. + // arko는 탈퇴 시 개인정보 컬럼을 빈 문자열로 채우므로 ''를 NULL로 뭉개면 + // 파기 상태가 사라진다. + row.setMberNmEnc(cipher.encrypt(row.getMberNm())); + row.setMberNmHash(hasher.hashName(row.getMberNm())); + row.setCryptoKeyVer(Integer.valueOf(MemberDataCipher.CURRENT_KEY_VER)); + ready.add(row); + } catch (Exception e) { + totalErr++; + // 회원ID만 남긴다 — 이름은 절대 로그에 쓰지 않는다. + LOG.error("[개인정보/암호화] 행 암호화 실패(건너뜀) mberId=" + row.getMberId(), e); + } + } + + int updated = 0; + if (!ready.isEmpty()) { + updated = dao.updateEncryptedBatch(ready); + } + totalDone += updated; + dao.updateProgress(lastKey, updated, 0); + + if (LOG.isInfoEnabled()) { + LOG.info("[개인정보/암호화] 청크 완료 — 누적 {}건, 실패 {}건, cursor={}", + new Object[] { Integer.valueOf(totalDone), Integer.valueOf(totalErr), lastKey }); + } + + if (sleepMs > 0) { + try { + Thread.sleep(sleepMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + dao.updateStatus(STTUS_STOP, "인터럽트 — 처리 " + totalDone + "건"); + return; + } + } + } + + long elapsed = System.currentTimeMillis() - started; + String msg = "백필 완료 — 처리 " + totalDone + "건, 실패 " + totalErr + "건, 소요 " + (elapsed / 1000) + "초"; + dao.updateStatus(totalErr > 0 ? STTUS_ERROR : STTUS_DONE, msg); + LOG.info("[개인정보/암호화] {}", msg); + + } catch (Throwable t) { + String msg = "백필 중단 — " + t.getClass().getSimpleName() + ": " + t.getMessage(); + try { + dao.updateStatus(STTUS_ERROR, msg); + } catch (Exception ignore) { + LOG.error("[개인정보/암호화] 진행상태 기록 실패", ignore); + } + LOG.error("[개인정보/암호화] 이름 백필 예외 — 누적 " + totalDone + "건", t); + } finally { + this.runner = null; + } + } + + /** + * 이관 검증 — 전건 복호화 대사. + * + *

77,053건 규모에서 샘플링할 이유가 없다. 전건을 복호화해 평문과 비교하고, 해시를 + * 재계산해 저장값과 대조한다. 하나라도 어긋나면 읽기 전환을 해서는 안 된다.

+ * + *

제안서가 요구하는 "이관 전후 건수·값 검증"의 값 검증 부분이며, + * 반환 문자열을 그대로 조치결과보고서 첨부에 쓸 수 있게 구성했다.

+ * + * @param chunkSize 청크 크기 + * @return 검증 결과 요약 + */ + public String verify(int chunkSize) { + int chunk = (chunkSize < 1) ? DEFAULT_CHUNK_SIZE : chunkSize; + ensureCrypto(); + + Map counts = dao.selectVerifyCounts(); + long checked = 0; + long decMismatch = 0; + long hashMismatch = 0; + long decError = 0; + + String lastKey = null; + while (true) { + List rows = dao.selectVerifyChunk(lastKey, chunk); + if (rows == null || rows.isEmpty()) { + break; + } + for (int i = 0; i < rows.size(); i++) { + MemberNmCryptoVO row = rows.get(i); + lastKey = row.getMberId(); + checked++; + try { + String decrypted = cipher.decrypt(row.getMberNmEnc()); + if (!equalsNullable(decrypted, row.getMberNm())) { + decMismatch++; + LOG.error("[개인정보/암호화] 복호화 불일치 mberId={}", row.getMberId()); + } + String expected = hasher.hashName(row.getMberNm()); + if (!equalsNullable(expected, row.getMberNmHash())) { + hashMismatch++; + LOG.error("[개인정보/암호화] 해시 불일치 mberId={}", row.getMberId()); + } + } catch (Exception e) { + decError++; + LOG.error("[개인정보/암호화] 복호화 오류 mberId=" + row.getMberId(), e); + } + } + } + + StringBuilder sb = new StringBuilder(400); + sb.append("[회원 이름 암호화 이관 검증]\n"); + if (counts != null) { + sb.append(" 총 행수 : ").append(counts.get("TOTAL_CNT")).append('\n'); + sb.append(" 평문 보유 : ").append(counts.get("PLAIN_CNT")).append('\n'); + sb.append(" 암호문 보유 : ").append(counts.get("ENC_CNT")).append('\n'); + sb.append(" 해시 보유 : ").append(counts.get("HASH_CNT")).append('\n'); + sb.append(" 누락(미암호) : ").append(counts.get("MISSING_CNT")).append(" <- 0 이어야 함\n"); + } + sb.append(" 대사 건수 : ").append(checked).append('\n'); + sb.append(" 복호화 불일치: ").append(decMismatch).append(" <- 0 이어야 함\n"); + sb.append(" 해시 불일치 : ").append(hashMismatch).append(" <- 0 이어야 함\n"); + sb.append(" 복호화 오류 : ").append(decError).append(" <- 0 이어야 함\n"); + boolean ok = decMismatch == 0 && hashMismatch == 0 && decError == 0 + && counts != null && isZero(counts.get("MISSING_CNT")); + sb.append(" 판정 : ").append(ok ? "합격 — 읽기 전환 가능" : "불합격 — 읽기 전환 금지"); + + String report = sb.toString(); + LOG.info("[개인정보/암호화] 검증 결과\n{}", report); + return report; + } + + private static boolean equalsNullable(String a, String b) { + return (a == null) ? (b == null) : a.equals(b); + } + + private static boolean isZero(Object v) { + if (v == null) { + return true; + } + if (v instanceof Number) { + return ((Number) v).longValue() == 0L; + } + return "0".equals(String.valueOf(v)); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/interceptor/MenuAccessLogInterceptor.java b/src/main/java/egovframework/com/cmm/privacy/interceptor/MenuAccessLogInterceptor.java index a796ed1..3a0154b 100644 --- a/src/main/java/egovframework/com/cmm/privacy/interceptor/MenuAccessLogInterceptor.java +++ b/src/main/java/egovframework/com/cmm/privacy/interceptor/MenuAccessLogInterceptor.java @@ -3,6 +3,7 @@ package egovframework.com.cmm.privacy.interceptor; import java.lang.reflect.Method; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -15,8 +16,11 @@ import javax.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; +import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo; + import egovframework.com.cmm.LoginVO; import egovframework.com.cmm.privacy.auditlog.AccessLogQueue; import egovframework.com.cmm.privacy.auditlog.ClientIpResolver; @@ -114,14 +118,40 @@ public class MenuAccessLogInterceptor extends HandlerInterceptorAdapter { private static final int TRGET_ID_MAX = 50; /** - * 처리대상 식별자 파라미터 우선순위. 식별자로 확정 가능한 이름만 둔다 - * (searchKeyword 등 검색어는 개인정보 본문일 수 있어 절대 넣지 않는다). + * 처리대상 식별자 파라미터 우선순위 — 화이트리스트 대상 컨트롤러의 {@code @RequestParam} + * 실측치다(추정 아님). 개인 식별자를 앞에, 단체·일련번호를 뒤에 둬서 한 요청에 여러 개가 + * 실려도 "누구의 개인정보인가"가 먼저 잡히게 한다. + * + *

넣지 않는 것: {@code tmpNm}(이름)·{@code searchKeyword}(검색어)처럼 값 자체가 + * 개인정보 본문이거나 본문일 수 있는 파라미터. 접속기록에 본문을 담지 않는다는 원칙 때문이며, + * 식별자만으로 정보주체를 특정할 수 있으므로 추적에 부족하지 않다.

+ * + *

{@code tmpId}(해당 회원ID)·{@code tmpGId}(그룹ID)·{@code tmpAppId}(신청ID)· + * {@code checkedIdForUp}(일괄 수정 대상)은 arko 관용 파라미터명이라 표준 이름만으로는 못 잡는다. + * 이들이 빠지면 신청·활동확인·일괄승인 화면의 처리대상이 통째로 비어버린다.

*/ private static final String[] TRGET_ID_PARAMS = { - "esntlId", "uniqId", "selectedId", "mberId", "emplyrId", "entrprsId", - "groupId", "userId", "checkedIdForDel", "checkedIdForUpdt", "sn" + // 개인 식별자 + "esntlId", "uniqId", "selectedId", "mberId", "tmpId", "user_id", "userId", + "emplyrId", "entrprsId", "toId", + // 다건 선택(체크박스) + "checkedIdForDel", "checkedIdForUp", + // 단체·신청·일련 식별자 + "groupId", "tmpGId", "tmpAppId", "tmpSeqId", "sn" }; + /** + * 처리 건수 취득에 쓸 모델 속성 후보(arko 목록·엑셀 화면 관용명). + * 코드목록 같은 부수 리스트를 세지 않도록 명시 후보만 본다. + */ + private static final String[] RESULT_LIST_ATTRS = { "resultList", "excel_data", "mberList" }; + + /** postHandle → afterCompletion 사이에 처리 건수를 넘기는 request attribute 키. */ + private static final String ATTR_TRGET_CN = MenuAccessLogInterceptor.class.getName() + ".TRGET_CN"; + + /** TRGET_CN 컬럼 길이(VARCHAR(200)) 보호. */ + private static final int TRGET_CN_MAX = 200; + // ===================== 설정(주입) ===================== /** @@ -163,6 +193,30 @@ public class MenuAccessLogInterceptor extends HandlerInterceptorAdapter { public void setMenuAuthDAO(PrivacyMenuAuthDAO menuAuthDAO) { this.menuAuthDAO = menuAuthDAO; } public void setAccessLogQueue(AccessLogQueue accessLogQueue) { this.accessLogQueue = accessLogQueue; } + /** + * 처리 건수를 확보한다 — "몇 건의 개인정보를 열람·반출했는가". + * + *

서비스 AOP 시절에는 반환값이 {@code Collection}이면 크기를 셌다. 요청 인터셉터는 서비스 + * 반환값을 볼 수 없으므로, 컨트롤러가 모델에 담은 목록에서 대신 얻는다. 이 값이 없으면 + * 명단 조회·엑셀 다운로드가 "처리대상 미상"으로 남아 무엇을 반출했는지 확인할 수 없다.

+ * + *

{@code postHandle}은 앞선 인터셉터가 요청을 차단하면 호출되지 않는다. 그때는 건수가 + * 없는 채로 {@code afterCompletion}이 실패(F) 기록을 남긴다 — 차단된 요청에 건수가 없는 것이 옳다.

+ */ + @Override + public void postHandle(HttpServletRequest request, HttpServletResponse response, + Object handler, ModelAndView modelAndView) { + try { + String cn = resolveResultCount(modelAndView); + if (cn != null) { + request.setAttribute(ATTR_TRGET_CN, cn); + } + } catch (Throwable t) { + // 건수는 부가정보다 — 취득 실패가 접속기록 자체를 막지 않는다. + LOG.error("[개인정보/SFR-004] 처리 건수 취득 중 예외(무시)", t); + } + } + /** * 요청 종료 시점에 접속기록 1건을 남긴다. * 처리결과(성공/실패)를 판정해야 하므로 preHandle 이 아닌 afterCompletion 이다. @@ -558,8 +612,30 @@ public class MenuAccessLogInterceptor extends HandlerInterceptorAdapter { return null; } - /** 다건 선택(체크박스 등)일 때만 "N건"을 남긴다. 단건은 TRGET_ID 로 충분하다. */ + /** + * 처리대상 범위(TRGET_CN)를 만든다 — "몇 건을 다뤘는가". + * + *

두 출처를 합친다. + * ① 다건 선택(체크박스 파라미터가 여러 값) → "선택 N건" + * ② 모델 목록 건수({@link #postHandle}이 담아둔 값) → "조회 N건" / "조회 N건(전체 M건)" + * 둘 다 있으면 함께 남긴다. 단건 상세는 TRGET_ID 로 특정되므로 비어도 무방하다.

+ */ private String extractTrgetCn(HttpServletRequest request) { + String selected = selectedCount(request); + Object fromModel = request.getAttribute(ATTR_TRGET_CN); + String listCn = (fromModel instanceof String) ? (String) fromModel : null; + + if (selected != null && listCn != null) { + return cut(selected + ", " + listCn, TRGET_CN_MAX); + } + if (selected != null) { + return cut(selected, TRGET_CN_MAX); + } + return (listCn == null) ? null : cut(listCn, TRGET_CN_MAX); + } + + /** 체크박스 등으로 2건 이상을 한 번에 지정했을 때의 건수. 단건이면 null(TRGET_ID 로 충분). */ + private String selectedCount(HttpServletRequest request) { for (int i = 0; i < TRGET_ID_PARAMS.length; i++) { String[] values = request.getParameterValues(TRGET_ID_PARAMS[i]); if (values == null) { @@ -572,15 +648,77 @@ public class MenuAccessLogInterceptor extends HandlerInterceptorAdapter { } } if (count > 1) { - return count + "건"; + return "선택 " + count + "건"; } if (count == 1) { + // 단일 값이지만 콤마로 여러 ID를 이어 보내는 화면이 있다(checkedIdForDel 관용). + int commas = countChar(values[0], ','); + if (commas > 0) { + return "선택 " + (commas + 1) + "건"; + } return null; } } return null; } + private static int countChar(String s, char c) { + if (s == null) { + return 0; + } + int n = 0; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == c) { + n++; + } + } + return n; + } + + /** + * 모델에서 처리 건수를 뽑는다. + * 목록 크기(실제 열람·반출 건수)와 전체 검색 건수가 다르면 둘 다 남긴다 — + * "이 관리자가 10건을 봤고 검색 범위는 3,412건이었다"가 감사에서 의미가 다르기 때문이다. + */ + private String resolveResultCount(ModelAndView modelAndView) { + if (modelAndView == null) { + return null; + } + Map model = modelAndView.getModel(); + if (model == null || model.isEmpty()) { + return null; + } + + Integer listSize = null; + for (int i = 0; i < RESULT_LIST_ATTRS.length; i++) { + Object v = model.get(RESULT_LIST_ATTRS[i]); + if (v instanceof Collection) { + listSize = Integer.valueOf(((Collection) v).size()); + break; + } + } + + Integer total = null; + Object pi = model.get("paginationInfo"); + if (pi instanceof PaginationInfo) { + int t = ((PaginationInfo) pi).getTotalRecordCount(); + if (t > 0) { + total = Integer.valueOf(t); + } + } + + if (listSize == null && total == null) { + return null; + } + if (listSize == null) { + return "전체 " + total + "건"; + } + if (total == null || total.intValue() == listSize.intValue()) { + return "조회 " + listSize + "건"; + } + return "조회 " + listSize + "건(전체 " + total + "건)"; + } + // ===================== 유틸 ===================== private static boolean contains(String s, String token) { diff --git a/src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java b/src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java new file mode 100644 index 0000000..5923a1e --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java @@ -0,0 +1,164 @@ +package egovframework.com.cmm.privacy.interceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; + +import egovframework.com.cmm.privacy.policy.AccessControlPolicyService; +import egovframework.com.cmm.privacy.policy.AccessControlPolicyVO; + +/** + * 접근통제 세션 정책 인터셉터 — 개인정보보호 기능개선 사업(SER-001 요건 5, 자동 접속차단). + * + *

「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안의 + * '미사용 자동 로그아웃'을 web.xml 수정·재배포 없이 관리자 설정값으로 즉시 반영하는 지점이다. + * 2025.9 자료요청 3번 "시간 설정 기능 없음" 지적에 대응한다.

+ * + *

하는 일 두 가지 + *

    + *
  1. 관리자 화면({@code /mnt/}) 요청마다 로그인 세션의 + * {@code maxInactiveInterval} 을 정책값(ACCESS_CONTROL_POLICY.AUTO_LOGOUT_MIN)으로 맞춘다. + * web.xml 의 {@code } 은 세션 생성 시 기본값일 뿐이며, 여기서 덮어쓴 값이 + * 해당 세션의 실제 타임아웃이 된다.
  2. + *
  3. 로그인 시각({@value #ATTR_LOGIN_TIME})을 세션에 도장 찍는다. 관리자 상단(top.jsp)의 + * "로그인 후 경과시간" 표시가 이 값을 쓴다. + *

    로그인 경로가 여럿(actionLogin / EgovSpringSecurityLoginFilter / SSO 필터)이라 + * 각 경로를 고쳐 도장을 찍는 대신, 세션에 loginVO 가 생긴 뒤 첫 관리자 요청에서 + * 한 번만 찍는다. 로그인 직후 곧바로 관리자 화면으로 이동하므로 실제 오차는 1초 미만이고, + * 인증 경로를 건드리지 않아 회귀 위험이 없다.

  4. + *
+ * + *

적용 범위 — {@code applyPathPrefix}(기본 {@code /mnt/}) 이면서 세션에 loginVO 가 있을 때만 + * 동작한다. 무인증 프론트(frt) 세션은 건드리지 않아 일반회원 이용에 영향이 없다.

+ * + *

fail-safe — 정책 조회 실패는 화면 접근을 막지 않는다. 서비스가 기본값(30분)을 돌려주며, + * 서비스 미주입 시에는 아무 것도 하지 않고 통과한다(web.xml 기본값이 그대로 유효).

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.10    URP     최초 생성 (접근통제 정책 적용 — 자동 로그아웃 정책값화)
+ * 
+ */ +public class SessionPolicyInterceptor extends HandlerInterceptorAdapter { + + private static final Logger LOG = LoggerFactory.getLogger(SessionPolicyInterceptor.class); + + /** 로그인 시각(ms) 세션 키. top.jsp 경과시간 표시가 참조한다. */ + public static final String ATTR_LOGIN_TIME = "PRIVACY_LOGIN_TIME"; + + /** 적용 중인 자동 로그아웃 시간(초) 세션 키. top.jsp 카운트다운이 참조한다. */ + public static final String ATTR_AUTO_LOGOUT_SEC = "PRIVACY_AUTO_LOGOUT_SEC"; + + /** 만료 임박 안내 시점(초 전) 세션 키. 0이면 안내 미사용. */ + public static final String ATTR_EXPIRE_NOTICE_SEC = "PRIVACY_EXPIRE_NOTICE_SEC"; + + /** 로그인 정보 세션 키. */ + private static final String ATTR_LOGIN_VO = "loginVO"; + + /** 적용 경로 접두사. 기본 관리자 영역. */ + private String applyPathPrefix = "/mnt/"; + + private AccessControlPolicyService accessControlPolicyService; + + public void setApplyPathPrefix(String applyPathPrefix) { + this.applyPathPrefix = applyPathPrefix; + } + + public void setAccessControlPolicyService(AccessControlPolicyService accessControlPolicyService) { + this.accessControlPolicyService = accessControlPolicyService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + try { + String uri = request.getRequestURI(); + String ctx = request.getContextPath(); + if (ctx != null && ctx.length() > 0 && uri.startsWith(ctx)) { + uri = uri.substring(ctx.length()); + } + if (applyPathPrefix != null && !uri.startsWith(applyPathPrefix)) { + return true; + } + + // 세션을 새로 만들지 않는다 — 무인증 요청까지 세션을 생성하면 불필요한 부하가 된다. + HttpSession session = request.getSession(false); + if (session == null || session.getAttribute(ATTR_LOGIN_VO) == null) { + return true; + } + + // 로그인 시각 도장 — 로그인 후 최초 1회만. + if (session.getAttribute(ATTR_LOGIN_TIME) == null) { + session.setAttribute(ATTR_LOGIN_TIME, Long.valueOf(System.currentTimeMillis())); + } + + // 자동 로그아웃 시간 적용(정책값). 미주입 시 web.xml 기본값 유지. + if (accessControlPolicyService != null) { + AccessControlPolicyVO policy = accessControlPolicyService.getCachedPolicy(); + int timeoutSec = policy.getAutoLogoutSec(); + if (timeoutSec > 0 && session.getMaxInactiveInterval() != timeoutSec) { + session.setMaxInactiveInterval(timeoutSec); + } + // 화면(top.jsp)이 스크립틀릿·별도 조회 없이 JSTL 로 읽어 쓰도록 세션에 실어둔다. + session.setAttribute(ATTR_AUTO_LOGOUT_SEC, Integer.valueOf(session.getMaxInactiveInterval())); + session.setAttribute(ATTR_EXPIRE_NOTICE_SEC, + Integer.valueOf(policy.isExpireNoticeUse() ? policy.getExpireNoticeMin() * 60 : 0)); + } else { + session.setAttribute(ATTR_AUTO_LOGOUT_SEC, Integer.valueOf(session.getMaxInactiveInterval())); + session.setAttribute(ATTR_EXPIRE_NOTICE_SEC, Integer.valueOf(0)); + } + } catch (Exception e) { + // 세션 정책 적용 실패가 화면 진입을 막아서는 안 된다. + LOG.error("[개인정보/접근통제] 세션 정책 적용 오류 - 요청은 계속 진행: {}", e.toString()); + } + return true; + } + + /** + * 로그인 후 경과 시간(초). 도장이 없으면 세션 생성시각을 대신 쓴다. + * + * @param session 현재 세션(널이면 0) + * @return 경과 초(음수가 나오지 않도록 0 하한) + */ + public static long elapsedLoginSec(HttpSession session) { + if (session == null) { + return 0L; + } + long base; + Object stamped = session.getAttribute(ATTR_LOGIN_TIME); + if (stamped instanceof Long) { + base = ((Long) stamped).longValue(); + } else { + base = session.getCreationTime(); + } + long elapsed = (System.currentTimeMillis() - base) / 1000L; + return elapsed < 0L ? 0L : elapsed; + } + + /** + * 자동 로그아웃까지 남은 시간(초). + * + *

세션 타임아웃은 마지막 접근시각부터 흐른다. 이 메서드는 화면 렌더 시점 기준의 + * 잔여시간을 돌려주며, 화면 타이머는 이 값에서 카운트다운한다.

+ * + * @param session 현재 세션(널이거나 타임아웃 미설정이면 0) + * @return 잔여 초(0 하한) + */ + public static long remainSec(HttpSession session) { + if (session == null) { + return 0L; + } + int timeoutSec = session.getMaxInactiveInterval(); + if (timeoutSec <= 0) { + return 0L; + } + long idle = (System.currentTimeMillis() - session.getLastAccessedTime()) / 1000L; + long remain = timeoutSec - idle; + return remain < 0L ? 0L : remain; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyController.java b/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyController.java new file mode 100644 index 0000000..e5f2bfc --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyController.java @@ -0,0 +1,253 @@ +package egovframework.com.cmm.privacy.policy; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; + +import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.privacy.interceptor.SessionPolicyInterceptor; +import egovframework.com.cmm.util.EgovUserDetailsHelper; + +/** + * 접근통제 정책 설정 컨트롤러 (SER-001 요건 4·5). + * + *

「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안의 + * 접근통제 정책 설정 화면을 담당한다.

+ * + *
    + *
  • {@code /mnt/sec/accessPolicy/policyView.do} — 정책 설정 화면(서버 렌더링)
  • + *
  • {@code /mnt/privacy/accessPolicyUpdate.do} — 정책 저장(AJAX/JSON)
  • + *
  • {@code /mnt/session/extend.do} — 세션 연장(만료 임박 안내에서 호출)
  • + *
  • {@code /mnt/session/status.do} — 로그인 경과/잔여시간 조회(상단 표시 갱신용)
  • + *
+ * + *

경로 선정 근거 — 세션 연장·상태 조회는 전체관리자 전용이 아니라 + * 로그인한 모든 취급자가 써야 한다. {@code /mnt/privacy/*}·{@code /mnt/sec/*} 는 + * AuthorityInterceptor 에서 ROLE_ADMIN 전용으로 매핑돼 있어 시도·시군구 관리자가 403 을 + * 맞으므로, 별도 대역 {@code /mnt/session/} 을 쓰고 두 판정 인터셉터의 예외에 등록한다.

+ * + *
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.10    URP     최초 생성 (접근통제 정책 적용 화면)
+ * 
+ */ +@Controller +public class AccessControlPolicyController { + + private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; + + @Resource(name = "accessControlPolicyService") + private AccessControlPolicyService accessControlPolicyService; + + // ────────────────────────────────────────────────────────── 정책 화면 + + /** + * 접근통제 정책 설정 화면. + * 뷰: /WEB-INF/jsp/site/mnt/sec/accessPolicy/policy.jsp + */ + @RequestMapping(value = "/mnt/sec/accessPolicy/policyView.do") + public String accessPolicyView(ModelMap model) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + model.addAttribute("policy", accessControlPolicyService.selectPolicy()); + return "site/mnt/sec/accessPolicy/policy"; + } + + /** 접근통제 정책 저장(AJAX). */ + @RequestMapping(value = "/mnt/privacy/accessPolicyUpdate.do") + public String updateAccessPolicy(@ModelAttribute("policy") AccessControlPolicyVO policy, + HttpServletRequest request, ModelMap model) throws Exception { + LoginVO loginVO = handlerUser(); + if (loginVO == null) { + return denyJson(model); + } + if (!isSameOrigin(request)) { + return failJson(model, "잘못된 요청 경로입니다."); + } + + String invalid = validate(policy); + if (invalid != null) { + return failJson(model, invalid); + } + + policy.setUpdId(loginVO.getId()); + accessControlPolicyService.updatePolicy(policy); + + // 저장한 관리자 본인의 현재 세션에도 즉시 반영(다음 요청을 기다리지 않는다). + HttpSession session = request.getSession(false); + if (session != null) { + session.setMaxInactiveInterval(policy.getAutoLogoutSec()); + } + + model.addAttribute("result", "success"); + return "jsonView"; + } + + /** + * 화면 우회 입력(직접 POST)에 대비한 서버측 범위 검증. 위반 시 사용자 메시지 반환. + * + *

상한을 두는 이유: 자동 로그아웃을 사실상 무한대로 늘리거나 잠금 임계값을 크게 잡아 + * 안전성 확보조치 기준 제6조를 우회하는 설정을 막는다.

+ */ + private String validate(AccessControlPolicyVO p) { + if (p.getLockThreshold() < 1 || p.getLockThreshold() > 10) { + return "인증 실패 계정 잠금 횟수는 1~10회 범위로 입력하세요. (기준 권고 5회)"; + } + if (!AccessControlPolicyVO.UNLOCK_MODE_HIST.equals(p.getUnlockMode()) + && !AccessControlPolicyVO.UNLOCK_MODE_HIST_RESN.equals(p.getUnlockMode())) { + return "잠금 해제 방식 값이 올바르지 않습니다."; + } + if (p.getAutoLogoutMin() < 5 || p.getAutoLogoutMin() > 120) { + return "미사용 자동 로그아웃 시간은 5~120분 범위로 입력하세요. (기준 권고 30분)"; + } + // 체크박스 미전송 시 null → 'N' 보정 + p.setExpireNoticeYn(normalizeYn(p.getExpireNoticeYn())); + p.setSessionRegenYn(normalizeYn(p.getSessionRegenYn())); + if (p.getExpireNoticeYn() == null) { + return "만료 임박 안내 사용여부 값이 올바르지 않습니다."; + } + if (p.getSessionRegenYn() == null) { + return "세션 재발급 사용여부 값이 올바르지 않습니다."; + } + // 안내가 꺼져 있으면 시점값을 검증하지 않는다(화면에서도 비활성 입력이라 과거값이 남아 있을 수 있다). + if ("Y".equals(p.getExpireNoticeYn()) + && (p.getExpireNoticeMin() < 1 || p.getExpireNoticeMin() >= p.getAutoLogoutMin())) { + return "만료 임박 안내 시점은 1분 이상이며 자동 로그아웃 시간보다 작아야 합니다."; + } + return null; + } + + /** 'Y'/'N' 이외(널·공백 포함)는 'N'. 값이 있으나 Y/N 이 아니면 null(=오류). */ + private String normalizeYn(String value) { + if (value == null || value.trim().length() == 0) { + return "N"; + } + String v = value.trim(); + if ("Y".equals(v) || "N".equals(v)) { + return v; + } + return null; + } + + // ────────────────────────────────────────────────────────── 세션 상태·연장 + + /** + * 세션 상태 조회(JSON). 관리자 상단의 로그인 경과시간·자동 로그아웃 잔여시간 표시가 + * 페이지 이동 없이 갱신할 때 쓴다. + * + *

주의 — 이 요청 자체가 세션을 갱신(lastAccessedTime 초기화)하므로, + * 화면 타이머는 이 엔드포인트를 주기 폴링하지 않는다. 그렇게 하면 "미사용 자동 + * 로그아웃"이 영원히 오지 않는다. 페이지 진입 시 1회, 그리고 사용자가 연장을 선택했을 때만 + * 호출한다.

+ */ + @RequestMapping(value = "/mnt/session/status.do") + public String sessionStatus(HttpServletRequest request, ModelMap model) throws Exception { + HttpSession session = request.getSession(false); + if (session == null || handlerUser() == null) { + model.addAttribute("result", "expired"); + return "jsonView"; + } + model.addAttribute("result", "success"); + model.addAttribute("elapsedSec", Long.valueOf(SessionPolicyInterceptor.elapsedLoginSec(session))); + model.addAttribute("timeoutSec", Integer.valueOf(session.getMaxInactiveInterval())); + return "jsonView"; + } + + /** + * 세션 연장(JSON). 만료 임박 안내에서 사용자가 '연장'을 선택하면 호출된다. + * + *

연장이라 해도 별도 처리는 필요 없다 — 이 요청이 컨테이너에 도달한 순간 + * lastAccessedTime 이 갱신되어 타임아웃이 처음부터 다시 흐른다. 응답으로 갱신된 + * 잔여시간을 돌려주어 화면 타이머를 재동기화한다.

+ */ + @RequestMapping(value = "/mnt/session/extend.do") + public String sessionExtend(HttpServletRequest request, ModelMap model) throws Exception { + HttpSession session = request.getSession(false); + if (session == null || handlerUser() == null) { + model.addAttribute("result", "expired"); + return "jsonView"; + } + // 정책값을 다시 적용해 두면 연장 직후부터 최신 설정이 반영된다. + session.setMaxInactiveInterval(accessControlPolicyService.getCachedPolicy().getAutoLogoutSec()); + + model.addAttribute("result", "success"); + model.addAttribute("elapsedSec", Long.valueOf(SessionPolicyInterceptor.elapsedLoginSec(session))); + model.addAttribute("timeoutSec", Integer.valueOf(session.getMaxInactiveInterval())); + return "jsonView"; + } + + // ────────────────────────────────────────────────────────── 공통 + + /** + * 취급자 영역(/mnt) 인증·구분 판정. 인터셉터 3단만으로는 무인증 노출 가능성이 있어 + * 컨트롤러 단에서 직접 판정한다(AccountMngController 와 동일 규약). + * + * @return 인증된 업무사용자(USR) LoginVO, 그 외에는 null + */ + private LoginVO handlerUser() { + if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) { + return null; + } + Object o = EgovUserDetailsHelper.getAuthenticatedUser(); + if (!(o instanceof LoginVO)) { + return null; + } + LoginVO loginVO = (LoginVO) o; + return "USR".equals(loginVO.getUserSe()) ? loginVO : null; + } + + /** + * CSRF 부분 완화. Spring Security 2.0.4 에는 CSRF 토큰 기능이 없으므로, + * Origin/Referer 가 전달된 경우에 한해 요청 호스트와 동일한지 확인한다. + */ + private boolean isSameOrigin(HttpServletRequest request) { + String source = request.getHeader("Origin"); + if (source == null) { + source = request.getHeader("Referer"); + } + if (source == null) { + return true; + } + String sourceHost = hostOf(source); + String requestHost = hostOf(request.getHeader("Host")); + return sourceHost != null && sourceHost.equalsIgnoreCase(requestHost); + } + + /** "https://host:8080/path" · "host:8080" 등에서 호스트명만 추출. */ + private String hostOf(String value) { + if (value == null) { + return null; + } + String host = value; + int scheme = host.indexOf("://"); + if (scheme >= 0) { + host = host.substring(scheme + 3); + } + int slash = host.indexOf('/'); + if (slash >= 0) { + host = host.substring(0, slash); + } + int colon = host.indexOf(':'); + if (colon >= 0) { + host = host.substring(0, colon); + } + return host.length() == 0 ? null : host; + } + + private String denyJson(ModelMap model) { + return failJson(model, "접근 권한이 없습니다. 관리자 계정으로 로그인 후 이용하세요."); + } + + private String failJson(ModelMap model, String message) { + model.addAttribute("result", "fail"); + model.addAttribute("message", message); + return "jsonView"; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyDAO.java b/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyDAO.java new file mode 100644 index 0000000..d18f466 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyDAO.java @@ -0,0 +1,45 @@ +package egovframework.com.cmm.privacy.policy; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 접근통제 정책 DAO (SER-001 요건 4·5). + * + *

statement 는 {@code PrivacyAccessControlPolicy_SQL_Mysql.xml} + * (namespace {@code AccessControlPolicyDAO}), sql-map-config 등록.

+ * + *

루트 컨텍스트 {@code @Repository} 빈이므로 자식(servlet) 컨텍스트의 + * 인터셉터에서도 참조할 수 있다.

+ */ +@Repository("accessControlPolicyDAO") +public class AccessControlPolicyDAO extends EgovComAbstractDAO { + + /** 정책 단일행 조회. 없으면 null(호출부 기본값 fallback). */ + public AccessControlPolicyVO selectPolicy() { + return (AccessControlPolicyVO) selectByPk("AccessControlPolicyDAO.selectPolicy", null); + } + + /** 정책 수정. */ + public int updatePolicy(AccessControlPolicyVO vo) { + return update("AccessControlPolicyDAO.updatePolicy", vo); + } + + /** + * 정책 잠금 임계값을 인증 경로가 읽는 LOGIN_LOCK_COUNT 에 동기화한다. + * + *

인증 실패 잠금 판정은 기존 자산({@code loginDAO.selectFailTotalCount} → LOGIN_LOCK_COUNT)을 + * 그대로 쓰므로, 정책 저장 시 이 한 줄이 있어야 "설정 즉시 활성 인증 경로에 적용"이 성립한다.

+ * + * @return 갱신 행 수(0이면 LOGIN_LOCK_COUNT 가 비어 있는 것 — 호출부에서 INSERT 보정) + */ + public int updateLoginLockCount(AccessControlPolicyVO vo) { + return update("AccessControlPolicyDAO.updateLoginLockCount", vo); + } + + /** LOGIN_LOCK_COUNT 가 비어 있을 때 임계값 1행을 생성한다. */ + public void insertLoginLockCount(AccessControlPolicyVO vo) { + insert("AccessControlPolicyDAO.insertLoginLockCount", vo); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyService.java b/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyService.java new file mode 100644 index 0000000..4a43278 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyService.java @@ -0,0 +1,23 @@ +package egovframework.com.cmm.privacy.policy; + +/** + * 접근통제 정책 서비스 (SER-001 요건 4·5). + * + *

계정 잠금 임계값·자동 로그아웃 시간·세션 재발급 여부의 조회/수정. + * 인터셉터·인증 컨트롤러가 요청마다 호출하므로 조회는 짧은 TTL 캐시를 둔다.

+ */ +public interface AccessControlPolicyService { + + /** 현재 정책 조회(미설정 시 기본값 VO 반환). DB 직접 조회 — 관리자 화면용. */ + AccessControlPolicyVO selectPolicy() throws Exception; + + /** + * 현재 정책 조회(TTL 캐시). 인터셉터·인증 경로 등 요청마다 호출되는 지점 전용. + * + *

조회 실패 시에도 예외를 던지지 않고 기본값 VO 를 반환한다(사이트 잠금 방지).

+ */ + AccessControlPolicyVO getCachedPolicy(); + + /** 정책 수정 + LOGIN_LOCK_COUNT 동기화 + 캐시 무효화. */ + void updatePolicy(AccessControlPolicyVO vo) throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyServiceImpl.java new file mode 100644 index 0000000..c2f6a9c --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyServiceImpl.java @@ -0,0 +1,86 @@ +package egovframework.com.cmm.privacy.policy; + +import javax.annotation.Resource; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.stereotype.Service; + +/** + * 접근통제 정책 서비스 구현 (SER-001 요건 4·5). + * + *

캐시 — {@code SessionPolicyInterceptor} 가 관리자 화면 요청마다 정책을 읽으므로 + * 매 요청 DB 조회를 피하기 위해 짧은 TTL(기본 60초) 캐시를 둔다. 저장 시에는 즉시 무효화하여 + * "설정 저장 → 즉시 적용"을 보장한다(시안의 '설정 저장·즉시 적용').

+ * + *

fail-safe — 정책 조회 실패(테이블 미생성·DB 오류)는 관리자 화면 접근 자체를 + * 막으면 안 된다. {@link #getCachedPolicy()} 는 예외를 삼키고 기본값 VO(잠금 5회·30분)를 + * 반환한다. 기본값은 강화된 정책값이라 fail-open 이 아니다.

+ * + *
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.10    URP     최초 생성 (접근통제 정책 적용 화면)
+ * 
+ */ +@Service("accessControlPolicyService") +public class AccessControlPolicyServiceImpl implements AccessControlPolicyService { + + private static final Log LOG = LogFactory.getLog(AccessControlPolicyServiceImpl.class); + + /** 캐시 수명(ms). 저장 시 즉시 무효화되므로 관리자 체감 지연은 없다. */ + private static final long CACHE_TTL_MS = 60 * 1000L; + + @Resource(name = "accessControlPolicyDAO") + private AccessControlPolicyDAO accessControlPolicyDAO; + + /** 캐시된 정책. volatile 참조 교체만 하므로 락 없이 안전(읽기 다수·쓰기 소수). */ + private volatile AccessControlPolicyVO cached; + private volatile long cachedAt; + + @Override + public AccessControlPolicyVO selectPolicy() throws Exception { + AccessControlPolicyVO vo = accessControlPolicyDAO.selectPolicy(); + // 정책 미설정(시드 미적용) 시 기본값 — 화면이 빈 값으로 뜨지 않게 한다. + return vo != null ? vo : new AccessControlPolicyVO(); + } + + @Override + public AccessControlPolicyVO getCachedPolicy() { + AccessControlPolicyVO snapshot = cached; + if (snapshot != null && (System.currentTimeMillis() - cachedAt) < CACHE_TTL_MS) { + return snapshot; + } + try { + AccessControlPolicyVO vo = accessControlPolicyDAO.selectPolicy(); + if (vo == null) { + vo = new AccessControlPolicyVO(); + } + cached = vo; + cachedAt = System.currentTimeMillis(); + return vo; + } catch (Exception e) { + // 조회 실패 — 관리자 화면을 막지 않는다. 기본값(잠금 5회·30분)으로 계속 진행. + LOG.error("[개인정보/접근통제] 정책 조회 실패 — 기본값 적용: " + e.getMessage()); + AccessControlPolicyVO fallback = snapshot != null ? snapshot : new AccessControlPolicyVO(); + cached = fallback; + cachedAt = System.currentTimeMillis(); + return fallback; + } + } + + @Override + public void updatePolicy(AccessControlPolicyVO vo) throws Exception { + accessControlPolicyDAO.updatePolicy(vo); + + // 잠금 임계값은 인증 경로가 LOGIN_LOCK_COUNT 를 읽는다 — 같은 트랜잭션에서 동기화해야 + // '저장 즉시 활성 인증 경로에 적용'이 성립한다. 행이 없으면 생성한다. + if (accessControlPolicyDAO.updateLoginLockCount(vo) == 0) { + accessControlPolicyDAO.insertLoginLockCount(vo); + } + + // 캐시 무효화 — 다음 요청부터 새 정책(자동 로그아웃 시간 등)이 적용된다. + cached = null; + cachedAt = 0L; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyVO.java b/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyVO.java new file mode 100644 index 0000000..48a8dda --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyVO.java @@ -0,0 +1,92 @@ +package egovframework.com.cmm.privacy.policy; + +import java.io.Serializable; + +/** + * 접근통제 정책 VO — ACCESS_CONTROL_POLICY(단일행) 매핑. + * + *

문화품앗이 개인정보보호 기능개선 사업 — 접근통제 정책 적용(SER-001 요건 4·5). + * 「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안 반영.

+ * + *

계정 잠금 임계값·자동 로그아웃 시간·세션 재발급 여부를 소스/web.xml 하드코딩이 아니라 + * 관리자 설정값으로 운영하기 위한 정책 값 묶음이다. 필드 기본값은 정책 미설정(테이블 empty) + * 상황에서도 화면·인터셉터가 안전하게 동작하도록 시안 기준값과 동일하게 둔다.

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.10    URP     최초 생성 (접근통제 정책 적용 화면)
+ * 
+ */ +public class AccessControlPolicyVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 잠금 해제 방식 — 관리자 화면 해제 + 해제 이력 적재. */ + public static final String UNLOCK_MODE_HIST = "ADMIN_HIST"; + /** 잠금 해제 방식 — 위 + 해제 사유 필수 입력. */ + public static final String UNLOCK_MODE_HIST_RESN = "ADMIN_HIST_RESN"; + + private int policyId = 1; + + /** 인증 실패 계정 잠금 임계값(회). 현행 10 → 5. */ + private int lockThreshold = 5; + + /** 잠금 해제 방식(ADMIN_HIST / ADMIN_HIST_RESN). */ + private String unlockMode = UNLOCK_MODE_HIST; + + /** 미사용 자동 로그아웃(분). 현행 web.xml 60 → 30. */ + private int autoLogoutMin = 30; + + /** 세션 만료 임박 안내 사용여부(Y/N). */ + private String expireNoticeYn = "Y"; + + /** 만료 임박 안내 시점(만료 N분 전). */ + private int expireNoticeMin = 5; + + /** 2차 인증 성공 시 세션 재발급(세션고정 차단) 사용여부(Y/N). */ + private String sessionRegenYn = "Y"; + + private String updId; + private String updDt; + + /** 자동 로그아웃 시간(초). 인터셉터가 setMaxInactiveInterval 에 그대로 사용한다. */ + public int getAutoLogoutSec() { + return autoLogoutMin * 60; + } + + /** 만료 임박 안내 사용 여부. */ + public boolean isExpireNoticeUse() { + return "Y".equals(expireNoticeYn); + } + + /** 세션 재발급 사용 여부. */ + public boolean isSessionRegenUse() { + return "Y".equals(sessionRegenYn); + } + + /** 잠금 해제 시 사유 필수 여부. */ + public boolean isUnlockResnRequired() { + return UNLOCK_MODE_HIST_RESN.equals(unlockMode); + } + + public int getPolicyId() { return policyId; } + public void setPolicyId(int policyId) { this.policyId = policyId; } + public int getLockThreshold() { return lockThreshold; } + public void setLockThreshold(int lockThreshold) { this.lockThreshold = lockThreshold; } + public String getUnlockMode() { return unlockMode; } + public void setUnlockMode(String unlockMode) { this.unlockMode = unlockMode; } + public int getAutoLogoutMin() { return autoLogoutMin; } + public void setAutoLogoutMin(int autoLogoutMin) { this.autoLogoutMin = autoLogoutMin; } + public String getExpireNoticeYn() { return expireNoticeYn; } + public void setExpireNoticeYn(String expireNoticeYn) { this.expireNoticeYn = expireNoticeYn; } + public int getExpireNoticeMin() { return expireNoticeMin; } + public void setExpireNoticeMin(int expireNoticeMin) { this.expireNoticeMin = expireNoticeMin; } + public String getSessionRegenYn() { return sessionRegenYn; } + public void setSessionRegenYn(String sessionRegenYn) { this.sessionRegenYn = sessionRegenYn; } + public String getUpdId() { return updId; } + public void setUpdId(String updId) { this.updId = updId; } + public String getUpdDt() { return updDt; } + public void setUpdDt(String updDt) { this.updDt = updDt; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/security/SessionRegenerator.java b/src/main/java/egovframework/com/cmm/privacy/security/SessionRegenerator.java index eff6746..9de7b58 100644 --- a/src/main/java/egovframework/com/cmm/privacy/security/SessionRegenerator.java +++ b/src/main/java/egovframework/com/cmm/privacy/security/SessionRegenerator.java @@ -90,4 +90,23 @@ public final class SessionRegenerator { } return newSession; } + + /** + * 세션 재발급 없이 2차 인증 완료 표식만 세팅한다. + * + *

접근통제 정책(SER-001 요건4·5)에서 세션 재발급 '미사용'을 선택한 경우에 쓴다. + * 표식까지 빠지면 {@code SecondFactorInterceptor} 가 인증을 통과시키지 않아 무한 리다이렉트가 + * 되므로, 재발급을 생략하더라도 표식은 반드시 남긴다.

+ * + * @param request HTTP 요청 + * @return 표식이 세팅된 현재 세션(요청이 널이면 null) + */ + public static HttpSession markSecondFactorDone(HttpServletRequest request) { + if (request == null) { + return null; + } + HttpSession session = request.getSession(true); + session.setAttribute(SECOND_FACTOR_DONE, Boolean.TRUE); + return session; + } } diff --git a/src/main/java/egovframework/com/sym/log/clg/service/EgovLoginLogAspect.java b/src/main/java/egovframework/com/sym/log/clg/service/EgovLoginLogAspect.java index 8121334..e73ceeb 100644 --- a/src/main/java/egovframework/com/sym/log/clg/service/EgovLoginLogAspect.java +++ b/src/main/java/egovframework/com/sym/log/clg/service/EgovLoginLogAspect.java @@ -1,8 +1,18 @@ package egovframework.com.sym.log.clg.service; import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.privacy.auditlog.ClientIpResolver; +import egovframework.com.cmm.privacy.conect.IpLocationResolver; import egovframework.com.cmm.util.EgovUserDetailsHelper; /** @@ -13,7 +23,26 @@ import egovframework.com.cmm.util.EgovUserDetailsHelper; * 수정일 수정자 수정내용 * ------- ------- ------------------- * 2009. 3. 11. 이삼섭 최초생성 - * 2011. 7. 01. 이기하 패키지 분리(sym.log -> sym.log.clg) + * 2011. 7. 01. 이기하 패키지 분리(sym.log -> sym.log.clg) + * 2026. 8. 07. URP 개인정보보호 #62 — 접속IP 유실 복구 + 접속위치 기록 + * + *

[2026-08-07 접속IP 유실 복구] 종전에는 세션 {@code LoginVO.getIp()} 만 읽었는데 + * 이 값이 실제로 채워지지 않아 {@code COMTNLOGINLOG.CONECT_IP} 가 전 건(373,959행) NULL + * 이었다(2026-08-07 운영 덤프 실측). 이제 현재 요청에서 + * {@link ClientIpResolver}(#34, X-Forwarded-For 보정 + 신뢰 프록시 검증)로 직접 취득하고, + * 요청 컨텍스트를 얻지 못하는 경우에만 세션 값으로 폴백한다.

+ * + *

[2026-08-07 접속ID 복구] {@code CONECT_ID} 는 고유식별자 자리인데 실제 적재값이 + * 16자리 hex 라 회원 테이블 4종 어디와도 매칭되지 않아(369,385행 실측) 접속자를 특정할 수 + * 없었다. 세션 {@code LoginVO.getId()}(로그인 계정ID)와 {@code getUserSe()} 를 + * {@code CONECT_ACNT_ID} / {@code CONECT_USER_SE} 에 직접 적재한다.

+ * + *

[접속위치] 취득한 IP 를 {@link IpLocationResolver} 로 판정해 + * {@code CONECT_LC} 에 적재 시점 스냅샷으로 남긴다(나중에 대역 매핑을 바꿔도 + * 과거 기록은 불변 — 감사증빙).

+ * + *

fail-safe: IP·위치 취득은 로그인/로그아웃 경로에서 실행된다. + * 어떤 예외도 밖으로 던지지 않는다 — 접속기록 때문에 로그인이 막히면 안 된다.

* * @author 공통 서비스 개발팀 이삼섭 * @since 2009. 3. 11. @@ -22,69 +51,131 @@ import egovframework.com.cmm.util.EgovUserDetailsHelper; * */ public class EgovLoginLogAspect { - + + private static final Log LOG = LogFactory.getLog(EgovLoginLogAspect.class); + @Resource(name="EgovLoginLogService") private EgovLoginLogService loginLogService; + /** + * 접속위치 판정기(개인정보보호 #62). + * + *

일부러 {@code @Resource} 가 아니라 {@code required=false} 로 받는다. 이 애스펙트는 + * DispatcherServlet 컨텍스트의 {@code } 이라 필수 주입으로 걸어두면 스캔 범위가 + * 어긋난 배포본에서 컨텍스트 기동 자체가 실패한다. 접속위치는 부가 정보이므로 + * 미주입 시 그 칸만 비우고 로그인·접속기록 적재는 계속되게 한다.

+ */ + @Autowired(required = false) + private IpLocationResolver ipLocationResolver; + /** * 로그인 로그정보를 생성한다. * EgovLoginController.actionMain Method - * - * @param + * + * @param * @return void - * @throws Exception + * @throws Exception */ public void logLogin() throws Throwable { - - String uniqId = ""; - String ip = ""; - - /* Authenticated */ - Boolean isAuthenticated = EgovUserDetailsHelper.isAuthenticated(); - if(isAuthenticated.booleanValue()) { - LoginVO user = (LoginVO)EgovUserDetailsHelper.getAuthenticatedUser(); - uniqId = user.getUniqId(); - ip = user.getIp(); - } - - LoginLog loginLog = new LoginLog(); - loginLog.setLoginId(uniqId); - loginLog.setLoginIp(ip); - loginLog.setLoginMthd("I"); // 로그인:I, 로그아웃:O - loginLog.setErrOccrrAt("N"); - loginLog.setErrorCode(""); - loginLogService.logInsertLoginLog(loginLog); - + writeLog("I"); } - + /** * 로그아웃 로그정보를 생성한다. * EgovLoginController.actionLogout Method - * - * @param + * + * @param * @return void - * @throws Exception + * @throws Exception */ public void logLogout() throws Throwable { - + writeLog("O"); + } + + /** + * 접속기록 1건 적재. + * + * @param mthd 접속구분 I:로그인 O:로그아웃 + */ + private void writeLog(String mthd) throws Exception { + String uniqId = ""; - String ip = ""; + String acntId = null; + String userSe = null; + String sessionIp = null; /* Authenticated */ - Boolean isAuthenticated = EgovUserDetailsHelper.isAuthenticated(); - if(isAuthenticated.booleanValue()) { - LoginVO user = (LoginVO)EgovUserDetailsHelper.getAuthenticatedUser(); + Boolean isAuthenticated = EgovUserDetailsHelper.isAuthenticated(); + if (isAuthenticated.booleanValue()) { + LoginVO user = (LoginVO) EgovUserDetailsHelper.getAuthenticatedUser(); uniqId = user.getUniqId(); - ip = user.getIp(); - } + acntId = user.getId(); // 로그인 계정ID — 접속기록의 '접속ID' + userSe = user.getUserSe(); // USR/GNR/GRO/ENT + sessionIp = user.getIp(); + } - LoginLog loginLog = new LoginLog(); - loginLog.setLoginId(uniqId); - loginLog.setLoginIp(ip); - loginLog.setLoginMthd("O"); // 로그인:I, 로그아웃:O - loginLog.setErrOccrrAt("N"); - loginLog.setErrorCode(""); - loginLogService.logInsertLoginLog(loginLog); + String ip = resolveIp(sessionIp); + + LoginLog loginLog = new LoginLog(); + loginLog.setLoginId(uniqId); + loginLog.setConectAcntId(acntId); + loginLog.setConectUserSe(userSe); + loginLog.setLoginIp(ip); + loginLog.setConectLc(resolveLocation(ip)); + loginLog.setLoginMthd(mthd); // 로그인:I, 로그아웃:O + loginLog.setErrOccrrAt("N"); + loginLog.setErrorCode(""); + + // ============================================================ + // [2026-08-10] 적재 실패를 삼킨다 — 단, 조용히 삼키지는 않는다. + // 이 메서드는 actionMain(로그인)·actionLogout(로그아웃)의 @Before 어드바이스라 + // 여기서 예외가 나가면 로그인/로그아웃 자체가 500 으로 죽는다. 실제로 로컬에서 + // 'Unknown system variable tx_isolation'(MySQL 8 + mysql-connector 5.1.17 조합) + // 때문에 로그인·로그아웃이 통째로 막혔다. 접속기록은 부가 기록이고 인증 흐름이 + // 본질이므로, 적재 실패가 인증을 막지 않게 한다(클래스 Javadoc 의 fail-safe 명세와 일치). + // + // ※ 다만 접속기록 유실은 SFR-004(접속기록 보관) 위반으로 직결되므로 ERROR 로 남긴다. + // 이 로그가 보이면 접속기록이 쌓이지 않고 있다는 뜻이니 원인(드라이버 등)을 반드시 해결할 것. + // ============================================================ + try { + loginLogService.logInsertLoginLog(loginLog); + } catch (Exception e) { + LOG.error("[접속기록/SFR-004] 로그인 로그 적재 실패 — 인증 흐름은 계속 진행." + + " mthd=" + mthd + ", acntId=" + acntId + ", ip=" + ip + " / 원인=" + e, e); + } + } + + /** + * 현재 요청에서 접속 IP 를 취득한다. 요청 컨텍스트가 없으면 세션 값으로 폴백. + * + * @param sessionIp 세션 LoginVO 의 IP(없을 수 있음) + * @return 접속 IP(확정 불가 시 null) + */ + private String resolveIp(String sessionIp) { + try { + RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); + if (attrs instanceof ServletRequestAttributes) { + HttpServletRequest request = ((ServletRequestAttributes) attrs).getRequest(); + String ip = ClientIpResolver.getClientIp(request); + if (ip != null && ip.length() > 0 && !"unknown".equalsIgnoreCase(ip)) { + return ip; + } + } + } catch (RuntimeException e) { + // 요청 컨텍스트 취득 실패 — 세션 값으로 폴백한다. + } + return sessionIp; + } + + /** 접속위치 판정(fail-safe — 실패 시 null 로 두고 적재는 계속한다). */ + private String resolveLocation(String ip) { + if (ipLocationResolver == null) { + return null; + } + try { + return ipLocationResolver.resolve(ip); + } catch (RuntimeException e) { + return null; + } } - } diff --git a/src/main/java/egovframework/com/sym/log/clg/service/LoginLog.java b/src/main/java/egovframework/com/sym/log/clg/service/LoginLog.java index 2cd95ea..594e90c 100644 --- a/src/main/java/egovframework/com/sym/log/clg/service/LoginLog.java +++ b/src/main/java/egovframework/com/sym/log/clg/service/LoginLog.java @@ -33,6 +33,17 @@ public class LoginLog implements Serializable { /** 접속IP */ private String loginIp; + /** 접속위치명 (개인정보보호 #62 — COMTNLOGINLOG.CONECT_LC) */ + private String conectLc; + + /** 접속 계정ID = 로그인 ID (개인정보보호 #62 — COMTNLOGINLOG.CONECT_ACNT_ID). + * loginId(=CONECT_ID)는 고유식별자 자리인데 실제 적재값으로 접속자를 특정할 수 없어 + * 계정ID를 별도 컬럼에 직접 남긴다. */ + private String conectAcntId; + + /** 사용자구분 USR/GNR/GRO/ENT (개인정보보호 #62 — COMTNLOGINLOG.CONECT_USER_SE) */ + private String conectUserSe; + /** 접속방식 */ private String loginMthd; @@ -169,6 +180,33 @@ public class LoginLog implements Serializable { this.creatDt = creatDt; } + /** 접속위치명 (개인정보보호 #62) */ + public String getConectLc() { + return conectLc; + } + + public void setConectLc(String conectLc) { + this.conectLc = conectLc; + } + + /** 접속 계정ID (개인정보보호 #62) */ + public String getConectAcntId() { + return conectAcntId; + } + + public void setConectAcntId(String conectAcntId) { + this.conectAcntId = conectAcntId; + } + + /** 사용자구분 (개인정보보호 #62) */ + public String getConectUserSe() { + return conectUserSe; + } + + public void setConectUserSe(String conectUserSe) { + this.conectUserSe = conectUserSe; + } + public String getSearchBgnDe() { return searchBgnDe; } diff --git a/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java b/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java index 123f96c..053bfb0 100644 --- a/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java +++ b/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java @@ -103,8 +103,15 @@ public class EgovLoginController { HttpServletRequest request, HttpServletResponse response, ModelMap model, - @CookieValue(value = "mobile", required = false, defaultValue = "false") Boolean mobile) + @RequestParam(value = "expired", required = false) String expired, + @CookieValue(value = "mobile", required = false, defaultValue = "false") Boolean mobile) throws Exception { + + // [개인정보보호 SER-001 요건5] 자동 로그아웃으로 돌아온 경우 사유를 알린다. + // 로그인 화면(login.jsp)은 ${message} 를 alert 로 띄운다 — 별도 화면 수정 불필요. + if ("Y".equals(expired)) { + model.addAttribute("message", "장시간 사용하지 않아 자동 로그아웃되었습니다. 다시 로그인해 주세요."); + } System.out.println("로그인 완료"); /* @@ -595,11 +602,32 @@ public class EgovLoginController { * @exception Exception */ @RequestMapping(value="/uat/uia/actionLogout.do") - public String actionLogout(HttpServletRequest request, ModelMap model) + public String actionLogout(HttpServletRequest request, ModelMap model, + @RequestParam(value="expired", required=false) String expired) throws Exception { - + String userIp = EgovClntInfo.getClntIP(request); - + + // ============================================================ + // [개인정보보호 SER-001 요건5] 자동 로그아웃(미사용 세션 만료) 경로 분기 + // · 관리자 상단(top.jsp) 타이머가 잔여시간 0에 도달하면 expired=Y 로 이 액션을 호출한다. + // · 이 경우는 사용자가 누른 로그아웃이 아니라 '자동 접속차단'이므로 + // ① 세션을 통째로 무효화하고(속성 null 처리만으로는 잔여 세션 데이터가 남는다) + // ② 프론트 메인이 아니라 '로그인 화면'으로 이동시킨다. + // · 수동 로그아웃(expired 없음)은 기존 동작(프론트 메인 이동)을 그대로 둔다. + // ============================================================ + if ("Y".equals(expired)) { + HttpSession session = request.getSession(false); + if (session != null) { + try { + session.invalidate(); + } catch (IllegalStateException e) { + // 이미 컨테이너가 만료시킨 세션 — 무시하고 로그인 화면으로 보낸다. + } + } + return "redirect:/uat/uia/egovLoginUsr.do?expired=Y"; + } + request.getSession().setAttribute("loginVO", null); // return "redirect:/j_spring_security_logout"; return "redirect:/frt/main.do"; diff --git a/src/main/java/egovframework/com/uat/uia/web/SecondFactorController.java b/src/main/java/egovframework/com/uat/uia/web/SecondFactorController.java index 97f6db2..2158ef9 100644 --- a/src/main/java/egovframework/com/uat/uia/web/SecondFactorController.java +++ b/src/main/java/egovframework/com/uat/uia/web/SecondFactorController.java @@ -23,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestParam; import egovframework.com.cmm.LoginVO; import egovframework.com.cmm.privacy.interceptor.SecondFactorInterceptor; +import egovframework.com.cmm.privacy.policy.AccessControlPolicyService; import egovframework.com.cmm.privacy.security.SessionRegenerator; import egovframework.com.cmm.privacy.util.MaskingUtil; import egovframework.com.uat.uia.service.AuthMethodResolver; @@ -84,6 +85,27 @@ public class SecondFactorController { @Resource(name = "otpAuthService") private OtpAuthService otpAuthService; + /** 접근통제 정책(SER-001 요건4·5) — 세션 재발급 사용여부 판정에 사용. */ + @Resource(name = "accessControlPolicyService") + private AccessControlPolicyService accessControlPolicyService; + + /** + * [개인정보보호 SER-001] 2차 인증 성공 시 세션 재발급(세션 고정 차단). + * + *

수행 여부는 접근통제 정책 설정 화면의 세션 재발급 값을 따른다. + * 정책 조회에 실패하면 기본값이 '사용'이므로 안전한 쪽(재발급)으로 동작한다.

+ */ + private void regenerateSessionIfEnabled(HttpServletRequest request) { + if (accessControlPolicyService == null + || accessControlPolicyService.getCachedPolicy().isSessionRegenUse()) { + SessionRegenerator.onSecondFactorSuccess(request); + } else { + // '미사용'이라도 2차 인증 완료 표식은 남겨야 인터셉터가 통과시킨다. + SessionRegenerator.markSecondFactorDone(request); + LOG.info("[개인정보/접근통제] 세션 재발급 정책 '미사용' — 재발급 생략"); + } + } + // ────────────────────────────────────────────────────────── 2차 인증 화면 /** @@ -181,7 +203,8 @@ public class SecondFactorController { if (ok) { // 성공 — 세션 재발급(세션 고정 차단, #16) + 완료표식 세팅. attempt 카운터는 세션과 함께 폐기됨. - SessionRegenerator.onSecondFactorSuccess(request); + // 재발급 수행 여부는 접근통제 정책(세션 재발급) 설정을 따른다. + regenerateSessionIfEnabled(request); LOG.info("[개인정보/#14] 2차 인증 성공 — 세션 재발급, 목적지 이동: user=" + user.getId() + ", target=" + target); return "redirect:" + target; } @@ -244,7 +267,7 @@ public class SecondFactorController { } if (ok) { - SessionRegenerator.onSecondFactorSuccess(request); + regenerateSessionIfEnabled(request); LOG.info("[개인정보/#18] OTP 등록·최초검증 완료 — 세션 재발급: user=" + user.getId()); return "redirect:" + DEFAULT_TARGET; } diff --git a/src/main/java/egovframework/com/uss/umt/service/EgovAccountUnlockManageService.java b/src/main/java/egovframework/com/uss/umt/service/EgovAccountUnlockManageService.java index e3793ba..0da6c0c 100644 --- a/src/main/java/egovframework/com/uss/umt/service/EgovAccountUnlockManageService.java +++ b/src/main/java/egovframework/com/uss/umt/service/EgovAccountUnlockManageService.java @@ -8,7 +8,20 @@ public interface EgovAccountUnlockManageService{ int selectUserLockCheckListTotCnt(UserDefaultVO userSearchVO); - void updateUnlock(String user_id, String user_gubun); - - void updateUnlockAll(); + /** + * 선택 계정 잠금 해제. + * + * @param user_id 대상 계정 ID(콤마 구분) + * @param user_gubun 대상 회원구분(콤마 구분, user_id 와 같은 순서) + * @param unlockResn 해제 사유. 접근통제 정책이 '사유 필수'일 때 화면에서 입력받는다. + * (개인정보보호 SER-001 요건4 — ACCOUNT_UNLOCK_HISTORY 적재값) + */ + void updateUnlock(String user_id, String user_gubun, String unlockResn); + + /** + * 전 회원 잠금 해제. + * + * @param unlockResn 해제 사유(위와 동일) + */ + void updateUnlockAll(String unlockResn); } diff --git a/src/main/java/egovframework/com/uss/umt/service/MberManageVO.java b/src/main/java/egovframework/com/uss/umt/service/MberManageVO.java index 41bc87d..90a8292 100644 --- a/src/main/java/egovframework/com/uss/umt/service/MberManageVO.java +++ b/src/main/java/egovframework/com/uss/umt/service/MberManageVO.java @@ -437,6 +437,35 @@ public class MberManageVO extends MberManageTalentVO{ public void setMberNm(String mberNm) { this.mberNm = mberNm; } + + /** + * 이름 암호문(Base64) — 개인정보보호 기능개선 사업(추가제안 Action4, 2026-08-10). + * 등록·수정 DAO가 {@link #mberNm} 값으로부터 채워 넣는다. 화면·서비스는 이 필드를 직접 다루지 않는다. + */ + private String mberNmEnc; + /** 이름 검색용 HMAC-SHA256 해시. 등록·수정 DAO가 채운다. */ + private String mberNmHash; + /** 암호화 키 버전. 등록·수정 DAO가 채운다. */ + private Integer cryptoKeyVer; + + public String getMberNmEnc() { + return mberNmEnc; + } + public void setMberNmEnc(String mberNmEnc) { + this.mberNmEnc = mberNmEnc; + } + public String getMberNmHash() { + return mberNmHash; + } + public void setMberNmHash(String mberNmHash) { + this.mberNmHash = mberNmHash; + } + public Integer getCryptoKeyVer() { + return cryptoKeyVer; + } + public void setCryptoKeyVer(Integer cryptoKeyVer) { + this.cryptoKeyVer = cryptoKeyVer; + } /** * mberSttus attribute 값을 리턴한다. * @return String diff --git a/src/main/java/egovframework/com/uss/umt/service/UserDefaultVO.java b/src/main/java/egovframework/com/uss/umt/service/UserDefaultVO.java index 28698ba..b29496a 100644 --- a/src/main/java/egovframework/com/uss/umt/service/UserDefaultVO.java +++ b/src/main/java/egovframework/com/uss/umt/service/UserDefaultVO.java @@ -603,6 +603,22 @@ public class UserDefaultVO implements Serializable { this.searchKeyword = searchKeyword; } + /** + * 이름 검색 완전일치 해시(개인정보보호 기능개선 사업 추가제안 Action4, 2026-08-10). + * 회원 이름이 암호화되며 부분검색(LIKE)이 불가능해져, {@code searchCondition="1"}(이름 검색)일 때 + * {@code searchKeyword}의 HMAC-SHA256 해시를 여기에 채워 완전일치로 검색한다. + * 화면·서비스는 이 필드를 직접 설정하지 않는다 — 이름 검색을 수행하는 DAO가 채운다. + */ + private String searchKeywordHash = ""; + + public String getSearchKeywordHash() { + return searchKeywordHash; + } + + public void setSearchKeywordHash(String searchKeywordHash) { + this.searchKeywordHash = searchKeywordHash; + } + /** * searchUseYn attribute 값을 리턴한다. * @return String diff --git a/src/main/java/egovframework/com/uss/umt/service/impl/EgovAccountUnlockManageServiceImpl.java b/src/main/java/egovframework/com/uss/umt/service/impl/EgovAccountUnlockManageServiceImpl.java index 051d209..a0605aa 100644 --- a/src/main/java/egovframework/com/uss/umt/service/impl/EgovAccountUnlockManageServiceImpl.java +++ b/src/main/java/egovframework/com/uss/umt/service/impl/EgovAccountUnlockManageServiceImpl.java @@ -48,7 +48,7 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp } @Override - public void updateUnlock(String user_id, String user_gubun) { + public void updateUnlock(String user_id, String user_gubun, String unlockResn) { String [] sUser_id = user_id.split(","); String [] sUser_gubun = user_gubun.split(","); @@ -64,13 +64,13 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp diapauseManageDAO.updateUnlock(vo); // 개인정보보호(SER-001 / #24) — 잠금 해제 이력 적재 - recordUnlockHistory(sUser_id[i], sUser_gubun[i], "I"); + recordUnlockHistory(sUser_id[i], sUser_gubun[i], "I", unlockResn); } } @Override - public void updateUnlockAll() { + public void updateUnlockAll(String unlockResn) { loginDAO.deleteLoginSuccessTodayAll(new LoginVO()); @@ -90,7 +90,7 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp diapauseManageDAO.updateUnlockAll(vo); // 개인정보보호(SER-001 / #24) — 전체 잠금 해제 이력 적재(대상 ID는 전체이므로 NULL) - recordUnlockHistory(null, "ALL", "A"); + recordUnlockHistory(null, "ALL", "A", unlockResn); } /** @@ -101,8 +101,9 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp * @param trgetId 해제 대상 계정 ID(전체해제 시 null) * @param trgetSe 대상 회원구분(GNR/GRO/ENT/USR/ALL) * @param unlockTy 해제 유형 I:개별 A:전체 + * @param unlockResn 화면에서 입력받은 해제 사유(미입력 시 기본 문구로 대체) */ - private void recordUnlockHistory(String trgetId, String trgetSe, String unlockTy) { + private void recordUnlockHistory(String trgetId, String trgetSe, String unlockTy, String unlockResn) { try { AccountUnlockHistoryVO hist = new AccountUnlockHistoryVO(); hist.setTrgetId(trgetId); @@ -125,9 +126,18 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp } } - // 사유: 관리자 화면에서 사유 입력 필드 연동은 프론트 후속. 현재는 기본값. - // TODO: 관리자 해제 화면에서 사유(필수) 입력받아 전달(#24 화면 후속) - hist.setUnlockResn("관리자 화면 잠금 해제"); + // 사유: 접근통제 정책이 '사유 필수'면 화면에서 반드시 입력되어 넘어온다. + // '필수 아님'으로 운영할 때는 미입력이 정상이므로 기본 문구로 대체한다. + if (unlockResn != null && unlockResn.trim().length() > 0) { + String resn = unlockResn.trim(); + // UNLOCK_RESN VARCHAR(500) — 길이 초과로 해제 자체가 실패하지 않도록 절단한다. + if (resn.length() > 500) { + resn = resn.substring(0, 500); + } + hist.setUnlockResn(resn); + } else { + hist.setUnlockResn("관리자 화면 잠금 해제"); + } accountUnlockHistoryDAO.insertUnlockHistory(hist); } catch (Exception e) { diff --git a/src/main/java/egovframework/com/uss/umt/service/impl/MberManageDAO.java b/src/main/java/egovframework/com/uss/umt/service/impl/MberManageDAO.java index 136c1ee..7b5249f 100644 --- a/src/main/java/egovframework/com/uss/umt/service/impl/MberManageDAO.java +++ b/src/main/java/egovframework/com/uss/umt/service/impl/MberManageDAO.java @@ -6,6 +6,8 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.stereotype.Repository; +import egovframework.com.cmm.privacy.crypto.MemberDataCipher; +import egovframework.com.cmm.privacy.crypto.MemberDataHasher; import egovframework.com.cmm.service.impl.EgovComAbstractDAO; import egovframework.com.uss.umt.service.EntrprsManageVO; import egovframework.com.uss.umt.service.MberManageVO; @@ -34,12 +36,38 @@ public class MberManageDAO extends EgovComAbstractDAO{ /** Log Info */ protected Log log = LogFactory.getLog(this.getClass()); + /** + * 이름 검색 완전일치 해시 계산(개인정보보호 기능개선 사업 추가제안 Action4, 2026-08-10). + * + *

{@code searchCondition="1"}(이름 검색) + 검색어가 있을 때만 계산한다. 이름이 암호화되며 + * SQL의 {@code LIKE}가 {@code MBER_NM_HASH = #searchKeywordHash#}로 바뀌었는데, 이 값은 + * SQL이 만들 수 없으므로(HMAC pepper는 DB가 모른다) 조회 직전에 여기서 채운다.

+ * + *

{@link MemberDataHasher#hashName(String)}은 앞뒤 공백만 제거하고 대소문자·중간공백은 + * 그대로 둔다 — 저장 시점(회원 등록/수정) 정규화와 반드시 같아야 검색이 맞아떨어진다. + * {@code mberNmHasher} 필드는 {@link #fillNmCrypto(MberManageVO)}와 공유한다(아래).

+ */ + private void fillSearchKeywordHash(UserDefaultVO vo) { + if (vo == null || !"1".equals(vo.getSearchCondition())) { + return; + } + String kw = vo.getSearchKeyword(); + if (kw == null || kw.trim().length() == 0) { + return; + } + if (mberNmHasher == null) { + mberNmHasher = new MemberDataHasher(); + } + vo.setSearchKeywordHash(mberNmHasher.hashName(kw)); + } + /** * 기 등록된 특정 일반회원의 정보를 데이터베이스에서 읽어와 화면에 출력 * @param userSearchVO 검색조건 * @return List 기업회원 목록정보 */ public List selectMberList(UserDefaultVO userSearchVO){ + fillSearchKeywordHash(userSearchVO); return list("mberManageDAO.selectMberList", userSearchVO); } @@ -59,6 +87,7 @@ public class MberManageDAO extends EgovComAbstractDAO{ * @return int 일반회원총갯수 */ public int selectMberListTotCnt(UserDefaultVO userSearchVO) { + fillSearchKeywordHash(userSearchVO); return (Integer)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectMberListTotCnt", userSearchVO); } @@ -99,12 +128,43 @@ public class MberManageDAO extends EgovComAbstractDAO{ update("mberManageDAO.updateDeleteMber_S", delId); } + /** + * 이름 암호화(개인정보보호 기능개선 사업 추가제안 Action4, 2026-08-10)를 위한 헬퍼. + * 지연 초기화 — 키 미설정 서버에서도 회원가입 외 다른 기능은 정상 기동하게 한다. + * 키가 없으면 이 DAO를 실제로 쓰는 시점(회원 등록)에만 예외가 난다({@link MemberDataCipher} 정책). + */ + private MemberDataCipher mberNmCipher; + private MemberDataHasher mberNmHasher; + + /** + * VO의 평문 이름({@code mberNm})으로부터 암호문·해시·키버전을 채운다. + * DAO가 매핑을 아는 유일한 위치가 되도록 서비스 계층은 이 필드를 직접 다루지 않는다. + */ + private void fillNmCrypto(MberManageVO vo) { + try { + if (mberNmCipher == null) { + mberNmCipher = new MemberDataCipher(); + } + if (mberNmHasher == null) { + mberNmHasher = new MemberDataHasher(); + } + vo.setMberNmEnc(mberNmCipher.encrypt(vo.getMberNm())); + vo.setMberNmHash(mberNmHasher.hashName(vo.getMberNm())); + vo.setCryptoKeyVer(Integer.valueOf(MemberDataCipher.CURRENT_KEY_VER)); + } catch (Exception e) { + // 암호화 실패를 삼키고 평문만 저장하면 이관 검증에서 "누락"으로 잡히긴 하지만 + // 그 사이 평문 전용 상태로 개인정보가 노출된다 — 등록 자체를 막는다. + throw new IllegalStateException("[개인정보/암호화] 회원 이름 암호화 실패", e); + } + } + /** * 일반회원의 기본정보를 화면에서 입력하여 항목의 정합성을 체크하고 데이터베이스에 저장 * @param mberManageVO 일반회원 등록정보 * @return String 등록결과 */ public String insertMber(MberManageVO mberManageVO){ + fillNmCrypto(mberManageVO); return (String)insert("mberManageDAO.insertMber_S", mberManageVO); } @@ -197,6 +257,7 @@ public class MberManageDAO extends EgovComAbstractDAO{ * @return List 기업회원 목록정보 */ public List selectExcelMberList(UserDefaultVO userSearchVO){ + fillSearchKeywordHash(userSearchVO); return list("mberManageDAO.selectExcelMberList", userSearchVO); } @@ -228,6 +289,7 @@ public class MberManageDAO extends EgovComAbstractDAO{ public List selectAwardList(UserDefaultVO userSearchVO) { // TODO Auto-generated method stub + fillSearchKeywordHash(userSearchVO); return list("mberManageDAO.selectAwardList", userSearchVO); } @@ -248,6 +310,7 @@ public class MberManageDAO extends EgovComAbstractDAO{ public int selectAwardListCnt(UserDefaultVO userSearchVO) { // TODO Auto-generated method stub + fillSearchKeywordHash(userSearchVO); return (Integer)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectAwardListCnt", userSearchVO); } diff --git a/src/main/java/egovframework/com/uss/umt/web/EgovAccountUnlockManageController.java b/src/main/java/egovframework/com/uss/umt/web/EgovAccountUnlockManageController.java index 5735278..4150c03 100644 --- a/src/main/java/egovframework/com/uss/umt/web/EgovAccountUnlockManageController.java +++ b/src/main/java/egovframework/com/uss/umt/web/EgovAccountUnlockManageController.java @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import egovframework.com.cmm.annotation.IncludedInfo; +import egovframework.com.cmm.privacy.policy.AccessControlPolicyService; import egovframework.com.uss.umt.service.EgovAccountUnlockManageService; import egovframework.com.uss.umt.service.UserDefaultVO; import egovframework.rte.fdl.property.EgovPropertyService; @@ -51,6 +52,13 @@ public class EgovAccountUnlockManageController { @Resource(name = "propertiesService") protected EgovPropertyService propertiesService; + /** + * 접근통제 정책(개인정보보호 SER-001 요건4) — 잠금 해제 방식. + * '관리자 화면 해제 + 해제 사유 필수'로 설정된 경우 사유 없는 해제를 거부한다. + */ + @Resource(name = "accessControlPolicyService") + private AccessControlPolicyService accessControlPolicyService; + /** Log Info */ protected Log log = LogFactory.getLog(this.getClass()); @@ -95,6 +103,11 @@ public class EgovAccountUnlockManageController { paginationInfo.setTotalRecordCount(totCnt); model.addAttribute("paginationInfo", paginationInfo); + // [개인정보보호 SER-001 요건4] 해제 사유 입력 여부는 접근통제 정책 설정을 따른다. + // (통합관리시스템 > 시스템관리 > 권한관리 > 접근통제 정책) + model.addAttribute("unlockResnRequired", + Boolean.valueOf(accessControlPolicyService.getCachedPolicy().isUnlockResnRequired())); + return "egovframework/com/uss/umt/EgovAccountUnlockManage"; } @@ -109,11 +122,17 @@ public class EgovAccountUnlockManageController { public String deleteMber( @RequestParam("user_id") String user_id , @RequestParam("user_gubun") String user_gubun , + @RequestParam(value="unlockResn", required=false) String unlockResn , Model model )throws Exception { + // [개인정보보호 SER-001 요건4] 정책이 '사유 필수'면 화면 우회 요청도 서버에서 거부한다. + if (isResnMissing(unlockResn)) { + model.addAttribute("resultMsg", "fail.privacy.unlock.resn"); + return "forward:/uss/umt/EgovAccountUnlockManage.do"; + } - egovAccountUnlockManageService.updateUnlock(user_id, user_gubun); + egovAccountUnlockManageService.updateUnlock(user_id, user_gubun, unlockResn); //Exception 없이 진행시 삭제성공메시지 model.addAttribute("resultMsg", "success.common.update"); @@ -126,13 +145,37 @@ public class EgovAccountUnlockManageController { * @throws Exception */ @RequestMapping("/uss/umt/EgovAccountUnlockUpdateAll.do") - public String deleteMber(Model model)throws Exception { + public String deleteMber( + @RequestParam(value="unlockResn", required=false) String unlockResn , + Model model)throws Exception { - egovAccountUnlockManageService.updateUnlockAll(); + // [개인정보보호 SER-001 요건4] 전체 해제는 영향 범위가 커서 사유 필수 정책을 동일하게 적용한다. + if (isResnMissing(unlockResn)) { + model.addAttribute("resultMsg", "fail.privacy.unlock.resn"); + return "forward:/uss/umt/EgovAccountUnlockManage.do"; + } + + egovAccountUnlockManageService.updateUnlockAll(unlockResn); //Exception 없이 진행시 삭제성공메시지 model.addAttribute("resultMsg", "success.common.update"); return "forward:/uss/umt/EgovAccountUnlockManage.do"; } + /** + * 접근통제 정책이 '해제 사유 필수'인데 사유가 비어 있는지 판정한다. + * 정책 조회가 실패해도 해제 자체를 막지 않는다(서비스 기본값은 '필수 아님'). + */ + private boolean isResnMissing(String unlockResn) { + try { + if (!accessControlPolicyService.getCachedPolicy().isUnlockResnRequired()) { + return false; + } + } catch (Exception e) { + log.error("[개인정보/접근통제] 잠금 해제 정책 조회 오류 - 해제는 계속 진행: " + e.getMessage()); + return false; + } + return unlockResn == null || unlockResn.trim().length() == 0; + } + } \ No newline at end of file diff --git a/src/main/java/kr/or/arko/cmm/batch/BatchScheduler.java b/src/main/java/kr/or/arko/cmm/batch/BatchScheduler.java index 598b25e..c11ec04 100644 --- a/src/main/java/kr/or/arko/cmm/batch/BatchScheduler.java +++ b/src/main/java/kr/or/arko/cmm/batch/BatchScheduler.java @@ -15,6 +15,7 @@ import org.springframework.stereotype.Component; import egovframework.com.cmm.EgovWebUtil; import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobService; import egovframework.com.cmm.privacy.batch.HashChainVerifyResultVO; import egovframework.com.cmm.privacy.batch.InspectionReportVO; import egovframework.com.cmm.privacy.batch.PrivacyBatchService; @@ -61,6 +62,14 @@ public class BatchScheduler { @Resource(name = "privacyBatchService") private PrivacyBatchService privacyBatchService; + /** [SFR-004-6] 비동기 다운로드 작업 — 만료 파일 파기용. */ + @Resource(name = "accessLogDownloadJobService") + private AccessLogDownloadJobService accessLogDownloadJobService; + + /** [SFR-001 #6] 권한이력 비동기 다운로드 작업 — 만료 파일 파기용. */ + @Resource(name = "authorHistDownloadJobService") + private egovframework.com.cmm.privacy.author.AuthorHistDownloadJobService authorHistDownloadJobService; + /** 개인정보보호 휴면 사전알림 서비스 (#10 D-30/D-7/D-Day + 관리자 알림) */ @Resource(name = "dormantAlertService") private egovframework.com.cmm.privacy.account.DormantAlertService dormantAlertService; @@ -244,6 +253,35 @@ public class BatchScheduler { + " 총 " + report.getTotalLogCnt() + "건, 무결성=" + report.getIntegrityResult()); } + /** + * [개인정보보호 SFR-004-6] 접속기록 CSV 비동기 다운로드 파일 파기 배치. + * + *

매일 01:00. 다른 개인정보 배치는 월 1회지만 이건 매일 돈다 — 생성 파일이 + * '대량 개인정보 파일'이라 보관기간(기본 3일)이 짧고, 월 1회로는 만료분이 최대 한 달간 + * 디스크에 남기 때문이다. 접속기록 본체의 보관기간(730일)과는 무관한 사본의 수명이다.

+ * + * @throws Exception 파기 오류 + */ + @Scheduled(cron = "0 0 1 * * ?") + public void purgeExpiredDownloadFileScheduler() throws Exception { + int purged = accessLogDownloadJobService.purgeExpiredJobs(); + log.info("[개인정보/SFR-004-6] 만료 다운로드 파일 파기 배치 완료 — " + purged + "건"); + } + + /** + * [개인정보보호 SFR-001 #6] 권한변경 이력 CSV 비동기 다운로드 파일 파기 배치. + * + *

매일 01:10. 접속기록 파기(01:00)와 10분 간격을 둬 같은 저장 디렉터리를 동시에 + * 훑지 않게 한다. 보관기간은 접속기록 사본과 같은 설정값(기본 3일)을 쓴다.

+ * + * @throws Exception 파기 오류 + */ + @Scheduled(cron = "0 10 1 * * ?") + public void purgeExpiredAuthorHistDownloadFileScheduler() throws Exception { + int purged = authorHistDownloadJobService.purgeExpiredJobs(); + log.info("[개인정보/SFR-001-6] 만료 권한이력 다운로드 파일 파기 배치 완료 — " + purged + "건"); + } + /** * [개인정보보호 #10] 휴면 전 사전 알림 배치 — D-30/D-7/D-Day 3단계 관리자 알림. * 매일 06:00, 정책(ACCOUNT_MNG_POLICY)의 ALERT_D1/D2/D3 시점에 해당하는 휴면 예정 diff --git a/src/main/java/kr/or/arko/frt/biz/merge/service/impl/BizMberMergeDAO.java b/src/main/java/kr/or/arko/frt/biz/merge/service/impl/BizMberMergeDAO.java index 93abfe9..76f24a5 100644 --- a/src/main/java/kr/or/arko/frt/biz/merge/service/impl/BizMberMergeDAO.java +++ b/src/main/java/kr/or/arko/frt/biz/merge/service/impl/BizMberMergeDAO.java @@ -4,6 +4,8 @@ import java.util.List; import org.springframework.stereotype.Repository; +import egovframework.com.cmm.privacy.crypto.MemberDataCipher; +import egovframework.com.cmm.privacy.crypto.MemberDataHasher; import egovframework.com.cmm.service.impl.EgovComAbstractDAO; import egovframework.com.uss.umt.service.EntrprsManageVO; import egovframework.com.uss.umt.service.MberManageVO; @@ -11,7 +13,31 @@ import kr.or.arko.frt.biz.merge.service.LibMberManageVO; @Repository("BizMberMergeDAO") public class BizMberMergeDAO extends EgovComAbstractDAO { - + + /** + * 이름 암호화(개인정보보호 기능개선 사업 추가제안 Action4, 2026-08-10) 헬퍼. + * 지연 초기화 — {@link MemberDataCipher} 정책과 동일하게 실제 저장 시점에만 키를 요구한다. + */ + private MemberDataCipher mberNmCipher; + private MemberDataHasher mberNmHasher; + + /** VO의 평문 이름({@code mberNm})으로부터 암호문·해시·키버전을 채운다. */ + private void fillNmCrypto(MberManageVO vo) { + try { + if (mberNmCipher == null) { + mberNmCipher = new MemberDataCipher(); + } + if (mberNmHasher == null) { + mberNmHasher = new MemberDataHasher(); + } + vo.setMberNmEnc(mberNmCipher.encrypt(vo.getMberNm())); + vo.setMberNmHash(mberNmHasher.hashName(vo.getMberNm())); + vo.setCryptoKeyVer(Integer.valueOf(MemberDataCipher.CURRENT_KEY_VER)); + } catch (Exception e) { + throw new IllegalStateException("[개인정보/암호화] 회원 이름 암호화 실패", e); + } + } + public List selectMberList(LibMberManageVO libMberManageVO) throws Exception { return list("bizMberMergeDAO.selectLibMberList", libMberManageVO); } @@ -28,6 +54,7 @@ public class BizMberMergeDAO extends EgovComAbstractDAO { return (LibMberManageVO) selectByPk("bizMberMergeDAO.selectLiActivity_S", libMberManageVO); } public void updateLibMber(MberManageVO mberManageVO){ + fillNmCrypto(mberManageVO); update("bizMberMergeDAO.updateMber_S",mberManageVO); } public void updateLibMberToVol(MberManageVO mberManageVO){ diff --git a/src/main/resources/egovframework/message/com/message-common_en_US.properties b/src/main/resources/egovframework/message/com/message-common_en_US.properties index a4d91e4..a433ddd 100644 --- a/src/main/resources/egovframework/message/com/message-common_en_US.properties +++ b/src/main/resources/egovframework/message/com/message-common_en_US.properties @@ -309,4 +309,5 @@ volunteer.sms.msg.11 = [\ubb38\ud654\uccb4\uc721\uc790\uc6d0\ubd09\uc0ac]\ud65c\ volunteer.sms.msg.12 = [\ubb38\ud654\uccb4\uc721\uc790\uc6d0\ubd09\uc0ac]\ubd09\uc0ac\uc790 \uc911\uac04\ud65c\ub3d9\uc778\uc815 \uc694\uccad\uc774 \uc811\uc218\ub418\uc5c8\uc2b5\ub2c8\ub2e4. csv.culture.go.kr\uc5d0\uc11c \ud655\uc778\ud558\uc138\uc694. volunteer.sms.msg.admin = \ubb38\ud654\ud488\uc557e - \uad00\ub9ac\uc790 \uacc4\uc815(ID:admin)\uc73c\ub85c \ub85c\uadf8\uc778 \ud558\uc168\uc2b5\ub2c8\ub2e4. -volunteer.sms.msg.phone = 01034340507 \ No newline at end of file +volunteer.sms.msg.phone = 01034340507 +fail.privacy.unlock.resn=Unlock reason is required by the access control policy. diff --git a/src/main/resources/egovframework/message/com/message-common_ko_KR.properties b/src/main/resources/egovframework/message/com/message-common_ko_KR.properties index 08fa59e..3c2d373 100644 --- a/src/main/resources/egovframework/message/com/message-common_ko_KR.properties +++ b/src/main/resources/egovframework/message/com/message-common_ko_KR.properties @@ -441,3 +441,4 @@ volunteer.sms.msg.12 = [\ubb38\ud654\uccb4\uc721\uc790\uc6d0\ubd09\uc0ac]\ubd09\ volunteer.sms.msg.admin = \ubb38\ud654\ud488\uc557e - \uad00\ub9ac\uc790 \uacc4\uc815(ID:admin)\uc73c\ub85c \ub85c\uadf8\uc778 \ud558\uc168\uc2b5\ub2c8\ub2e4. volunteer.sms.msg.phone = 01034340507 +fail.privacy.unlock.resn=\uacc4\uc815 \uc7a0\uae08 \ud574\uc81c \uc0ac\uc720\ub97c \uc785\ub825\ud558\uc138\uc694. (\uc811\uadfc\ud1b5\uc81c \uc815\ucc45 - \ud574\uc81c \uc0ac\uc720 \ud544\uc218) diff --git a/src/main/resources/egovframework/sqlmap/com/sym/log/clg/EgovLoginLog_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/com/sym/log/clg/EgovLoginLog_SQL_Mysql.xml index c54f41e..7a3c8dd 100644 --- a/src/main/resources/egovframework/sqlmap/com/sym/log/clg/EgovLoginLog_SQL_Mysql.xml +++ b/src/main/resources/egovframework/sqlmap/com/sym/log/clg/EgovLoginLog_SQL_Mysql.xml @@ -56,20 +56,30 @@ + + @@ -94,7 +105,7 @@ FROM( - a.MBER_NM LIKE CONCAT('%',#searchKeyword#,'%') + a.MBER_NM_HASH = #searchKeywordHash# @@ -197,7 +208,7 @@ FROM( - a.MBER_NM LIKE CONCAT('%',#searchKeyword#,'%') + a.MBER_NM_HASH = #searchKeywordHash# @@ -297,7 +308,7 @@ FROM( - a.MBER_NM LIKE CONCAT('%',#searchKeyword#,'%') + a.MBER_NM_HASH = #searchKeywordHash# @@ -351,7 +362,7 @@ FROM( - MBER_NM LIKE CONCAT('%',#searchKeyword#,'%') + MBER_NM_HASH = #searchKeywordHash# @@ -381,7 +392,10 @@ FROM( ESNTL_ID , MBER_ID , MBER_NM , - PASSWORD , + MBER_NM_ENC , + MBER_NM_HASH , + CRYPTO_KEY_VER , + PASSWORD , IHIDNUM , SEXDSTN_CODE , ZIP , @@ -424,7 +438,10 @@ FROM( #uniqId#, #mberId#, #mberNm#, - #password#, + #mberNmEnc#, + #mberNmHash#, + #cryptoKeyVer#, + #password#, #ihidnum#, #sexdstnCode#, #zip#, @@ -896,7 +913,7 @@ FROM( - a.MBER_NM LIKE CONCAT('%',#searchKeyword#,'%') + a.MBER_NM_HASH = #searchKeywordHash# diff --git a/src/main/resources/egovframework/sqlmap/com/uss/umt/EgovMberManage_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/com/uss/umt/EgovMberManage_SQL_Oracle.xml index 758b117..c8659c4 100644 --- a/src/main/resources/egovframework/sqlmap/com/uss/umt/EgovMberManage_SQL_Oracle.xml +++ b/src/main/resources/egovframework/sqlmap/com/uss/umt/EgovMberManage_SQL_Oracle.xml @@ -1,6 +1,11 @@ + @@ -69,7 +74,7 @@ SELECT * FROM ( SELECT rownum rn, TB.* FROM ( a.MBER_ID LIKE #searchKeyword# - a.MBER_NM LIKE '%' ||#searchKeyword#|| '%' + a.MBER_NM_HASH = #searchKeywordHash# a.ACTIVE_SIDO_AREA1 = #searchCondition1# @@ -105,7 +110,7 @@ SELECT * FROM ( SELECT rownum rn, TB.* FROM ( MBER_ID LIKE #searchKeyword# - MBER_NM LIKE '%' ||#searchKeyword#|| '%' + MBER_NM_HASH = #searchKeywordHash# ACTIVE_SIDO_AREA1 = #searchCondition1# @@ -134,7 +139,10 @@ SELECT * FROM ( SELECT rownum rn, TB.* FROM ( ESNTL_ID , MBER_ID , MBER_NM , - PASSWORD , + MBER_NM_ENC , + MBER_NM_HASH , + CRYPTO_KEY_VER , + PASSWORD , IHIDNUM , SEXDSTN_CODE , ZIP , @@ -176,7 +184,10 @@ SELECT * FROM ( SELECT rownum rn, TB.* FROM ( #uniqId#, #mberId#, #mberNm#, - #password#, + #mberNmEnc#, + #mberNmHash#, + #cryptoKeyVer#, + #password#, #ihidnum#, #sexdstnCode#, #zip#, @@ -594,7 +605,7 @@ SELECT * FROM ( SELECT rownum rn, TB.* FROM ( a.MBER_ID LIKE #searchKeyword# - a.MBER_NM LIKE '%' ||#searchKeyword#|| '%' + a.MBER_NM_HASH = #searchKeywordHash# a.ACTIVE_SIDO_AREA1 = #searchCondition1# diff --git a/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml b/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml index ba87408..139b8f3 100644 --- a/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml +++ b/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml @@ -115,6 +115,9 @@ + + + @@ -135,4 +138,16 @@ + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/frt/biz/merge/BizLibMember_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/frt/biz/merge/BizLibMember_SQL_Mysql.xml index 9ad6cfb..f7e8379 100644 --- a/src/main/resources/egovframework/sqlmap/site/frt/biz/merge/BizLibMember_SQL_Mysql.xml +++ b/src/main/resources/egovframework/sqlmap/site/frt/biz/merge/BizLibMember_SQL_Mysql.xml @@ -172,6 +172,9 @@ SET MBER_ID = #mberId#, MBER_NM = #mberNm#, + MBER_NM_ENC = #mberNmEnc#, + MBER_NM_HASH = #mberNmHash#, + CRYPTO_KEY_VER = #cryptoKeyVer#, PASSWORD = #password#, SEXDSTN_CODE = #sexdstnCode#, BIRTH_YEAR = #birthYear#, diff --git a/src/main/resources/egovframework/sqlmap/site/frt/biz/merge/BizLibMember_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/frt/biz/merge/BizLibMember_SQL_Oracle.xml index 8dd1fb8..f5d6749 100644 --- a/src/main/resources/egovframework/sqlmap/site/frt/biz/merge/BizLibMember_SQL_Oracle.xml +++ b/src/main/resources/egovframework/sqlmap/site/frt/biz/merge/BizLibMember_SQL_Oracle.xml @@ -172,6 +172,9 @@ SET MBER_ID = #mberId#, MBER_NM = #mberNm#, + MBER_NM_ENC = #mberNmEnc#, + MBER_NM_HASH = #mberNmHash#, + CRYPTO_KEY_VER = #cryptoKeyVer#, PASSWORD = #password#, SEXDSTN_CODE = #sexdstnCode#, BIRTH_YEAR = #birthYear#, diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessControlPolicy_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessControlPolicy_SQL_Mysql.xml new file mode 100644 index 0000000..f2c525b --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessControlPolicy_SQL_Mysql.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessControlPolicy_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessControlPolicy_SQL_Oracle.xml new file mode 100644 index 0000000..eb77c5a --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessControlPolicy_SQL_Oracle.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml index c7d8b39..f8d78c1 100644 --- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml @@ -104,14 +104,17 @@ PREV_HASH_VAL AS prevHashVal FROM PERSONAL_DATA_ACCESS_LOG - CONECT_ID = #conectId# + + CONECT_ID LIKE #conectIdLike# ESCAPE '!' JOB_SE_CODE = #jobSeCode# - TRGET_ID = #trgetId# - CONECT_IP = #conectIp# + TRGET_ID LIKE #trgetIdLike# ESCAPE '!' + CONECT_IP LIKE #conectIpLike# ESCAPE '!' DML_GUBUN = #dmlGubun# PROCESS_RESULT = #processResult# - CONECT_DT >= #fromDt# - CONECT_DT <= #toDt# + CONECT_DT >= #fromDtParam# + CONECT_DT <= #toDtParam# ORDER BY LOG_ID DESC LIMIT #firstIndex#, #recordCountPerPage# @@ -123,17 +126,49 @@ SELECT COUNT(1) FROM PERSONAL_DATA_ACCESS_LOG - CONECT_ID = #conectId# + + CONECT_ID LIKE #conectIdLike# ESCAPE '!' JOB_SE_CODE = #jobSeCode# - TRGET_ID = #trgetId# - CONECT_IP = #conectIp# + TRGET_ID LIKE #trgetIdLike# ESCAPE '!' + CONECT_IP LIKE #conectIpLike# ESCAPE '!' DML_GUBUN = #dmlGubun# PROCESS_RESULT = #processResult# - CONECT_DT >= #fromDt# - CONECT_DT <= #toDt# + CONECT_DT >= #fromDtParam# + CONECT_DT <= #toDtParam# + + + + CONECT_ID LIKE #conectIdLike# ESCAPE '!' JOB_SE_CODE = #jobSeCode# - TRGET_ID = #trgetId# - CONECT_IP = #conectIp# + TRGET_ID LIKE #trgetIdLike# ESCAPE '!' + CONECT_IP LIKE #conectIpLike# ESCAPE '!' DML_GUBUN = #dmlGubun# PROCESS_RESULT = #processResult# - CONECT_DT >= #fromDt# - CONECT_DT <= #toDt# + CONECT_DT >= #fromDtParam# + CONECT_DT <= #toDtParam# ORDER BY LOG_ID DESC ) T WHERE ROWNUM <= #firstIndex# + #recordCountPerPage# @@ -109,17 +110,39 @@ SELECT COUNT(1) FROM PERSONAL_DATA_ACCESS_LOG - CONECT_ID = #conectId# + + CONECT_ID LIKE #conectIdLike# ESCAPE '!' JOB_SE_CODE = #jobSeCode# - TRGET_ID = #trgetId# - CONECT_IP = #conectIp# + TRGET_ID LIKE #trgetIdLike# ESCAPE '!' + CONECT_IP LIKE #conectIpLike# ESCAPE '!' DML_GUBUN = #dmlGubun# PROCESS_RESULT = #processResult# - CONECT_DT >= #fromDt# - CONECT_DT <= #toDt# + CONECT_DT >= #fromDtParam# + CONECT_DT <= #toDtParam# + + + - + + = DORMANT_DAY + W(휴면예정) : 미접속일수 >= (DORMANT_DAY - ALERT_D1) + N(사용) : 그 외 + 기준일수는 하드코딩하지 않고 ACCOUNT_MNG_POLICY(#9/#13)에서 그때그때 읽는다. + (정책 기본값 DORMANT_DAY=90 / ALERT_D1=30 이면 시안의 '60일 휴면예정 / 90일 자동잠금'과 일치) --> + + + + - + + e.EMPLYR_ID AS accountId, e.USER_NM AS userNm, e.ORGNZT_ID AS orgnztId, e.OFCPS_NM AS ofcpsNm, + e.ESNTL_ID AS esntlId, DATE_FORMAT(IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE), '%Y-%m-%d') AS lastLoginDt, DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) AS noConnectDays, e.EMPLYR_STTUS_CODE AS sttusCode, DATE_FORMAT(DATE_ADD(IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE), - INTERVAL (SELECT IFNULL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY) DAY), '%Y-%m-%d') AS dormantExpectDt, + INTERVAL p.DORMANT_DAY DAY), '%Y-%m-%d') AS dormantExpectDt, + CASE WHEN e.EMPLYR_STTUS_CODE = 'D' THEN 'D' + WHEN e.EMPLYR_STTUS_CODE = 'S' THEN 'L' + WHEN d.DIAPAUSE_STAT = 'P' THEN 'L' + WHEN DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) >= p.DORMANT_DAY THEN 'L' + WHEN DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) >= (p.DORMANT_DAY - p.ALERT_D1) THEN 'W' + ELSE 'N' END AS acntState, 'EMP' AS mberTyCode + + + FROM COMTNEMPLYRINFO e - ]]> + LEFT JOIN COMTNDIAPAUSEMBER d + ON d.MBER_ID = e.EMPLYR_ID + AND d.ESNTL_ID = e.ESNTL_ID + CROSS JOIN (SELECT IFNULL(MAX(DORMANT_DAY), 365) AS DORMANT_DAY, + IFNULL(MAX(ALERT_D1), 30) AS ALERT_D1 + FROM ACCOUNT_MNG_POLICY) p + + + + - e.EMPLYR_ID = #accountId# - e.USER_NM LIKE CONCAT('%', #userNm#, '%') - e.EMPLYR_STTUS_CODE = #sttusCode# + t.accountId = #accountId# + t.userNm LIKE CONCAT('%', #userNm#, '%') + + (t.userNm LIKE CONCAT('%', #searchKeyword#, '%') OR t.accountId LIKE CONCAT('%', #searchKeyword#, '%')) + + t.sttusCode = #sttusCode# + t.acntState = #searchState# - DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) >= #minNoConnectDays# + t.noConnectDays >= #minNoConnectDays# - ORDER BY noConnectDays DESC + + + + + + + + + + + + + + + + + + + + + + + + + + + + 'D' + ]]> + + + + + + + + + + e.EMPLYR_ID AS accountId, e.USER_NM AS userNm, e.ORGNZT_ID AS orgnztId, + e.OFCPS_NM AS ofcpsNm, e.ESNTL_ID AS esntlId, + TO_CHAR(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE), 'YYYY-MM-DD') AS lastLoginDt, + TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) AS noConnectDays, + e.EMPLYR_STTUS_CODE AS sttusCode, + TO_CHAR(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE) + p.DORMANT_DAY, 'YYYY-MM-DD') AS dormantExpectDt, + CASE WHEN e.EMPLYR_STTUS_CODE = 'D' THEN 'D' + WHEN e.EMPLYR_STTUS_CODE = 'S' THEN 'L' + WHEN d.DIAPAUSE_STAT = 'P' THEN 'L' + WHEN (TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE))) >= p.DORMANT_DAY THEN 'L' + WHEN (TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE))) >= (p.DORMANT_DAY - p.ALERT_D1) THEN 'W' + ELSE 'N' END AS acntState, + 'EMP' AS mberTyCode + + + + FROM COMTNEMPLYRINFO e + LEFT JOIN COMTNDIAPAUSEMBER d + ON d.MBER_ID = e.EMPLYR_ID + AND d.ESNTL_ID = e.ESNTL_ID + CROSS JOIN (SELECT NVL(MAX(DORMANT_DAY), 365) AS DORMANT_DAY, + NVL(MAX(ALERT_D1), 30) AS ALERT_D1 + FROM ACCOUNT_MNG_POLICY) p + + + + + t.accountId = #accountId# + t.userNm LIKE '%' || #userNm# || '%' + + (t.userNm LIKE '%' || #searchKeyword# || '%' OR t.accountId LIKE '%' || #searchKeyword# || '%') + + t.sttusCode = #sttusCode# + t.acntState = #searchState# + + t.noConnectDays >= #minNoConnectDays# + + + + + + + + + + + + + + + + + + + + + + + 'D' + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistDownloadJob_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistDownloadJob_SQL_Oracle.xml new file mode 100644 index 0000000..2fe8d3c --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistDownloadJob_SQL_Oracle.xml @@ -0,0 +1,177 @@ + + + + + + + + + + SELECT AUTHOR_HIST_DL_JOB_SEQ.CURRVAL FROM DUAL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Mysql.xml index 8d099d5..40a0750 100644 --- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Mysql.xml +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Mysql.xml @@ -60,32 +60,12 @@ WHERE REQUEST_ID = #value# - - - + @@ -142,37 +122,53 @@ + + + LEFT JOIN COMTNEMPLYRINFO te ON te.ESNTL_ID = h.TRGET_UNIQ_ID + + + + + + (h.TRGET_ID = #searchTrgetId# OR h.TRGET_UNIQ_ID = #searchTrgetId#) + h.CHANGER_ID = #searchChangerId# + h.CHG_SE_CODE = #searchChgSeCode# + h.CHG_DT >= #fromDt# + + h.CHG_DT < DATE_ADD(DATE(#toDt#), INTERVAL 1 DAY) + + + + + + + FROM COMTNEMPLYRINFO e + LEFT JOIN COMTNORGNZTINFO o ON o.ORGNZT_ID = e.ORGNZT_ID + LEFT JOIN COMTNEMPLYRSCRTYESTBS s ON s.SCRTY_DTRMN_TRGET_ID = e.ESNTL_ID + LEFT JOIN COMTNAUTHORINFO a ON a.AUTHOR_CODE = s.AUTHOR_CODE + LEFT JOIN (SELECT TRGET_UNIQ_ID, MAX(REQUEST_ID) AS REQUEST_ID + FROM AUTHOR_CHANGE_REQUEST + WHERE CONFM_STTUS = 'R' + GROUP BY TRGET_UNIQ_ID) p ON p.TRGET_UNIQ_ID = e.ESNTL_ID + LEFT JOIN AUTHOR_CHANGE_REQUEST pr ON pr.REQUEST_ID = p.REQUEST_ID + LEFT JOIN (SELECT TRGET_UNIQ_ID, MAX(HIST_ID) AS HIST_ID + FROM AUTHOR_CHANGE_HISTORY + GROUP BY TRGET_UNIQ_ID) h ON h.TRGET_UNIQ_ID = e.ESNTL_ID + LEFT JOIN AUTHOR_CHANGE_HISTORY hh ON hh.HIST_ID = h.HIST_ID + LEFT JOIN COMTNAUTHORINFO ab + ON ab.AUTHOR_CODE = COALESCE(NULLIF(pr.BEFORE_AUTHOR, ''), NULLIF(hh.BEFORE_AUTHOR, '')) + LEFT JOIN COMTNAUTHORINFO aa + ON aa.AUTHOR_CODE = COALESCE(NULLIF(pr.AFTER_AUTHOR, ''), NULLIF(hh.AFTER_AUTHOR, '')) + + + + + e.ORGNZT_ID = #searchOrgnztId# + s.AUTHOR_CODE = #searchAuthorCode# + + (e.EMPLYR_ID LIKE CONCAT('%', #searchKeyword#, '%') OR e.USER_NM LIKE CONCAT('%', #searchKeyword#, '%')) + + + p.REQUEST_ID IS NOT NULL + + + p.REQUEST_ID IS NULL AND (s.AUTHOR_CODE IS NULL OR e.EMPLYR_STTUS_CODE = 'D') + + + p.REQUEST_ID IS NULL AND s.AUTHOR_CODE IS NOT NULL AND e.EMPLYR_STTUS_CODE != 'D' + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Oracle.xml index 5160665..19ab55f 100644 --- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Oracle.xml +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Oracle.xml @@ -59,35 +59,11 @@ WHERE REQUEST_ID = #value# - + - @@ -135,25 +111,41 @@ ]]> + + + LEFT JOIN COMTNEMPLYRINFO te ON te.ESNTL_ID = h.TRGET_UNIQ_ID + + + + + + (h.TRGET_ID = #searchTrgetId# OR h.TRGET_UNIQ_ID = #searchTrgetId#) + h.CHANGER_ID = #searchChangerId# + h.CHG_SE_CODE = #searchChgSeCode# + h.CHG_DT >= #fromDt# + h.CHG_DT < TRUNC(TO_DATE(SUBSTR(#toDt#, 1, 10), 'YYYY-MM-DD')) + 1 + + + @@ -161,14 +153,8 @@ + + + + FROM COMTNEMPLYRINFO e + LEFT JOIN COMTNORGNZTINFO o ON o.ORGNZT_ID = e.ORGNZT_ID + LEFT JOIN COMTNEMPLYRSCRTYESTBS s ON s.SCRTY_DTRMN_TRGET_ID = e.ESNTL_ID + LEFT JOIN COMTNAUTHORINFO a ON a.AUTHOR_CODE = s.AUTHOR_CODE + LEFT JOIN (SELECT TRGET_UNIQ_ID, MAX(REQUEST_ID) AS REQUEST_ID + FROM AUTHOR_CHANGE_REQUEST + WHERE CONFM_STTUS = 'R' + GROUP BY TRGET_UNIQ_ID) p ON p.TRGET_UNIQ_ID = e.ESNTL_ID + LEFT JOIN AUTHOR_CHANGE_REQUEST pr ON pr.REQUEST_ID = p.REQUEST_ID + LEFT JOIN (SELECT TRGET_UNIQ_ID, MAX(HIST_ID) AS HIST_ID + FROM AUTHOR_CHANGE_HISTORY + GROUP BY TRGET_UNIQ_ID) h ON h.TRGET_UNIQ_ID = e.ESNTL_ID + LEFT JOIN AUTHOR_CHANGE_HISTORY hh ON hh.HIST_ID = h.HIST_ID + LEFT JOIN COMTNAUTHORINFO ab + ON ab.AUTHOR_CODE = COALESCE(pr.BEFORE_AUTHOR, hh.BEFORE_AUTHOR) + LEFT JOIN COMTNAUTHORINFO aa + ON aa.AUTHOR_CODE = COALESCE(pr.AFTER_AUTHOR, hh.AFTER_AUTHOR) + + + + + e.ORGNZT_ID = #searchOrgnztId# + s.AUTHOR_CODE = #searchAuthorCode# + + (e.EMPLYR_ID LIKE '%' || #searchKeyword# || '%' OR e.USER_NM LIKE '%' || #searchKeyword# || '%') + + + p.REQUEST_ID IS NOT NULL + + + p.REQUEST_ID IS NULL AND (s.AUTHOR_CODE IS NULL OR e.EMPLYR_STTUS_CODE = 'D') + + + p.REQUEST_ID IS NULL AND s.AUTHOR_CODE IS NOT NULL AND e.EMPLYR_STTUS_CODE != 'D' + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyConectLog_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyConectLog_SQL_Mysql.xml new file mode 100644 index 0000000..8f088fd --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyConectLog_SQL_Mysql.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + a.CONECT_ACNT_ID LIKE CONCAT('%', #searchUserId#, '%') + + + a.CONECT_MTHD = #searchMthd# + + + a.CONECT_USER_SE = #searchUserSe# + + + a.CONECT_IP LIKE CONCAT('%', #searchIp#, '%') + + + a.CONECT_LC = #searchLc# + + + a.CREAT_DT >= CONCAT(#searchBgnDe#, ' 00:00:00') + + + a.CREAT_DT <= CONCAT(#searchEndDe#, ' 23:59:59') + + + + + + + + SELECT a.LOG_ID AS logId + , DATE_FORMAT(a.CREAT_DT, '%Y-%m-%d %H:%i:%s') AS creatDt + , a.CONECT_MTHD AS conectMthd + , a.CONECT_IP AS conectIp + , a.CONECT_LC AS conectLc + , a.CONECT_ID AS conectId + , a.CONECT_ACNT_ID AS userId + , a.CONECT_USER_SE AS userSe + , a.ERROR_OCCRRNC_AT AS errOccrrAt + , CASE a.CONECT_USER_SE + WHEN 'USR' THEN (SELECT e.USER_NM FROM COMTNEMPLYRINFO e WHERE e.EMPLYR_ID = a.CONECT_ACNT_ID) + WHEN 'GNR' THEN (SELECT g.MBER_NM FROM COMTNGNRLMBER g WHERE g.MBER_ID = a.CONECT_ACNT_ID) + WHEN 'GRO' THEN (SELECT b.GROUP_NM FROM BIZ_GROUP b WHERE b.GROUP_ID = a.CONECT_ACNT_ID) + WHEN 'ENT' THEN (SELECT n.CMPNY_NM FROM COMTNENTRPRSMBER n WHERE n.ENTRPRS_MBER_ID = a.CONECT_ACNT_ID) + ELSE NULL + END AS userNm + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyConectLog_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyConectLog_SQL_Oracle.xml new file mode 100644 index 0000000..0a85ed9 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyConectLog_SQL_Oracle.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + a.CONECT_ACNT_ID LIKE '%' || #searchUserId# || '%' + + + a.CONECT_MTHD = #searchMthd# + + + a.CONECT_USER_SE = #searchUserSe# + + + a.CONECT_IP LIKE '%' || #searchIp# || '%' + + + a.CONECT_LC = #searchLc# + + + a.CREAT_DT >= TO_DATE(#searchBgnDe# || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS') + + + a.CREAT_DT <= TO_DATE(#searchEndDe# || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS') + + + + + + SELECT a.LOG_ID AS logId + , TO_CHAR(a.CREAT_DT, 'YYYY-MM-DD HH24:MI:SS') AS creatDt + , a.CONECT_MTHD AS conectMthd + , a.CONECT_IP AS conectIp + , a.CONECT_LC AS conectLc + , a.CONECT_ID AS conectId + , a.CONECT_ACNT_ID AS userId + , a.CONECT_USER_SE AS userSe + , a.ERROR_OCCRRNC_AT AS errOccrrAt + , CASE a.CONECT_USER_SE + WHEN 'USR' THEN (SELECT e.USER_NM FROM COMTNEMPLYRINFO e WHERE e.EMPLYR_ID = a.CONECT_ACNT_ID) + WHEN 'GNR' THEN (SELECT g.MBER_NM FROM COMTNGNRLMBER g WHERE g.MBER_ID = a.CONECT_ACNT_ID) + WHEN 'GRO' THEN (SELECT b.GROUP_NM FROM BIZ_GROUP b WHERE b.GROUP_ID = a.CONECT_ACNT_ID) + WHEN 'ENT' THEN (SELECT n.CMPNY_NM FROM COMTNENTRPRSMBER n WHERE n.ENTRPRS_MBER_ID = a.CONECT_ACNT_ID) + ELSE NULL + END AS userNm + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Mysql.xml new file mode 100644 index 0000000..311df7e --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Mysql.xml @@ -0,0 +1,189 @@ + + + + + + + + + + SELECT LAST_INSERT_ID() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Oracle.xml new file mode 100644 index 0000000..ae2de63 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Oracle.xml @@ -0,0 +1,190 @@ + + + + + + + + + + SELECT PDAL_DOWNLOAD_JOB_SEQ.CURRVAL FROM DUAL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberNmCrypto_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberNmCrypto_SQL_Mysql.xml new file mode 100644 index 0000000..c69702f --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberNmCrypto_SQL_Mysql.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + /* MemberNmCryptoDAO.updateEncrypted */ + UPDATE COMTNGNRLMBER + SET MBER_NM_ENC = #mberNmEnc# + , MBER_NM_HASH = #mberNmHash# + , CRYPTO_KEY_VER = #cryptoKeyVer# + WHERE MBER_ID = #mberId# + + + + + + + + + + + + + + + /* MemberNmCryptoDAO.insertProgressIfAbsent */ + INSERT INTO MIG_CRYPTO_PROGRESS (JOB_ID, LAST_KEY, DONE_CNT, ERR_CNT, JOB_STTUS, STR_DT) + SELECT #jobId#, NULL, 0, 0, 'R', NOW() + FROM DUAL + WHERE NOT EXISTS ( + SELECT 1 FROM MIG_CRYPTO_PROGRESS WHERE JOB_ID = #jobId# + ) + + + + /* MemberNmCryptoDAO.updateProgress */ + UPDATE MIG_CRYPTO_PROGRESS + SET LAST_KEY = #lastKey# + , DONE_CNT = DONE_CNT + #doneDelta# + , ERR_CNT = ERR_CNT + #errDelta# + WHERE JOB_ID = #jobId# + + + + /* MemberNmCryptoDAO.updateStatus */ + UPDATE MIG_CRYPTO_PROGRESS + SET JOB_STTUS = #jobSttus# + , RM = #rm# + , END_DT = CASE WHEN #jobSttus# IN ('C','E') THEN NOW() ELSE END_DT END + WHERE JOB_ID = #jobId# + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberNmCrypto_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberNmCrypto_SQL_Oracle.xml new file mode 100644 index 0000000..62a9f42 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberNmCrypto_SQL_Oracle.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + /* MemberNmCryptoDAO.updateEncrypted */ + UPDATE COMTNGNRLMBER + SET MBER_NM_ENC = #mberNmEnc# + , MBER_NM_HASH = #mberNmHash# + , CRYPTO_KEY_VER = #cryptoKeyVer# + WHERE MBER_ID = #mberId# + + + + + + + + + + + + /* MemberNmCryptoDAO.insertProgressIfAbsent */ + INSERT INTO MIG_CRYPTO_PROGRESS (JOB_ID, LAST_KEY, DONE_CNT, ERR_CNT, JOB_STTUS, STR_DT) + SELECT #jobId#, NULL, 0, 0, 'R', SYSDATE + FROM DUAL + WHERE NOT EXISTS ( + SELECT 1 FROM MIG_CRYPTO_PROGRESS WHERE JOB_ID = #jobId# + ) + + + + /* MemberNmCryptoDAO.updateProgress */ + UPDATE MIG_CRYPTO_PROGRESS + SET LAST_KEY = #lastKey# + , DONE_CNT = DONE_CNT + #doneDelta# + , ERR_CNT = ERR_CNT + #errDelta# + WHERE JOB_ID = #jobId# + + + + /* MemberNmCryptoDAO.updateStatus */ + UPDATE MIG_CRYPTO_PROGRESS + SET JOB_STTUS = #jobSttus# + , RM = #rm# + , END_DT = CASE WHEN #jobSttus# IN ('C','E') THEN SYSDATE ELSE END_DT END + WHERE JOB_ID = #jobId# + + + + + diff --git a/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml b/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml index 6bf30ee..b9331c1 100644 --- a/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml +++ b/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml @@ -75,6 +75,10 @@ ============================================================ --> + + + + + + + + + \A/mnt/session/.*\Z + \A/mnt/session/.*\Z \A/uat/uia/.*\Z + + \A/mnt/sec/(accessLog|authorHistory)/jobStatus\.do.*\Z diff --git a/src/main/webapp/WEB-INF/jsp/egovframework/com/uss/umt/EgovAccountUnlockManage.jsp b/src/main/webapp/WEB-INF/jsp/egovframework/com/uss/umt/EgovAccountUnlockManage.jsp index a845c96..7b4fb03 100644 --- a/src/main/webapp/WEB-INF/jsp/egovframework/com/uss/umt/EgovAccountUnlockManage.jsp +++ b/src/main/webapp/WEB-INF/jsp/egovframework/com/uss/umt/EgovAccountUnlockManage.jsp @@ -79,6 +79,8 @@ function fnRun(){ if(checkedIds.length > 0) { if(confirm("선택된 사용자의 잠금을 해제하시겠습니까?")){ + // [개인정보보호 SER-001 요건4] 접근통제 정책이 '해제 사유 필수'면 사유를 받아 이력에 남긴다. + if(!fnUnlockResn()) { return; } document.listForm.user_id.value=checkedIds; document.listForm.user_gubun.value=checkedGubuns; document.listForm.action = ""; @@ -89,6 +91,34 @@ function fnRun(){ } } +/********************************************************* + * [개인정보보호 SER-001 요건4] 잠금 해제 사유 입력 + * · 접근통제 정책(시스템관리 > 권한관리 > 접근통제 정책)이 + * '관리자 화면 해제 + 해제 사유 필수'일 때만 입력을 요구한다. + * · 입력값은 ACCOUNT_UNLOCK_HISTORY.UNLOCK_RESN 으로 적재되어 감사 대상이 된다. + * @return 진행 가능하면 true, 사용자가 취소하거나 미입력이면 false + ******************************************************** */ +function fnUnlockResn() { + var required = ${unlockResnRequired eq true ? 'true' : 'false'}; + if (!required) { + document.listForm.unlockResn.value = ""; + return true; + } + var resn = prompt("잠금 해제 사유를 입력하세요. (해제 이력에 기록됩니다)", ""); + if (resn === null) { return false; } + resn = resn.replace(/^\s+|\s+$/g, ""); + if (resn.length === 0) { + alert("해제 사유는 필수 입력입니다."); + return false; + } + if (resn.length > 500) { + alert("해제 사유는 500자 이내로 입력하세요."); + return false; + } + document.listForm.unlockResn.value = resn; + return true; +} + /********************************************************* * 전체선택 @@ -123,6 +153,8 @@ function fnCheckAll() { ******************************************************** */ function fnUserAll(){ if(confirm("전 회원(개인,그룹,활동처,기관관리자)의 잠금을 해제하시겠습니까?")){ + // 전체 해제는 영향 범위가 커서 개별 해제와 동일하게 사유 정책을 적용한다. + if(!fnUnlockResn()) { return; } document.listForm.action = ""; document.listForm.submit(); } @@ -252,6 +284,9 @@ box-sizing: inherit; +<%-- [개인정보보호 SER-001 요건4] 잠금 해제 사유. 접근통제 정책이 '해제 사유 필수'일 때 + fnRun()/fnUserAll() 이 입력받아 채우며, ACCOUNT_UNLOCK_HISTORY 에 적재된다. --%> + diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp index 8e54b83..a4ffbd6 100644 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp @@ -48,13 +48,13 @@ || param.page eq '9100022' || param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100041' || param.page eq '9100042'|| param.page eq '9100043' || param.page eq '9100071' || param.page eq '9100072' || param.page eq '9100080' - || param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045' || param.page eq ''}">class="on">시스템관리 + || param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045' || param.page eq '9100046' || param.page eq '9100047' || param.page eq ''}">class="on">시스템관리
    style="display: block;"> + || param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045' || param.page eq '9100046' || param.page eq '9100047' || param.page eq ''}">style="display: block;">
  • class="on">${menuUpperVo2.menuNm} @@ -94,10 +94,10 @@
  • -
  • class="on">${menuUpperVo2.menuNm} +
  • class="on">${menuUpperVo2.menuNm} -
      style="display: block;"> +
        style="display: block;"> @@ -130,6 +130,14 @@
      • class="on">${menuUpperVo4.menuNm}
      • + <%-- [개인정보/#62] 로그인·로그아웃 접속기록 조회·다운로드 --%> + +
      • class="on">${menuUpperVo4.menuNm}
      • +
        + <%-- [개인정보/SER-001 요건4·5] 접근통제 정책 설정(계정잠금·자동로그아웃·세션재발급) --%> + +
      • class="on">${menuUpperVo4.menuNm}
      • +
      diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp index 399bfa7..d834a0d 100644 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp @@ -42,6 +42,135 @@ function fn_download() { document.frm.dlReason.value = ""; document.frm.action = ""; } + +/* 다운로드 '요청'(비동기) : 사유를 받아 작업만 등록하고 목록으로 돌아온다. + 파일 생성은 서버 백그라운드가 맡으므로 화면은 기다리지 않는다. */ +function fn_downloadRequest() { + var reason = prompt("다운로드 사유를 입력하세요. (필수 — 다운로드 이력에 기록됩니다)", ""); + if (reason == null) { + return; /* 취소 */ + } + if (reason.replace(/^\s+|\s+$/g, "") == "") { + alert("다운로드 사유는 필수입니다."); + return; + } + document.frm.dlReason.value = reason; + document.frm.method = "get"; + document.frm.action = ""; + document.frm.submit(); +} + +/* 완료된 파일 내려받기. 요청자 본인만 가능하며 서버에서 다시 확인한다. */ +function fn_downloadFile(jobId) { + location.href = "?jobId=" + jobId; +} + +/* ── 다운로드 진행률 폴링 ───────────────────────────────────────────── + 종전에는 5초마다 이 목록화면을 통째로 다시 열었다. 그런데 이 화면은 접속기록 수집 + 대상(/mnt/sec/**)이라 새로고침 한 번이 곧 접속기록 1행이었다 — 사용자는 가만히 있는데 + 분당 12행씩 감사로그가 자기 자신을 기록했고, 정기점검·이상탐지 신호를 오염시켰다. + 지금은 수집에서 제외된 jobStatus.do 만 호출해 상태 칸만 제자리에서 고쳐 쓴다. + 화면 전체 재요청이 없으므로 폴링 중 접속기록은 단 1행도 늘지 않는다. */ +var DL_POLL_MS = 5000; + +function fn_pollIfRunning() { + if (fn_dlAnyRunning()) { + setTimeout(fn_dlPollOnce, DL_POLL_MS); + } +} + +function fn_dlAnyRunning() { + var el = document.getElementById("dlJobRunning"); + return !!(el && el.value == "Y"); +} + +function fn_dlPollOnce() { + $.ajax({ + url: "", + cache: false, + dataType: "text" + }).done(function(txt) { + if (fn_dlApply(txt)) { + setTimeout(fn_dlPollOnce, DL_POLL_MS); /* 아직 진행중 — 계속 본다 */ + } + /* 전부 끝났으면 폴링을 멈춘다. 목록은 이미 제자리 갱신돼 있어 새로고침이 필요 없다. */ + }).fail(function() { + /* 세션만료·일시적 오류 — 조용히 간격을 늘려 재시도한다(화면을 막지 않는다). */ + setTimeout(fn_dlPollOnce, DL_POLL_MS * 3); + }); +} + +/* 응답(한 줄 1작업: jobId|status|pct|done|total|errMsg)을 표에 반영. + @return 아직 대기/생성중인 작업이 남았으면 true */ +function fn_dlApply(txt) { + var lines = (txt || "").split("\n"); + var anyRunning = false; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (!line) { continue; } + var f = line.split("|"); + if (f.length < 5) { continue; } + + var jobId = f[0], st = f[1], pct = f[2], done = f[3], total = f[4]; + var err = (f.length > 5) ? f[5] : ""; + var row = document.getElementById("dlrow-" + jobId); + if (!row) { continue; } /* 폴링 중 새로 생긴 작업은 다음 화면 진입에서 보인다 */ + + if (st == "W" || st == "R") { anyRunning = true; } + fn_dlCell(row, "dl_st", fn_dlStatusHtml(st, err)); + fn_dlCell(row, "dl_pg", fn_dlProgressHtml(st, pct, done, total)); + fn_dlCell(row, "dl_dl", fn_dlLinkHtml(st, jobId)); + } + var flag = document.getElementById("dlJobRunning"); + if (flag) { flag.value = anyRunning ? "Y" : "N"; } + return anyRunning; +} + +function fn_dlCell(row, cls, html) { + var cells = row.getElementsByTagName("td"); + for (var i = 0; i < cells.length; i++) { + if (cells[i].className.indexOf(cls) >= 0) { + cells[i].innerHTML = html; + return; + } + } +} + +function fn_dlStatusHtml(st, err) { + if (st == "W") { return '대기'; } + if (st == "R") { return '생성중'; } + if (st == "S") { return '완료'; } + if (st == "F") { return '실패'; } + return '파기됨'; +} + +function fn_dlProgressHtml(st, pct, done, total) { + if (st == "S") { return fn_dlEsc(done) + '건'; } + if (st == "R") { return fn_dlEsc(pct) + '% (' + fn_dlEsc(done) + '/' + fn_dlEsc(total) + ')'; } + return '-'; +} + +function fn_dlLinkHtml(st, jobId) { + if (st == "S") { + return '내려받기'; + } + return '-'; +} + +/* 서버 문자열을 그대로 innerHTML 에 넣지 않는다(실패사유에 태그가 섞일 수 있다). */ +function fn_dlEsc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'"); +} + +/* 처리행위 요약 클릭 : 해당 처리구분으로 좁혀 재검색한다. + 같은 구분을 다시 누르면 해제(전체)해서 토글로 쓰게 한다. */ +function fn_dml(gubun) { + var sel = document.getElementById("dmlGubun"); + sel.value = (sel.value == gubun) ? "" : gubun; + fn_list('1'); +} //--> - + @@ -71,56 +217,139 @@ function fn_download() {
회원정보 목록
+ + + + + + + + + + + + + + + +
계정 잠금 정책
* + + 회 +
* + +
+ +
자동 로그아웃 (세션 타임아웃)
+ + + + + + + + + + + + + + + + +
자동 로그아웃 정책
* + + 분 +
* + + 만료 + + 분 전 안내 +
+ +
세션 재발급
+ + + + + + + + + + + + + + + + +
세션 재발급 정책
* + +
최종 수정 + + - + / + +
+ + + + + + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorHistory/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorHistory/list.jsp index 84d13fa..20b5088 100644 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorHistory/list.jsp +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorHistory/list.jsp @@ -9,6 +9,8 @@ + + @@ -45,10 +144,32 @@ function fn_download() { .badge.g { background:#3a9d5d; } .badge.m { background:#5b7ec2; } .badge.r { background:#c25b5b; } +.badge.s { background:#3a9d5d; } +.badge.f { background:#c25b5b; } +.trget_nm { display:block; } +.trget_id { display:block; font-size:11px; color:#888; } +.ip { font-family:Consolas, monospace; font-size:12px; color:#444; } +.nodata { color:#aaa; } +.dml { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; background:#eee; color:#555; } +.dl_job { margin:12px 0; padding:10px 12px; border:1px solid #d6e2f0; background:#f7fafd; } +.dl_job strong { font-size:13px; } +.dl_job .hash { margin-left:8px; font-size:11px; color:#777; } +.guide { margin:10px 0; padding:10px 12px; border:1px solid #e5d9a8; background:#fdfaef; font-size:12px; line-height:1.7; } +/* 검색 조건 5종(대상자ID·변경자ID·변경구분·기간·전체보기)을 한 줄에 둔다. + 공용 .w4 가 width:25% 라 입력칸 4개만으로 100%를 넘겨 줄바꿈이 생긴다 → + 이 화면에서는 px 고정폭을 쓰고 nowrap 으로 묶는다. 좁은 창에서는 가로 스크롤로 흘린다. */ +#search_wrap { overflow-x:auto; } +#search_wrap .date_search { white-space:nowrap; } +#search_wrap .date_search span { padding-left:8px; } +#search_wrap .date_search input.sch_txt { width:100px; } +#search_wrap .date_search input.sch_date { width:86px; } +#search_wrap .date_search select { max-width:90px; } +/* datepicker 가 showOn:"both" 로 붙이는 달력 아이콘 — 입력칸에 바짝 붙인다. */ +#search_wrap .date_search img.ui-datepicker-trigger { margin-left:3px; cursor:pointer; } - + @@ -56,14 +177,16 @@ function fn_download() { + <%-- 다운로드 사유 — 요청 시에만 채워 보낸다(검색 submit 에는 빈 값). --%> +
@@ -83,33 +206,100 @@ function fn_download() { + <%-- 비동기 다운로드 작업 목록 — 요청자 본인 것만 보인다. + 대용량은 요청만 걸어두고 다른 일을 하다가 완료 후 내려받는다. --%> + +
+
+ + + + + + + +
+ 내 다운로드 요청 + 파일은 암호화 보관되며 만료 시 자동 파기됩니다. 요청자 본인만 내려받을 수 있습니다. + + + + + + + + + + + + <%-- tr 의 id 와 td 의 dl_st/dl_pg/dl_dl 클래스는 폴링(fn_dlApply)이 제자리 갱신할 + 지점이다. 이름을 바꾸면 진행률이 멈춘 것처럼 보이니 함께 고칠 것. --%> + + + + + + + + + + + +
내 다운로드 요청 목록
요청일시검색조건상태진행만료내려받기
+ + 대기 + 생성중 + 완료 + 실패 + 파기됨 + + + + 건 + % (/) + - + + + + + '); return false;">내려받기 + + - + +
+
+
+ - - - - - - + + + + + + + - + - + + @@ -117,7 +307,10 @@ function fn_download() { - + + - + @@ -145,6 +344,13 @@ function fn_download() { + +
+ 변경자 IP — 권한을 실제로 반영한 취급자의 접속 IP입니다(프록시 환경은 X-Forwarded-For 보정값). + 권한 부여·변경·회수 내역은 최소 3년 보관되며 위·변조 방지를 위해 수정·삭제되지 않습니다.
+ 다운로드 — 사유 입력 후 요청하면 서버가 백그라운드에서 파일을 만듭니다. + 목록의 상태가 완료 로 바뀌면 내려받을 수 있고, 만료된 파일은 자동 파기됩니다. +
diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorRequest/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorRequest/list.jsp index 0c676f7..07e3280 100644 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorRequest/list.jsp +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorRequest/list.jsp @@ -17,8 +17,14 @@ function fn_list(pageIndex) { document.frm.action = ""; document.frm.submit(); } -function fn_regist() { - location.href = ""; +// 권한 부여(신설) 신청 — 대상자 미지정 +function fn_grant() { + location.href = "?requestSe=N"; +} +// 권한 변경/말소 신청 — 대상자 계정ID 프리필 +function fn_request(trgetId, requestSe) { + location.href = "?requestSe=" + requestSe + + "&trgetId=" + encodeURIComponent(trgetId); } function fn_approve(requestId) { if (!confirm("해당 권한변경 신청을 승인하시겠습니까?\n승인 시 권한이 즉시 반영되고 변경이력이 적재됩니다.")) { @@ -65,9 +71,17 @@ function fn_reject_submit() { #rejectLayer .box textarea { width:100%; height:120px; } #rejectLayer .box .btn_zone { margin-top:12px; text-align:right; } .badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; } -.badge.r { background:#5b7ec2; } -.badge.a { background:#3a9d5d; } -.badge.j { background:#c25b5b; } +.badge.n { background:#3a9d5d; } +.badge.w { background:#d09a2c; } +.badge.r { background:#888; } +.role { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; background:#e8eff8; color:#2c5c94; } +.role.none { background:#f0f0f0; color:#888; } +.role.before { background:#f2f2f2; color:#666; } +.role.after { background:#e8f3ea; color:#256c3c; } +.req_se { display:block; margin-top:3px; font-size:11px; color:#c07a12; } +.list_summary { margin:8px 0; font-size:13px; } +.list_summary strong { color:#1a6fb5; } +.notice_box { margin-top:14px; padding:12px 14px; border:1px solid #e5d9a8; background:#fdfaef; font-size:12px; line-height:1.7; } @@ -77,90 +91,136 @@ function fn_reject_submit() { - +
- + +
+ 전체 명 + · 승인대기 건 +
-
권한변경 이력 목록
NO. 변경일시대상자ID대상자 변경구분 변경전 권한 변경후 권한 변경사유 승인자 변경자ID변경자IP
+ + + 부여 @@ -131,11 +324,17 @@ function fn_download() { + + - + + +
- +
권한변경 신청 목록
+ - - + + + - + + - + - - - - - - - + + + + + + + + + - - - + + + + - - - + + + + - + @@ -170,6 +230,14 @@ function fn_reject_submit() { + +
+ 말소 처리 안내 — 인사이동·퇴직 발생 시 지체 없이 권한을 변경·말소해야 하며(안전성 확보조치 기준 제5조), + 모든 부여·변경·말소 내역은 자동으로 이력에 기록되어 최소 3년간 보관됩니다. + 권한 변경은 신청 후 승인 시점에 반영되며, 승인 전까지는 승인대기 로 표시됩니다.
+ 변경 전·후 역할 — 승인대기 행은 처리 대기 중인 신청 내용(신청 시점 권한 → 신청 목표 권한)을, + 그 외 행은 가장 최근 확정된 권한변경 이력을 표시합니다. 변경 이력이 없으면 변경 전은 ‘-’, 변경 후는 현재 역할로 표기됩니다. +
diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorRequest/regist.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorRequest/regist.jsp index aba0470..a9c9032 100644 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorRequest/regist.jsp +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorRequest/regist.jsp @@ -14,6 +14,10 @@ function fn_list() { location.href = ""; } +// 목록 [부여/변경/말소] 진입으로 대상자 계정ID가 프리필된 경우 고유식별자를 자동 조회 +function fn_prefill() { + if ($.trim(document.frm.trgetId.value).length > 0) { fn_find_target(); } +} function fn_toggleAfter() { // 회수(R)는 목표권한 입력 불필요 → 비활성 var se = document.frm.requestSe.value; @@ -134,7 +138,7 @@ function fn_cancel() { document.frm.reset(); fn_toggleAfter(); } - + diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp new file mode 100644 index 0000000..b2be44c --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp @@ -0,0 +1,209 @@ +<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%> +<%--// Head --%> +<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%> +<%@ taglib prefix="ui" uri="http://egovframework.gov/ctl/ui"%> +<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%> +<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> + +<%-- + 로그인·로그아웃 접속기록 조회·다운로드 (개인정보보호 #62) + · COMTNLOGINLOG 적재분(접속ID·접속일시·접속IP·접속위치)을 조건별로 검색 + · 접속ID 는 원본 CONECT_ID(고유식별자)가 아니라 회원 테이블 조인으로 얻은 계정ID + · 다운로드는 사유 필수 — 미입력 시 서버가 400 으로 차단하고 파일을 열지 않음 + · 접속기록 위·변조 방지 — 이 화면은 수정·삭제를 제공하지 않는다 +--%> + + + + + + + + + + + + + + + + + +
+ +
+ + +
+ · 시스템 로그인·로그아웃 기록입니다. 기간은 yyyy-MM-dd 형식으로 입력하세요.
+ · 접속ID는 로그인에 사용된 계정ID를 기록 시점에 그대로 저장한 값입니다. 성명은 회원정보에서 보조 조회하며, 탈퇴 등으로 대조되지 않으면 비어 있습니다.
+ · 접속위치는 접속 IP가 속한 대역(CONECT_IP_LOCATION 매핑)을 기록 시점에 판정해 저장한 값입니다. 이후 매핑을 바꿔도 지난 기록은 변하지 않습니다.
+ · 2026-08-07 이전 기록은 접속ID·접속IP·접속위치가 비어 있습니다 — 종전 적재 로직이 IP를 남기지 않았고, 저장된 고유식별자로는 계정을 특정할 수 없습니다(소급 복원 불가). 이 경우 고유식별자를 회색으로 표시합니다.
+ · 다운로드는 사유 입력이 필수이며, 다운로드자·일시·IP·사유·건수가 다운로드 이력에 기록됩니다. 접속기록의 수정·삭제는 제공하지 않습니다. +
+ +
+ 총 건 + +
+ +
개인정보취급자 권한 관리 목록
NO.신청일시대상자ID신청구분신청사유신청자처리상태처리취급자명아이디소속기관변경 전 역할변경 후 역할접근범위권한상태최근변경일관리
- 권한신설 - 권한변경 - 권한회수 - - - - - 신청 - 승인 - 반려 - + - + - - ');" class="btn2">승인 - ');" class="btn1">반려 + + - + 권한없음 + + + + ( + 신설신청 + 변경신청 + 말소신청 + + ) + + 정상 + 승인대기 + 말소 + + + + + + ');" class="btn2">승인 + ');" class="btn1">반려 + + + ', 'N');" class="btn3">부여 + + + ', 'C');" class="btn3">변경 + ', 'R');" class="btn1">말소 + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
로그인·로그아웃 접속기록 목록
NO.접속일시접속ID성명사용자구분접속구분접속IP접속위치
+ + + + + + + 업무사용자 + 개인 + 그룹 + 활동처 + - + + + + 로그인 + 로그아웃 + + + + + + 미기록 + + + + + 미기록 + +
+ +
+ +
+ + + +
+ + + + + + + diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/dormantStatus/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/dormantStatus/list.jsp index b722488..19c1d71 100644 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/dormantStatus/list.jsp +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/dormantStatus/list.jsp @@ -13,18 +13,108 @@ @@ -36,81 +126,172 @@ function fn_list(pageIndex) { + <%-- 최초 진입 여부 표시 — 'Y' 면 사용자가 검색조건을 직접 지정한 것으로 본다. --%> + + <%-- 조치 사유 — 잠금·말소 요청 시에만 채워 보낸다(검색 submit 에는 빈 값). --%> + + + +
+
+ + + + + + + + + + + + +
장기 미사용 계정 요약
+ + 전체 계정 + + + 휴면 예정(일↑) + + + 휴면/잠금(일↑) + + + + 자동(매일) + 자동(매주) + 자동(매월) + 수동 + + + 정책 적용중 · 파기 사용 +
+
+ +
+ + + 미접속 일 이상 + 전체 + + 건 + + 선택 잠금 + 선택 말소 +
+ - + + + + - - - - - + - - - - - - - - - + + + + + + + + - - - - - - - + + + + + + - + - + @@ -120,6 +301,14 @@ function fn_list(pageIndex) { + +
+ 자동 정책 — 미접속 일 경과 시 휴면예정 으로 표시하고 사전알림을 보냅니다. + 미접속 일 경과 시 휴면·잠금 으로 자동 전환되어 로그인이 차단되고, + 잠금 후에도 미사용이면 관리자가 확인해 말소 합니다. 모든 조치는 사유와 함께 이력에 기록됩니다.
+ 기준일수·알림시점·배치주기는 시스템관리 > 권한관리 > 계정 관리 정책 화면에서 변경할 수 있습니다(소스 하드코딩 아님). + 말소된 계정의 개인정보는 보존기간 경과 후 파기 배치가 처리합니다. 관리자 본인 계정은 조치 대상에서 제외됩니다. +
diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/top.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/top.jsp index c28f49e..1dcbe76 100644 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/top.jsp +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/top.jsp @@ -1,6 +1,14 @@ <%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%><%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%><%--// Head --%> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%> +<%-- [개인정보보호 SER-001 요건5] 로그인 경과시간·자동 로그아웃 표시의 서버 기준시각. + 클라이언트 시계가 틀어져 있어도 표시가 어긋나지 않도록 서버 시각으로 보정한다. --%> + + +
장기 미사용 계정 현황 목록
NO.계정ID성명소속직위최종로그인일미접속일수상태휴면예정일취급자명아이디역할(Role)마지막 접속일미접속 일수계정상태조치
- 사용 - 승인대기 - 사용중지 - 말소 + + + + + + + + + + - + + + + wl">일 + + + 사용 + 휴면예정 + 휴면·잠금 + 말소 + + + ', 'D'); return false;">말소 + + + ', 'L'); return false;">잠금 + + - + +