diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessController.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessController.java new file mode 100644 index 0000000..0389dd5 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessController.java @@ -0,0 +1,146 @@ +/** + * Class Name : AbnormalAccessController.java + * Description : 비정상 접근 탐지 알림함 화면 — 개인정보보호 기능개선 사업(SFR-004 / SER-002 / #49) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.27 개인정보보호 최초 생성 — 탐지 5유형(#35)의 화면 출력 + * + * @since 2026. 08. 27 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.auditlog; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +import javax.annotation.Resource; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.util.EgovUserDetailsHelper; + +/** + * 비정상 접근 탐지 알림함 (과업내용 다-① "비정상 접근 탐지 및 기록", SER-002). + * + *

{@link AbnormalAccessDetectionService}(#35)의 5유형 탐지 결과를 기간 단위로 보여준다. + * 화면: {@code /WEB-INF/jsp/site/mnt/sec/abnormal/list.jsp}

+ * + *

왜 저장 테이블이 아니라 즉시 집계인가: 탐지 임계치는 정책값이라 언제든 바뀐다 + * ({@link AbnormalAccessPolicy}). 탐지 결과를 별도 테이블에 적재해 두면 임계치를 조정한 뒤에도 + * 과거 기준으로 굳은 행이 남아 "지금 기준으로 몇 건인가"에 답할 수 없다. 원천인 접속기록은 + * 730일 보존되므로, 조회 시점 기준으로 다시 세는 편이 정확하다. + * 월 단위 스냅샷이 필요한 경우는 정기점검 리포트(#43)가 {@code ABNORMAL_SUMMARY} 로 남긴다.

+ * + *

조회 부하 주의: 5유형 각각이 접속기록 전체를 기간으로 훑는 집계라 기간이 넓으면 + * 무거워진다. 그래서 기본 조회기간을 최근 30일로 두고, 최대 {@link #MAX_RANGE_DAYS}일로 제한한다.

+ * + *

접근통제: 인터셉터 ①인증판정이 비활성이라 화면 단에서 다시 판정한다 + * ({@link PersonalDataAccessLogController} 와 동일 방식). 롤 판정은 {@code urlRoleMapping} + * 의 {@code /mnt/sec/*} = ROLE_ADMIN 이 담당한다.

+ */ +@Controller +public class AbnormalAccessController { + + private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; + + /** 기본 조회기간(일) — 최근 30일. */ + private static final int DEFAULT_RANGE_DAYS = 30; + + /** 최대 조회기간(일). 5유형 집계가 접속기록 전체를 훑으므로 상한을 둔다. */ + private static final int MAX_RANGE_DAYS = 366; + + private static final String DATE_PATTERN = "yyyy-MM-dd"; + + @Resource(name = "abnormalAccessDetectionService") + private AbnormalAccessDetectionService abnormalAccessDetectionService; + + /** + * 비정상 접근 탐지 결과 화면. + * + * @param fromDt 시작일(yyyy-MM-dd). 미지정 시 오늘 - 30일 + * @param toDt 종료일(yyyy-MM-dd). 미지정 시 오늘 + */ + @RequestMapping(value = "/mnt/sec/abnormal/listView.do") + public String listView(@RequestParam(value = "fromDt", required = false) String fromDt, + @RequestParam(value = "toDt", required = false) String toDt, + ModelMap model) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + + SimpleDateFormat dateFmt = new SimpleDateFormat(DATE_PATTERN); + Calendar cal = Calendar.getInstance(); + + String toDate = isBlank(toDt) ? dateFmt.format(cal.getTime()) : toDt.trim(); + String fromDate; + if (isBlank(fromDt)) { + cal.add(Calendar.DAY_OF_MONTH, -DEFAULT_RANGE_DAYS); + fromDate = dateFmt.format(cal.getTime()); + } else { + fromDate = fromDt.trim(); + } + + String rangeMsg = null; + long days = diffDays(dateFmt, fromDate, toDate); + if (days < 0) { + // 시작 > 종료 — 조회하지 않고 되돌린다(뒤집힌 조건으로 "이상징후 0건"이 나오면 오독한다) + rangeMsg = "시작일이 종료일보다 뒤입니다. 기간을 다시 지정하세요."; + } else if (days > MAX_RANGE_DAYS) { + rangeMsg = "조회기간은 최대 " + MAX_RANGE_DAYS + "일까지입니다. 기간을 좁혀 주세요."; + } + + if (rangeMsg == null) { + // 종료일 당일(23:59:59)까지 포함 — 접속기록 조회화면과 같은 규칙 + List results = + abnormalAccessDetectionService.detect(fromDate + " 00:00:00", toDate + " 23:59:59"); + model.addAttribute("resultList", results); + model.addAttribute("summary", + abnormalAccessDetectionService.summarize(fromDate + " 00:00:00", toDate + " 23:59:59")); + } + + model.addAttribute("fromDt", fromDate); + model.addAttribute("toDt", toDate); + model.addAttribute("resultMsg", rangeMsg); + return "site/mnt/sec/abnormal/list"; + } + + /** 두 날짜(yyyy-MM-dd) 간 일수 차. 파싱 실패 시 0(=검증 통과)로 두고 조회를 진행한다. */ + private long diffDays(SimpleDateFormat fmt, String from, String to) { + try { + Date f = fmt.parse(from); + Date t = fmt.parse(to); + return (t.getTime() - f.getTime()) / (24L * 60L * 60L * 1000L); + } catch (Exception e) { + return 0L; + } + } + + private boolean isBlank(String v) { + return v == null || v.trim().length() == 0; + } + + /** + * 인증된 업무사용자(USR)만 통과. 그 외에는 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/auditlog/AbnormalAccessDetectionServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessDetectionServiceImpl.java index 448e936..c89f7d2 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessDetectionServiceImpl.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessDetectionServiceImpl.java @@ -51,8 +51,8 @@ public class AbnormalAccessDetectionServiceImpl implements AbnormalAccessDetecti // ② 대량조회(의심 접속자 목록) List> mass = abnormalAccessDetectionDAO.selectMassQuery(p); - results.add(new AbnormalAccessResultVO(AbnormalAccessType.MASS_QUERY, size(mass), - offenders(mass, "cnt", "건"))); + results.add(withRows(new AbnormalAccessResultVO(AbnormalAccessType.MASS_QUERY, size(mass), + offenders(mass, "cnt", "건")), mass)); // ③ 비정상시간대접속 int off = abnormalAccessDetectionDAO.countOffHours(p); @@ -60,13 +60,13 @@ public class AbnormalAccessDetectionServiceImpl implements AbnormalAccessDetecti // ④ 계정공유의심(의심 접속자 목록) List> sharing = abnormalAccessDetectionDAO.selectAccountSharing(p); - results.add(new AbnormalAccessResultVO(AbnormalAccessType.ACCOUNT_SHARING, size(sharing), - offenders(sharing, "ipCnt", "IP"))); + results.add(withRows(new AbnormalAccessResultVO(AbnormalAccessType.ACCOUNT_SHARING, size(sharing), + offenders(sharing, "ipCnt", "IP")), sharing)); // ⑤ 인증실패반복(의심 접속자 목록) List> authFail = abnormalAccessDetectionDAO.selectRepeatedAuthFailure(p); - results.add(new AbnormalAccessResultVO(AbnormalAccessType.REPEATED_AUTH_FAILURE, size(authFail), - offenders(authFail, "cnt", "회"))); + results.add(withRows(new AbnormalAccessResultVO(AbnormalAccessType.REPEATED_AUTH_FAILURE, size(authFail), + offenders(authFail, "cnt", "회")), authFail)); return results; } @@ -91,6 +91,15 @@ public class AbnormalAccessDetectionServiceImpl implements AbnormalAccessDetecti return sb.length() == 0 ? "(이상징후 없음)" : sb.toString(); } + /** + * 대상자형 유형에 의심 대상 상세 행을 실어 준다(알림함 화면 #49 용). + * 요약 문자열({@code detail})은 종전 그대로라 리포트(#43) 동작에는 영향이 없다. + */ + private static AbnormalAccessResultVO withRows(AbnormalAccessResultVO vo, List> rows) { + vo.setRows(rows); + return vo; + } + private static int size(List> list) { return list == null ? 0 : list.size(); } diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessResultVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessResultVO.java index e532051..63ac328 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessResultVO.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessResultVO.java @@ -1,6 +1,8 @@ package egovframework.com.cmm.privacy.auditlog; import java.io.Serializable; +import java.util.List; +import java.util.Map; /** * 비정상 접근 탐지 결과 1유형 (#35). @@ -38,4 +40,16 @@ public class AbnormalAccessResultVO implements Serializable { public String getDetail() { return detail; } public void setDetail(String detail) { this.detail = detail; } + + /** + * 의심 대상 상세 행(접속자ID + 건수/IP수). 대상자형 유형(대량조회·계정공유·인증실패반복)에만 있고, + * 건수형(비인가접근·비정상시간대)은 비어 있다 — 이 둘은 특정 대상자가 아니라 기간 전체의 건수다. + * + *

알림함 화면(#49)이 "누가 걸렸는지"를 요약 문자열에서 파싱하지 않도록 원본 행을 그대로 싣는다. + * 개인정보 본문은 담지 않는다(접속자ID·집계값뿐).

+ */ + private List> rows; + + public List> getRows() { return rows; } + public void setRows(List> rows) { this.rows = rows; } } diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogPurgeHistoryController.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogPurgeHistoryController.java new file mode 100644 index 0000000..f96e932 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogPurgeHistoryController.java @@ -0,0 +1,102 @@ +/** + * Class Name : AccessLogPurgeHistoryController.java + * Description : 개인정보 접속기록 파기이력 조회 화면 — 개인정보보호 기능개선 사업(SFR-004 / DBR-001 / SER-002) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.27 개인정보보호 최초 생성 — 파기 배치(#37) 이력의 화면 출력 + * + * @since 2026. 08. 27 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.auditlog; + +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.privacy.batch.AccessLogPartitionDAO; +import egovframework.com.cmm.util.EgovUserDetailsHelper; +import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo; + +/** + * 접속기록 파기·증설 이력 조회 컨트롤러 (SER-002-2 / DBR-001-2). + * + *

보관기간(730일) 경과 파티션을 파기한 내역과, 미래 월 파티션을 미리 확보한 내역을 보여준다. + * 화면: {@code /WEB-INF/jsp/site/mnt/sec/purgeHistory/list.jsp}

+ * + *

조회 전용이다. 파기 이력 자체가 "접속기록을 규정대로 지웠고 그 외 경로로는 손대지 + * 않았다"는 증빙이므로, 화면에서 이력을 고치거나 지울 수 있으면 증빙 가치가 사라진다. + * 등록·수정·삭제 엔드포인트를 두지 않는다.

+ * + *

접근통제: 인터셉터 3단 중 ①인증판정이 비활성이라 /mnt 하위라도 무인증 진입이 + * 가능하므로 {@link #handlerUser()} 로 화면 단에서 다시 판정한다 + * ({@link PersonalDataAccessLogController} 와 동일 방식). 롤 판정은 {@code urlRoleMapping} + * 의 {@code /mnt/sec/*} = ROLE_ADMIN 이 담당한다.

+ */ +@Controller +public class AccessLogPurgeHistoryController { + + private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; + + @Resource(name = "accessLogPartitionDAO") + private AccessLogPartitionDAO accessLogPartitionDAO; + + /** + * 파기이력 목록 화면. + * 뷰: /WEB-INF/jsp/site/mnt/sec/purgeHistory/list.jsp + */ + @RequestMapping(value = "/mnt/sec/purgeHistory/listView.do") + public String listView(@ModelAttribute("accessLogPurgeHistorySearchVO") AccessLogPurgeHistorySearchVO searchVO, + ModelMap model) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + + if (searchVO.getPageIndex() < 1) { + searchVO.setPageIndex(1); + } + if (searchVO.getRecordCountPerPage() < 1) { + searchVO.setRecordCountPerPage(10); + } + searchVO.setFirstIndex((searchVO.getPageIndex() - 1) * searchVO.getRecordCountPerPage()); + + int totalCount = accessLogPartitionDAO.selectPurgeHistoryListCount(searchVO); + searchVO.setTotalRecordCount(totalCount); + List list = accessLogPartitionDAO.selectPurgeHistoryList(searchVO); + + PaginationInfo paginationInfo = new PaginationInfo(); + paginationInfo.setCurrentPageNo(searchVO.getPageIndex()); + paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage()); + paginationInfo.setPageSize(10); + paginationInfo.setTotalRecordCount(totalCount); + + model.addAttribute("resultList", list); + model.addAttribute("paginationInfo", paginationInfo); + return "site/mnt/sec/purgeHistory/list"; + } + + /** + * 인증된 업무사용자(USR)만 통과. 그 외에는 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/auditlog/AccessLogPurgeHistorySearchVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogPurgeHistorySearchVO.java new file mode 100644 index 0000000..4090217 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogPurgeHistorySearchVO.java @@ -0,0 +1,77 @@ +/** + * Class Name : AccessLogPurgeHistorySearchVO.java + * Description : 접속기록 파기이력 검색조건 VO — 개인정보보호 기능개선 사업(SFR-004 / SER-002) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.27 개인정보보호 최초 생성 + * + * @since 2026. 08. 27 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.auditlog; + +import java.io.Serializable; + +/** + * 파기이력 조회 검색조건 + 페이징. + * + *

종료일 보정: 사용자가 {@code 2026-08-27} 처럼 날짜만 넣으면 그날 00:00:00 이 되어 + * 당일 파기분이 통째로 빠진다. {@link #getToDtParam()} 가 날짜만 들어온 경우 + * {@code 23:59:59} 를 붙여 당일을 포함시킨다 — 접속기록 조회화면 + * ({@link PersonalDataAccessLogSearchVO})과 같은 규칙이라 두 화면의 기간 검색이 어긋나지 않는다.

+ */ +public class AccessLogPurgeHistorySearchVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 유형 — DROP / ADD / 전체(빈값). */ + private String purgeType; + /** 수행일시 시작(yyyy-MM-dd 또는 yyyy-MM-dd HH:mm:ss). */ + private String fromDt; + /** 수행일시 종료. */ + private String toDt; + + private int pageIndex = 1; + private int recordCountPerPage = 10; + /** 조회 시작 offset(0-base) = (pageIndex-1) * recordCountPerPage */ + private int firstIndex = 0; + private int totalRecordCount = 0; + + public String getPurgeType() { return purgeType; } + public void setPurgeType(String purgeType) { this.purgeType = purgeType; } + + public String getFromDt() { return fromDt; } + public void setFromDt(String fromDt) { this.fromDt = fromDt; } + + public String getToDt() { return toDt; } + public void setToDt(String toDt) { this.toDt = toDt; } + + 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; } + + /** 시작일시 바인딩값 — 날짜만 들어오면 00:00:00 을 붙인다. */ + public String getFromDtParam() { + return isDateOnly(fromDt) ? fromDt + " 00:00:00" : fromDt; + } + + /** 종료일시 바인딩값 — 날짜만 들어오면 당일 23:59:59 까지 포함시킨다. */ + public String getToDtParam() { + return isDateOnly(toDt) ? toDt + " 23:59:59" : toDt; + } + + private boolean isDateOnly(String v) { + return v != null && v.trim().length() == 10; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogPurgeHistoryVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogPurgeHistoryVO.java new file mode 100644 index 0000000..4810279 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogPurgeHistoryVO.java @@ -0,0 +1,60 @@ +/** + * Class Name : AccessLogPurgeHistoryVO.java + * Description : 개인정보 접속기록 파기·증설 이력 VO — 개인정보보호 기능개선 사업(SFR-004 / DBR-001 / SER-002) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.27 개인정보보호 최초 생성 — 파기이력 조회 화면(#37 후속) + * + * @since 2026. 08. 27 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.auditlog; + +import java.io.Serializable; + +/** + * {@code PDAL_PURGE_HISTORY} 한 행 — 파기 배치(#37)가 수행한 파티션 DROP(파기)·ADD(선행증설) 내역. + * + *

이 이력이 증빙인 이유: 접속기록 본문 테이블은 UPDATE/DELETE statement 자체를 두지 않고, + * 삭제는 오직 파티션 DROP 으로만 일어난다. 그래서 "언제 무엇이 지워졌는가"를 답할 수 있는 유일한 + * 근거가 이 표다 — 보관기간 경과분을 실제로 파기했다는 사실(SER-002-2)과, 그 외 경로로는 지운 적이 + * 없다는 사실을 함께 보인다.

+ */ +public class AccessLogPurgeHistoryVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 이력 일련번호. */ + private long purgeId; + /** 대상 파티션명(예: p202607). */ + private String partitionName; + /** 파티션 상한 경계값(LESS THAN). */ + private String boundaryDt; + /** 유형 — DROP:파기 / ADD:선행증설. */ + private String purgeType; + /** 수행 일시. */ + private String purgeDt; + /** 비고(보관기간·사유). */ + private String note; + + public long getPurgeId() { return purgeId; } + public void setPurgeId(long purgeId) { this.purgeId = purgeId; } + + public String getPartitionName() { return partitionName; } + public void setPartitionName(String partitionName) { this.partitionName = partitionName; } + + public String getBoundaryDt() { return boundaryDt; } + public void setBoundaryDt(String boundaryDt) { this.boundaryDt = boundaryDt; } + + public String getPurgeType() { return purgeType; } + public void setPurgeType(String purgeType) { this.purgeType = purgeType; } + + public String getPurgeDt() { return purgeDt; } + public void setPurgeDt(String purgeDt) { this.purgeDt = purgeDt; } + + public String getNote() { return note; } + public void setNote(String note) { this.note = note; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/AccessLogPartitionDAO.java b/src/main/java/egovframework/com/cmm/privacy/batch/AccessLogPartitionDAO.java index 6d8b655..6191245 100644 --- a/src/main/java/egovframework/com/cmm/privacy/batch/AccessLogPartitionDAO.java +++ b/src/main/java/egovframework/com/cmm/privacy/batch/AccessLogPartitionDAO.java @@ -5,6 +5,8 @@ import java.util.Map; import org.springframework.stereotype.Repository; +import egovframework.com.cmm.privacy.auditlog.AccessLogPurgeHistorySearchVO; +import egovframework.com.cmm.privacy.auditlog.AccessLogPurgeHistoryVO; import egovframework.com.cmm.service.impl.EgovComAbstractDAO; /** @@ -54,4 +56,24 @@ public class AccessLogPartitionDAO extends EgovComAbstractDAO { public void insertPurgeHistory(Map param) throws Exception { insert("PersonalDataAccessLogDAO.insertPurgeHistory", param); } + + /** + * 파기·증설 이력 목록(화면 조회용, 페이징). + * + *

배치가 쓰는 DAO 에 조회를 함께 두는 이유는 {@code PDAL_PURGE_HISTORY} 의 소유자가 + * 이 클래스이기 때문이다. 이력을 읽는 statement 를 다른 sqlmap 으로 흩어 놓으면 + * 적재 컬럼이 바뀔 때 조회 쪽이 조용히 어긋난다.

+ * + * @param searchVO 유형·기간·페이징 + */ + @SuppressWarnings("unchecked") + public List selectPurgeHistoryList(AccessLogPurgeHistorySearchVO searchVO) { + return list("PersonalDataAccessLogDAO.selectPurgeHistoryList", searchVO); + } + + /** 파기·증설 이력 총건수(페이징용). 목록과 같은 검색조건을 유지할 것. */ + public int selectPurgeHistoryListCount(AccessLogPurgeHistorySearchVO searchVO) { + Object cnt = selectByPk("PersonalDataAccessLogDAO.selectPurgeHistoryListCount", searchVO); + return cnt == null ? 0 : ((Number) cnt).intValue(); + } } diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/InspectionResultController.java b/src/main/java/egovframework/com/cmm/privacy/batch/InspectionResultController.java index e4647a6..a8edb8d 100644 --- a/src/main/java/egovframework/com/cmm/privacy/batch/InspectionResultController.java +++ b/src/main/java/egovframework/com/cmm/privacy/batch/InspectionResultController.java @@ -1,5 +1,6 @@ package egovframework.com.cmm.privacy.batch; +import java.net.URLEncoder; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -12,13 +13,20 @@ import org.springframework.ui.ModelMap; import org.springframework.web.bind.annotation.RequestMapping; import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.util.EgovUserDetailsHelper; /** * 정기점검 결과 입력·보관 컨트롤러 (#44, SFR-004/SER-002). * - *

취급자 영역(/mnt) 백엔드 엔드포인트. 응답은 jsonView(화면 JSP는 #48 대시보드 후속). + *

취급자 영역(/mnt) 엔드포인트. 기존 {@code list.do}/{@code detail.do}/{@code saveResult.do} + * 는 jsonView 로 응답하는 백엔드 계약이고, 2026.08.27 추가된 {@code listView.do}/ + * {@code saveResultView.do} 가 관리자 화면(#48)이다 — 두 계약을 병존시켜 기존 호출부를 깨지 않는다. * 점검자 식별자는 세션 loginVO 에서 취득한다.

* + *

화면이 필요한 이유: 월 1회 점검 자체는 배치(#43)가 집계하지만, "누가 언제 보고 무엇을 + * 조치했는가"는 사람이 입력해야 남는다. 그 입력 경로가 JSON 엔드포인트뿐이면 점검 사실을 + * 증빙으로 제출할 수 없다(SFR-004-7).

+ * * @author arko-privacy-auditlog (Fork C - 7차) * @since 2026-07-21 */ @@ -27,6 +35,8 @@ public class InspectionResultController { protected static final String JSON = "jsonView"; + private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; + @Resource(name = "inspectionResultService") private InspectionResultService inspectionResultService; @@ -35,6 +45,82 @@ public class InspectionResultController { return (o instanceof LoginVO) ? (LoginVO) o : null; } + /** + * 정기점검 리포트 목록·상세 화면(서버 렌더링, #48). + * 뷰: /WEB-INF/jsp/site/mnt/sec/inspection/list.jsp + * + *

월 1행짜리 표라 페이징을 두지 않는다(연 12행). 연도 필터와 점검여부 필터만 제공한다. + * {@code rptId} 가 있으면 그 리포트 상세와 점검결과 입력 폼을 함께 편다 — + * 목록에서 상세로 화면을 옮기면 점검 중 다른 달과 대조하기가 불편하다.

+ */ + @RequestMapping(value = "/mnt/sec/inspection/listView.do") + public String listView(@org.springframework.web.bind.annotation.RequestParam(value = "rptYmPrefix", required = false) String rptYmPrefix, + @org.springframework.web.bind.annotation.RequestParam(value = "inspectedYn", required = false) String inspectedYn, + @org.springframework.web.bind.annotation.RequestParam(value = "rptId", required = false) Long rptId, + @org.springframework.web.bind.annotation.RequestParam(value = "resultMsg", required = false) String resultMsg, + ModelMap model) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + Map param = new HashMap(); + param.put("rptYmPrefix", rptYmPrefix); + param.put("inspectedYn", inspectedYn); + List list = inspectionResultService.selectReportList(param); + + model.addAttribute("resultList", list); + model.addAttribute("totalCount", list == null ? 0 : list.size()); + model.addAttribute("rptYmPrefix", rptYmPrefix); + model.addAttribute("inspectedYn", inspectedYn); + model.addAttribute("resultMsg", resultMsg); + if (rptId != null) { + model.addAttribute("report", inspectionResultService.selectReport(rptId.longValue())); + } + return "site/mnt/sec/inspection/list"; + } + + /** + * 점검결과 입력·보관(화면용, #48). 저장 후 같은 리포트를 펼친 목록으로 되돌린다. + * + *

배치가 적재한 집계값은 손대지 않고 점검자 입력 필드만 갱신한다 + * (감사 증빙이므로 집계값이 사후에 바뀌면 안 된다).

+ */ + @RequestMapping(value = "/mnt/sec/inspection/saveResultView.do") + public String saveResultView(InspectionReportVO vo, HttpServletRequest request) throws Exception { + LoginVO user = handlerUser(); + if (user == null) { + return LOGIN_REDIRECT; + } + vo.setInspectorId(user.getId()); + String message; + try { + int cnt = inspectionResultService.saveInspectionResult(vo); + message = (cnt > 0) ? "점검결과를 저장했습니다." : "저장 대상 리포트가 없습니다."; + } catch (IllegalArgumentException e) { + // 점검결과 미입력 등 유효성 위반 — 화면에 사유를 그대로 보여준다 + message = e.getMessage(); + } + // Spring 3.0.5 에는 RedirectAttributes 가 없다(3.1 도입). 이 코드베이스의 관용대로 + // 결과 메시지를 URL 인코딩해 쿼리스트링으로 넘긴다(AccountMngController 와 동일 방식). + return "redirect:/mnt/sec/inspection/listView.do?rptId=" + vo.getRptId() + + "&resultMsg=" + URLEncoder.encode(message == null ? "" : message, "UTF-8"); + } + + /** + * 인증된 업무사용자(USR)만 통과. 그 외에는 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; + } + /** 정기점검 리포트 월별 목록 (검색: rptYmPrefix 연도, inspectedYn Y/N) */ @RequestMapping(value = "/mnt/sec/inspection/list.do") public String list(@org.springframework.web.bind.annotation.RequestParam(value = "rptYmPrefix", required = false) String rptYmPrefix, diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoAccessHistoryVO.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoAccessHistoryVO.java new file mode 100644 index 0000000..49f0f38 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoAccessHistoryVO.java @@ -0,0 +1,90 @@ +/** + * Class Name : MberCryptoAccessHistoryVO.java + * Description : 암호화 회원정보 관리자 접근 이력 VO — 개인정보보호 기능개선 사업(추가제안 Action4) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.27 개인정보보호 최초 생성 — 사유·재인증 이력 + * + * @since 2026. 08. 27 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.crypto; + +import java.io.Serializable; + +/** + * {@code MBER_CRYPTO_ACCESS_HISTORY} 한 행 — 관리자가 평문 개인정보를 열람·변경한 행위의 소명 근거. + * + *

개인정보 본문은 담지 않는다. 무엇을 바꿨는지는 {@link #changedItems}(항목명 목록)로만 + * 남기고 값은 적재하지 않는다 — 이력이 또 하나의 평문 개인정보 저장소가 되면 안 된다.

+ */ +public class MberCryptoAccessHistoryVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 행위 — 복호화 조회. */ + public static final String ACTION_SELECT = "S"; + /** 행위 — 신규 등록. */ + public static final String ACTION_INSERT = "I"; + /** 행위 — 수정. */ + public static final String ACTION_UPDATE = "U"; + /** 행위 — 개인정보 파기. */ + public static final String ACTION_DELETE = "D"; + + private long histId; + /** 처리자(취급자) 계정ID. */ + private String handlrId; + /** 행위 S/I/U/D. */ + private String actionTy; + /** 대상 회원ID. */ + private String trgetMberId; + /** 사유(필수). */ + private String accessReason; + /** 변경 항목명 목록(값 미적재). */ + private String changedItems; + /** 처리자 IP. */ + private String handlrIp; + /** 재인증 통과 여부 Y/N. */ + private String reauthYn; + /** 처리 일시. */ + private String accessDt; + + public long getHistId() { return histId; } + public void setHistId(long histId) { this.histId = histId; } + + public String getHandlrId() { return handlrId; } + public void setHandlrId(String handlrId) { this.handlrId = handlrId; } + + public String getActionTy() { return actionTy; } + public void setActionTy(String actionTy) { this.actionTy = actionTy; } + + public String getTrgetMberId() { return trgetMberId; } + public void setTrgetMberId(String trgetMberId) { this.trgetMberId = trgetMberId; } + + public String getAccessReason() { return accessReason; } + public void setAccessReason(String accessReason) { this.accessReason = accessReason; } + + public String getChangedItems() { return changedItems; } + public void setChangedItems(String changedItems) { this.changedItems = changedItems; } + + public String getHandlrIp() { return handlrIp; } + public void setHandlrIp(String handlrIp) { this.handlrIp = handlrIp; } + + public String getReauthYn() { return reauthYn; } + public void setReauthYn(String reauthYn) { this.reauthYn = reauthYn; } + + public String getAccessDt() { return accessDt; } + public void setAccessDt(String accessDt) { this.accessDt = accessDt; } + + /** 행위코드의 한글 표기(화면·리포트용). */ + public String getActionNm() { + if (ACTION_SELECT.equals(actionTy)) { return "복호화조회"; } + if (ACTION_INSERT.equals(actionTy)) { return "등록"; } + if (ACTION_UPDATE.equals(actionTy)) { return "수정"; } + if (ACTION_DELETE.equals(actionTy)) { return "개인정보파기"; } + return actionTy; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoAdminController.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoAdminController.java new file mode 100644 index 0000000..17cbb38 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoAdminController.java @@ -0,0 +1,426 @@ +/** + * Class Name : MberCryptoAdminController.java + * Description : 암호화 회원정보 관리자 전용 CRUD 화면 — 개인정보보호 기능개선 사업(추가제안 Action4) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.27 개인정보보호 최초 생성 — 복호화 조회·수정·삭제·등록 + 사유·재인증 + * + * @since 2026. 08. 27 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.crypto; + +import java.net.URLEncoder; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +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 org.springframework.web.bind.annotation.RequestParam; + +import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.privacy.auditlog.ClientIpResolver; +import egovframework.com.cmm.privacy.util.MaskingUtil; +import egovframework.com.cmm.util.EgovUserDetailsHelper; +import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo; + +/** + * 관리자 전용 회원정보(암호화 항목) 화면 — 제안서 추가제안 Action4(슬라이드16). + * + *

화면 구성 + *

    + *
  • {@code listView.do} 목록 — 항상 마스킹해서 보여준다(이름 홍*동, 휴대폰 010-****-1234).
  • + *
  • {@code view.do} 상세 — 사유 입력 + 재인증을 통과해야 평문이 보인다.
  • + *
  • {@code registView.do}/{@code regist.do} 신규 등록, {@code update.do} 수정, + * {@code deletePii.do} 개인정보 파기 — 모두 사유·재인증 필수.
  • + *
  • {@code historyView.do} 이 화면에서 일어난 접근 이력(사유 포함) 조회.
  • + *
+ * + *

목록을 마스킹으로 고정한 이유: 관리자가 목록만 열어도 평문 이름·연락처가 화면 한 장에 + * 수십 건씩 뜨면, 사유를 받는 의미가 사라진다(정작 대량 노출은 목록에서 일어난다). + * 평문은 대상을 특정해 사유를 남긴 상세 한 건에서만 보이게 한다.

+ * + *

접근통제 3중: ① {@code urlRoleMapping} 의 {@code /mnt/sec/*} = ROLE_ADMIN(전체관리자 + * 전용) ② {@link #handlerUser()} 로 업무사용자(USR) 세션 재확인 ③ 평문에 닿는 모든 경로에서 + * {@link MberCryptoReauth} 재인증(10분). 여기에 접속기록 인터셉터가 {@code /mnt/sec/**} 를 + * 자동 수집하므로 '언제 누가 열었는가'는 별도로도 남는다.

+ * + *

상태 변경은 POST 만 받는다. 등록·수정·파기가 GET 으로도 열리면 링크 한 번, 이미지 + * 태그 하나로 실행될 수 있다.

+ */ +@Controller +public class MberCryptoAdminController { + + private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; + private static final String LIST_REDIRECT = "redirect:/mnt/sec/mberCrypto/listView.do?page=9100051"; + + /** 재인증 화면으로 되돌아올 수 있는 경로의 접두사. 이 밖의 값은 받지 않는다(오픈 리다이렉트 차단). */ + private static final String RETURN_URL_PREFIX = "/mnt/sec/mberCrypto/"; + + @Resource(name = "mberCryptoAdminService") + private MberCryptoAdminService mberCryptoAdminService; + + /* ================================================================ + * 목록 — 항상 마스킹 + * ================================================================ */ + + /** + * 회원정보 목록(마스킹). 뷰: /WEB-INF/jsp/site/mnt/sec/mberCrypto/list.jsp + * + *

이름 검색은 완전일치만 된다(암호문은 부분검색이 원리적으로 불가). 그 사실을 화면 안내에 + * 명시하고, 검색 결과가 0건일 때 "부분일치로는 찾을 수 없다"는 점을 함께 보여준다.

+ */ + @RequestMapping(value = "/mnt/sec/mberCrypto/listView.do") + public String listView(@ModelAttribute("mberCryptoSearchVO") MberCryptoSearchVO searchVO, + @RequestParam(value = "resultMsg", required = false) String resultMsg, + HttpServletRequest request, ModelMap model) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + + if (searchVO.getPageIndex() < 1) { + searchVO.setPageIndex(1); + } + if (searchVO.getRecordCountPerPage() < 1) { + searchVO.setRecordCountPerPage(10); + } + searchVO.setFirstIndex((searchVO.getPageIndex() - 1) * searchVO.getRecordCountPerPage()); + + List list = mberCryptoAdminService.selectMberList(searchVO); + maskAll(list); + + 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("resultMsg", resultMsg); + model.addAttribute("reauthRemain", Long.valueOf(MberCryptoReauth.remainSeconds(request))); + return "site/mnt/sec/mberCrypto/list"; + } + + /* ================================================================ + * 재인증 + * ================================================================ */ + + /** 재인증 입력 화면. 뷰: /WEB-INF/jsp/site/mnt/sec/mberCrypto/reauth.jsp */ + @RequestMapping(value = "/mnt/sec/mberCrypto/reauthView.do") + public String reauthView(@RequestParam(value = "returnUrl", required = false) String returnUrl, + @RequestParam(value = "resultMsg", required = false) String resultMsg, + ModelMap model) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + model.addAttribute("returnUrl", safeReturnUrl(returnUrl)); + model.addAttribute("resultMsg", resultMsg); + return "site/mnt/sec/mberCrypto/reauth"; + } + + /** + * 재인증 처리(본인 비밀번호 재확인). 성공하면 원래 가려던 화면으로 돌려보낸다. + * + *

실패 문구는 사유를 구분하지 않는다(계정 없음/비밀번호 불일치 동일) — 계정 열거를 막는 + * 이 사업의 기존 처리(#23)와 같은 방침이다.

+ */ + @RequestMapping(value = "/mnt/sec/mberCrypto/reauth.do", method = org.springframework.web.bind.annotation.RequestMethod.POST) + public String reauth(@RequestParam(value = "password", required = false) String password, + @RequestParam(value = "returnUrl", required = false) String returnUrl, + HttpServletRequest request) throws Exception { + LoginVO user = handlerUser(); + if (user == null) { + return LOGIN_REDIRECT; + } + String target = safeReturnUrl(returnUrl); + + if (mberCryptoAdminService.verifyHandlerPassword(user.getId(), password)) { + MberCryptoReauth.markVerified(request); + return "redirect:" + target; + } + MberCryptoReauth.clear(request); + return "redirect:/mnt/sec/mberCrypto/reauthView.do?returnUrl=" + URLEncoder.encode(target, "UTF-8") + + "&resultMsg=" + URLEncoder.encode("본인 확인에 실패했습니다. 비밀번호를 다시 입력하세요.", "UTF-8"); + } + + /* ================================================================ + * 상세(복호화) — 사유 + 재인증 + * ================================================================ */ + + /** + * 회원 상세(평문). 사유 미입력이면 열지 않고, 재인증이 만료됐으면 재인증 화면으로 보낸다. + * 뷰: /WEB-INF/jsp/site/mnt/sec/mberCrypto/view.jsp + */ + @RequestMapping(value = "/mnt/sec/mberCrypto/view.do") + public String view(@RequestParam(value = "mberId", required = false) String mberId, + @RequestParam(value = "reason", required = false) String reason, + @RequestParam(value = "resultMsg", required = false) String resultMsg, + HttpServletRequest request, ModelMap model) throws Exception { + LoginVO user = handlerUser(); + if (user == null) { + return LOGIN_REDIRECT; + } + if (isBlank(mberId)) { + return listRedirect("대상 회원이 지정되지 않았습니다."); + } + if (isBlank(reason)) { + return listRedirect("개인정보 조회 사유는 필수입니다. 목록에서 사유를 입력한 뒤 조회하세요."); + } + if (!MberCryptoReauth.isVerified(request)) { + return reauthRedirect(viewUrl(mberId, reason)); + } + + MberCryptoVO vo = mberCryptoAdminService.selectMber(mberId); + if (vo == null) { + return listRedirect("대상 회원을 찾을 수 없습니다."); + } + + // 평문을 실제로 화면에 띄우는 시점에 사유와 함께 이력을 남긴다. + mberCryptoAdminService.recordDecryptView(mberId, user.getId(), + ClientIpResolver.getClientIp(request), reason); + + model.addAttribute("mber", vo); + model.addAttribute("reason", reason); + model.addAttribute("resultMsg", resultMsg); + model.addAttribute("reauthRemain", Long.valueOf(MberCryptoReauth.remainSeconds(request))); + return "site/mnt/sec/mberCrypto/view"; + } + + /* ================================================================ + * 등록 / 수정 / 파기 — 모두 POST + 사유 + 재인증 + * ================================================================ */ + + /** 신규 등록 화면. 뷰: /WEB-INF/jsp/site/mnt/sec/mberCrypto/regist.jsp */ + @RequestMapping(value = "/mnt/sec/mberCrypto/registView.do") + public String registView(@ModelAttribute("mberCryptoVO") MberCryptoVO vo, + @RequestParam(value = "resultMsg", required = false) String resultMsg, + HttpServletRequest request, ModelMap model) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + if (!MberCryptoReauth.isVerified(request)) { + return reauthRedirect(RETURN_URL_PREFIX + "registView.do?page=9100051"); + } + model.addAttribute("resultMsg", resultMsg); + model.addAttribute("reauthRemain", Long.valueOf(MberCryptoReauth.remainSeconds(request))); + return "site/mnt/sec/mberCrypto/regist"; + } + + /** 신규 등록 처리. */ + @RequestMapping(value = "/mnt/sec/mberCrypto/regist.do", method = org.springframework.web.bind.annotation.RequestMethod.POST) + public String regist(@ModelAttribute("mberCryptoVO") MberCryptoVO vo, + @RequestParam(value = "reason", required = false) String reason, + HttpServletRequest request) throws Exception { + LoginVO user = handlerUser(); + if (user == null) { + return LOGIN_REDIRECT; + } + if (!MberCryptoReauth.isVerified(request)) { + return reauthRedirect(RETURN_URL_PREFIX + "registView.do?page=9100051"); + } + try { + String mberId = mberCryptoAdminService.registMber(vo, user.getId(), + ClientIpResolver.getClientIp(request), reason); + return listRedirect("회원을 등록했습니다: " + mberId); + } catch (IllegalArgumentException e) { + return registRedirect(e.getMessage()); + } + } + + /** 수정 처리. */ + @RequestMapping(value = "/mnt/sec/mberCrypto/update.do", method = org.springframework.web.bind.annotation.RequestMethod.POST) + public String update(@ModelAttribute("mberCryptoVO") MberCryptoVO vo, + @RequestParam(value = "reason", required = false) String reason, + HttpServletRequest request) throws Exception { + LoginVO user = handlerUser(); + if (user == null) { + return LOGIN_REDIRECT; + } + if (!MberCryptoReauth.isVerified(request)) { + return reauthRedirect(viewUrl(vo.getMberId(), reason)); + } + try { + int cnt = mberCryptoAdminService.updateMber(vo, user.getId(), + ClientIpResolver.getClientIp(request), reason); + return listRedirect(cnt > 0 ? "회원정보를 수정했습니다." : "수정 대상이 없습니다."); + } catch (IllegalArgumentException e) { + return listRedirect(e.getMessage()); + } + } + + /** + * 개인정보 파기 처리. + * + *

되돌릴 수 없는 작업이라 화면에서 회원ID를 직접 입력해 확인시키고 + * ({@code confirmId}), 서버에서도 대상과 일치하는지 다시 본다 — 확인 절차가 화면에만 있으면 + * 요청을 직접 만드는 경로에서는 없는 것과 같다.

+ */ + @RequestMapping(value = "/mnt/sec/mberCrypto/deletePii.do", method = org.springframework.web.bind.annotation.RequestMethod.POST) + public String deletePii(@RequestParam(value = "mberId", required = false) String mberId, + @RequestParam(value = "confirmId", required = false) String confirmId, + @RequestParam(value = "reason", required = false) String reason, + HttpServletRequest request) throws Exception { + LoginVO user = handlerUser(); + if (user == null) { + return LOGIN_REDIRECT; + } + if (!MberCryptoReauth.isVerified(request)) { + return reauthRedirect(viewUrl(mberId, reason)); + } + if (isBlank(mberId) || confirmId == null || !mberId.trim().equals(confirmId.trim())) { + return listRedirect("파기 확인을 위해 대상 회원ID를 정확히 입력해야 합니다."); + } + try { + int cnt = mberCryptoAdminService.deleteMberPii(mberId, user.getId(), + ClientIpResolver.getClientIp(request), reason); + return listRedirect(cnt > 0 + ? "개인정보를 파기하고 회원상태를 탈퇴로 전환했습니다: " + mberId.trim() + : "파기 대상이 없습니다."); + } catch (IllegalArgumentException e) { + return listRedirect(e.getMessage()); + } + } + + /* ================================================================ + * 접근 이력 + * ================================================================ */ + + /** 이 화면에서 일어난 접근 이력(사유 포함) 조회. 뷰: .../mberCrypto/history.jsp */ + @RequestMapping(value = "/mnt/sec/mberCrypto/historyView.do") + public String historyView(@RequestParam(value = "trgetMberId", required = false) String trgetMberId, + @RequestParam(value = "handlrId", required = false) String handlrId, + @RequestParam(value = "actionTy", required = false) String actionTy, + @RequestParam(value = "pageIndex", required = false, defaultValue = "1") int pageIndex, + ModelMap model) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + int page = pageIndex < 1 ? 1 : pageIndex; + int perPage = 10; + + Map param = new HashMap(); + param.put("trgetMberId", trim(trgetMberId)); + param.put("handlrId", trim(handlrId)); + param.put("actionTy", trim(actionTy)); + param.put("firstIndex", Integer.valueOf((page - 1) * perPage)); + param.put("recordCountPerPage", Integer.valueOf(perPage)); + + int total = mberCryptoAdminService.selectAccessHistoryListCount(param); + List list = mberCryptoAdminService.selectAccessHistoryList(param); + + PaginationInfo paginationInfo = new PaginationInfo(); + paginationInfo.setCurrentPageNo(page); + paginationInfo.setRecordCountPerPage(perPage); + paginationInfo.setPageSize(10); + paginationInfo.setTotalRecordCount(total); + + model.addAttribute("resultList", list); + model.addAttribute("paginationInfo", paginationInfo); + model.addAttribute("totalCount", Integer.valueOf(total)); + model.addAttribute("trgetMberId", trgetMberId); + model.addAttribute("handlrId", handlrId); + model.addAttribute("actionTy", actionTy); + return "site/mnt/sec/mberCrypto/history"; + } + + /* ================================================================ + * 내부 헬퍼 + * ================================================================ */ + + /** 목록에 실릴 값을 전부 마스킹한다(평문은 상세에서만 보여준다). */ + private void maskAll(List list) { + if (list == null) { + return; + } + for (int i = 0; i < list.size(); i++) { + MberCryptoVO vo = list.get(i); + vo.setMberNm(MaskingUtil.maskName(vo.getMberNm())); + vo.setMoblphonNo(MaskingUtil.maskPhone(vo.getMoblphonNo())); + vo.setMberEmailAdres(MaskingUtil.maskEmail(vo.getMberEmailAdres())); + // 암호문·해시는 화면으로 나갈 이유가 없다. 목록 모델에서 지운다. + vo.setMberNmEnc(null); + vo.setMberNmHash(null); + } + } + + /** + * 돌아갈 URL 을 이 화면 대역으로 제한한다. + * 외부 주소나 다른 화면이 들어오면 목록으로 되돌린다(오픈 리다이렉트 차단). + */ + private String safeReturnUrl(String returnUrl) { + if (returnUrl == null) { + return RETURN_URL_PREFIX + "listView.do?page=9100051"; + } + String u = returnUrl.trim(); + // "//host" 형태는 프로토콜 상대 URL 이라 외부로 나간다 — 접두사 검사만으로는 막히지 않는다. + if (u.startsWith(RETURN_URL_PREFIX) && !u.startsWith("//")) { + return u; + } + return RETURN_URL_PREFIX + "listView.do?page=9100051"; + } + + private String viewUrl(String mberId, String reason) throws Exception { + StringBuilder sb = new StringBuilder(RETURN_URL_PREFIX).append("view.do?page=9100051"); + if (!isBlank(mberId)) { + sb.append("&mberId=").append(URLEncoder.encode(mberId.trim(), "UTF-8")); + } + if (!isBlank(reason)) { + sb.append("&reason=").append(URLEncoder.encode(reason.trim(), "UTF-8")); + } + return sb.toString(); + } + + private String reauthRedirect(String returnUrl) throws Exception { + return "redirect:/mnt/sec/mberCrypto/reauthView.do?returnUrl=" + + URLEncoder.encode(returnUrl, "UTF-8"); + } + + private String listRedirect(String message) throws Exception { + if (message == null) { + return LIST_REDIRECT; + } + return LIST_REDIRECT + "&resultMsg=" + URLEncoder.encode(message, "UTF-8"); + } + + private String registRedirect(String message) throws Exception { + String base = "redirect:/mnt/sec/mberCrypto/registView.do?page=9100051"; + if (message == null) { + return base; + } + return base + "&resultMsg=" + URLEncoder.encode(message, "UTF-8"); + } + + private String trim(String v) { + return (v == null || v.trim().length() == 0) ? null : v.trim(); + } + + private boolean isBlank(String v) { + return v == null || v.trim().length() == 0; + } + + /** + * 인증된 업무사용자(USR)만 통과. 그 외에는 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/crypto/MberCryptoAdminDAO.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoAdminDAO.java new file mode 100644 index 0000000..93b289a --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoAdminDAO.java @@ -0,0 +1,107 @@ +/** + * Class Name : MberCryptoAdminDAO.java + * Description : 암호화 회원정보 관리자 CRUD DAO — 개인정보보호 기능개선 사업(추가제안 Action4) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.27 개인정보보호 최초 생성 + * + * @since 2026. 08. 27 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.crypto; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 관리자 전용 회원정보 화면의 데이터 접근. + * + *

복호화 지점을 여기 한 곳으로 모은다. 조회 statement 는 {@code MBER_NM_ENC} 를 + * {@code mberNm} 별칭으로 가져오고, 이 DAO 가 반환 직전 + * {@link MemberNmDecryptSupport} 로 같은 자리에 평문을 되돌려 쓴다. 서비스·화면은 복호화를 + * 의식하지 않는다 — 읽기 전환 시 정착된 이 코드베이스의 관용을 그대로 따른다.

+ * + *

기존 회원관리(mberManageDAO)와 왜 분리했나: 표준 회원관리 VO 는 활동지역·재능 등 + * 수십 개 필드를 함께 다뤄 이 화면이 필요로 하지 않는 값까지 오간다. 개인정보 항목만 좁게 + * 취급하는 별도 계층을 두는 편이 "무엇을 읽고 무엇을 바꿨는가"를 이력에 정확히 남기기 쉽다.

+ */ +@Repository("mberCryptoAdminDAO") +public class MberCryptoAdminDAO extends EgovComAbstractDAO { + + /** 회원 목록(검색·페이징). 반환 직전 이름을 복호화해 {@code mberNm} 에 되돌려 쓴다. */ + @SuppressWarnings("unchecked") + public List selectMberList(MberCryptoSearchVO searchVO) { + List rows = list("MberCryptoAdminDAO.selectMberList", searchVO); + MemberNmDecryptSupport.decryptListInPlace(rows, "mberNm"); + return rows; + } + + /** 회원 목록 총건수(페이징용). 목록과 같은 검색조건을 유지할 것. */ + public int selectMberListCount(MberCryptoSearchVO searchVO) { + Object cnt = selectByPk("MberCryptoAdminDAO.selectMberListCount", searchVO); + return cnt == null ? 0 : ((Number) cnt).intValue(); + } + + /** 회원 단건. 없으면 null. 반환 직전 이름을 복호화한다. */ + public MberCryptoVO selectMber(String mberId) { + MberCryptoVO row = (MberCryptoVO) selectByPk("MberCryptoAdminDAO.selectMber", mberId); + MemberNmDecryptSupport.decryptInPlace(row, "mberNm"); + return row; + } + + /** 회원ID 중복 여부(신규 등록 전 확인). */ + public int selectMberIdCount(String mberId) { + Object cnt = selectByPk("MberCryptoAdminDAO.selectMberIdCount", mberId); + return cnt == null ? 0 : ((Number) cnt).intValue(); + } + + /** + * 신규 회원 등록. 이름은 암호문·해시로만 저장하며, 평문 컬럼에는 표식만 들어간다 + * (컬럼이 NOT NULL 이라 비울 수 없다 — sqlmap 주석 참조). + */ + public void insertMber(MberCryptoVO vo) { + insert("MberCryptoAdminDAO.insertMber", vo); + } + + /** 회원 개인정보 수정(이름은 암호문·해시 동시 갱신). */ + public int updateMber(MberCryptoVO vo) { + return update("MberCryptoAdminDAO.updateMber", vo); + } + + /** + * 개인정보 파기 — 행을 지우지 않고 개인정보 항목만 비우고 상태를 탈퇴('D')로 바꾼다. + * 물리 삭제를 하지 않는 이유는 서비스 Javadoc 참조(참조 무결성·통계 정합). + */ + public int deleteMberPii(MberCryptoVO vo) { + return update("MberCryptoAdminDAO.deleteMberPii", vo); + } + + /** 취급자 비밀번호(재인증 대조용). 계정이 없으면 null. */ + public String selectHandlerPassword(String emplyrId) { + return (String) selectByPk("MberCryptoAdminDAO.selectHandlerPassword", emplyrId); + } + + /** 관리자 접근 이력 적재(사유·재인증 여부 포함). */ + public void insertAccessHistory(MberCryptoAccessHistoryVO vo) { + insert("MberCryptoAdminDAO.insertAccessHistory", vo); + } + + /** 관리자 접근 이력 목록. param: trgetMberId/handlrId/actionTy/fromDt/toDt/firstIndex/recordCountPerPage */ + @SuppressWarnings("unchecked") + public List selectAccessHistoryList(Map param) { + return list("MberCryptoAdminDAO.selectAccessHistoryList", param); + } + + /** 관리자 접근 이력 총건수. */ + public int selectAccessHistoryListCount(Map param) { + Object cnt = selectByPk("MberCryptoAdminDAO.selectAccessHistoryListCount", param); + return cnt == null ? 0 : ((Number) cnt).intValue(); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoAdminService.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoAdminService.java new file mode 100644 index 0000000..cfedb4d --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoAdminService.java @@ -0,0 +1,83 @@ +/** + * Class Name : MberCryptoAdminService.java + * Description : 암호화 회원정보 관리자 CRUD 서비스 — 개인정보보호 기능개선 사업(추가제안 Action4) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.27 개인정보보호 최초 생성 + * + * @since 2026. 08. 27 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.crypto; + +import java.util.List; +import java.util.Map; + +/** + * 관리자 전용 회원정보(암호화 항목) CRUD. + * + *

제안서 추가제안 Action4(슬라이드16)의 요건을 구현한다 — + * 관리자 전용 화면에서 복호화 조회·수정·삭제·신규 등록, 조회·수정·삭제 시 사유 입력 및 재인증 연계.

+ * + *

사유는 서비스가 강제한다. 화면에서만 막으면 URL 을 직접 부르는 경로가 뚫린다. + * 사유가 비어 있으면 {@link IllegalArgumentException} 으로 작업 자체를 거절한다 + * (접속기록 다운로드 사유 필수 처리와 같은 방식).

+ * + *

모든 변경은 이력과 한 트랜잭션이다. {@code egovframework.com..*Impl.*} 이 + * 트랜잭션 어드바이스 대상이라, 회원정보 변경만 커밋되고 이력이 빠지는 상태가 생기지 않는다.

+ */ +public interface MberCryptoAdminService { + + /** 회원 목록(검색·페이징). 이름 검색은 완전일치(해시)로 처리된다. */ + List selectMberList(MberCryptoSearchVO searchVO) throws Exception; + + /** 회원 단건 조회(복호화). 없으면 null. */ + MberCryptoVO selectMber(String mberId) throws Exception; + + /** + * 복호화 상세 조회 행위를 이력에 남긴다(SFR: 조회 시 사유 입력). + * + * @throws IllegalArgumentException 사유 미입력 + */ + void recordDecryptView(String mberId, String handlerId, String handlerIp, String reason) throws Exception; + + /** + * 신규 회원 등록. 이름은 암호문·해시로 저장한다. + * + * @return 등록된 회원ID + * @throws IllegalArgumentException 필수값 누락·사유 미입력·회원ID 중복 + */ + String registMber(MberCryptoVO vo, String handlerId, String handlerIp, String reason) throws Exception; + + /** + * 회원 개인정보 수정. 바뀐 항목명만 이력에 남긴다(값은 남기지 않는다). + * + * @return 갱신 건수(대상 없음 0) + * @throws IllegalArgumentException 사유 미입력·대상 회원 없음 + */ + int updateMber(MberCryptoVO vo, String handlerId, String handlerIp, String reason) throws Exception; + + /** + * 개인정보 파기 — 행은 남기고 개인정보 항목만 비운 뒤 상태를 탈퇴('D')로 바꾼다. + * + * @return 처리 건수 + * @throws IllegalArgumentException 사유 미입력·대상 회원 없음 + */ + int deleteMberPii(String mberId, String handlerId, String handlerIp, String reason) throws Exception; + + /** + * 취급자 비밀번호 재확인(재인증). + * + * @return 일치하면 true + */ + boolean verifyHandlerPassword(String handlerId, String rawPassword) throws Exception; + + /** 관리자 접근 이력 목록. */ + List selectAccessHistoryList(Map param) throws Exception; + + /** 관리자 접근 이력 총건수. */ + int selectAccessHistoryListCount(Map param) throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoAdminServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoAdminServiceImpl.java new file mode 100644 index 0000000..52a9f33 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoAdminServiceImpl.java @@ -0,0 +1,317 @@ +/** + * Class Name : MberCryptoAdminServiceImpl.java + * Description : 암호화 회원정보 관리자 CRUD 서비스 구현 — 개인정보보호 기능개선 사업(추가제안 Action4) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.27 개인정보보호 최초 생성 + * + * @since 2026. 08. 27 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.crypto; + +import java.util.List; +import java.util.Map; + +import javax.annotation.Resource; + +import org.springframework.stereotype.Service; + +import egovframework.com.utl.sim.service.EgovFileScrty; +import egovframework.rte.fdl.idgnr.EgovIdGnrService; + +/** + * {@link MberCryptoAdminService} 구현. + * + *

암호화 헬퍼를 필드로 들고 있는 이유: {@link MemberDataCipher}·{@link MemberDataHasher} + * 는 생성 시점에 키·pepper 를 읽고 없으면 예외를 던진다. 매 호출마다 새로 만들면 키 미설정을 + * 요청 시점에야 알게 되므로, 최초 사용 시 한 번 만들어 재사용한다(둘 다 불변·스레드 안전).

+ * + *

왜 지연 생성인가: 필드 초기화로 생성하면 키가 없는 개발 환경에서 이 빈 하나 때문에 + * 컨텍스트 기동이 통째로 실패한다. 회원정보 암호화가 적용되지 않은 환경에서도 나머지 관리자 + * 기능은 떠야 하므로, 이 화면을 실제로 쓸 때 실패하게 미룬다.

+ */ +@Service("mberCryptoAdminService") +public class MberCryptoAdminServiceImpl implements MberCryptoAdminService { + + /** 파기 후 평문 이름 컬럼(NOT NULL)에 남기는 표식. */ + private static final String PII_PURGED_MARK = "(파기)"; + + /** 신규 등록 시 평문 이름 컬럼(NOT NULL)에 남기는 표식 — 평문은 저장하지 않는다. */ + private static final String ENCRYPTED_MARK = "(암호화)"; + + @Resource(name = "mberCryptoAdminDAO") + private MberCryptoAdminDAO mberCryptoAdminDAO; + + /** ESNTL_ID 채번 — 표준 회원관리(EgovMberManageServiceImpl)와 같은 채번기를 쓴다. */ + @Resource(name = "egovUsrCnfrmIdGnrService") + private EgovIdGnrService idgenService; + + private volatile MemberDataCipher cipher; + private volatile MemberDataHasher hasher; + + private MemberDataCipher cipher() { + MemberDataCipher c = cipher; + if (c == null) { + synchronized (this) { + c = cipher; + if (c == null) { + c = new MemberDataCipher(); + cipher = c; + } + } + } + return c; + } + + private MemberDataHasher hasher() { + MemberDataHasher h = hasher; + if (h == null) { + synchronized (this) { + h = hasher; + if (h == null) { + h = new MemberDataHasher(); + hasher = h; + } + } + } + return h; + } + + @Override + public List selectMberList(MberCryptoSearchVO searchVO) throws Exception { + // 이름은 암호문이라 LIKE 가 불가능하다. 입력 이름을 해시로 바꿔 완전일치로만 찾는다. + // 평문 이름은 SQL 에 실리지 않는다(바인딩되는 값은 해시뿐). + if (isNotBlank(searchVO.getMberNm())) { + searchVO.setMberNmHash(hasher().hashName(searchVO.getMberNm())); + } else { + searchVO.setMberNmHash(null); + } + + int total = mberCryptoAdminDAO.selectMberListCount(searchVO); + searchVO.setTotalRecordCount(total); + if (total == 0) { + return new java.util.ArrayList(); + } + return mberCryptoAdminDAO.selectMberList(searchVO); + } + + @Override + public MberCryptoVO selectMber(String mberId) throws Exception { + if (!isNotBlank(mberId)) { + return null; + } + return mberCryptoAdminDAO.selectMber(mberId.trim()); + } + + @Override + public void recordDecryptView(String mberId, String handlerId, String handlerIp, String reason) + throws Exception { + String r = requireReason(reason); + writeHistory(MberCryptoAccessHistoryVO.ACTION_SELECT, handlerId, handlerIp, mberId, r, null); + } + + @Override + public String registMber(MberCryptoVO vo, String handlerId, String handlerIp, String reason) + throws Exception { + String r = requireReason(reason); + if (vo == null || !isNotBlank(vo.getMberId())) { + throw new IllegalArgumentException("회원ID는 필수입니다."); + } + if (!isNotBlank(vo.getMberNm())) { + throw new IllegalArgumentException("이름은 필수입니다."); + } + if (!isNotBlank(vo.getPassword())) { + throw new IllegalArgumentException("초기 비밀번호는 필수입니다."); + } + String mberId = vo.getMberId().trim(); + if (mberCryptoAdminDAO.selectMberIdCount(mberId) > 0) { + throw new IllegalArgumentException("이미 사용 중인 회원ID입니다: " + mberId); + } + + vo.setMberId(mberId); + vo.setUniqId(idgenService.getNextStringId()); + // 이름은 암호문·해시로만 들어간다. 평문 컬럼(NOT NULL)에는 표식만 남긴다. + vo.setMberNmEnc(cipher().encrypt(vo.getMberNm().trim())); + vo.setMberNmHash(hasher().hashName(vo.getMberNm().trim())); + vo.setCryptoKeyVer(Integer.valueOf(MemberDataCipher.CURRENT_KEY_VER)); + vo.setMberNm(ENCRYPTED_MARK); + // 초기 비밀번호는 솔트 해시로 저장한다(평문·무솔트 저장 금지 — SER-003). + vo.setPassword(EgovFileScrty.encryptPasswordWithSalt(vo.getPassword())); + if (!isNotBlank(vo.getMberSttus())) { + vo.setMberSttus("P"); + } + normalizeBlanks(vo); + + mberCryptoAdminDAO.insertMber(vo); + writeHistory(MberCryptoAccessHistoryVO.ACTION_INSERT, handlerId, handlerIp, mberId, r, + "이름,이메일,휴대폰,주소"); + return mberId; + } + + @Override + public int updateMber(MberCryptoVO vo, String handlerId, String handlerIp, String reason) + throws Exception { + String r = requireReason(reason); + if (vo == null || !isNotBlank(vo.getMberId())) { + throw new IllegalArgumentException("회원ID는 필수입니다."); + } + if (!isNotBlank(vo.getMberNm())) { + throw new IllegalArgumentException("이름은 필수입니다."); + } + String mberId = vo.getMberId().trim(); + MberCryptoVO before = mberCryptoAdminDAO.selectMber(mberId); + if (before == null) { + throw new IllegalArgumentException("대상 회원을 찾을 수 없습니다: " + mberId); + } + + // 바뀐 항목명만 뽑는다 — 값은 이력에 남기지 않는다(이력이 평문 저장소가 되면 안 된다). + String changed = diffItems(before, vo); + + vo.setMberId(mberId); + vo.setMberNmEnc(cipher().encrypt(vo.getMberNm().trim())); + vo.setMberNmHash(hasher().hashName(vo.getMberNm().trim())); + vo.setCryptoKeyVer(Integer.valueOf(MemberDataCipher.CURRENT_KEY_VER)); + if (!isNotBlank(vo.getMberSttus())) { + vo.setMberSttus(before.getMberSttus()); + } + normalizeBlanks(vo); + + int cnt = mberCryptoAdminDAO.updateMber(vo); + if (cnt > 0) { + writeHistory(MberCryptoAccessHistoryVO.ACTION_UPDATE, handlerId, handlerIp, mberId, r, + changed.length() == 0 ? "(변경 항목 없음)" : changed); + } + return cnt; + } + + @Override + public int deleteMberPii(String mberId, String handlerId, String handlerIp, String reason) + throws Exception { + String r = requireReason(reason); + if (!isNotBlank(mberId)) { + throw new IllegalArgumentException("회원ID는 필수입니다."); + } + String id = mberId.trim(); + MberCryptoVO before = mberCryptoAdminDAO.selectMber(id); + if (before == null) { + throw new IllegalArgumentException("대상 회원을 찾을 수 없습니다: " + id); + } + + MberCryptoVO vo = new MberCryptoVO(); + vo.setMberId(id); + vo.setMberNm(PII_PURGED_MARK); + + int cnt = mberCryptoAdminDAO.deleteMberPii(vo); + if (cnt > 0) { + writeHistory(MberCryptoAccessHistoryVO.ACTION_DELETE, handlerId, handlerIp, id, r, + "이름,이메일,휴대폰,주소,생년월일,성별"); + } + return cnt; + } + + @Override + public boolean verifyHandlerPassword(String handlerId, String rawPassword) throws Exception { + if (!isNotBlank(handlerId) || !isNotBlank(rawPassword)) { + return false; + } + String stored = mberCryptoAdminDAO.selectHandlerPassword(handlerId.trim()); + if (stored == null || stored.length() == 0) { + // 계정이 없거나 비밀번호가 비어 있으면 통과시키지 않는다. + // (존재 여부를 응답으로 구분해 주지 않기 위해 호출부는 실패 문구를 한 가지로 쓴다) + return false; + } + // 솔트 유무를 자동 판별해 대조한다(무솔트 레거시 해시도 그대로 검증된다). + return EgovFileScrty.matches(rawPassword, stored); + } + + @Override + public List selectAccessHistoryList(Map param) throws Exception { + return mberCryptoAdminDAO.selectAccessHistoryList(param); + } + + @Override + public int selectAccessHistoryListCount(Map param) throws Exception { + return mberCryptoAdminDAO.selectAccessHistoryListCount(param); + } + + /** + * 이력 1행 적재. 재인증은 컨트롤러가 이미 통과시킨 뒤에만 이 서비스에 닿으므로 'Y'로 기록한다. + * (재인증 없이 도달할 수 있는 경로가 생기면 이 값도 인자로 받도록 바꿀 것) + */ + private void writeHistory(String actionTy, String handlerId, String handlerIp, + String trgetMberId, String reason, String changedItems) { + MberCryptoAccessHistoryVO h = new MberCryptoAccessHistoryVO(); + h.setActionTy(actionTy); + h.setHandlrId(handlerId); + h.setHandlrIp(handlerIp); + h.setTrgetMberId(trgetMberId); + h.setAccessReason(reason); + h.setChangedItems(changedItems); + h.setReauthYn("Y"); + mberCryptoAdminDAO.insertAccessHistory(h); + } + + /** + * NOT NULL 컬럼에 대응하는 필드의 null 을 빈 문자열로 바꾼다. + * + *

{@code COMTNGNRLMBER} 는 ZIP·ADRES·MBTLNUM 등이 NOT NULL 이라, 화면에서 비워 둔 값이 + * 그대로 null 로 내려가면 INSERT/UPDATE 가 제약 위반으로 실패한다. SQL 쪽에서 {@code IFNULL} + * 로 감싸는 방법도 있으나, 그러면 "이 컬럼은 비어도 되는가"의 답이 SQL 에 흩어진다. + * 값의 형태를 맞추는 일은 서비스가 한 곳에서 한다.

+ */ + private void normalizeBlanks(MberCryptoVO vo) { + vo.setZip(blankIfNull(vo.getZip())); + vo.setAdres(blankIfNull(vo.getAdres())); + vo.setDetailAdres(blankIfNull(vo.getDetailAdres())); + vo.setSexdstnCode(blankIfNull(vo.getSexdstnCode())); + vo.setMoblphonNo(blankIfNull(vo.getMoblphonNo())); + // MBER_EMAIL_ADRES 는 NULL 허용 컬럼이라 그대로 둔다(빈 문자열과 미입력을 구분). + } + + private String blankIfNull(String v) { + return v == null ? "" : v.trim(); + } + + /** 사유 필수 검증. 통과하면 앞뒤 공백을 다듬은 값을 돌려준다. */ + private String requireReason(String reason) { + if (!isNotBlank(reason)) { + throw new IllegalArgumentException("개인정보 처리 사유는 필수 입력입니다. (접근 이력에 기록됩니다)"); + } + String r = reason.trim(); + return r.length() > 500 ? r.substring(0, 500) : r; + } + + /** 변경된 항목명만 콤마로 잇는다(값은 담지 않는다). */ + private String diffItems(MberCryptoVO before, MberCryptoVO after) { + StringBuilder sb = new StringBuilder(); + appendIfChanged(sb, "이름", before.getMberNm(), after.getMberNm()); + appendIfChanged(sb, "이메일", before.getMberEmailAdres(), after.getMberEmailAdres()); + appendIfChanged(sb, "휴대폰", before.getMoblphonNo(), after.getMoblphonNo()); + appendIfChanged(sb, "우편번호", before.getZip(), after.getZip()); + appendIfChanged(sb, "주소", before.getAdres(), after.getAdres()); + appendIfChanged(sb, "상세주소", before.getDetailAdres(), after.getDetailAdres()); + appendIfChanged(sb, "성별", before.getSexdstnCode(), after.getSexdstnCode()); + appendIfChanged(sb, "회원상태", before.getMberSttus(), after.getMberSttus()); + return sb.toString(); + } + + private void appendIfChanged(StringBuilder sb, String label, String before, String after) { + String b = before == null ? "" : before; + String a = after == null ? "" : after; + if (b.equals(a)) { + return; + } + if (sb.length() > 0) { + sb.append(','); + } + sb.append(label); + } + + private boolean isNotBlank(String v) { + return v != null && v.trim().length() > 0; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoReauth.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoReauth.java new file mode 100644 index 0000000..377e369 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoReauth.java @@ -0,0 +1,90 @@ +/** + * Class Name : MberCryptoReauth.java + * Description : 암호화 회원정보 화면 재인증 세션 표식 — 개인정보보호 기능개선 사업(추가제안 Action4) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.27 개인정보보호 최초 생성 + * + * @since 2026. 08. 27 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.crypto; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +/** + * 관리자 회원정보 화면의 재인증 상태를 세션에 표시하고 유효시간을 판정한다. + * + *

제안서 슬라이드16의 "조회·수정·삭제 시 사유 입력 및 재인증 연계" 요건이다. + * 로그인 자체가 아니라 이 화면에 들어올 때 한 번 더 본인을 확인한다 — 자리를 비운 사이 + * 열려 있던 세션으로 평문 개인정보를 훑는 경로를 끊는 것이 목적이다.

+ * + *

왜 로그인 2차 인증(SECOND_FACTOR_DONE)을 재사용하지 않는가: 2차 인증은 로그인 시점에 + * 한 번 통과하면 세션 내내 유지된다(30분 자동 로그아웃까지). 그 표식을 그대로 쓰면 "다시 확인"이 + * 되지 못한다. 또 OTP 를 등록하지 않은 취급자는 2차 인증을 통과한 적이 없어 이 화면에 영영 + * 들어오지 못한다. 그래서 본인 비밀번호 재확인이라는 독립 표식을 둔다.

+ * + *

유효시간을 짧게 두는 이유: 한 번 통과하면 세션이 끝날 때까지 열어 두는 방식은 + * 사실상 재인증이 없는 것과 같다. {@link #TTL_MILLIS}(10분)마다 다시 묻는다. 작업 중 만료되면 + * 재인증 화면을 거쳐 원래 위치로 돌아온다.

+ * + *

연장하지 않는다. 통과 시각을 갱신하지 않고 최초 통과 시각을 그대로 두므로, 화면을 + * 계속 쓰더라도 10분마다 반드시 다시 확인한다(슬라이딩 만료가 아니다).

+ */ +public final class MberCryptoReauth { + + /** 재인증 통과 시각(밀리초)을 담는 세션 속성명. */ + public static final String SESSION_KEY = "MBER_CRYPTO_REAUTH_AT"; + + /** 재인증 유효시간 — 10분. */ + public static final long TTL_MILLIS = 10L * 60L * 1000L; + + private MberCryptoReauth() { + } + + /** 재인증이 유효한 상태인지 판정한다. 세션이 없거나 만료면 false. */ + public static boolean isVerified(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return false; + } + Object v = session.getAttribute(SESSION_KEY); + if (!(v instanceof Long)) { + return false; + } + long at = ((Long) v).longValue(); + return (System.currentTimeMillis() - at) < TTL_MILLIS; + } + + /** 재인증 통과를 표시한다(현재 시각 기록). */ + public static void markVerified(HttpServletRequest request) { + request.getSession(true).setAttribute(SESSION_KEY, Long.valueOf(System.currentTimeMillis())); + } + + /** 재인증 표식을 지운다(화면을 벗어나거나 실패했을 때). */ + public static void clear(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session != null) { + session.removeAttribute(SESSION_KEY); + } + } + + /** 남은 유효시간(초). 만료·미인증이면 0. 화면에 남은 시간을 알려 주기 위한 값이다. */ + public static long remainSeconds(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return 0L; + } + Object v = session.getAttribute(SESSION_KEY); + if (!(v instanceof Long)) { + return 0L; + } + long elapsed = System.currentTimeMillis() - ((Long) v).longValue(); + long remain = TTL_MILLIS - elapsed; + return remain <= 0 ? 0L : remain / 1000L; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoSearchVO.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoSearchVO.java new file mode 100644 index 0000000..a376736 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoSearchVO.java @@ -0,0 +1,117 @@ +/** + * Class Name : MberCryptoSearchVO.java + * Description : 암호화 회원정보 관리자 화면 검색조건 VO — 개인정보보호 기능개선 사업(추가제안 Action4) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.27 개인정보보호 최초 생성 + * + * @since 2026. 08. 27 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.crypto; + +import java.io.Serializable; + +/** + * 관리자 회원정보 검색조건 + 페이징. + * + *

이름은 완전일치만 된다. 이름은 랜덤 IV AES-GCM 으로 저장돼 같은 이름도 매번 다른 + * 암호문이라 {@code LIKE '%홍%'} 이 원리적으로 불가능하다. 대신 검색 전용 HMAC 해시 컬럼 + * ({@code MBER_NM_HASH})에 인덱스가 걸려 있어 정확히 같은 이름은 빠르게 찾는다. + * 서비스가 입력 이름을 {@link MemberDataHasher#hashName(String)} 로 바꿔 {@link #setMberNmHash} + * 에 실어 주면 SQL 은 해시만 비교한다 — 평문 이름은 SQL 에 등장하지 않는다.

+ * + *

이메일·휴대폰·회원ID 는 아직 평문 컬럼이라 전방일치 검색이 가능하다 + * (암호화 대상 확대는 후속 단계 — 확대되면 이 필드들도 해시 검색으로 옮겨야 한다).

+ */ +public class MberCryptoSearchVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 회원ID(전방일치). */ + private String mberId; + /** 이름(완전일치 — 해시 검색). 화면 입력값이며 SQL 에는 해시만 나간다. */ + private String mberNm; + /** 이메일(전방일치). */ + private String mberEmailAdres; + /** 휴대폰(전방일치). */ + private String moblphonNo; + /** 회원상태 P/D. 빈값이면 전체. */ + private String mberSttus; + + /** 서비스가 채우는 이름 해시(파생값). 화면에서 직접 입력받지 않는다. */ + private String mberNmHash; + + private int pageIndex = 1; + private int recordCountPerPage = 10; + /** 조회 시작 offset(0-base) = (pageIndex-1) * recordCountPerPage */ + private int firstIndex = 0; + private int totalRecordCount = 0; + + private static final String LIKE_ESCAPE = "!"; + + 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 getMberEmailAdres() { return mberEmailAdres; } + public void setMberEmailAdres(String mberEmailAdres) { this.mberEmailAdres = mberEmailAdres; } + + public String getMoblphonNo() { return moblphonNo; } + public void setMoblphonNo(String moblphonNo) { this.moblphonNo = moblphonNo; } + + public String getMberSttus() { return mberSttus; } + public void setMberSttus(String mberSttus) { this.mberSttus = mberSttus; } + + public String getMberNmHash() { return mberNmHash; } + public void setMberNmHash(String mberNmHash) { this.mberNmHash = mberNmHash; } + + 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; } + + /** 회원ID 전방일치 바인딩값. */ + public String getMberIdLike() { return toPrefixLike(mberId); } + + /** 이메일 전방일치 바인딩값. */ + public String getMberEmailAdresLike() { return toPrefixLike(mberEmailAdres); } + + /** 휴대폰 전방일치 바인딩값. */ + public String getMoblphonNoLike() { return toPrefixLike(moblphonNo); } + + /** + * 전방일치 LIKE 값으로 변환한다. 사용자가 넣은 {@code %} {@code _} 는 와일드카드가 아니라 + * 글자 그대로 찾게 이스케이프한다(SQL 은 {@code ESCAPE '!'} 로 받는다). + */ + private String toPrefixLike(String v) { + if (v == null) { + return null; + } + String t = v.trim(); + if (t.length() == 0) { + return null; + } + StringBuilder sb = new StringBuilder(t.length() + 8); + for (int i = 0; i < t.length(); i++) { + char c = t.charAt(i); + if (c == '%' || c == '_' || c == '!') { + sb.append(LIKE_ESCAPE); + } + sb.append(c); + } + return sb.append('%').toString(); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoVO.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoVO.java new file mode 100644 index 0000000..3f7be27 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoVO.java @@ -0,0 +1,110 @@ +/** + * Class Name : MberCryptoVO.java + * Description : 암호화 회원정보 관리자 화면 VO — 개인정보보호 기능개선 사업(추가제안 Action4) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.27 개인정보보호 최초 생성 — 관리자 전용 복호화 CRUD 화면 + * + * @since 2026. 08. 27 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.crypto; + +import java.io.Serializable; + +/** + * 관리자 전용 회원정보 화면이 다루는 {@code COMTNGNRLMBER} 한 행. + * + *

{@code mberNm} 에 무엇이 들어있는가는 단계에 따라 다르다. DAO 가 조회하는 컬럼은 + * {@code MBER_NM_ENC}(암호문)이고, DAO 반환 직전 {@link MemberNmDecryptSupport} 가 같은 자리에 + * 평문을 되돌려 쓴다 — 서비스·화면은 항상 평문으로 본다. 저장(등록·수정) 방향은 서비스가 + * 평문을 받아 {@link MemberDataCipher#encrypt(String)}·{@link MemberDataHasher#hashName(String)} + * 로 {@code mberNmEnc}/{@code mberNmHash} 를 채운 뒤 DAO 에 넘긴다.

+ * + *

평문 컬럼 {@code MBER_NM} 은 이 화면이 건드리지 않는다. 암호화 이관 당시 롤백 창구로 + * 남겨둔 컬럼이라, 관리자 화면이 여기에 새 평문을 쓰기 시작하면 "이관이 끝나면 평문 컬럼을 지운다"는 + * 계획이 조용히 무너진다. 신규 등록 시에도 평문 자리에는 마스킹 표식만 넣는다 + * ({@code MBER_NM} 이 NOT NULL 이라 빈 값을 넣을 수 없기 때문이다).

+ */ +public class MberCryptoVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 회원ID(PK). */ + private String mberId; + /** 내부 식별자 ESNTL_ID. */ + private String uniqId; + + /** + * 이름 — 조회 결과에서는 복호화된 평문, 등록·수정 입력에서도 평문. + * (DB 컬럼 MBER_NM_ENC 와 이 필드를 잇는 변환은 서비스·DAO 가 담당한다) + */ + private String mberNm; + /** 이름 암호문(내부 전달용). 화면에 노출하지 않는다. */ + private String mberNmEnc; + /** 이름 검색용 HMAC 해시(내부 전달용). */ + private String mberNmHash; + /** 암호화 키 버전. 미암호화 행은 null. */ + private Integer cryptoKeyVer; + + private String mberEmailAdres; + private String moblphonNo; + private String zip; + private String adres; + private String detailAdres; + private String sexdstnCode; + /** 회원상태 — P:정상 D:탈퇴(기존 코드계 그대로). */ + private String mberSttus; + private String sbscrbDe; + + /** 신규 등록 시 초기 비밀번호(평문 입력). 저장 시 솔트 해시로 변환되며 조회에는 실리지 않는다. */ + private String password; + + public String getMberId() { return mberId; } + public void setMberId(String mberId) { this.mberId = mberId; } + + public String getUniqId() { return uniqId; } + public void setUniqId(String uniqId) { this.uniqId = uniqId; } + + 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; } + + public String getMberEmailAdres() { return mberEmailAdres; } + public void setMberEmailAdres(String mberEmailAdres) { this.mberEmailAdres = mberEmailAdres; } + + public String getMoblphonNo() { return moblphonNo; } + public void setMoblphonNo(String moblphonNo) { this.moblphonNo = moblphonNo; } + + public String getZip() { return zip; } + public void setZip(String zip) { this.zip = zip; } + + public String getAdres() { return adres; } + public void setAdres(String adres) { this.adres = adres; } + + public String getDetailAdres() { return detailAdres; } + public void setDetailAdres(String detailAdres) { this.detailAdres = detailAdres; } + + public String getSexdstnCode() { return sexdstnCode; } + public void setSexdstnCode(String sexdstnCode) { this.sexdstnCode = sexdstnCode; } + + public String getMberSttus() { return mberSttus; } + public void setMberSttus(String mberSttus) { this.mberSttus = mberSttus; } + + public String getSbscrbDe() { return sbscrbDe; } + public void setSbscrbDe(String sbscrbDe) { this.sbscrbDe = sbscrbDe; } + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } +} 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 7ed2e38..ccbccf3 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 @@ -152,4 +152,7 @@ + + + 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 f8d78c1..77bcec1 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 @@ -409,4 +409,45 @@ ]]> + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Oracle.xml index fffead4..b7d7d7d 100644 --- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Oracle.xml +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Oracle.xml @@ -332,4 +332,47 @@ ]]> + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberCryptoAdmin_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberCryptoAdmin_SQL_Mysql.xml new file mode 100644 index 0000000..6264a32 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberCryptoAdmin_SQL_Mysql.xml @@ -0,0 +1,256 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberCryptoAdmin_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberCryptoAdmin_SQL_Oracle.xml new file mode 100644 index 0000000..d076a72 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberCryptoAdmin_SQL_Oracle.xml @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 5675110..3705a72 100644 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp @@ -94,10 +94,10 @@ -
  • class="on">${menuUpperVo2.menuNm} +
  • class="on">${menuUpperVo2.menuNm} -
      style="display: block;"> +
        style="display: block;"> @@ -131,6 +131,22 @@
      • class="on">${menuUpperVo4.menuNm}
      • + <%-- [개인정보/SFR-004-7 #48] 접속기록 정기점검 리포트(점검결과 입력·보관) --%> + +
      • class="on">${menuUpperVo4.menuNm}
      • +
        + <%-- [개인정보/과업내용 다-① #49] 비정상접근 탐지 5유형 --%> + +
      • class="on">${menuUpperVo4.menuNm}
      • +
        + <%-- [개인정보/SER-002-2] 접속기록 파기이력(파티션 DROP/ADD 증빙) --%> + +
      • class="on">${menuUpperVo4.menuNm}
      • +
        + <%-- [개인정보/추가제안 Action4] 회원정보 암호화 관리(복호화 CRUD·사유·재인증) --%> + +
      • class="on">${menuUpperVo4.menuNm}
      • +
      diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/abnormal/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/abnormal/list.jsp new file mode 100644 index 0000000..c724db4 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/abnormal/list.jsp @@ -0,0 +1,221 @@ +<%@ 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="spring" uri="http://www.springframework.org/tags"%> + +<%-- + 비정상 접근 탐지 알림함 (개인정보보호 과업내용 다-① / SER-002 / #49) + · AbnormalAccessDetectionService(#35)의 5유형을 기간 단위로 즉시 집계해 보여준다 + · 탐지 결과를 별도 테이블에 적재하지 않는 이유는 컨트롤러 Javadoc 참조(임계치 변경 시 과거 기준이 굳는 문제) + · 각 항목은 접속기록 조회화면(#47)으로 조건을 넘겨 바로 원본 기록을 확인할 수 있게 연결한다 +--%> + + + + + + + + + + + + +
      +
      + +
      + +
      + +
      + +
      + +
      + · 개인정보 접속기록(730일 보존)을 조회 시점의 정책 임계치로 다시 집계한 결과입니다. 탐지 결과를 따로 저장하지 않으므로 임계치를 조정하면 과거 기간도 새 기준으로 다시 계산됩니다.
      + · 탐지되었다는 것이 곧 위반은 아닙니다. 업무상 정당한 대량조회·야간작업이 있을 수 있으므로, 각 항목의 접속기록 보기로 원본 기록을 확인한 뒤 판단하세요.
      + · 월 단위 점검 기록으로 남겨야 하는 경우에는 정기점검 리포트 화면에서 점검결과·조치사항을 입력하세요(이 화면은 조회 전용입니다).
      + · 5유형 각각이 기간 전체를 훑는 집계라 기간이 넓으면 조회가 느려집니다(최대 366일). +
      + + +
      + ~ 탐지 요약  :  +
      +
      + + <%-- 유형별 요약 카드 5장 --%> +
      + +
      +
      +
      + + + + 건 + 명 + + +
      +
      +
      +
      +
      +
      + + <%-- 유형별 상세 --%> + +
      +

      + + + + 처리결과가 실패·차단(F)으로 남은 기록입니다 — 권한 없는 화면 접근 시도가 여기에 잡힙니다. + 단위기간 조회(S) 건수가 임계를 넘은 취급자입니다. + 업무시간 밖(기본 09시 이전·18시 이후)에 처리된 기록입니다. + 같은 계정이 서로 다른 IP에서 사용된 경우입니다 — 계정 공유·탈취 의심 신호입니다. + 같은 취급자에게서 실패(F) 기록이 반복된 경우입니다. + + +

      + + + <%-- 대상자형(대량조회·계정공유·인증실패반복) : 누가 걸렸는지 목록으로 --%> + + + + + + + + + + + + + + + + + + + + + + +
      의심 대상 목록
      NO.접속자ID + + 사용 IP 수 + 실패 횟수 + 조회 건수 + + 원본 확인
      + + + + + + <%-- 접속기록 조회화면(#47)으로 조건을 넘겨 원본을 바로 확인한다. + 인증실패반복은 처리결과 F, 대량조회는 처리구분 S 로 좁혀 준다. --%> + &processResult=F&dmlGubun=S">접속기록 보기 +
      +
      + + <%-- 건수형(비인가접근·비정상시간대) : 대상자가 아니라 기간 전체 건수라 목록이 없다 --%> + + + + +
      + 기간 내 건 — + 접속기록에서 해당 기간 기록 보기 + (시간대 조건은 접속기록 화면의 접속일시로 확인하세요) +
      +
      + +
      해당 기간에 탐지된 항목이 없습니다.
      +
      +
      +
      +
      + + + + + + + +
      비정상 접근 탐지 결과
      +
      + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/inspection/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/inspection/list.jsp new file mode 100644 index 0000000..4d03319 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/inspection/list.jsp @@ -0,0 +1,243 @@ +<%@ 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="spring" uri="http://www.springframework.org/tags"%> + +<%-- + 개인정보 접속기록 정기점검 리포트 (개인정보보호 SFR-004-7 / SER-002 / #48) + · 월 1회 배치(#43)가 전월분 접속기록을 집계해 1행씩 적재한 PDAL_INSPECTION_REPORT + · 점검자가 점검결과·조치사항을 입력하면 INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER/INSPECT_DT 가 채워진다(#44) + · 배치 집계값(총건수·무결성·이상징후 등)은 화면에서 수정하지 않는다 — 감사 증빙이라 사후 변경되면 안 된다 +--%> + + + + + + + + + + + + +
      +
      + +
      + +
      + +
      + +
      + +
      + · 매월 1일 배치가 전월분 접속기록을 집계해 리포트 1건을 생성합니다(개인정보의 안전성 확보조치 기준 제8조 — 월 1회 이상 점검).
      + · 집계값(총 처리건수·실패건수·무결성 검증결과·이상징후·다운로드 건수)은 배치가 적재한 값이며 화면에서 수정할 수 없습니다 — 사후 변경이 가능하면 점검 증빙이 되지 못합니다.
      + · 점검자가 입력하는 항목은 점검결과·조치사항이며, 저장 시 점검자 계정과 점검일시가 자동 기록됩니다.
      + · 무결성 위반(VIOLATION)은 접속기록 해시체인이 끊긴 상태로, 위·변조 가능성을 뜻합니다. 최초 불일치 기록번호를 확인해 즉시 조치사항에 남기세요. +
      + +
      + 총 건 +
      + + + + + + + + + + + + + + + + + + + + + + class="on"> + + + + + + + + + + + + + + + + +
      정기점검 리포트 목록
      대상연월집계기간총 처리실패/차단무결성이상징후 요약점검여부점검자/일시
      '); return false;"> ~ + + 정상 + 위반 + + + + 점검완료 + 미점검 + + + + +
      +
      + - +
      +
      + + <%-- 상세 + 점검결과 입력 --%> + +
      +

      정기점검 리포트 상세

      +
      +
      +
      집계기간
      +
      ~
      +
      총 처리건수
      +
      건 (실패/차단 건)
      +
      다운로드
      +
      건
      +
      무결성 검증
      +
      + + + 정상 검증대상 건 — 해시체인 연결 이상 없음 + + + 위반 검증대상 건 — + 최초 불일치 기록번호 + + +
      +
      이상징후
      +
      +
      취급자별 현황
      +
      +
      리포트 생성
      +
      +
      + +
      + + + + + + + + + + + + + + + + + + +
      점검결과 입력
      점검결과 * + +
      조치사항 + +
      점검자 / 점검일시 + + + / + (저장 시 현재 로그인 계정·시각으로 갱신됩니다) + + 저장 시 현재 로그인 계정·시각이 자동 기록됩니다. + +
      +
      + +
      +
      +
      +
      +
      + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/history.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/history.jsp new file mode 100644 index 0000000..acbeebb --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/history.jsp @@ -0,0 +1,165 @@ +<%@ 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="spring" uri="http://www.springframework.org/tags"%> + +<%-- + 암호화 회원정보 관리 — 접근 이력 (사유·재인증 기록) + · 개인정보 접속기록(PDAL)이 '언제 어느 화면을 열었나'라면, 이 표는 '왜 열어봤나'를 담는다 + · 개인정보 본문은 담기지 않는다 — 변경 항목명만 남고 값은 남지 않는다 +--%> + + + + + + + + + + + +
      + + + + +
      + +
      + + +
      + · 관리자 전용 회원정보 화면에서 평문 개인정보를 열람·변경한 행위의 소명 근거입니다.
      + · 접속기록(개인정보 접속기록 화면)이 언제 어느 화면을 열었는가를 남긴다면, 이 표는 왜 열어봤는가(사유)를 남깁니다. 둘은 서로를 대조하는 이중 기록입니다.
      + · 개인정보 본문은 기록되지 않습니다 — 무엇을 바꿨는지는 항목명만 남고 값은 남기지 않습니다(이력이 또 하나의 평문 저장소가 되면 안 됩니다).
      + · 이 이력은 수정·삭제할 수 없습니다(해당 SQL 구문 자체를 두지 않았습니다). +
      + +
      + 총 건 +   +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      암호화 회원정보 접근 이력
      처리일시처리자행위대상 회원사유변경 항목처리자 IP재인증
      + + 복호화조회 + 등록 + 수정 + 파기 + + + + + + - + + + + + - + + + + 통과 + - + +
      + +
      + +
      + +
      + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/list.jsp new file mode 100644 index 0000000..4bc5c5f --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/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="form" uri="http://www.springframework.org/tags/form"%> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> + +<%-- + 암호화 회원정보 관리 — 목록 (개인정보보호 추가제안 Action4 / 제안서 슬라이드16) + · 목록은 항상 마스킹이다. 평문은 대상을 특정해 사유를 남긴 '상세 한 건'에서만 보인다 + (목록에서 평문이 뜨면 사유를 받는 의미가 없다 — 대량 노출은 목록에서 일어난다) + · 이름 검색은 완전일치만 된다. 랜덤 IV AES-GCM 이라 부분검색이 원리적으로 불가능하다 +--%> + + + + + + + + + + + + +
      +
      + + + + + + +
      + +
      + + +
      + · 이 목록의 이름·휴대폰·이메일은 항상 마스킹되어 표시됩니다. 평문은 사유를 입력하고 상세를 연 한 건에서만 보입니다.
      + · 이름 검색은 완전일치만 가능합니다. 이름은 암호화(AES-GCM, 랜덤 IV) 저장되어 같은 이름도 저장된 값이 매번 달라 부분검색(예: '홍')이 원리적으로 불가능합니다 — 검색 전용 해시로 정확히 같은 이름만 찾습니다.
      + · 조회·수정·파기에는 사유 입력과 재인증(본인 비밀번호 재확인, 10분 유효)이 필요하며, 처리자·일시·IP·사유가 접근 이력에 기록됩니다.
      + · 이 화면의 모든 요청은 개인정보 접속기록에도 자동으로 남습니다. +
      + +
      + + + 재인증 유효 — 남은 시간 초 + 재인증 필요 (상세·등록·수정·파기 시 본인 확인을 요청합니다) + + + 총 건 +   + +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      암호화 회원정보 목록
      NO.회원ID이름이메일휴대폰주소상태가입일상세
      + + <%-- 암호화 여부 표시 — 이관 후 남은 미암호화 행을 눈으로 찾을 수 있게 한다 --%> + + 암호화 + 미암호화 + + + + + - + + + + 정상 + 탈퇴 + + + + '); return false;">사유입력·조회 +
      + + +
      이름은 완전일치로만 검색됩니다. 성만 입력하거나 일부만 입력하면 찾을 수 없습니다. +
      +
      + +
      + +
      + +
      + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/reauth.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/reauth.jsp new file mode 100644 index 0000000..e9a0a4e --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/reauth.jsp @@ -0,0 +1,82 @@ +<%@ 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 --%> + +<%-- + 암호화 회원정보 관리 — 재인증(본인 비밀번호 재확인) + · 제안서 슬라이드16 "조회·수정·삭제 시 사유 입력 및 재인증 연계" 요건 + · 로그인 2차 인증을 재사용하지 않는 이유는 MberCryptoReauth Javadoc 참조 + (2차 인증은 로그인 시 한 번이라 '다시 확인'이 되지 못하고, 미등록 취급자는 영영 못 들어온다) +--%> + + + + + + + + + + + +
      +

      본인 확인

      +
      + +
      +
      + +
      + 개인정보 평문을 다루는 화면입니다. 본인 비밀번호를 한 번 더 입력해 주세요.
      + 확인 후 10분 동안 유효하며, 시간이 지나면 다시 확인합니다. +
      + +
      + + + + +
      + + +
      +
      +
      +
      + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/regist.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/regist.jsp new file mode 100644 index 0000000..877fffd --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/regist.jsp @@ -0,0 +1,141 @@ +<%@ 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 --%> + +<%-- + 암호화 회원정보 관리 — 신규 등록 + · 이름은 입력 즉시 암호화되어 저장된다(평문 컬럼에는 '(암호화)' 표식만 들어간다) + · 초기 비밀번호는 솔트 해시로 저장된다 +--%> + + + + + + + + + + + + +
      +
      + +
      + · 이름은 저장 시점에 암호화되며 평문으로는 보관되지 않습니다(검색용 해시가 함께 만들어집니다).
      + · 초기 비밀번호는 솔트를 붙인 해시로 저장됩니다. 등록 후 회원에게 변경을 안내하세요.
      + · 이 화면은 개인정보 항목만 다룹니다. 활동지역·재능 등 부가 정보는 기존 회원관리 화면에서 입력합니다.
      + · 등록에도 사유 입력이 필요하며 처리자·일시·IP·사유가 접근 이력에 기록됩니다. +
      · 재인증 남은 시간 초
      +
      + +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      회원 신규 등록
      회원ID *초기 비밀번호 *
      이름 *성별 + +
      이메일휴대폰
      우편번호회원상태 + +
      주소 + + +
      + +
      + + +
      +
      + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/view.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/view.jsp new file mode 100644 index 0000000..b7aa597 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/view.jsp @@ -0,0 +1,222 @@ +<%@ 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"%> + +<%-- + 암호화 회원정보 관리 — 상세(평문) / 수정 / 파기 + · 이 화면에 도달했다는 것은 사유 입력과 재인증을 모두 통과했다는 뜻이다(컨트롤러가 판정) + · 화면을 연 사실 자체가 이미 접근 이력에 기록되어 있다(복호화조회 S) +--%> + + + + + + + + + + + + +
      +
      + +
      + 조회 사유 : +  |  이 조회는 접근 이력에 기록되었습니다. +  |  재인증 남은 시간 초 +
      + +
      + · 아래 값은 복호화된 평문입니다. 화면을 열어 둔 채 자리를 비우지 마세요.
      + · 수정 시 이름은 다시 암호화되고 검색용 해시도 함께 갱신됩니다(평문 이름은 저장되지 않습니다).
      + · 비밀번호·가입일·내부 식별자는 이 화면에서 변경하지 않습니다. +
      + +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      회원 개인정보 상세
      회원ID + + + 정상 + 탈퇴 + + 내부 식별자
      이름 * + + + 암호화 v + + 성별 + +
      이메일 + + 휴대폰 + +
      우편번호 + + 회원상태 + +
      주소 + + +
      가입일
      + +
      + + +
      +
      + +
      + 개인정보 파기 — 이름·이메일·휴대폰·주소·성별을 삭제하고 회원상태를 '탈퇴'로 바꿉니다. + 암호문과 검색용 해시도 함께 지워지며 되돌릴 수 없습니다. + 회원 행 자체는 남깁니다(신청·활동 기록이 참조하고 있어 행을 지우면 그 기록들이 주인 없는 값을 가리키게 됩니다). +
      + +
      + + + + +
      + +
      +
      + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/purgeHistory/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/purgeHistory/list.jsp new file mode 100644 index 0000000..91dddb6 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/purgeHistory/list.jsp @@ -0,0 +1,151 @@ +<%@ 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="form" uri="http://www.springframework.org/tags/form"%> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> + +<%-- + 개인정보 접속기록 파기이력 (개인정보보호 SFR-004 / DBR-001 / SER-002-2) + · 보관기간(730일) 경과 파티션 DROP(파기) + 미래 월 파티션 ADD(선행증설) 내역 + · 조회 전용 — 이력을 화면에서 고치거나 지울 수 있으면 증빙 가치가 사라지므로 + 등록·수정·삭제 엔드포인트를 두지 않는다(컨트롤러에도 없다) +--%> + + + + + + + + + + + + + + + +
      + +
      + + +
      + · 개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG)의 파기·증설 이력입니다. 기간은 yyyy-MM-dd 또는 yyyy-MM-dd HH:mm:ss 형식으로 입력하세요(날짜만 입력 시 종료일 당일 포함).
      + · 접속기록의 삭제 경로는 월 단위 파티션 삭제(DROP) 하나뿐입니다. 개별 레코드 DELETE 는 사용하지 않으며, 접속기록 테이블에는 UPDATE·DELETE 구문 자체가 정의되어 있지 않습니다.
      + · 따라서 이 목록은 “보관기간(730일) 경과분을 규정대로 파기했고, 그 외 경로로는 손대지 않았다”는 사실을 함께 입증하는 증빙 자료입니다(안전성 확보조치 기준 제8조).
      + · 선행증설(ADD)은 앞으로 쌓일 기록을 담을 월 파티션을 미리 만들어 두는 작업입니다 — 파기가 아니라 수집이 끊기지 않게 하는 조치입니다.
      + · 이 화면은 조회 전용입니다. 이력의 수정·삭제 기능은 제공하지 않습니다. +
      + +
      + 총 건 +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      개인정보 접속기록 파기·증설 이력 목록
      NO.수행일시대상 파티션파티션 경계유형비고
      + + + - + + + + 파기 + 선행증설 + + + + + + - + +
      + +
      + +
      + +
      + + + + + + + + + diff --git a/src/script/mysql/ddl/privacy.mber_crypto_access_history_create_mysql.sql b/src/script/mysql/ddl/privacy.mber_crypto_access_history_create_mysql.sql new file mode 100644 index 0000000..2e8d463 --- /dev/null +++ b/src/script/mysql/ddl/privacy.mber_crypto_access_history_create_mysql.sql @@ -0,0 +1,55 @@ +-- ===================================================================== +-- 암호화 회원정보 관리자 접근 이력 : MBER_CRYPTO_ACCESS_HISTORY +-- 문화품앗이 개인정보보호 기능개선 사업 (추가제안 Action4 / 제안서 Ⅲ 슬라이드16) +-- 작성일: 2026-08-27 DB: MySQL 8 / MariaDB (스키마 csv) +-- --------------------------------------------------------------------- +-- [목적] 관리자 전용 회원정보 화면에서 일어난 '복호화 조회·수정·삭제·등록' 행위를 +-- 사유와 함께 남긴다. 제안서 슬라이드16의 접근통제 요건: +-- "관리자 페이지 별도 권한 부여, 조회·수정·삭제 시 사유 입력 및 재인증 연계" +-- +-- [PERSONAL_DATA_ACCESS_LOG(#30)와 무엇이 다른가] +-- · 접속기록(PDAL)은 URL 기준으로 '언제 어느 화면을 열었는가'를 자동 수집한다. +-- 이 화면도 /mnt/sec/** 라 자동 수집 대상이지만, 거기에는 사유가 없다. +-- · 이 표는 '왜 열어봤는가(사유)'와 '재인증을 거쳤는가'를 담는다. 평문 개인정보를 +-- 실제로 눈으로 본 행위의 소명 근거라 접속기록과 목적이 다르다. +-- · 둘 다 남기는 것은 중복이 아니라 이중 기록이다(하나가 비면 다른 하나로 대조). +-- +-- [담지 않는 것] 개인정보 본문은 적재하지 않는다. 대상 회원ID와 '어떤 항목을 바꿨는가'의 +-- 항목명만 남긴다 — 이력 테이블이 또 하나의 평문 개인정보 저장소가 되면 안 된다. +-- +-- [보관] 접속기록과 같은 2년(730일) 이상 보관을 전제로 한다. 별도 파기 배치는 두지 않고 +-- 행 수가 적어(관리자 수동 조회 기록) 운영 중 증가량을 관찰한 뒤 결정한다. +-- +-- [재실행 안전] CREATE TABLE IF NOT EXISTS — 반복 실행 무해. +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS MBER_CRYPTO_ACCESS_HISTORY +( + HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호', + HANDLR_ID VARCHAR(20) NOT NULL COMMENT '처리자(취급자) 계정ID', + ACTION_TY CHAR(1) NOT NULL COMMENT '행위 S:복호화조회 I:등록 U:수정 D:개인정보파기', + TRGET_MBER_ID VARCHAR(20) NULL COMMENT '대상 회원ID(목록 검색은 NULL)', + ACCESS_REASON VARCHAR(500) NOT NULL COMMENT '사유(필수 입력)', + CHANGED_ITEMS VARCHAR(500) NULL COMMENT '변경 항목명 목록(값은 미적재). 예: 이름,휴대폰', + HANDLR_IP VARCHAR(45) NULL COMMENT '처리자 IP(IPv6 대비 45)', + REAUTH_YN CHAR(1) NOT NULL DEFAULT 'N' COMMENT '재인증 통과 여부', + ACCESS_DT DATETIME NOT NULL COMMENT '처리 일시', + PRIMARY KEY (HIST_ID), + KEY IDX_MCAH_01 (ACCESS_DT), + KEY IDX_MCAH_02 (HANDLR_ID), + KEY IDX_MCAH_03 (TRGET_MBER_ID) +) +ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 +COMMENT='암호화 회원정보 관리자 접근 이력(사유·재인증, 추가제안 Action4)'; + +-- --------------------------------------------------------------------- +-- [적용 확인] +-- --------------------------------------------------------------------- +-- SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE FROM information_schema.COLUMNS +-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MBER_CRYPTO_ACCESS_HISTORY' +-- ORDER BY ORDINAL_POSITION; + +-- --------------------------------------------------------------------- +-- [롤백] +-- --------------------------------------------------------------------- +-- DROP TABLE IF EXISTS MBER_CRYPTO_ACCESS_HISTORY; diff --git a/src/script/mysql/dml/privacy.menu_privacy_screens4_insert_mysql.sql b/src/script/mysql/dml/privacy.menu_privacy_screens4_insert_mysql.sql new file mode 100644 index 0000000..2d72eec --- /dev/null +++ b/src/script/mysql/dml/privacy.menu_privacy_screens4_insert_mysql.sql @@ -0,0 +1,127 @@ +-- ===================================================================== +-- 관리자 메뉴 등록 : 개인정보보호 화면 4종 (MENU_NO 9100048 ~ 9100051) +-- 문화품앗이 개인정보보호 기능개선 사업 +-- 작성일: 2026-08-27 DB: MySQL 8 / MariaDB (스키마 csv) +-- --------------------------------------------------------------------- +-- [등록 대상] +-- 9100048 / ORDR 14 정기점검 리포트 /mnt/sec/inspection/listView.do (SFR-004-7, #48) +-- 9100049 / ORDR 15 비정상접근 탐지 /mnt/sec/abnormal/listView.do (과업내용 다-①, #49) +-- 9100050 / ORDR 16 접속기록 파기이력 /mnt/sec/purgeHistory/listView.do (SER-002-2 / DBR-001-2) +-- 9100051 / ORDR 17 회원정보 암호화관리 /mnt/sec/mberCrypto/listView.do (추가제안 Action4) +-- +-- [배치] 시스템관리(9100000) > 권한관리(9100030) 하위. 직전 항목은 +-- 접근통제 정책(9100047, ORDR 13)이라 그 뒤 순번을 이어 붙인다. +-- +-- [메뉴번호 근거] 9100044(접속기록)·9100047(접근통제 정책)까지 사용 중이고, +-- 9100045·9100046 은 2026-08-11 화면 폐기와 함께 삭제됐다(재사용하지 않는다 — +-- 폐기 이력이 있는 번호를 되쓰면 과거 권한매핑 잔재와 섞인다). +-- 9100048~9100051 은 레포 전체 미사용을 확인했다(9100044~9100070 구간에 9100070 만 존재). +-- 노출 순서는 MENU_NO 가 아니라 MENU_ORDR 을 따른다. +-- +-- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 동반 배포 필수 ★★ +-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라 +-- ① 권한관리(9100030) 블록에 9100048~9100051 분기 4개 +-- ② 시스템관리/권한관리 상위 하이라이트 조건(param.page eq '910004X') 2곳 +-- 이 함께 배포돼야 렌더된다. (소스에는 이미 반영 완료) +-- +-- [재실행 안전] 전 구간 NOT EXISTS 가드 → 반복 실행 무해. +-- [노출권한] 기존 권한관리 메뉴와 동일하게 IS_AUTHENTICATED_FULLY, ROLE_ADMIN 2종. +-- 실제 접근 차단은 urlRoleMapping(/mnt/sec/* = ROLE_ADMIN)이 담당한다. +-- [한글 리터럴 주의] git-bash + mysql.exe 로 이 파일을 stdin 파이프 실행하면 한글이 +-- 이중 인코딩으로 손상된다(기존 실측). 적용 후 반드시 HEX(MENU_NM) 로 검증하고, +-- 손상 시 CONVERT(UNHEX('') USING utf8mb4) UPDATE 로 교정할 것. +-- [Oracle] 대상 운영 DB가 MySQL(csv)이라 Oracle 쌍 미작성(기존 privacy 메뉴 DML 과 동일). +-- ===================================================================== + +-- ── [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 'InspectionReportList' AS PROGRM_FILE_NM, '/mnt/sec/inspection/' AS PROGRM_STRE_PATH, + '정기점검 리포트' AS PROGRM_KOREAN_NM, + '접속기록 정기점검 리포트 조회·점검결과 입력(SFR-004-7)' AS PROGRM_DC, + '/mnt/sec/inspection/listView.do?page=9100048' AS URL +) seed +WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM); + +INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) +SELECT * FROM ( + SELECT 'AbnormalAccessList' AS PROGRM_FILE_NM, '/mnt/sec/abnormal/' AS PROGRM_STRE_PATH, + '비정상접근 탐지' AS PROGRM_KOREAN_NM, + '비정상 접근 탐지 5유형 조회(과업내용 다-①)' AS PROGRM_DC, + '/mnt/sec/abnormal/listView.do?page=9100049' AS URL +) seed +WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM); + +INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) +SELECT * FROM ( + SELECT 'AccessLogPurgeHistory' AS PROGRM_FILE_NM, '/mnt/sec/purgeHistory/' AS PROGRM_STRE_PATH, + '접속기록 파기이력' AS PROGRM_KOREAN_NM, + '접속기록 파티션 파기·증설 이력 조회(SER-002-2)' AS PROGRM_DC, + '/mnt/sec/purgeHistory/listView.do?page=9100050' AS URL +) seed +WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM); + +INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) +SELECT * FROM ( + SELECT 'MberCryptoAdmin' AS PROGRM_FILE_NM, '/mnt/sec/mberCrypto/' AS PROGRM_STRE_PATH, + '회원정보 암호화 관리' AS PROGRM_KOREAN_NM, + '암호화 회원정보 복호화 조회·수정·파기·등록(추가제안 Action4)' AS PROGRM_DC, + '/mnt/sec/mberCrypto/listView.do?page=9100051' AS URL +) seed +WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM); + +-- ── [2] 메뉴 등록 : 권한관리(9100030) 하위 ── +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, 'InspectionReportList' AS PROGRM_FILE_NM, 9100048 AS MENU_NO, + 9100030 AS UPPER_MENU_NO, 14 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 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, 'AbnormalAccessList' AS PROGRM_FILE_NM, 9100049 AS MENU_NO, + 9100030 AS UPPER_MENU_NO, 15 AS MENU_ORDR, '비정상 접근 탐지 5유형 조회' 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 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, 'AccessLogPurgeHistory' AS PROGRM_FILE_NM, 9100050 AS MENU_NO, + 9100030 AS UPPER_MENU_NO, 16 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 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, 'MberCryptoAdmin' AS PROGRM_FILE_NM, 9100051 AS MENU_NO, + 9100030 AS UPPER_MENU_NO, 17 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] 권한↔메뉴 노출 매핑 (4메뉴 × 2권한, FK 안전) ── +INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE) +SELECT n.mno, g.ac + FROM (SELECT 9100048 AS mno UNION ALL SELECT 9100049 UNION ALL + SELECT 9100050 UNION ALL SELECT 9100051) n + 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 = n.mno AND x.AUTHOR_CODE = g.ac); + +-- ── [검증] 등록결과 + 한글 손상 여부(HEX 가 EA/EB/EC 대로 시작해야 정상) ── +-- SELECT m.MENU_NO, m.UPPER_MENU_NO, m.MENU_ORDR, m.MENU_NM, HEX(m.MENU_NM), 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 9100048 AND 9100051 +-- ORDER BY m.MENU_ORDR; + +-- ── [롤백] (역순) ── +-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO BETWEEN 9100048 AND 9100051; +-- DELETE FROM COMTNMENUINFO WHERE MENU_NO BETWEEN 9100048 AND 9100051; +-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM IN +-- ('InspectionReportList','AbnormalAccessList','AccessLogPurgeHistory','MberCryptoAdmin'); diff --git a/src/script/mysql/privacy.install_mysql.sql b/src/script/mysql/privacy.install_mysql.sql index c3de0ed..e44cb8f 100644 --- a/src/script/mysql/privacy.install_mysql.sql +++ b/src/script/mysql/privacy.install_mysql.sql @@ -1054,6 +1054,33 @@ DROP VIEW IF EXISTS selectboardarticlelist; DROP VIEW IF EXISTS selectmiddleappconfirmlist; +-- --------------------------------------------------------------------- +-- [privacy.mber_crypto_access_history_create_mysql.sql] 2026-08-27 추가 +-- 암호화 회원정보 관리자 접근 이력(추가제안 Action4 / 제안서 슬라이드16) +-- · 관리자 전용 회원정보 화면의 복호화 조회·등록·수정·파기 행위를 '사유'와 함께 남긴다. +-- · 접속기록(PERSONAL_DATA_ACCESS_LOG)이 '언제 어느 화면을 열었나'라면 이 표는 '왜 열어봤나'다. +-- 둘 다 남기는 것은 중복이 아니라 이중 기록이다(하나가 비면 다른 하나로 대조). +-- · 개인정보 본문은 적재하지 않는다 — 변경 항목명만 남기고 값은 남기지 않는다. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS MBER_CRYPTO_ACCESS_HISTORY +( + HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호', + HANDLR_ID VARCHAR(20) NOT NULL COMMENT '처리자(취급자) 계정ID', + ACTION_TY CHAR(1) NOT NULL COMMENT '행위 S:복호화조회 I:등록 U:수정 D:개인정보파기', + TRGET_MBER_ID VARCHAR(20) NULL COMMENT '대상 회원ID(목록 검색은 NULL)', + ACCESS_REASON VARCHAR(500) NOT NULL COMMENT '사유(필수 입력)', + CHANGED_ITEMS VARCHAR(500) NULL COMMENT '변경 항목명 목록(값은 미적재). 예: 이름,휴대폰', + HANDLR_IP VARCHAR(45) NULL COMMENT '처리자 IP(IPv6 대비 45)', + REAUTH_YN CHAR(1) NOT NULL DEFAULT 'N' COMMENT '재인증 통과 여부', + ACCESS_DT DATETIME NOT NULL COMMENT '처리 일시', + PRIMARY KEY (HIST_ID), + KEY IDX_MCAH_01 (ACCESS_DT), + KEY IDX_MCAH_02 (HANDLR_ID), + KEY IDX_MCAH_03 (TRGET_MBER_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='암호화 회원정보 관리자 접근 이력(사유·재인증, 추가제안 Action4)'; + + -- ===================================================================== -- PART 2. DML (기준/시드 데이터) ※ 멱등: NOT EXISTS 가드로 중복 적재 방지 -- 수록: ACCOUNT_MNG_POLICY(기본정책 1행) @@ -1249,6 +1276,91 @@ SELECT 9100047, g.ac -- ('AuthorRequestList','AuthorHistoryList','DormantStatusList','AccountPolicySet'); +-- ── [7] 개인정보보호 화면 4종 메뉴(9100048~9100051) — 2026-08-27 추가 ── +-- 9100048 / ORDR 14 정기점검 리포트 /mnt/sec/inspection/listView.do (SFR-004-7) +-- 9100049 / ORDR 15 비정상접근 탐지 /mnt/sec/abnormal/listView.do (과업내용 다-①) +-- 9100050 / ORDR 16 접속기록 파기이력 /mnt/sec/purgeHistory/listView.do (SER-002-2) +-- 9100051 / ORDR 17 회원정보 암호화관리 /mnt/sec/mberCrypto/listView.do (추가제안 Action4) +-- ★ left.jsp 9100048~9100051 분기 4개 + 상위 하이라이트 조건 2곳 동반 배포 필수 +-- ※ 9100045·9100046 은 2026-08-11 화면 폐기와 함께 삭제된 번호라 재사용하지 않는다. +INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) +SELECT * FROM ( + SELECT 'InspectionReportList' AS PROGRM_FILE_NM, '/mnt/sec/inspection/' AS PROGRM_STRE_PATH, + '정기점검 리포트' AS PROGRM_KOREAN_NM, + '접속기록 정기점검 리포트 조회·점검결과 입력(SFR-004-7)' AS PROGRM_DC, + '/mnt/sec/inspection/listView.do?page=9100048' AS URL +) seed +WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM); + +INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) +SELECT * FROM ( + SELECT 'AbnormalAccessList' AS PROGRM_FILE_NM, '/mnt/sec/abnormal/' AS PROGRM_STRE_PATH, + '비정상접근 탐지' AS PROGRM_KOREAN_NM, + '비정상 접근 탐지 5유형 조회(과업내용 다-①)' AS PROGRM_DC, + '/mnt/sec/abnormal/listView.do?page=9100049' AS URL +) seed +WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM); + +INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) +SELECT * FROM ( + SELECT 'AccessLogPurgeHistory' AS PROGRM_FILE_NM, '/mnt/sec/purgeHistory/' AS PROGRM_STRE_PATH, + '접속기록 파기이력' AS PROGRM_KOREAN_NM, + '접속기록 파티션 파기·증설 이력 조회(SER-002-2)' AS PROGRM_DC, + '/mnt/sec/purgeHistory/listView.do?page=9100050' AS URL +) seed +WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM); + +INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) +SELECT * FROM ( + SELECT 'MberCryptoAdmin' AS PROGRM_FILE_NM, '/mnt/sec/mberCrypto/' AS PROGRM_STRE_PATH, + '회원정보 암호화 관리' AS PROGRM_KOREAN_NM, + '암호화 회원정보 복호화 조회·수정·파기·등록(추가제안 Action4)' AS PROGRM_DC, + '/mnt/sec/mberCrypto/listView.do?page=9100051' 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, 'InspectionReportList' AS PROGRM_FILE_NM, 9100048 AS MENU_NO, + 9100030 AS UPPER_MENU_NO, 14 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 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, 'AbnormalAccessList' AS PROGRM_FILE_NM, 9100049 AS MENU_NO, + 9100030 AS UPPER_MENU_NO, 15 AS MENU_ORDR, '비정상 접근 탐지 5유형 조회' 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 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, 'AccessLogPurgeHistory' AS PROGRM_FILE_NM, 9100050 AS MENU_NO, + 9100030 AS UPPER_MENU_NO, 16 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 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, 'MberCryptoAdmin' AS PROGRM_FILE_NM, 9100051 AS MENU_NO, + 9100030 AS UPPER_MENU_NO, 17 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 n.mno, g.ac + FROM (SELECT 9100048 AS mno UNION ALL SELECT 9100049 UNION ALL + SELECT 9100050 UNION ALL SELECT 9100051) n + CROSS JOIN (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 = n.mno AND x.AUTHOR_CODE = g.ac); + + -- ===================================================================== -- PART 4. 뷰 GNR 컷오버 — comvnusermaster / comvnuserinfo (2026-08-11 통합) -- 원본: src/script/mysql/ddl/privacy.mber_nm_view_hash_cutover_mysql.sql diff --git a/개인정보보호_개발진행현황.md b/개인정보보호_개발진행현황.md index 5d5a51f..d018289 100644 --- a/개인정보보호_개발진행현황.md +++ b/개인정보보호_개발진행현황.md @@ -25,6 +25,29 @@ --- +## 1-0. 2026-08-27 작업 — 관리자 화면 4종 신설 + +> ⚠ 아래 §1-1 이후의 기록은 **2026-07-22 기준**이라 현재 소스와 어긋난다(그 뒤 커밋에서 +> 인터셉터 `enforce=true`·`urlRoleMapping` 활성, 관리자 화면 8종, OTP DB영속, 회원정보 암호화 +> 컷오버가 모두 반영됨). 갱신은 별도 작업으로 남긴다. + +| 대상 | 요구사항 | 산출물 | 상태 | +|------|----------|--------|:----:| +| #48 정기점검 리포트 화면 | SFR-004-7 | `InspectionResultController.listView/saveResultView` + `site/mnt/sec/inspection/list.jsp` + 메뉴 9100048 | ✅ 완료 | +| #49 비정상접근 탐지 화면 | 과업내용 다-①, SER-002 | `AbnormalAccessController`(신규) + `AbnormalAccessResultVO.rows` 확장 + `abnormal/list.jsp` + 메뉴 9100049 | ✅ 완료 | +| 접속기록 파기이력 화면(신규 항목) | SER-002-2 / DBR-001-2 | `AccessLogPurgeHistoryController`+VO 2종, `AccessLogPartitionDAO` 조회 2메서드, sqlmap 2 statement, `purgeHistory/list.jsp`, 메뉴 9100050 | ✅ 완료 | +| 회원정보 암호화 관리 CRUD(추가제안 Action4) | 제안서 슬라이드16 | `MberCryptoAdmin*` 8클래스 + `PrivacyMberCryptoAdmin_SQL_Mysql/Oracle` + `MBER_CRYPTO_ACCESS_HISTORY` DDL + JSP 5종 + 메뉴 9100051 | ✅ 완료 | + +- 검증: JDK8 `mvn -o compile` **EXIT=0**, JSP 9종 Jasper 사전컴파일 **오류 0건**. +- 배포 델타: `_workspace_privacy/deploy/20260827_1410/DELTA.md` (SQL 2건 적용순서 포함). +- **미완**: Tomcat 기동 상태 실화면 검증(관리자 계정 필요), 개발/운영 서버 반영. +- **후속 발견**: 기존 탈퇴 구문 `mberManageDAO.updateDeleteMber_S` 는 개인정보를 비우면서도 + **이름(MBER_NM/MBER_NM_ENC/MBER_NM_HASH)은 지우지 않는다** — 탈퇴 후에도 이름이 남는 파기 누락 + 소지. 신설 화면의 파기(`MberCryptoAdminDAO.deleteMberPii`)는 이름까지 파기한다. 기존 탈퇴 경로 + 보정은 별도 판단 필요(영향 범위: 프론트 탈퇴 전체). + +--- + ## 1-1. 오늘의 작업 (2026-07-21, ~17:00 목표) fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반 항목 6개**를 당일 완료 목표로 착수한다.