From 65bbe591d168872da7cc7e903d8d52e8c06efc45 Mon Sep 17 00:00:00 2001 From: infraurp Date: Wed, 5 Aug 2026 14:48:24 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20IP=20=EA=B8=B0=EB=A1=9D=C2=B7=EB=B3=B4?= =?UTF-8?q?=EA=B4=80=EA=B8=B0=EA=B0=84=C2=B7=EC=A1=B0=ED=9A=8C=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EA=B5=AC=ED=98=84=20(#22-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #22(실패이력 활성 경로 이설)로 적재는 살아났으나 LOGIN_HISTORY가 ID/CREATE_DATE/GUBUN 3컬럼뿐이라 (1) 접속 IP 미기록 (2) 로그인 성공 시 당일 이력 DELETE로 무차별 대입 흔적 소멸 문제가 있었다. - DDL: LOGIN_HISTORY에 CONECT_IP(45)/RESET_YN + 인덱스 2종, ACCOUNT_MNG_POLICY.LOGIN_HIST_KEEP_DAY(기본 730일) 추가 - 적재: insertFailHistory 컬럼 명시 전환 + ClientIpResolver IP 세팅 (종전 무컬럼 VALUES(3값)은 컬럼 추가 시 ERROR 1136 — DDL과 한 쌍) - 보존: 성공 시 DELETE -> RESET_YN='Y' 소프트 리셋. selectFailCount가 RESET_YN='N'만 세므로 계정잠금 동작은 종전과 동일 - 화면: /mnt/sec/loginFailHistory/listView.do (계정·구분·IP·기간·리셋여부 검색 + 페이징), 메뉴 9100039 + left.jsp 분기 - 정책화면: 계정 관리 정책에 보관기간 입력 항목·검증 추가 - 배치: purgeExpiredLoginFailHistoryScheduler 매일 03:30, 1회 5,000건 상한 - Mysql/Oracle sqlmap 쌍 + sql-map-config 등록 Co-Authored-By: Claude Opus 5 (1M context) --- .../privacy/account/AccountMngController.java | 179 +++++++++++++- .../privacy/account/AccountMngPolicyVO.java | 14 ++ .../lock/LoginFailHistoryController.java | 95 ++++++++ .../cmm/privacy/lock/LoginFailHistoryDAO.java | 61 +++++ .../privacy/lock/LoginFailHistoryService.java | 33 +++ .../lock/LoginFailHistoryServiceImpl.java | 52 +++++ .../cmm/privacy/lock/LoginFailHistoryVO.java | 91 ++++++++ .../com/uat/uia/service/impl/LoginDAO.java | 13 +- .../com/uat/uia/web/EgovLoginController.java | 5 + .../kr/or/arko/cmm/batch/BatchScheduler.java | 71 +++++- .../com/uat/uia/EgovLoginUsr_SQL_Mysql.xml | 95 +++++--- .../com/uat/uia/EgovLoginUsr_SQL_Oracle.xml | 123 ++++++---- .../mysql/sql-map-config-mysql-0-site.xml | 6 + .../privacy/PrivacyAccountMng_SQL_Mysql.xml | 4 + .../privacy/PrivacyAccountMng_SQL_Oracle.xml | 6 +- .../PrivacyLoginFailHistory_SQL_Mysql.xml | 94 ++++++++ .../PrivacyLoginFailHistory_SQL_Oracle.xml | 86 +++++++ src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp | 21 +- .../jsp/site/mnt/sec/accountPolicy/policy.jsp | 218 ++++++++++++++++++ .../site/mnt/sec/loginFailHistory/list.jsp | 150 ++++++++++++ .../privacy.login_history_extend_mysql.sql | 55 +++++ ...y.menu_login_fail_history_insert_mysql.sql | 54 +++++ src/script/mysql/privacy.install_mysql.sql | 195 +++++++++++++++- 개인정보보호_개발진행현황.md | 34 +-- 24 files changed, 1636 insertions(+), 119 deletions(-) create mode 100644 src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryController.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryDAO.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryService.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryServiceImpl.java create mode 100644 src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryVO.java create mode 100644 src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyLoginFailHistory_SQL_Mysql.xml create mode 100644 src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyLoginFailHistory_SQL_Oracle.xml create mode 100644 src/main/webapp/WEB-INF/jsp/site/mnt/sec/accountPolicy/policy.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/site/mnt/sec/loginFailHistory/list.jsp create mode 100644 src/script/mysql/ddl/privacy.login_history_extend_mysql.sql create mode 100644 src/script/mysql/dml/privacy.menu_login_fail_history_insert_mysql.sql 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 5b43ad2..f558e36 100644 --- a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java +++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java @@ -3,21 +3,27 @@ package egovframework.com.cmm.privacy.account; import java.util.List; import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; 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.util.EgovUserDetailsHelper; +import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo; + /** * 계정 관리 정책·현황 컨트롤러 (SFR-002). * - *

#13 계정 관리 정책 조회/수정, #11 장기 미사용 계정 현황 조회 백엔드 엔드포인트. - * 화면(JSP)은 후속(#11/#13 프론트) — 여기서는 jsonView 반환.

+ *

#13 계정 관리 정책 조회/수정, #11 장기 미사용 계정 현황 조회 백엔드 엔드포인트.

*/ @Controller public class AccountMngController { + private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; + @Resource(name = "accountMngPolicyService") private AccountMngPolicyService accountMngPolicyService; @@ -27,25 +33,188 @@ public class AccountMngController { /** #13 정책 조회. */ @RequestMapping(value = "/mnt/privacy/accountPolicy.do") public String selectPolicy(ModelMap model) throws Exception { + if (handlerUser() == null) { + return denyJson(model); + } model.addAttribute("policy", accountMngPolicyService.selectPolicy()); return "jsonView"; } /** #13 정책 수정. */ @RequestMapping(value = "/mnt/privacy/accountPolicyUpdate.do") - public String updatePolicy(@ModelAttribute("policy") AccountMngPolicyVO policy, ModelMap model) throws Exception { + public String updatePolicy(@ModelAttribute("policy") AccountMngPolicyVO policy, + HttpServletRequest request, ModelMap model) throws Exception { + LoginVO loginVO = handlerUser(); + if (loginVO == null) { + return denyJson(model); + } + if (!isSameOrigin(request)) { + model.addAttribute("result", "fail"); + model.addAttribute("message", "잘못된 요청 경로입니다."); + return "jsonView"; + } + + String invalid = validatePolicy(policy); + if (invalid != null) { + model.addAttribute("result", "fail"); + model.addAttribute("message", invalid); + return "jsonView"; + } + + policy.setUpdId(loginVO.getId()); accountMngPolicyService.updatePolicy(policy); model.addAttribute("result", "success"); return "jsonView"; } - /** #11 장기 미사용 계정 현황 목록(페이징). */ + /** + * #13 계정 관리 정책 설정 화면(서버 렌더링). + * 뷰: /WEB-INF/jsp/site/mnt/sec/accountPolicy/policy.jsp + */ + @RequestMapping(value = "/mnt/sec/accountPolicy/policyView.do") + public String accountPolicyView(ModelMap model) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + model.addAttribute("policy", accountMngPolicyService.selectPolicy()); + return "site/mnt/sec/accountPolicy/policy"; + } + + /** 화면 우회 입력(직접 POST)에 대비한 서버측 범위 검증. 위반 시 사용자 메시지 반환. */ + private String validatePolicy(AccountMngPolicyVO p) { + if (p.getDormantDay() < 1 || p.getDormantDay() > 3650) { + return "휴면 전환 기준일수는 1~3650일 범위로 입력하세요."; + } + if (p.getAlertD1() < 0 || p.getAlertD2() < 0 || p.getAlertD3() < 0 + || p.getAlertD1() >= p.getDormantDay() || p.getAlertD2() >= p.getDormantDay() || p.getAlertD3() >= p.getDormantDay()) { + return "사전알림 시점은 0 이상이며 휴면 기준일수보다 작아야 합니다."; + } + if (p.getAlertD1() <= p.getAlertD2() || p.getAlertD2() <= p.getAlertD3()) { + return "사전알림 시점은 1차 > 2차 > 3차 순서로 입력하세요."; + } + if (p.getDeletePreserveDay() < 1 || p.getDeletePreserveDay() > 3650) { + return "삭제 보존기간은 1~3650일 범위로 입력하세요."; + } + if (!"DAILY".equals(p.getBatchCycle()) && !"WEEKLY".equals(p.getBatchCycle()) && !"MONTHLY".equals(p.getBatchCycle())) { + return "배치 주기가 올바르지 않습니다."; + } + // [개인정보/#22-1] 인증 실패 이력 보관기간. 화면 우회 입력 대비 서버측 범위 검증. + if (p.getLoginHistKeepDay() < 1 || p.getLoginHistKeepDay() > 3650) { + return "인증 실패 이력 보관기간은 1~3650일 범위로 입력하세요."; + } + // #12 파기 배치 사용여부 — 체크박스 미전송 시 null 이 오므로 'N' 으로 보정 + if (p.getPurgeUseYn() == null || p.getPurgeUseYn().trim().length() == 0) { + p.setPurgeUseYn("N"); + } else if (!"Y".equals(p.getPurgeUseYn()) && !"N".equals(p.getPurgeUseYn())) { + return "계정 파기 사용여부 값이 올바르지 않습니다."; + } + String email = p.getAdminEmail(); + if (email != null && email.trim().length() > 0) { + // ADMIN_EMAIL VARCHAR(200) — 길이 초과 시 DB 오류 대신 사용자 메시지로 반환 + if (email.length() > 200 || !email.matches("^[\\w.%+-]+@[\\w.-]+\\.[A-Za-z]{2,}$")) { + return "관리자 수신 이메일 형식이 올바르지 않습니다."; + } + } + return null; + } + + /** + * 취급자 영역(/mnt) 인증·구분 판정. 인터셉터 3단(인증판정 비활성 / SecondFactor·Authority 는 + * 미인증 통과 fail-open)만으로는 이 컨트롤러가 다루는 계정정책·취급자 목록이 무인증으로 + * 노출되므로, 화면·엔드포인트 단에서 직접 판정한다. + * + * @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) { + model.addAttribute("result", "fail"); + model.addAttribute("message", "접근 권한이 없습니다. 관리자 계정으로 로그인 후 이용하세요."); + return "jsonView"; + } + + /** #11 장기 미사용 계정 현황 목록(페이징, AJAX/JSON). */ @RequestMapping(value = "/mnt/privacy/dormantStatusList.do") public String selectDormantStatusList(@ModelAttribute("searchVO") DormantAccountStatusVO searchVO, ModelMap model) throws Exception { + if (handlerUser() == null) { + return denyJson(model); + } List list = dormantAccountStatusService.selectDormantStatusList(searchVO); model.addAttribute("resultList", list); model.addAttribute("totalCount", Integer.valueOf(searchVO.getTotalRecordCount())); - // TODO 화면 #11 프론트 후속(JSP) return "jsonView"; } + + /** + * #11 장기 미사용 계정 현황 조회 화면(서버 렌더링). + * 뷰: /WEB-INF/jsp/site/mnt/sec/dormantStatus/list.jsp + */ + @RequestMapping(value = "/mnt/sec/dormantStatus/listView.do") + public String dormantStatusListView(@ModelAttribute("dormantAccountStatusVO") DormantAccountStatusVO searchVO, + ModelMap model) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + PaginationInfo paginationInfo = new PaginationInfo(); + paginationInfo.setCurrentPageNo(searchVO.getPageIndex()); + paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage()); + paginationInfo.setPageSize(10); + + // selectDormantStatusList 내부에서 firstIndex/totalRecordCount 를 세팅한다. + List list = dormantAccountStatusService.selectDormantStatusList(searchVO); + paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount()); + + model.addAttribute("resultList", list); + model.addAttribute("paginationInfo", paginationInfo); + return "site/mnt/sec/dormantStatus/list"; + } } diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyVO.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyVO.java index cbda2d9..29d6244 100644 --- a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyVO.java +++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyVO.java @@ -34,6 +34,16 @@ public class AccountMngPolicyVO implements Serializable { private int deletePreserveDay = 365; /** 배치 주기(DAILY/WEEKLY/MONTHLY). */ private String batchCycle = "DAILY"; + /** + * 장기 미사용 계정 파기 배치 사용여부(#12). Y:사용 N:미사용. + * 기본 'N' — 관리자가 정책 화면에서 명시적으로 켜야 파기가 수행된다(되돌릴 수 없는 처리). + */ + private String purgeUseYn = "N"; + /** + * 인증 실패 이력(LOGIN_HISTORY) 보관기간(일, 기본 730=2년, #22-1). + * 경과분은 BatchScheduler.purgeExpiredLoginFailHistoryScheduler 가 파기한다. + */ + private int loginHistKeepDay = 730; /** 사전알림 관리자 수신 이메일. */ private String adminEmail; private String updId; @@ -53,6 +63,10 @@ public class AccountMngPolicyVO implements Serializable { public void setDeletePreserveDay(int deletePreserveDay) { this.deletePreserveDay = deletePreserveDay; } 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; } + public int getLoginHistKeepDay() { return loginHistKeepDay; } + public void setLoginHistKeepDay(int loginHistKeepDay) { this.loginHistKeepDay = loginHistKeepDay; } public String getAdminEmail() { return adminEmail; } public void setAdminEmail(String adminEmail) { this.adminEmail = adminEmail; } public String getUpdId() { return updId; } diff --git a/src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryController.java b/src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryController.java new file mode 100644 index 0000000..371daa4 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryController.java @@ -0,0 +1,95 @@ +package egovframework.com.cmm.privacy.lock; + +import java.util.List; + +import javax.annotation.Resource; + +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.util.EgovUserDetailsHelper; +import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo; + +/** + * 인증 실패 이력 조회 컨트롤러 — 개인정보보호 기능개선 사업(SER-001 / #22-1). + * + *

#22 로 살아난 {@code LOGIN_HISTORY} 적재분을 관리자가 계정·IP·기간으로 + * 조회한다. 화면: {@code /WEB-INF/jsp/site/mnt/sec/loginFailHistory/list.jsp}

+ * + *

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

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.05    URP     최초 생성 (개인정보보호 SER-001 #22-1)
+ * 
+ */ +@Controller +public class LoginFailHistoryController { + + private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; + + @Resource(name = "loginFailHistoryService") + private LoginFailHistoryService loginFailHistoryService; + + /** + * 인증 실패 이력 조회 화면(서버 렌더링). + * 뷰: /WEB-INF/jsp/site/mnt/sec/loginFailHistory/list.jsp + */ + @RequestMapping(value = "/mnt/sec/loginFailHistory/listView.do") + public String listView(@ModelAttribute("loginFailHistoryVO") LoginFailHistoryVO searchVO, + ModelMap model) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + + List list = loginFailHistoryService.selectLoginFailHistoryList(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); + return "site/mnt/sec/loginFailHistory/list"; + } + + /** 인증 실패 이력 목록(AJAX/JSON). */ + @RequestMapping(value = "/mnt/privacy/loginFailHistoryList.do") + public String selectList(@ModelAttribute("searchVO") LoginFailHistoryVO searchVO, + ModelMap model) throws Exception { + if (handlerUser() == null) { + model.addAttribute("result", "fail"); + model.addAttribute("message", "접근 권한이 없습니다. 관리자 계정으로 로그인 후 이용하세요."); + return "jsonView"; + } + model.addAttribute("resultList", loginFailHistoryService.selectLoginFailHistoryList(searchVO)); + model.addAttribute("totalCount", Integer.valueOf(searchVO.getTotalRecordCount())); + return "jsonView"; + } + + /** + * 취급자 영역(/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/lock/LoginFailHistoryDAO.java b/src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryDAO.java new file mode 100644 index 0000000..f89babb --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryDAO.java @@ -0,0 +1,61 @@ +package egovframework.com.cmm.privacy.lock; + +import java.util.HashMap; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 인증 실패 이력 DAO — 개인정보보호 기능개선 사업(SER-001 / #22-1). + * + *

{@code LOGIN_HISTORY} 조회(화면) + 보관기간 경과분 파기(배치). + * 적재(insert)·리셋(update)은 로그인 흐름 소유인 {@code loginDAO} 가 담당하므로 + * 여기서는 정의하지 않는다(이중 경로 방지).

+ * + *

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

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.05    URP     최초 생성 (개인정보보호 SER-001 #22-1)
+ * 
+ */ +@Repository("loginFailHistoryDAO") +public class LoginFailHistoryDAO extends EgovComAbstractDAO { + + /** 인증 실패 이력 목록(검색·페이징) */ + @SuppressWarnings("unchecked") + public List selectLoginFailHistoryList(LoginFailHistoryVO vo) { + return list("LoginFailHistoryDAO.selectLoginFailHistoryList", vo); + } + + /** 인증 실패 이력 전체 건수(페이징) */ + public int selectLoginFailHistoryListCount(LoginFailHistoryVO vo) { + Object cnt = selectByPk("LoginFailHistoryDAO.selectLoginFailHistoryListCount", vo); + return cnt == null ? 0 : ((Integer) cnt).intValue(); + } + + /** 보관기간(일) 정책값 조회. 미설정 시 730 fallback 은 statement 내부에서 처리. */ + public int selectKeepDay() { + Object day = selectByPk("LoginFailHistoryDAO.selectLoginHistKeepDay", null); + return day == null ? 730 : ((Integer) day).intValue(); + } + + /** + * 보관기간 경과 실패 이력 파기. + * + * @param keepDay 보관기간(일) + * @param limit 1회 처리 상한(백로그 폭주 방지) + * @return 파기 건수 + */ + public int deleteExpiredLoginFailHistory(int keepDay, int limit) { + HashMap param = new HashMap(); + param.put("keepDay", Integer.valueOf(keepDay)); + param.put("limitCnt", Integer.valueOf(limit)); + return delete("LoginFailHistoryDAO.deleteExpiredLoginFailHistory", param); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryService.java b/src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryService.java new file mode 100644 index 0000000..b4734f8 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryService.java @@ -0,0 +1,33 @@ +package egovframework.com.cmm.privacy.lock; + +import java.util.List; + +/** + * 인증 실패 이력 조회·파기 서비스 — 개인정보보호 기능개선 사업(SER-001 / #22-1). + * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.05    URP     최초 생성 (개인정보보호 SER-001 #22-1)
+ * 
+ */ +public interface LoginFailHistoryService { + + /** + * 인증 실패 이력 목록 조회. 호출 후 {@code searchVO.totalRecordCount} 가 세팅된다. + * + * @param searchVO 검색조건·페이징 + * @return 실패 이력 목록 + * @throws Exception 조회 오류 + */ + List selectLoginFailHistoryList(LoginFailHistoryVO searchVO) throws Exception; + + /** + * 보관기간(ACCOUNT_MNG_POLICY.LOGIN_HIST_KEEP_DAY) 경과 실패 이력을 파기한다. + * + * @return 파기 건수 + * @throws Exception 파기 오류 + */ + int purgeExpiredLoginFailHistory() throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryServiceImpl.java new file mode 100644 index 0000000..2d36752 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryServiceImpl.java @@ -0,0 +1,52 @@ +package egovframework.com.cmm.privacy.lock; + +import java.util.List; + +import javax.annotation.Resource; + +import org.springframework.stereotype.Service; + +/** + * 인증 실패 이력 조회·파기 서비스 구현 — 개인정보보호 기능개선 사업(SER-001 / #22-1). + * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.05    URP     최초 생성 (개인정보보호 SER-001 #22-1)
+ * 
+ */ +@Service("loginFailHistoryService") +public class LoginFailHistoryServiceImpl implements LoginFailHistoryService { + + /** 1회 파기 처리 상한 — 배치가 오래 멈췄다 재가동되는 날 백로그 폭주 방지. */ + private static final int PURGE_LIMIT = 5000; + + /** 정책값 미설정 시 보관기간 기본값(일). */ + private static final int DEFAULT_KEEP_DAY = 730; + + @Resource(name = "loginFailHistoryDAO") + private LoginFailHistoryDAO loginFailHistoryDAO; + + @Override + public List selectLoginFailHistoryList(LoginFailHistoryVO searchVO) throws Exception { + if (searchVO.getRecordCountPerPage() < 1) { + searchVO.setRecordCountPerPage(10); + } + if (searchVO.getPageIndex() < 1) { + searchVO.setPageIndex(1); + } + searchVO.setFirstIndex((searchVO.getPageIndex() - 1) * searchVO.getRecordCountPerPage()); + searchVO.setTotalRecordCount(loginFailHistoryDAO.selectLoginFailHistoryListCount(searchVO)); + return loginFailHistoryDAO.selectLoginFailHistoryList(searchVO); + } + + @Override + public int purgeExpiredLoginFailHistory() throws Exception { + int keepDay = loginFailHistoryDAO.selectKeepDay(); + if (keepDay < 1) { + keepDay = DEFAULT_KEEP_DAY; + } + return loginFailHistoryDAO.deleteExpiredLoginFailHistory(keepDay, PURGE_LIMIT); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryVO.java b/src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryVO.java new file mode 100644 index 0000000..f7ff416 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/lock/LoginFailHistoryVO.java @@ -0,0 +1,91 @@ +package egovframework.com.cmm.privacy.lock; + +import java.io.Serializable; + +/** + * 인증 실패 이력 VO — 개인정보보호 기능개선 사업(SER-001 / #22-1). + * + *

{@code LOGIN_HISTORY} 조회 결과 + 검색조건 + 페이징. + * #22(실패이력 활성 경로 이설)로 적재가 살아난 뒤, #22-1 에서 접속 IP(CONECT_IP)와 + * 보존(RESET_YN 소프트 리셋)을 추가하면서 조회 화면 대상이 되었다.

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.05    URP     최초 생성 (개인정보보호 SER-001 #22-1)
+ * 
+ */ +public class LoginFailHistoryVO implements Serializable { + + private static final long serialVersionUID = 1L; + + // ---- 조회 결과 ---- + /** 시도 계정ID(LOGIN_HISTORY.ID) */ + private String id; + /** 사용자구분(GNR:개인 GRO:그룹 ENT:활동처 USR:업무사용자) */ + private String gubun; + /** 실패 일시(yyyy-MM-dd HH:mm:ss) */ + private String createDate; + /** 접속 IP(#22-1) */ + private String conectIp; + /** 잠금카운터 리셋여부 Y:이후 로그인 성공으로 리셋 N:유효 */ + private String resetYn; + /** 성명(회원 테이블 조인, 없으면 null) */ + private String userNm; + + // ---- 검색조건 ---- + /** 계정ID(부분일치) */ + private String searchId; + /** 사용자구분(완전일치) */ + private String searchGubun; + /** 접속 IP(부분일치) */ + private String searchIp; + /** 조회 시작일(yyyy-MM-dd) */ + private String searchBgnDe; + /** 조회 종료일(yyyy-MM-dd) */ + private String searchEndDe; + /** 리셋여부 필터(Y/N, 공백=전체) */ + private String searchResetYn; + + // ---- 페이징 ---- + private int pageIndex = 1; + private int recordCountPerPage = 10; + private int firstIndex = 0; + private int totalRecordCount = 0; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getGubun() { return gubun; } + public void setGubun(String gubun) { this.gubun = gubun; } + public String getCreateDate() { return createDate; } + public void setCreateDate(String createDate) { this.createDate = createDate; } + public String getConectIp() { return conectIp; } + public void setConectIp(String conectIp) { this.conectIp = conectIp; } + public String getResetYn() { return resetYn; } + public void setResetYn(String resetYn) { this.resetYn = resetYn; } + public String getUserNm() { return userNm; } + public void setUserNm(String userNm) { this.userNm = userNm; } + + public String getSearchId() { return searchId; } + public void setSearchId(String searchId) { this.searchId = searchId; } + public String getSearchGubun() { return searchGubun; } + public void setSearchGubun(String searchGubun) { this.searchGubun = searchGubun; } + public String getSearchIp() { return searchIp; } + public void setSearchIp(String searchIp) { this.searchIp = searchIp; } + 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 String getSearchResetYn() { return searchResetYn; } + public void setSearchResetYn(String searchResetYn) { this.searchResetYn = searchResetYn; } + + 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/uat/uia/service/impl/LoginDAO.java b/src/main/java/egovframework/com/uat/uia/service/impl/LoginDAO.java index ad2d1da..4d2f9fa 100644 --- a/src/main/java/egovframework/com/uat/uia/service/impl/LoginDAO.java +++ b/src/main/java/egovframework/com/uat/uia/service/impl/LoginDAO.java @@ -144,14 +144,17 @@ public class LoginDAO extends EgovComAbstractDAO { insert("loginDAO.insertFailHistory", vo); } - // KTH. 20190603 로그인 성공 시 당일 실패 이력 정보 delete + // KTH. 20190603 로그인 성공 시 당일 실패 이력 정보 정리 + // [개인정보/#22-1] 실제 동작은 DELETE 가 아니라 RESET_YN='Y' 소프트 리셋. + // 이력은 보관기간(ACCOUNT_MNG_POLICY.LOGIN_HIST_KEEP_DAY)까지 보존하고, + // 잠금 카운트만 리셋한다. 메서드명은 호출부 영향 최소화를 위해 유지. public void deleteLoginSuccess(LoginVO vo) { - delete("loginDAO.deleteLoginSuccess", vo); + update("loginDAO.deleteLoginSuccess", vo); } - - // KTH. 20190607 + + // KTH. 20190607 / [개인정보/#22-1] 동일 — 당일 전체 소프트 리셋 public void deleteLoginSuccessTodayAll(LoginVO vo) { - delete("loginDAO.deleteLoginSuccessAll", vo); + update("loginDAO.deleteLoginSuccessAll", vo); } // 휴먼고객 확인 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 3c5e04f..123f96c 100644 --- a/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java +++ b/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java @@ -27,6 +27,7 @@ import egovframework.com.cmm.EgovMessageSource; import egovframework.com.cmm.EgovWebUtil; import egovframework.com.cmm.LoginVO; import egovframework.com.cmm.annotation.IncludedInfo; +import egovframework.com.cmm.privacy.auditlog.ClientIpResolver; import egovframework.com.cmm.service.EgovCmmUseService; import egovframework.com.cmm.service.Globals; import egovframework.com.cmm.util.EgovUserDetailsHelper; @@ -221,6 +222,10 @@ public class EgovLoginController { threshold = totVo.getLockCount(); } // 실패 이력 적재(LOGIN_HISTORY) + // [개인정보/#22-1] 접속 IP 기록 — 어디서 시도된 실패인지 추적 가능하게 한다. + // ClientIpResolver(#34): X-Forwarded-For → Proxy-Client-IP → getRemoteAddr, + // 신뢰 프록시 CIDR 검증 포함(Apache+L4 구성에서 getRemoteAddr 만으로는 부정확). + loginVO.setIp(ClientIpResolver.getClientIp(request)); loginService.insertLoginFail(loginVO); // 당일 누적 실패 횟수 currentFail = loginService.selectFailCount(map); 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 39bfc58..598b25e 100644 --- a/src/main/java/kr/or/arko/cmm/batch/BatchScheduler.java +++ b/src/main/java/kr/or/arko/cmm/batch/BatchScheduler.java @@ -65,6 +65,14 @@ public class BatchScheduler { @Resource(name = "dormantAlertService") private egovframework.com.cmm.privacy.account.DormantAlertService dormantAlertService; + /** 개인정보보호 장기 미사용 계정 파기 서비스 (#12 보존기간 경과 계정 개인정보 파기) */ + @Resource(name = "accountDeleteService") + private egovframework.com.cmm.privacy.account.AccountDeleteService accountDeleteService; + + /** [개인정보/#22-1] 인증 실패 이력 보관기간 파기 */ + @Resource(name = "loginFailHistoryService") + private egovframework.com.cmm.privacy.lock.LoginFailHistoryService loginFailHistoryService; + /** * 테스트 배치 예제 *//* @@ -180,10 +188,9 @@ public class BatchScheduler { * [개인정보보호 #5] 권한 변경 이력(AUTHOR_CHANGE_HISTORY) 파기 배치. * 매월 1일 04:00, 보관기간(1,095일=3년) 경과 이력만 선별 파기. 개별 삭제 API 미제공. * - *

※ 스케줄 활성화 주의: 본 프로젝트는 {@code }이 - * context-common.xml/egov-com-servlet.xml 양쪽 모두 주석 처리되어 있어 현재 - * {@code @Scheduled}가 동작하지 않는다(배치는 OS crontab 운영). 활성화 시 기존 휴면/메일 등 - * 휴면 배치가 함께 기동되므로, 활성화 여부·경합은 운영과 협의해 결정한다.

+ *

※ 2026-07-28 context-common.xml의 {@code } 활성화로 + * 본 클래스의 {@code @Scheduled}가 실제 기동된다. OS crontab에서 동일 배치를 병행 운영 중이면 + * 이중 실행이 되므로 운영과 경합 여부를 확인해야 한다.

* * @throws Exception 파기 오류 */ @@ -211,7 +218,7 @@ public class BatchScheduler { * [개인정보보호 #37] 개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 보관정책 파기 배치. * 매월 1일 02:00(저부하 시간대), 보관기간(730일) 경과 파티션을 DROP(개별 DELETE 미사용)하고 * 미래 월 파티션을 선행 증설한다. COMTNSYSLOG 7일 삭제정책은 이 테이블에 적용하지 않는다(#38). - * (스케줄 활성화 주의는 {@link #purgeAuthorChangeHistoryScheduler()} 참조 — 현재 전역 비활성.) + * (스케줄 활성화 주의는 {@link #purgeAuthorChangeHistoryScheduler()} 참조.) * * @throws Exception 파기/증설 오류 */ @@ -226,8 +233,7 @@ public class BatchScheduler { * 매월 1일 05:00(기존 파기 02:00/무결성 03:00/이력파기 04:00 배치와 시각 분리). * 전월분 접속기록을 집계(총건수·실패·무결성#40·취급자별 처리현황)해 PDAL_INSPECTION_REPORT 적재. * - *

※ 스케줄 활성화 주의: 전역 {@code } 비활성 상태라 - * 현재 {@code @Scheduled}는 동작하지 않는다(OS crontab 운영). 활성화·경합은 운영과 협의.

+ * (스케줄 활성화 주의는 {@link #purgeAuthorChangeHistoryScheduler()} 참조.) * * @throws Exception 집계/적재 오류 */ @@ -239,19 +245,60 @@ public class BatchScheduler { } /** - * [개인정보보호 #10] 휴면 전 사전 알림 배치 — D-30/D-7/D-Day 3단계 + 관리자 알림. + * [개인정보보호 #10] 휴면 전 사전 알림 배치 — D-30/D-7/D-Day 3단계 관리자 알림. * 매일 06:00, 정책(ACCOUNT_MNG_POLICY)의 ALERT_D1/D2/D3 시점에 해당하는 휴면 예정 - * 계정을 본인·관리자에게 사전 통지한다. 발송 실패는 fail-safe(로깅 후 계속). + * 계정을 모아 관리자(ADMIN_EMAIL)에게 요약 1통으로 통지한다. 발송 실패는 fail-safe(로깅 후 계속). * - *

※ 전역 {@code } 비활성 상태라 현재 @Scheduled 는 동작하지 - * 않는다(OS crontab 운영). 활성화·경합은 운영과 협의(핸드오프).

+ *

2026-07-30: 대상자 본인 개별 발송을 제거했다(요구사항은 관리자 알림이며, 5만건 규모에서 + * 본인 건별 발송이 휴면 배치 처리량을 지배했다). 상세는 {@code DormantAlertServiceImpl} 주석 참조.

+ * + * (스케줄 활성화 주의는 {@link #purgeAuthorChangeHistoryScheduler()} 참조.) * * @throws Exception 알림 처리 오류 */ @Scheduled(cron = "0 0 6 * * ?") public void dormantPreAlertScheduler() throws Exception { int sent = dormantAlertService.sendDormantPreAlerts(); - log.info("[개인정보/#10] 휴면 사전알림 배치 완료 — " + sent + "건 발송"); + log.info("[개인정보/#10] 휴면 사전알림 배치 완료 — 관리자 요약 " + sent + "통 발송"); + } + + /** + * [개인정보보호 #12] 장기 미사용 계정 개인정보 파기 배치. + * 매일 02:30, 휴면 확정('P') 후 보존기간(ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY 정책값)이 + * 경과한 계정의 개인정보를 파기(PII 마스킹 + 상태 'D')하고 ACCOUNT_DELETE_HISTORY 에 이력을 남긴다. + * 휴면 등록 배치(01:00)·사전알림(06:00)과 시각을 분리했다. + * + *

실제 파기 수행 여부는 정책 플래그로 제어한다. 배치는 매일 등록되어 돌지만 + * {@code ACCOUNT_MNG_POLICY.PURGE_USE_YN}(기본 'N')이 'Y'가 아니면 서비스가 즉시 0건으로 + * 종료한다. 켜고 끄는 것은 계정 관리 정책 화면(#13)에서 하며 재배포가 필요 없다. + * 1회 처리건수는 SQL에서 200건으로 상한(백로그 일시 폭주 방지).

+ * + * @throws Exception 파기 오류 + */ + @Scheduled(cron = "0 30 2 * * ?") + public void purgeExpiredAccountScheduler() throws Exception { + int purged = accountDeleteService.purgeExpiredAccounts(); + log.info("[개인정보/#12] 장기 미사용 계정 파기 배치 완료 — " + purged + "건"); + } + + /** + * [개인정보보호 #22-1] 인증 실패 이력(LOGIN_HISTORY) 보관기간 파기 배치. + * 매일 03:30, {@code ACCOUNT_MNG_POLICY.LOGIN_HIST_KEEP_DAY}(기본 730일=2년)이 + * 경과한 실패 이력을 삭제한다. 1회 처리 상한 5,000건(백로그 폭주 방지, 잔여분은 다음 회차). + * + *

#22 이설 이전에는 로그인 성공 시 당일 이력을 DELETE 해 사실상 보관 개념이 없었다. + * #22-1 에서 성공 시 처리를 소프트 리셋(RESET_YN='Y')으로 바꿔 이력을 보존하게 되면서, + * 무한 적재를 막는 파기 경로로 이 배치가 필요해졌다.

+ * + *

계정 파기(#12, 02:30)·접속기록 파티션 파기(#37, 매월 1일 02:00)와 시각을 분리했다. + * (스케줄 활성화 주의는 {@link #purgeAuthorChangeHistoryScheduler()} 참조.)

+ * + * @throws Exception 파기 오류 + */ + @Scheduled(cron = "0 30 3 * * ?") + public void purgeExpiredLoginFailHistoryScheduler() throws Exception { + int purged = loginFailHistoryService.purgeExpiredLoginFailHistory(); + log.info("[개인정보/#22-1] 인증 실패 이력 파기 배치 완료 — " + purged + "건"); } } \ No newline at end of file diff --git a/src/main/resources/egovframework/sqlmap/com/uat/uia/EgovLoginUsr_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/com/uat/uia/EgovLoginUsr_SQL_Mysql.xml index 0c2aab6..ac3cd81 100644 --- a/src/main/resources/egovframework/sqlmap/com/uat/uia/EgovLoginUsr_SQL_Mysql.xml +++ b/src/main/resources/egovframework/sqlmap/com/uat/uia/EgovLoginUsr_SQL_Mysql.xml @@ -141,48 +141,66 @@ + - - - + + + + - - - - + + + + - - - - + + + + + (SELECT IFNULL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY) /* No.9 정책값화(미설정 시 365) */ AND a.MBER_ID NOT IN (SELECT MBER_ID FROM COMTNDIAPAUSEMBER) AND a.MBER_STTUS = 'P' + LIMIT 2000 ]]> - + (SELECT IFNULL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY) /* No.9 정책값화(미설정 시 365) */ AND e.EMPLYR_ID NOT IN (SELECT MBER_ID FROM COMTNDIAPAUSEMBER) AND e.EMPLYR_STTUS_CODE = 'P' + LIMIT 2000 ]]> - + + - + diff --git a/src/main/resources/egovframework/sqlmap/com/uat/uia/EgovLoginUsr_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/com/uat/uia/EgovLoginUsr_SQL_Oracle.xml index 1f74615..253d617 100644 --- a/src/main/resources/egovframework/sqlmap/com/uat/uia/EgovLoginUsr_SQL_Oracle.xml +++ b/src/main/resources/egovframework/sqlmap/com/uat/uia/EgovLoginUsr_SQL_Oracle.xml @@ -140,48 +140,60 @@ + - - - + + + + - - - - + + + + - - - - + + + + @@ -584,6 +596,7 @@ WHERE NVL(SYSDATE - b.CREAT_DT,SYSDATE - a.SBSCRB_DE) > (SELECT NVL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY) /* No.9 정책값화 */ AND a.MBER_ID NOT IN (SELECT MBER_ID FROM COMTNDIAPAUSEMBER) AND a.MBER_STTUS = 'P' + AND ROWNUM <= 2000 /* 2026-07-28 백로그 최초가동 폭주 방지, 07-30 상한 200→2,000 재산정 */ ]]> @@ -603,23 +616,31 @@ WHERE NVL(SYSDATE - e.LAST_LOGIN_DT, SYSDATE - e.SBSCRB_DE) > (SELECT NVL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY) /* No.9 정책값화 */ AND e.EMPLYR_ID NOT IN (SELECT MBER_ID FROM COMTNDIAPAUSEMBER) AND e.EMPLYR_STTUS_CODE = 'P' + AND ROWNUM <= 2000 /* 2026-07-28 백로그 최초가동 폭주 방지, 07-30 상한 200→2,000 재산정 */ ]]> - + @@ -647,20 +668,28 @@ AND ESNTL_ID = #esntl_id# - + 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 a2f6df5..ba87408 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 @@ -129,4 +129,10 @@ + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Mysql.xml index e40bd44..f29da23 100644 --- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Mysql.xml +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Mysql.xml @@ -20,6 +20,8 @@ ALERT_D3 AS alertD3, DELETE_PRESERVE_DAY AS deletePreserveDay, BATCH_CYCLE AS batchCycle, + PURGE_USE_YN AS purgeUseYn, + LOGIN_HIST_KEEP_DAY AS loginHistKeepDay, ADMIN_EMAIL AS adminEmail, UPD_ID AS updId, UPD_DT AS updDt @@ -38,6 +40,8 @@ ALERT_D3 = #alertD3#, DELETE_PRESERVE_DAY = #deletePreserveDay#, BATCH_CYCLE = #batchCycle#, + PURGE_USE_YN = #purgeUseYn#, + LOGIN_HIST_KEEP_DAY = #loginHistKeepDay#, ADMIN_EMAIL = #adminEmail#, UPD_ID = #updId#, UPD_DT = NOW() diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Oracle.xml index 832df1a..ecbaf74 100644 --- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Oracle.xml +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Oracle.xml @@ -13,6 +13,8 @@ SELECT POLICY_ID AS policyId, DORMANT_DAY AS dormantDay, ALERT_D1 AS alertD1, ALERT_D2 AS alertD2, ALERT_D3 AS alertD3, DELETE_PRESERVE_DAY AS deletePreserveDay, BATCH_CYCLE AS batchCycle, + PURGE_USE_YN AS purgeUseYn, + LOGIN_HIST_KEEP_DAY AS loginHistKeepDay, ADMIN_EMAIL AS adminEmail, UPD_ID AS updId, UPD_DT AS updDt FROM ACCOUNT_MNG_POLICY WHERE POLICY_ID = 1 ]]> @@ -24,7 +26,9 @@ UPDATE ACCOUNT_MNG_POLICY SET DORMANT_DAY = #dormantDay#, ALERT_D1 = #alertD1#, ALERT_D2 = #alertD2#, ALERT_D3 = #alertD3#, DELETE_PRESERVE_DAY = #deletePreserveDay#, - BATCH_CYCLE = #batchCycle#, ADMIN_EMAIL = #adminEmail#, + BATCH_CYCLE = #batchCycle#, PURGE_USE_YN = #purgeUseYn#, + LOGIN_HIST_KEEP_DAY = #loginHistKeepDay#, + ADMIN_EMAIL = #adminEmail#, UPD_ID = #updId#, UPD_DT = SYSDATE WHERE POLICY_ID = 1 ]]> diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyLoginFailHistory_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyLoginFailHistory_SQL_Mysql.xml new file mode 100644 index 0000000..1a36fcd --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyLoginFailHistory_SQL_Mysql.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + h.ID LIKE CONCAT('%', #searchId#, '%') + + + h.GUBUN = #searchGubun# + + + h.CONECT_IP LIKE CONCAT('%', #searchIp#, '%') + + + h.RESET_YN = #searchResetYn# + + + h.CREATE_DATE >= CONCAT(#searchBgnDe#, ' 00:00:00') + + + h.CREATE_DATE <= CONCAT(#searchEndDe#, ' 23:59:59') + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyLoginFailHistory_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyLoginFailHistory_SQL_Oracle.xml new file mode 100644 index 0000000..4d48499 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyLoginFailHistory_SQL_Oracle.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + h.ID LIKE '%' || #searchId# || '%' + + + h.GUBUN = #searchGubun# + + + h.CONECT_IP LIKE '%' || #searchIp# || '%' + + + h.RESET_YN = #searchResetYn# + + + h.CREATE_DATE >= TO_DATE(#searchBgnDe# || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS') + + + h.CREATE_DATE <= TO_DATE(#searchEndDe# || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS') + + + + + + + + + + + + + + + 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 138d86a..b8b2057 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 ''}">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 ''}">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 ''}">style="display: block;">
  • class="on">${menuUpperVo2.menuNm} @@ -94,10 +94,10 @@
  • -
  • class="on">${menuUpperVo2.menuNm} +
  • class="on">${menuUpperVo2.menuNm} -
      style="display: block;"> + diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accountPolicy/policy.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accountPolicy/policy.jsp new file mode 100644 index 0000000..74e9660 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accountPolicy/policy.jsp @@ -0,0 +1,218 @@ +<%@ 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"%> + + + + + + + + + + + + + +
      + 장기 미사용 계정 관리 정책(SFR-002) — 아래 설정값은 저장 즉시 휴면 전환 판정·사전알림 발송 배치에 반영되며, + 재배포가 필요하지 않습니다. 사전알림은 휴면 예정일 기준 D-1차 > D-2차 > D-3차 순서로 발송됩니다. +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <%-- [개인정보/#22-1] 인증 실패 이력 보관기간 --%> + + + + + + + + + + + + + + + + + +
      계정 관리 정책 설정
      * + + 일 이상 미접속 시 휴면 전환 (1~3650) +
      * + 휴면 예정일 D- + 일 (기본 30) +
      * + 휴면 예정일 D- + 일 (기본 7) +
      * + 휴면 예정일 D- + 일 (기본 0 = 당일) +
      * + + 일 (휴면 전환 후 이 기간이 지나면 개인정보 파기 대상) +
      * + + '사용' 선택 시 매일 02:30 배치가 위 기준일 경과 휴면계정의 개인정보를 파기합니다(1회 최대 200건). + 파기된 개인정보는 복구할 수 없습니다. +
      * + + 일 (기본 730 = 2년). 매일 03:30 배치가 이 기간을 넘긴 로그인 실패 이력을 파기합니다. + 이력은 [시스템관리 > 권한관리 > 인증 실패 이력] 화면에서 조회합니다. +
      * + + 휴면 판정 배치 실행 주기 +
      + + 사전알림 관리자 사본 수신 주소 (미입력 시 관리자 알림 미발송) +
      최종 수정 + + - + / + +
      + + +
      + + +
      + +
      + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/loginFailHistory/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/loginFailHistory/list.jsp new file mode 100644 index 0000000..9d83645 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/loginFailHistory/list.jsp @@ -0,0 +1,150 @@ +<%@ 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"%> + +<%-- + 인증 실패 이력 조회 (개인정보보호 SER-001 / #22-1) + · LOGIN_HISTORY 적재분(계정·일시·구분·접속IP)을 계정/IP/기간으로 검색 + · 리셋여부 'Y' = 이후 로그인 성공으로 잠금카운터가 리셋된 행(이력은 보존) +--%> + + + + + + + + + + + + + + + + +
      + +
      + + +
      + · 로그인 비밀번호 인증에 실패한 기록입니다. 존재하지 않는 계정으로의 시도는 성명이 비어 있을 수 있습니다.
      + · 리셋됨은 이후 로그인에 성공해 계정잠금 카운터가 초기화된 기록으로, 이력 자체는 보관기간까지 보존됩니다.
      + · 보관기간은 [시스템관리 > 권한관리 > 계정 관리 정책]의 인증 실패 이력 보관기간 설정값을 따릅니다. +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      인증 실패 이력 목록
      NO.계정ID성명구분실패일시접속IP리셋여부
      + + 업무사용자 + 개인 + 그룹 + 활동처 + + + + + 리셋됨 + 유효 + +
      + +
      + +
      + +
      + + + + + + + + + diff --git a/src/script/mysql/ddl/privacy.login_history_extend_mysql.sql b/src/script/mysql/ddl/privacy.login_history_extend_mysql.sql new file mode 100644 index 0000000..3dfcfce --- /dev/null +++ b/src/script/mysql/ddl/privacy.login_history_extend_mysql.sql @@ -0,0 +1,55 @@ +-- ===================================================================== +-- 인증 실패 이력 확장 : LOGIN_HISTORY (IP 기록 · 보관기간) +-- 문화품앗이 개인정보보호 기능개선 사업 (SER-001, 개발항목 #22-1) +-- 작성일: 2026-08-05 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [배경] #22(인증 실패 이력 활성 경로 이설)로 실패이력 적재는 살아났으나 +-- LOGIN_HISTORY 는 ID/CREATE_DATE/GUBUN 3컬럼뿐이라 +-- · 어디서(IP) 시도된 실패인지 추적 불가 +-- · 로그인 성공 시 당일 이력을 DELETE 해 무차별 대입 흔적이 소멸 +-- → IP 컬럼 + 보존(소프트 리셋) 구조 + 보관기간 정책을 추가한다. +-- +-- [CONECT_IP] ClientIpResolver(#34, XFF→Proxy-Client-IP→getRemoteAddr, +-- 신뢰 CIDR 검증)로 취득한 값. IPv6 대비 VARCHAR(45). +-- +-- [RESET_YN] 기존 동작은 '성공 시 당일 실패행 DELETE'였고, 그 DELETE 가 +-- 곧 잠금 카운터 리셋이었다. 이력을 보존하려면 지울 수 없으므로 +-- 행을 남기되 RESET_YN='Y' 로 표시하고, 잠금 카운트(selectFailCount)는 +-- RESET_YN='N' 인 당일 행만 센다. → 잠금 동작은 종전과 완전히 동일. +-- +-- [보관기간] ACCOUNT_MNG_POLICY.LOGIN_HIST_KEEP_DAY (기본 730일 = 2년). +-- 경과분은 BatchScheduler.purgeExpiredLoginFailHistoryScheduler 가 파기. +-- +-- ★ 필수 동반 변경 ★ +-- EgovLoginUsr_SQL_Mysql.xml / _Oracle.xml 의 loginDAO.insertFailHistory 는 +-- 원래 `INSERT INTO LOGIN_HISTORY VALUES(#id#, SYSDATE(), #userSe#)` 형태로 +-- 컬럼목록이 없어, 이 ALTER 후에는 컬럼 수 불일치(ERROR 1136)로 실패한다. +-- 반드시 컬럼 명시 INSERT 로 교체된 sqlmap 과 함께 배포할 것. +-- +-- [재실행] MySQL 8 은 ADD COLUMN IF NOT EXISTS 미지원 — 기존 환경 재실행 시 +-- 중복 오류(1060/1061)는 무시. +-- ===================================================================== + +ALTER TABLE LOGIN_HISTORY + ADD COLUMN CONECT_IP VARCHAR(45) NULL COMMENT '접속 IP(#22-1, ClientIpResolver 취득)', + ADD COLUMN RESET_YN CHAR(1) NOT NULL DEFAULT 'N' COMMENT '잠금카운터 리셋여부 Y:성공으로 리셋됨 N:유효(#22-1)'; + +-- 조회화면 검색(계정·일자)·파기배치(일자) 대응 인덱스 +ALTER TABLE LOGIN_HISTORY + ADD KEY IX_LOGIN_HISTORY_01 (CREATE_DATE), + ADD KEY IX_LOGIN_HISTORY_02 (ID, GUBUN, CREATE_DATE); + +-- 보관기간 정책값(단일행 정책 테이블에 편입 — 하드코딩 금지 원칙) +ALTER TABLE ACCOUNT_MNG_POLICY + ADD COLUMN LOGIN_HIST_KEEP_DAY INT NOT NULL DEFAULT 730 + COMMENT '인증 실패 이력 보관기간(일, 기본 730=2년, #22-1)'; + +-- [검증] +-- SHOW CREATE TABLE LOGIN_HISTORY; +-- SELECT LOGIN_HIST_KEEP_DAY FROM ACCOUNT_MNG_POLICY WHERE POLICY_ID = 1; +-- +-- [롤백] +-- ALTER TABLE LOGIN_HISTORY DROP COLUMN CONECT_IP, DROP COLUMN RESET_YN, +-- DROP KEY IX_LOGIN_HISTORY_01, DROP KEY IX_LOGIN_HISTORY_02; +-- ALTER TABLE ACCOUNT_MNG_POLICY DROP COLUMN LOGIN_HIST_KEEP_DAY; +-- ※ 롤백 시 sqlmap 의 컬럼 명시 INSERT 도 함께 되돌려야 한다. diff --git a/src/script/mysql/dml/privacy.menu_login_fail_history_insert_mysql.sql b/src/script/mysql/dml/privacy.menu_login_fail_history_insert_mysql.sql new file mode 100644 index 0000000..9ed3125 --- /dev/null +++ b/src/script/mysql/dml/privacy.menu_login_fail_history_insert_mysql.sql @@ -0,0 +1,54 @@ +-- ===================================================================== +-- 관리자 메뉴 등록 : 인증 실패 이력 조회 (MENU_NO 9100039) +-- 문화품앗이 개인정보보호 기능개선 사업 (SER-001, 개발항목 #22-1) +-- 작성일: 2026-08-05 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [배치] 시스템관리(9100000) > 권한관리(9100030) 하위 5번째(MENU_ORDR 9). +-- 선행 4건(9100035~9100038)과 동일 계열 — 계정·권한 보안 화면 묶음. +-- [권한] IS_AUTHENTICATED_FULLY, ROLE_ADMIN (선행 4건과 동일) +-- [재실행] 모든 INSERT NOT EXISTS 가드 → 반복 실행 무해. +-- +-- ★★ 이 DML 만으로는 좌측메뉴에 안 뜬다 ★★ +-- left.jsp 는 메뉴번호 하드코딩 JSP라 9100039 분기 + +-- 상위 하이라이트 조건(param.page eq '9100039')이 함께 배포돼야 렌더된다. +-- (arko_renual 소스에는 반영 완료 — 배포본에 JSP 포함 여부만 확인) +-- ===================================================================== + +-- ── [1] 프로그램(URL) 등록 (COMTNMENUINFO.PROGRM_FILE_NM FK, 메뉴보다 먼저) ── +INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) +SELECT * FROM ( + SELECT 'LoginFailHistoryList' AS PROGRM_FILE_NM, + '/mnt/sec/loginFailHistory/' AS PROGRM_STRE_PATH, + '인증 실패 이력' AS PROGRM_KOREAN_NM, + '로그인 인증 실패 이력 조회(계정·접속IP·기간, SER-001 #22-1)' AS PROGRM_DC, + '/mnt/sec/loginFailHistory/listView.do?page=9100039' AS URL +) seed +WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM); + +-- ── [2] 메뉴 등록 : 권한관리(9100030) 하위, MENU_ORDR 9 ── +INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM) +SELECT * FROM ( + SELECT '인증 실패 이력' AS MENU_NM, 'LoginFailHistoryList' AS PROGRM_FILE_NM, 9100039 AS MENU_NO, + 9100030 AS UPPER_MENU_NO, 9 AS MENU_ORDR, '로그인 인증 실패 이력 조회' AS MENU_DC, + '/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM +) seed +WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO); + +-- ── [3] 권한↔메뉴 노출 매핑 (1건 × 2권한, FK 안전) ── +INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE) +SELECT 9100039, g.ac + FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g + WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) + AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x + WHERE x.MENU_NO = 9100039 AND x.AUTHOR_CODE = g.ac); + +-- ── [검증] ── +-- SELECT m.MENU_NO, m.MENU_NM, m.MENU_ORDR, p.URL, +-- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO = m.MENU_NO) grants +-- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM = p.PROGRM_FILE_NM +-- WHERE m.MENU_NO = 9100039; +-- +-- ── [롤백] ── +-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO = 9100039; +-- DELETE FROM COMTNMENUINFO WHERE MENU_NO = 9100039; +-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'LoginFailHistoryList'; diff --git a/src/script/mysql/privacy.install_mysql.sql b/src/script/mysql/privacy.install_mysql.sql index ff0fe4a..06d3f71 100644 --- a/src/script/mysql/privacy.install_mysql.sql +++ b/src/script/mysql/privacy.install_mysql.sql @@ -2,9 +2,15 @@ -- arko_renual 설치용 통합 SQL (개인정보보호 기능개선 사업 · arko 대비 변동분) -- 대상 DB : MySQL 5.7 / MariaDB (스키마 csv) -- 구성 : [PART 1] DDL(테이블 생성·변경) → [PART 2] DML(기준/시드 데이터) --- 실행순서: 반드시 PART 1 → PART 2 순서로 실행(테이블 생성 후 시드 적재) +-- → [PART 3] 관리자 메뉴 등록 DML(좌측메뉴 노출, COMTNPROGRMLIST/ +-- COMTNMENUINFO/COMTNMENUCREATDTLS, 9100035~9100039) +-- 실행순서: 반드시 PART 1 → PART 2 → PART 3 순서로 실행 +-- (테이블 생성 → 시드 적재 → 메뉴 등록. 메뉴는 FK로 프로그램 등록을 먼저 요구) -- 주의 : 신규 설치용. 기존 테이블이 있는 환경 재실행 시 CREATE 중복 오류 가능 --- (IF NOT EXISTS 미지정 테이블). 그런 환경은 PART 2(DML)만 실행하세요. +-- (IF NOT EXISTS 미지정 테이블). 그런 환경은 PART 2/PART 3(모두 NOT EXISTS +-- 가드로 재실행 안전)만 실행하세요. +-- ★★ PART 3 적용만으로는 좌측메뉴에 안 뜬다 — left.jsp 하드코딩 분기가 +-- 반드시 함께 배포돼야 한다(소스에는 이미 반영됨). 상세는 PART 3 헤더 참조. ★★ -- ##################################################################### @@ -471,6 +477,102 @@ ALTER TABLE COMTNDIAPAUSEMBER ADD INDEX IX_DIAPAUSE_MBERTY (MBER_TY_CODE, DIAPAUSE_STAT); +-- ───────────────────────────────────────────────────────────── +-- [privacy.account_mng_policy_purge_useyn_mysql.sql] +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 계정 관리 정책 확장 : ACCOUNT_MNG_POLICY 에 파기 배치 사용여부 컬럼 추가 +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-002, 개발항목 #12) +-- 작성일: 2026-07-28 +-- --------------------------------------------------------------------- +-- 파기(#12)는 되돌릴 수 없는 처리라 배치를 항상 등록해 두되 실제 수행 여부를 +-- 이 플래그로 제어한다('N'이면 서비스가 즉시 0건 반환). 기본 'N'이므로 설치 +-- 직후에는 아무것도 파기되지 않으며, 관리자가 계정 관리 정책 화면(#13)에서 +-- '사용'으로 바꿔야 시작된다. 파기 기준일은 기존 DELETE_PRESERVE_DAY 를 사용. +-- +-- ※ 신규 설치(위 PART 1 에서 ACCOUNT_MNG_POLICY 를 막 생성한 경우)에는 이미 +-- 컬럼이 없으므로 이 ALTER 가 필요하다. 기존 환경 재실행 시 중복 오류는 무시. +-- ===================================================================== + +ALTER TABLE ACCOUNT_MNG_POLICY + ADD COLUMN PURGE_USE_YN CHAR(1) NOT NULL DEFAULT 'N' + COMMENT '장기 미사용 계정 파기 배치 사용여부 Y:사용 N:미사용(#12)'; + + +-- ───────────────────────────────────────────────────────────── +-- [privacy.account_delete_history_create_mysql.sql] +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 장기 미사용 계정 개인정보 파기 이력 : ACCOUNT_DELETE_HISTORY +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-002, 개발항목 #12 계정 삭제 기능) +-- 작성일: 2026-07-28 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [목적] 휴면 확정(COMTNDIAPAUSEMBER.DIAPAUSE_STAT='P') 후 정책상 보존기간 +-- (ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY)이 경과한 계정의 개인정보를 +-- 파기(마스킹)한 이력을 남긴다. 실제 파기 방식은 행 DELETE가 아니라 +-- PII 컬럼 마스킹(기존 회원탈퇴 MBER_STTUS='D' 컨벤션과 동일) — 다른 +-- 테이블(게시글·마일리지·활동확인 등)이 MBER_ID/EMPLYR_ID를 참조하는 +-- 소프트 조인이 많아 행 자체를 지우면 그쪽이 깨지기 때문. +-- +-- [보존기간] ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY 값을 실행 시점에 그대로 +-- 조회해 사용(하드코딩 없음). 이 테이블의 PRESERVE_DAY 컬럼은 +-- 그 시점에 적용된 값의 스냅샷(정책이 나중에 바뀌어도 이력은 불변). +-- +-- [처리량 제한] 배치가 오래 정지돼 있다가 재가동되는 날 백로그가 한꺼번에 +-- 몰리는 것을 막기 위해 1회 처리건수를 상한(LIMIT 200)으로 제한. +-- +-- ★ 배치(BatchScheduler.purgeExpiredAccountScheduler)는 파기가 되돌릴 수 없는 +-- 처리라 기본 비활성(@Scheduled 주석) 상태다. 보존기간 확정·운영협의 후 활성화. +-- ===================================================================== + +CREATE TABLE ACCOUNT_DELETE_HISTORY ( + DEL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '파기 일련번호(PK)', + MBER_ID VARCHAR(32) NOT NULL COMMENT '파기 대상 계정ID(MBER_ID/EMPLYR_ID)', + MBER_TY_CODE CHAR(3) NOT NULL COMMENT '회원유형 GNR:일반회원 EMP:개인정보취급자', + ESNTL_ID VARCHAR(20) NULL COMMENT '고유식별자(파기 시점 스냅샷, 참고용)', + DIAPAUSE_DT DATETIME NULL COMMENT '휴면 확정 기준일시(파기 판단 기준)', + PRESERVE_DAY INT NOT NULL COMMENT '파기 시점 적용된 보존기간(정책값 스냅샷, 일)', + DEL_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '파기 처리 일시', + PRIMARY KEY (DEL_ID), + KEY IX_ADH_MBER (MBER_ID), + KEY IX_ADH_DELDT (DEL_DT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='장기 미사용 계정 개인정보 파기 이력(개인정보보호 SFR-002 #12)'; + + +-- ───────────────────────────────────────────────────────────── +-- [privacy.login_history_extend_mysql.sql] +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 인증 실패 이력 확장 : LOGIN_HISTORY (IP 기록 · 보관기간) +-- 문화품앗이 개인정보보호 기능개선 사업 (SER-001, 개발항목 #22-1) +-- 작성일: 2026-08-05 +-- --------------------------------------------------------------------- +-- [목적] #22 로 살아난 실패이력 적재에 ① 접속 IP ② 보존(성공 시 DELETE→소프트 +-- 리셋) ③ 보관기간 정책을 추가한다. 잠금 카운트는 RESET_YN='N' 인 +-- 당일 행만 세므로 계정잠금 동작은 종전과 동일하다. +-- +-- ★ 이 ALTER 는 sqlmap 동반 배포 필수 ★ +-- loginDAO.insertFailHistory 가 컬럼목록 없는 `VALUES(...)` 3값 INSERT 라 +-- 컬럼 추가 후 ERROR 1136(Column count doesn't match)로 실패한다. +-- 컬럼 명시 INSERT 로 교체된 EgovLoginUsr_SQL_Mysql.xml 과 함께 배포할 것. +-- +-- [재실행] MySQL 8 은 ADD COLUMN IF NOT EXISTS 미지원 — 중복 오류(1060/1061) 무시. +-- ===================================================================== + +ALTER TABLE LOGIN_HISTORY + ADD COLUMN CONECT_IP VARCHAR(45) NULL COMMENT '접속 IP(#22-1, ClientIpResolver 취득)', + ADD COLUMN RESET_YN CHAR(1) NOT NULL DEFAULT 'N' COMMENT '잠금카운터 리셋여부 Y:성공으로 리셋됨 N:유효(#22-1)'; + +ALTER TABLE LOGIN_HISTORY + ADD KEY IX_LOGIN_HISTORY_01 (CREATE_DATE), + ADD KEY IX_LOGIN_HISTORY_02 (ID, GUBUN, CREATE_DATE); + +ALTER TABLE ACCOUNT_MNG_POLICY + ADD COLUMN LOGIN_HIST_KEEP_DAY INT NOT NULL DEFAULT 730 + COMMENT '인증 실패 이력 보관기간(일, 기본 730=2년, #22-1)'; + + -- ===================================================================== -- PART 2. DML (기준/시드 데이터) ※ 멱등: NOT EXISTS 가드로 중복 적재 방지 -- 수록: PDAL_JOB_MAPPING(업무유형 매핑 13행), ACCOUNT_MNG_POLICY(기본정책 1행) @@ -506,4 +608,93 @@ SELECT 1, 365, 30, 7, 0, 365, 'DAILY', 'SYSTEM', NOW() -- INSERT INTO LOGIN_LOCK_COUNT (LOCK_COUNT) -- SELECT 5 FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM LOGIN_LOCK_COUNT); +-- ===================================================================== +-- PART 3. 관리자 메뉴 등록 DML (좌측/상단 메뉴 노출) +-- 구성: 권한관리(9100030) 하위에 순서대로 9100035~9100039 5건 등록 +-- 권한 신청·승인 / 권한 변경 이력 / 미사용계정 현황 / 계정 관리 정책 / 인증 실패 이력 +-- 원본: src/script/mysql/dml/privacy.menu_author_request_insert_mysql.sql +-- src/script/mysql/dml/privacy.menu_author_history_insert_mysql.sql +-- src/script/mysql/dml/privacy.menu_dormant_status_insert_mysql.sql +-- src/script/mysql/dml/privacy.menu_account_policy_insert_mysql.sql +-- (개별 파일에 화면별 배치 근거·롤백 SQL이 더 상세히 있음 — 여기서는 통합 실행용) +-- +-- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 배포 필수 ★★ +-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라 +-- 권한관리(9100030) 블록에 9100035~9100038 각각의 분기 + +-- 시스템관리/권한관리 상위 하이라이트 조건(param.page eq '910003x')이 +-- 반드시 함께 배포돼야 렌더된다. (arko_renual 소스에는 4건 모두 이미 반영 완료 — +-- 운영 배포 시 WAR/배포본에 이 JSP가 포함됐는지만 확인하면 됨) +-- +-- [재실행 안전] 모든 INSERT NOT EXISTS 가드 → 반복 실행 무해. +-- [노출권한] 4건 모두 IS_AUTHENTICATED_FULLY, ROLE_ADMIN 동일 +-- [Oracle] 대상 운영 DB가 MySQL(csv)이라 Oracle 쌍 미작성 +-- ===================================================================== + +-- ── [1] 프로그램(URL) 등록 (COMTNMENUINFO.PROGRM_FILE_NM → COMTNPROGRMLIST FK, 메뉴보다 먼저) ── +INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) +SELECT * FROM ( + SELECT 'AuthorRequestList' AS PROGRM_FILE_NM, '/mnt/sec/authorRequest/' AS PROGRM_STRE_PATH, '권한 신청·승인' AS PROGRM_KOREAN_NM, '권한 변경 신청·승인 워크플로(SFR-001 #46)' AS PROGRM_DC, '/mnt/sec/authorRequest/listView.do?page=9100035' AS URL UNION ALL + SELECT 'AuthorHistoryList', '/mnt/sec/authorHistory/', '권한 변경 이력', '권한 변경 이력 조회·검색·다운로드(SFR-001 #6)', '/mnt/sec/authorHistory/listView.do?page=9100036' UNION ALL + SELECT 'DormantStatusList', '/mnt/sec/dormantStatus/', '장기 미사용계정 현황', '장기 미사용 계정 현황 조회(SFR-002 #11)', '/mnt/sec/dormantStatus/listView.do?page=9100037' UNION ALL + SELECT 'AccountPolicySet', '/mnt/sec/accountPolicy/', '계정 관리 정책 설정', '장기 미사용 계정 관리 정책 설정(SFR-002 #13)', '/mnt/sec/accountPolicy/policyView.do?page=9100038' +) seed +WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM); + +-- ── [2] 메뉴 등록 : 권한관리(9100030) 하위, MENU_ORDR 5~8 순서 ── +INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM) +SELECT * FROM ( + SELECT '권한 신청·승인' AS MENU_NM, 'AuthorRequestList' AS PROGRM_FILE_NM, 9100035 AS MENU_NO, 9100030 AS UPPER_MENU_NO, 5 AS MENU_ORDR, '권한 변경 신청·승인 워크플로' AS MENU_DC, '/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM UNION ALL + SELECT '권한 변경 이력', 'AuthorHistoryList', 9100036, 9100030, 6, '권한 변경 이력 조회·검색·다운로드', '/', '/' UNION ALL + SELECT '미사용계정 현황', 'DormantStatusList', 9100037, 9100030, 7, '장기 미사용 계정 현황 조회', '/', '/' UNION ALL + SELECT '계정 관리 정책', 'AccountPolicySet', 9100038, 9100030, 8, '장기 미사용 계정 관리 정책 설정', '/', '/' +) seed +WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO); + +-- ── [3] 권한↔메뉴 노출 매핑 (4건 × 2권한, FK 안전) ── +INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE) +SELECT seed.menu_no, g.ac + FROM (SELECT 9100035 AS menu_no UNION ALL SELECT 9100036 UNION ALL SELECT 9100037 UNION ALL SELECT 9100038) seed + CROSS JOIN (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g + WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) -- FK 안전 + AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x + WHERE x.MENU_NO = seed.menu_no AND x.AUTHOR_CODE = g.ac); + +-- ── [4] 인증 실패 이력 조회 메뉴(9100039) — SER-001 #22-1, 2026-08-05 추가 ── +-- 원본: src/script/mysql/dml/privacy.menu_login_fail_history_insert_mysql.sql +-- ★ left.jsp 9100039 분기 동반 배포 필수(위와 동일 제약) +INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) +SELECT * FROM ( + SELECT 'LoginFailHistoryList' AS PROGRM_FILE_NM, '/mnt/sec/loginFailHistory/' AS PROGRM_STRE_PATH, + '인증 실패 이력' AS PROGRM_KOREAN_NM, '로그인 인증 실패 이력 조회(SER-001 #22-1)' AS PROGRM_DC, + '/mnt/sec/loginFailHistory/listView.do?page=9100039' AS URL +) seed +WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM); + +INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM) +SELECT * FROM ( + SELECT '인증 실패 이력' AS MENU_NM, 'LoginFailHistoryList' AS PROGRM_FILE_NM, 9100039 AS MENU_NO, + 9100030 AS UPPER_MENU_NO, 9 AS MENU_ORDR, '로그인 인증 실패 이력 조회' AS MENU_DC, + '/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM +) seed +WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO); + +INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE) +SELECT 9100039, g.ac + FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g + WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) + AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x + WHERE x.MENU_NO = 9100039 AND x.AUTHOR_CODE = g.ac); + +-- ── [검증] ── +-- SELECT m.MENU_NO,m.UPPER_MENU_NO,m.MENU_NM,m.MENU_ORDR,p.URL, +-- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO=m.MENU_NO) grants +-- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM=p.PROGRM_FILE_NM +-- WHERE m.MENU_NO BETWEEN 9100035 AND 9100039 ORDER BY m.MENU_NO; + +-- ── [롤백] (역순 권장, 4건 일괄) ── +-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO BETWEEN 9100035 AND 9100038; +-- DELETE FROM COMTNMENUINFO WHERE MENU_NO BETWEEN 9100035 AND 9100038; +-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM IN +-- ('AuthorRequestList','AuthorHistoryList','DormantStatusList','AccountPolicySet'); + -- ############################## END ############################## diff --git a/개인정보보호_개발진행현황.md b/개인정보보호_개발진행현황.md index b3109ac..303ce84 100644 --- a/개인정보보호_개발진행현황.md +++ b/개인정보보호_개발진행현황.md @@ -61,7 +61,7 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반 | **C** | resolver+분석 | #38 COMTNSYSLOG 7일 삭제 분리 | 기존 7일 삭제 SQL 진단 + 접속기록 미적용 분리 노트 | ✅ 완료 | - 2026-07-21 2차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환. - +1q2w3e!Q ### 3차 작업 (2026-07-21, 추가 6개 목표 — 통합 단계) 1·2차 산출물(#1·#16·#17·#21·#30·#34·#39)을 실제로 연결·활용. 공용 config는 fork별로 배타 소유(A=egov-com-servlet.xml, B=sql-map-config+AOP, C=impl java만). @@ -135,6 +135,7 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반 | **B** | 처리기록확장(aspect·AOP context 단독) | #36 처리기록 적재 범위 확장 | 2개 Controller→전 처리지점, TRGET_ID 규약(sqlmap 무변경) | ✅ 완료 | | **C** | 점검결과보관(report·등록sqlmap 단독) | #44 점검결과 입력·보관(백엔드) | 점검자·일시·결과·조치사항 입력→PDAL_INSPECTION_REPORT | ✅ 완료 | +- 2026-08-05 **#22-1 완료** (인증 실패 이력 IP 기록·보관기간·조회화면): #22 이설로 적재는 살아났으나 IP 미기록 + 성공 시 당일 이력 DELETE 로 흔적이 소멸하던 것을 보완. 신규 6파일(`privacy/lock/LoginFailHistory{VO,DAO,Service,ServiceImpl,Controller}`, `PrivacyLoginFailHistory_SQL_Mysql/Oracle`) + JSP 1 + DDL/메뉴DML 2 + 기존 8파일 수정. **핵심 함정: `insertFailHistory`가 컬럼목록 없는 `VALUES(3값)` 이라 DDL 단독 배포 시 ERROR 1136 으로 로그인 실패 적재가 깨진다 — sqlmap 동반 배포 필수.** 잠금 동작은 `selectFailCount`가 `RESET_YN='N'`만 세도록 해 종전과 동일. 후속: javac 미검증(사용자 요청으로 컴파일 생략), 배포 DB에 ALTER 적용 필요. - 2026-07-21 7차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환. - 2026-07-21 **Fork B(7차) 완료** (#36): 처리기록 pointcut 5→16 서비스 확장 + TRGET_ID 식별자 추출 규약(본문 미적재, fail-safe). 실측 확인 후 ✅(XML well-formed·헬퍼 배선·sqlmap 미변경). - 2026-07-21 **Fork C(7차) 완료** (#44): 점검결과 입력·보관(InspectionResult 3파일+INSPECT_DT+statement 3종). 실측 확인 후 ✅(미입력 차단·양쪽 XML well-formed·sql-map-config 미변경). **접속기록 영역(30~44) 15/15 완결.** 배포된 테이블엔 INSPECT_DT ALTER 필요(핸드오프). @@ -187,21 +188,21 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반 | 1 | `getAuthorities()` 구현 | ✅ 진짜완료 | **[2026-07-22 진짜완료 — 코드·로직 재확인]** [Fork A] `EgovUserDetailsSessionServiceImpl` 세션 LoginVO.uniqId→`EgovUserAuthorityDAO.selectAuthoritiesByUniqId`→`PrivacyAuthority_SQL_Mysql.xml`(COMTNEMPLYRSCRTYESTBS 조회). sql-map-config 등록 + 빈 DAO 주입 배선 완료. 미인증 시 빈 목록(fail-safe) | | 2 | AuthorityInterceptor 신규 등록 | ✅ 진짜완료 | **[2026-07-22 진짜완료 — 코드·로직 재확인. ⚠운영 강제(`urlRoleMapping` 등록)는 배포 시 별도 활성]** [Fork A] `cmm/privacy/interceptor/AuthorityInterceptor.java` + `egov-com-servlet.xml` 등록. `/mnt/` 한정, `EgovUserDetailsHelper.getAuthorities()`(#1) 대조 미허용 403. **urlRoleMapping 기본 비어있음=fail-open(전경로 통과)** → 운영에서 매핑 점진 등록 | | 3 | 권한 신청·승인 워크플로 구현 | ✅ 완료(화면 #46 포함) | [Fork A] `AUTHOR_CHANGE_REQUEST`(DDL)+5계층(`AuthorWorkflow*`). `approveRequest()` 승인 시 스냅샷→권한반영(COMTNEMPLYRSCRTYESTBS)→상태A→#4이력을 **단일TX**(context-transaction AOP, 예외 시 롤백). **화면 #46 구현: `AuthorWorkflowController`에 `listView.do`/`registView.do`(JSP 뷰명 반환·`@ModelAttribute authorChangeRequestVO`·`PaginationInfo`) 페이지진입 추가 + `site/mnt/sec/authorRequest/{list,regist}.jsp`(JstlView, 검색·목록·`ui:pagination`, 승인/반려 jQuery AJAX(approve/reject.do)·반려사유 모달, 신청등록 폼). 기존 jsonView 엔드포인트 병존. **메뉴 등록: `src/script/mysql/dml/privacy.menu_author_request_insert_mysql.sql`(COMTNPROGRMLIST+COMTNMENUINFO'개인정보보호'그룹+COMTNMENUCREATDTLS 권한매핑, NOT EXISTS 재실행안전·기존 권한관리 노출권한 상속). ⚠운영 DB 적용 필요(메뉴번호 충돌·관리자 AUTHOR_CODE 배포 시 확인).**| -| 4 | AUTHOR_CHANGE_HISTORY 이력 테이블 신설 | ✅ 완료 | `src/script/mysql/ddl/privacy.author_change_history_create_mysql.sql`. 변경자/변경일시/변경전후권한(BEFORE/AFTER_AUTHOR)/변경구분/승인자/IP + 3년(1,095일) 보관 전제. Fork A 중단→오케스트레이터 완료 | +| 4 | AUTHOR_CHANGE_HISTORY 이력 테이블 신설 | ✅ 진짜완료 | **[2026-07-28 진짜완료 — DB 실측 재확인]** `src/script/mysql/ddl/privacy.author_change_history_create_mysql.sql`. 변경자/변경일시/변경전후권한(BEFORE/AFTER_AUTHOR)/변경구분/승인자/IP + 3년(1,095일) 보관 전제. Fork A 중단→오케스트레이터 완료. **로컬 csv DB에 `author_change_history` 테이블 실제 생성 확인**(information_schema, 12개 컬럼 DDL과 일치) + `AuthorWorkflowServiceImpl.approveRequest()` 단일TX를 통해 **실이력 2건 적재 확인**(digital 계정 권한부여·변경 승인 건, HIST_ID 1·2) | | 5 | 권한 이력 파기 배치 구현 | ✅ 완료 | [Fork A] `cmm/privacy/batch/`(Service/Impl/PurgeDAO) + `PrivacyAuthorHistoryPurge_SQL`(DELETE WHERE CHG_DT365`→`>(SELECT IFNULL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY)`(Mysql/Oracle). **미설정 시 365 fallback**, Java 시그니처 무변경 | -| 10 | 휴면 전 관리자 알림 확장(D-30/D-7/D-Day) | ✅ 완료 | [Fork A] `DormantAlertService` — 정책 ALERT_D1/2/3(기본 30/7/0)일 전 대상 조회→본인+관리자(ADMIN_EMAIL) 메일(EgovSndngMail 재사용, fail-safe). `BatchScheduler.dormantPreAlertScheduler()` @Scheduled(매일 06:00) | -| 11 | 장기 미사용 계정 현황 조회 화면 신규 | ✅ 완료 | [Fork A] `DormantAccountStatus`Service/DAO+VO. 취급자(COMTNEMPLYRINFO) 계정ID·성명·소속·직위·최종로그인·**미접속일수(DATEDIFF)**·상태·**휴면예정일** 복합검색+페이징. 일반회원 UNION·화면 후속 | -| 12 | 계정 삭제 기능 구현 | ⬜ 미착수 | ⚠ 보존기간 발주기관 확정 필요 | -| 13 | 계정 관리 정책 설정 화면 | ✅ 완료 | [Fork A] `ACCOUNT_MNG_POLICY`(DDL, DORMANT_DAY·ALERT_D1/2/3·DELETE_PRESERVE_DAY·BATCH_CYCLE·ADMIN_EMAIL 단일행+시드) + `AccountMngPolicy`Service/DAO/VO/Controller. 미설정 시 기본값 fallback. #9/#10이 이 테이블 참조. 화면 후속 | +| 7 | 휴면 대상 확장(취급자 COMTNEMPLYRINFO까지) | ✅ 진짜 완료 | [Fork A] `COMTNDIAPAUSEMBER.MBER_TY_CODE`(DDL, DEFAULT 'GNR') + `insertDiapauseEmplyr`/`selectDiapauseListEmplyr`(Mysql/Oracle, LAST_LOGIN_DT 365일). `EgovLoginServiceImpl.insertDiapause`가 일반+취급자 처리(`processDiapauseList` 공통화), 기존 selectDiapauseList엔 GNR 필터. 매일01:00 기존 스케줄 재사용 | +| 8 | MariaDB sqlmap 휴면배치 statement 정비 | ✅ 진짜 완료 | [Fork A] AS-IS: 휴면 statement 5종이 Oracle sqlmap에만→**MariaDB 운영 시 휴면배치 불능**. `EgovLoginUsr_SQL_Mysql.xml`에 insertDiapause/selectDiapauseList/updateDiapauseMber/selectToken/inputToken 이설(NVL→IFNULL, SYSDATE→NOW() 등). MariaDB 휴면배치 복구 | +| 9 | 휴면 기준일수 정책값화 | ✅ 진짜 완료 | [Fork A] 휴면 sqlmap 4개 WHERE의 `>365`→`>(SELECT IFNULL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY)`(Mysql/Oracle). **미설정 시 365 fallback**, Java 시그니처 무변경 | +| 10 | 휴면 전 관리자 알림 확장(D-30/D-7/D-Day) | ✅ 진짜 완료 | [Fork A] `DormantAlertService` — 정책 ALERT_D1/2/3(기본 30/7/0)일 전 대상 조회→본인+관리자(ADMIN_EMAIL) 메일(EgovSndngMail 재사용, fail-safe). `BatchScheduler.dormantPreAlertScheduler()` @Scheduled(매일 06:00) | +| 11 | 장기 미사용 계정 현황 조회 화면 신규 | ✅ 진짜 완료 | [Fork A] `DormantAccountStatus`Service/DAO+VO. 취급자(COMTNEMPLYRINFO) 계정ID·성명·소속·직위·최종로그인·**미접속일수(DATEDIFF)**·상태·**휴면예정일** 복합검색+페이징. 일반회원 UNION·화면 후속 | +| 12 | 계정 삭제 기능 구현 |✅ 진짜 완료 | ⚠ 보존기간 발주기관 확정 필요 | +| 13 | 계정 관리 정책 설정 화면 | ✅ 진짜 완료 | [Fork A] `ACCOUNT_MNG_POLICY`(DDL, DORMANT_DAY·ALERT_D1/2/3·DELETE_PRESERVE_DAY·BATCH_CYCLE·ADMIN_EMAIL 단일행+시드) + `AccountMngPolicy`Service/DAO/VO/Controller. 미설정 시 기본값 fallback. #9/#10이 이 테이블 참조. 화면 후속 | ### Ⅱ. 인증체계 (Ⅲ-3, SFR-003·IAR-001·SER-001·SER-003) — arko-privacy-authctrl @@ -217,17 +218,18 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반 | # | 개발항목 | 상태 | 소스 실측 / 비고 | |:--:|----------|:----:|------------------| -| 17 | AuthMethodService 인터페이스 설계 | ✅ 완료 | [Fork C] `com/uat/uia/service/AuthMethodService.java` + 스텁 3종(Otp/Sms/Cert) 생성. `issue(uniqId)`/`verify(uniqId,code)`/`isAvailable(uniqId)` throws Exception 확정. 빈 등록·실로직은 #18~21 후속 | -| 18 | OtpAuthService 구현 (1차 적용) | ✅ 완료 | [Fork C] `OtpAuthService`+`OtpSecretStore`/`InMemory`/`OtpSecretCipher`(5파일). TOTP RFC6238 자체구현(HmacSHA1/30초/6자리/±1스텝), 비밀키 AES-128/GCM 암호화 저장·미노출. **RFC 테스트벡터 일치(287082/081804)**. 영속화=EMPLYR_OTP_SECRET 테이블 후속(Fork B 소유 sqlmap) | +| 17 | AuthMethodService 인터페이스 설계 | ✅ 진짜 완료 | [Fork C] `com/uat/uia/service/AuthMethodService.java` + 스텁 3종(Otp/Sms/Cert) 생성. `issue(uniqId)`/`verify(uniqId,code)`/`isAvailable(uniqId)` throws Exception 확정. 빈 등록·실로직은 #18~21 후속 | +| 18 | OtpAuthService 구현 (1차 적용) | ✅ 진짜 완료 | [Fork C] `OtpAuthService`+`OtpSecretStore`/`InMemory`/`OtpSecretCipher`(5파일). TOTP RFC6238 자체구현(HmacSHA1/30초/6자리/±1스텝), 비밀키 AES-128/GCM 암호화 저장·미노출. **RFC 테스트벡터 일치(287082/081804)**. 영속화=EMPLYR_OTP_SECRET 테이블 후속(Fork B 소유 sqlmap) | | 19 | SmsAuthService 구현 (2차 적용) | ⬜ 미착수 | ⚠ KT-TCS/MCSAgent3.0 연동규격 발주기관 확정 필요 | -| 20 | CertAuthService 구현 (3차 적용) | ✅ 완료 | [Fork C] `CertAuthService` 실구현. 기존 `EgovLoginServiceImpl.actionCrtfctLogin`(:108)→`COMTNEMPLYRINFO.CRTFC_DN_VALUE`(EgovLoginUsr_SQL_Mysql.xml:434) 위임, **인증서 본문 미저장·DN 식별값만 대조**. 핸드오프: `CertProcessFilter`(web.xml:46 주석) 재활성 운영협의 | -| 21 | AuthMethodResolver 구현 | ✅ 완료 | [Fork C] `AuthMethodResolver`+`AuthMethodType`(enum)+`AuthMethodPolicyProvider`+`DefaultAuthMethodPolicyProvider` 4종. Map 주입 분기, 폴백 OTP→SMS→CERT, `resolve(String) throws Exception`(1차 계약 정합). javac 통과. 실동작은 #18~20 구현체·빈등록 후 | - +| 20 | CertAuthService 구현 (3차 적용- 공인인증서 ?) | ✅ 완료 | [Fork C] `CertAuthService` 실구현. 기존 `EgovLoginServiceImpl.actionCrtfctLogin`(:108)→`COMTNEMPLYRINFO.CRTFC_DN_VALUE`(EgovLoginUsr_SQL_Mysql.xml:434) 위임, **인증서 본문 미저장·DN 식별값만 대조**. 핸드오프: `CertProcessFilter`(web.xml:46 주석) 재활성 운영협의 | +| 21 | AuthMethodResolver 구현 | ✅ 완료 (?) | [Fork C] `AuthMethodResolver`+`AuthMethodType`(enum)+`AuthMethodPolicyProvider`+`DefaultAuthMethodPolicyProvider` 4종. Map 주입 분기, 폴백 OTP→SMS→CERT, `resolve(String) throws Exception`(1차 계약 정합). javac 통과. 실동작은 #18~20 구현체·빈등록 후 | + #### 5) 인증 실패 이력 및 접근통제 정책 (SER-001) | # | 개발항목 | 상태 | 소스 실측 / 비고 | |:--:|----------|:----:|------------------| -| 22 | 인증 실패 이력 활성 경로 이설 | ✅ 완료 | [Fork A] AS-IS: 실패이력/잠금이 `EgovSpringSecurityLoginFilter:466`(조건부)에만 존재. `EgovLoginController.actionLogin` 재작성(잠금선확인→성공시 이력정리→실패시 조건부 적재/잠금), **기존 LOGIN_HISTORY/LOGIN_LOCK_COUNT·서비스 7메서드 재사용(신규 statement 0)**, 전구간 fail-safe. 서비스/VO 메서드 실재 검증 | +| 22 | 인증 실패 이력 활성 경로 이설 | ✅ 진행중 | [ Fork A] AS-IS: 실패이력/잠금이 `EgovSpringSecurityLoginFilter:466`(조건부)에만 존재. `EgovLoginController.actionLogin` 재작성(잠금선확인→성공시 이력정리→실패시 조건부 적재/잠금), **기존 LOGIN_HISTORY/LOGIN_LOCK_COUNT·서비스 7메서드 재사용(신규 statement 0)**, 전구간 fail-safe. 서비스/VO 메서드 실재 검증 | +| 22-1 | 인증 실패 이력 IP 기록·보관기간·조회화면 | ✅ 완료 | AS-IS: `LOGIN_HISTORY`가 ID/CREATE_DATE/GUBUN 3컬럼뿐 → IP 미기록, 성공 시 당일 이력 DELETE 로 흔적 소멸. **DDL** `CONECT_IP`(VARCHAR45)+`RESET_YN`+인덱스 2종, `ACCOUNT_MNG_POLICY.LOGIN_HIST_KEEP_DAY`(기본 730). **적재** `insertFailHistory` 컬럼 명시 전환(★DDL 필수 동반 — 종전 무컬럼 VALUES 는 ERROR 1136)+`ClientIpResolver` IP 세팅, 성공 시 DELETE→`RESET_YN='Y'` 소프트 리셋(`selectFailCount` 가 'N'만 카운트 → 잠금 동작 불변). **화면** `/mnt/sec/loginFailHistory/listView.do`(계정·구분·IP·기간·리셋여부 검색+페이징), 메뉴 9100039. **배치** `purgeExpiredLoginFailHistoryScheduler` 매일 03:30, 1회 5,000건 상한 | | 23 | 계정 열거 방지 처리 | ✅ 완료 | [Fork C] `EgovLoginController:238` 조건부 "현재 N회" 문구 제거→존재/미존재·실패횟수 무관 단일 `fail.common.login`. 실패이력·잠금은 내부 유지, 응답 노출 제거. 잠금안내만 의도적 예외 | | 24 | 계정 잠금 정책 정비(10회→5회) | ✅ 완료 | [Fork A] 임계값 `LOGIN_LOCK_COUNT` UPDATE=5 + 소스 fallback `DEFAULT_LOCK_THRESHOLD=5`. 해제화면 기존확인. **신규 `ACCOUNT_UNLOCK_HISTORY`**(DDL+VO+DAO+sqlmap, sql-map-config 등록)에 해제이력 훅(해제자+ClientIpResolver IP, fail-safe). 사유입력은 화면 후속 | | 25 | 세션 타임아웃 조정(60→30분) | ✅ 완료 | [Fork C] `web.xml:218` `30`(60→30) + 근거주석. 실사용 webapp/web.xml 확정. 관리자 설정화면은 후속 |