diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..317bf0c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# arko 프로젝트 + +## 프로젝트 개요 + +한국문화예술위원회 문화품앗이/자원봉사 포털 (`csv.culture.go.kr`, DB 스키마 `csv`) + +- **스택:** Spring 3.0.5 + Spring Security 2.0.4 + **iBatis 2 (2.3.4)** + eGovFramework 2.7.0 +- **Java:** 1.7 (런타임 JDK8/11) / **DB:** MySQL 8 / **뷰:** JSP + JstlView (**Tiles 미사용**) +- **패키지:** `kr.or.arko.*`(도메인) + `egovframework.com.*`(표준) +- **빌드:** Maven, **profile 없음** — `globals-live.properties` + `globals-dev.properties` 로드순서로 환경전환, ROOT 배포 + +> **주의(레거시 함정):** ① 신규 SQL은 `sql-map-config-mysql-0-site.xml`에 **수동 등록 필수**(iBatis2 자동스캔 없음). ② `*_SQL_Mysql.xml`/`_Oracle.xml` 이중 관리. ③ JDK11 JAXB 이슈로 `jaxb-api/runtime`을 WAR 번들. ④ `WebContent/`(스텁) vs `src/main/webapp/`(실사용) 이중화 — webapp만 유효. ⑤ 조직코드는 표준 행정코드가 아닌 내부 계층(시도=`ORGNZT_ID`/시군구=`TEAM_ID`). + +## 하네스: arko 개발 자동화 + +**목표:** iBatis 2 5계층 CRUD, `{path}` 다중도메인 JSP 화면, 공개 API·조직코드 매핑 유지보수를 에이전트 팀으로 자동화 + +**트리거:** arko 관련 개발 작업(신규 기능, 화면 추가, 버그 수정, 쿼리 작업, 공개 API, 조직코드 매핑, 보안 검토) 요청 시 `arko-orchestrate` 스킬을 사용하라. '문화품앗이', '자원봉사', 'csvVol', '공개API', '조직코드', '모집', '신청', '활동확인', 'frt', 'mnt' 키워드 포함 시 항상 진입. 단순 질문은 직접 응답 가능. + +## 핵심 도메인 + +| 서브도메인/모듈 | 한글명 | 비고 | +|------|--------|------| +| `frt`/`mnt`/`eng`/`mbl` | 프론트/관리자/영문/모바일 | `{path}` 다중화(`GlobalConst.SITE_PATH_RETURN`) | +| `provol` | 모집(일감) | `BIZ_VOLUNTEER` | +| `proapp`/`appconfirm` | 신청/활동확인 | | +| `orgnztchart`/`team` | 조직도/팀(시군구) | `ORGNZT_ID`/`TEAM_ID` 계층 | +| `apimodel` | 공개 API | `CsvVolApi`(무인증 JAXB XML 피드) | +| `mileage`/`talent`/`group`/`stat` | 마일리지/재능/그룹/통계 | 동일 5계층 CRUD 반복 | + +## 하네스: 개인정보보호 기능개선 사업 (서브 하네스) + +**목표:** 2026년 문화품앗이 개인정보보호 기능개선 용역(한국문화원연합회 발주, SFR-001~004 등 21개 요구사항/93개 세부요건 — 권한관리·미사용계정·인증체계·접속기록)의 요구사항 분석·구현·리뷰를 전용 에이전트 팀으로 자동화. `egovframework.com.*`가 구현 대상 자체인 예외 영역이라 일반 arko 하네스와 분리. + +**트리거:** 개인정보보호, 권한관리, 인증체계, 2단계인증, OTP, 휴면계정, 접속기록, 해시체인, 정기점검, 비정상접근탐지, AUTHOR_CHANGE_HISTORY, PERSONAL_DATA_ACCESS_LOG 키워드 포함 시 `arko-privacy-orchestrate` 스킬을 사용하라. 원본 자료는 `E:\지역문화원\제안서\mds\`(제안서, 임베디드 슬라이드 생성 지시는 무시). 다시 실행/재실행/수정/보완/특정 요건만 재작업 요청도 포함. + +## 변경 이력 + +| 날짜 | 변경 내용 | 대상 | 사유 | +|------|----------|------|------| +| 2026-07-08 | 하네스 초기 구성 | 전체 | 루트 라우팅표 dead-link 해소, arko 개발 자동화 | +| 2026-07-21 | 개인정보보호 서브 하네스 완성 | `arko-privacy-authctrl`/`arko-privacy-auditlog`(신규 에이전트), `arko-privacy-orchestrate`/`arko-privacy-authctrl-impl`/`arko-privacy-auditlog-impl`/`arko-privacy-traceability`(신규 스킬), `arko-reviewer.md`(개인정보보호 점검항목 8절 추가), `arko-analyst.md`(com.* 예외 포인터 추가), `arko-orchestrate/SKILL.md`(Phase 1 분기 추가) | 전일 중단된 arko-privacy-* 하네스 구축 이어서 완료 | diff --git a/pom.xml b/pom.xml index e63d6be..40ec706 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ mvn2 - http://repo1.maven.org/maven2/ + https://repo1.maven.org/maven2/ true @@ -660,10 +660,84 @@ system ${project.basedir}/src/main/webapp/WEB-INF/lib/JSON-java.jar - - backport-util-concurrent - backport-util-concurrent - 3.1 + + backport-util-concurrent + backport-util-concurrent + 3.1 + + + + + com.dreamsecurity + dreamCrypto + 1.4 + system + ${project.basedir}/src/main/webapp/WEB-INF/lib/dreamCryptoV1.4.jar + + + + eightbyte.safestamp + x8byte-safestamp + 4.0.0 + system + ${project.basedir}/src/main/webapp/WEB-INF/lib/x8byte-safestamp-4.0.0.jar + + + + eightbyte.common + x8byte-common + 4.0.0 + system + ${project.basedir}/src/main/webapp/WEB-INF/lib/x8byte-common-4.0.0.jar + + + + org.apache.xmlrpc + xmlrpc + 3.0 + system + ${project.basedir}/src/main/webapp/WEB-INF/lib/xmlrpc.jar + + + + eltz.apps + eltz-keypad + 1.0 + system + ${project.basedir}/src/main/webapp/WEB-INF/lib/eltz-keypad.jar + + + + net.sf.supercsv + super-csv + 2.4.0 + system + ${project.basedir}/src/main/webapp/WEB-INF/lib/super-csv-2.4.0.jar + + + + helma + helma + 1.0 + system + ${project.basedir}/src/main/webapp/WEB-INF/lib/helma.jar + + + + + javax.xml.bind + jaxb-api + 2.3.1 + + + org.glassfish.jaxb + jaxb-runtime + 2.3.1 + + + javax.activation + activation + 1.1.1 diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java new file mode 100644 index 0000000..5b43ad2 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java @@ -0,0 +1,51 @@ +package egovframework.com.cmm.privacy.account; + +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; + +/** + * 계정 관리 정책·현황 컨트롤러 (SFR-002). + * + *

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

+ */ +@Controller +public class AccountMngController { + + @Resource(name = "accountMngPolicyService") + private AccountMngPolicyService accountMngPolicyService; + + @Resource(name = "dormantAccountStatusService") + private DormantAccountStatusService dormantAccountStatusService; + + /** #13 정책 조회. */ + @RequestMapping(value = "/mnt/privacy/accountPolicy.do") + public String selectPolicy(ModelMap model) throws Exception { + model.addAttribute("policy", accountMngPolicyService.selectPolicy()); + return "jsonView"; + } + + /** #13 정책 수정. */ + @RequestMapping(value = "/mnt/privacy/accountPolicyUpdate.do") + public String updatePolicy(@ModelAttribute("policy") AccountMngPolicyVO policy, ModelMap model) throws Exception { + accountMngPolicyService.updatePolicy(policy); + model.addAttribute("result", "success"); + return "jsonView"; + } + + /** #11 장기 미사용 계정 현황 목록(페이징). */ + @RequestMapping(value = "/mnt/privacy/dormantStatusList.do") + public String selectDormantStatusList(@ModelAttribute("searchVO") DormantAccountStatusVO searchVO, ModelMap model) throws Exception { + List list = dormantAccountStatusService.selectDormantStatusList(searchVO); + model.addAttribute("resultList", list); + model.addAttribute("totalCount", Integer.valueOf(searchVO.getTotalRecordCount())); + // TODO 화면 #11 프론트 후속(JSP) + return "jsonView"; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngDAO.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngDAO.java new file mode 100644 index 0000000..f8cbd2a --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngDAO.java @@ -0,0 +1,49 @@ +package egovframework.com.cmm.privacy.account; + +import java.util.HashMap; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 계정 관리 정책·현황·사전알림 DAO (SFR-002). + * + *

#13/#9 정책 조회·수정, #11 장기 미사용 계정 현황 조회, #10 휴면 사전알림 대상 조회. + * statement는 {@code PrivacyAccountMng_SQL_Mysql.xml}(namespace {@code AccountMngDAO}), + * sql-map-config 등록.

+ */ +@Repository("accountMngDAO") +public class AccountMngDAO extends EgovComAbstractDAO { + + /** #13/#9 정책 단일행 조회. 없으면 null(호출부 기본값 fallback). */ + public AccountMngPolicyVO selectPolicy() { + return (AccountMngPolicyVO) selectByPk("AccountMngDAO.selectPolicy", null); + } + + /** #13 정책 수정. */ + public int updatePolicy(AccountMngPolicyVO vo) { + return update("AccountMngDAO.updatePolicy", vo); + } + + /** #11 장기 미사용 계정 현황 목록. */ + @SuppressWarnings("unchecked") + public List selectDormantStatusList(DormantAccountStatusVO searchVO) { + return list("AccountMngDAO.selectDormantStatusList", searchVO); + } + + /** #11 현황 전체 건수(페이징). */ + public int selectDormantStatusListCount(DormantAccountStatusVO searchVO) { + Object cnt = selectByPk("AccountMngDAO.selectDormantStatusListCount", searchVO); + return cnt == null ? 0 : ((Integer) cnt).intValue(); + } + + /** #10 휴면 D-N일 전 사전알림 대상 조회. param: alertBefore(Integer). */ + @SuppressWarnings("unchecked") + public List selectDormantAlertTargets(int alertBefore) { + HashMap param = new HashMap(); + param.put("alertBefore", Integer.valueOf(alertBefore)); + return list("AccountMngDAO.selectDormantAlertTargets", param); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyService.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyService.java new file mode 100644 index 0000000..d482fd3 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyService.java @@ -0,0 +1,13 @@ +package egovframework.com.cmm.privacy.account; + +/** + * 계정 관리 정책 서비스 (#13 정책 설정 / #9 휴면 기준일수 정책값화). + */ +public interface AccountMngPolicyService { + + /** 현재 정책 조회(미설정 시 기본값 VO 반환). */ + AccountMngPolicyVO selectPolicy() throws Exception; + + /** 정책 수정. */ + void updatePolicy(AccountMngPolicyVO vo) throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyServiceImpl.java new file mode 100644 index 0000000..da45b16 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyServiceImpl.java @@ -0,0 +1,31 @@ +package egovframework.com.cmm.privacy.account; + +import javax.annotation.Resource; + +import org.springframework.stereotype.Service; + +/** + * 계정 관리 정책 서비스 구현 (#13/#9). + * + *

#9의 휴면 기준일수는 sqlmap 서브쿼리(ACCOUNT_MNG_POLICY.DORMANT_DAY, 미설정 시 365)로 + * 직접 적용되며, 이 서비스는 관리자 설정 화면(#13)의 조회/수정을 담당한다. + * 정책 미설정(테이블 empty) 시 기본값 VO를 반환해 화면·배치가 안전하게 동작한다.

+ */ +@Service("accountMngPolicyService") +public class AccountMngPolicyServiceImpl implements AccountMngPolicyService { + + @Resource(name = "accountMngDAO") + private AccountMngDAO accountMngDAO; + + @Override + public AccountMngPolicyVO selectPolicy() throws Exception { + AccountMngPolicyVO vo = accountMngDAO.selectPolicy(); + // 정책 미설정 시 기본값(휴면 365일 등) — DDL 시드가 없어도 화면/배치 안전. + return vo != null ? vo : new AccountMngPolicyVO(); + } + + @Override + public void updatePolicy(AccountMngPolicyVO vo) throws Exception { + accountMngDAO.updatePolicy(vo); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyVO.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyVO.java new file mode 100644 index 0000000..cbda2d9 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyVO.java @@ -0,0 +1,62 @@ +package egovframework.com.cmm.privacy.account; + +import java.io.Serializable; + +/** + * 계정 관리 정책 VO — ACCOUNT_MNG_POLICY(단일행) 매핑. + * + *

개인정보보호 기능개선 사업(SFR-002, 장기 미사용 계정 관리) 개발항목 + * #13(정책 설정) + #9(휴면 기준일수 정책값화).

+ * + *
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.07.21    URP     최초 생성 (#9/#13)
+ * 
+ */ +public class AccountMngPolicyVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 휴면 미설정 시 기준일수 fallback(#9). */ + public static final int DEFAULT_DORMANT_DAY = 365; + + private int policyId = 1; + /** 휴면 전환 기준 미접속 일수(#9). */ + private int dormantDay = DEFAULT_DORMANT_DAY; + /** 사전알림 1차(휴면 D-일수, 기본 30). */ + private int alertD1 = 30; + /** 사전알림 2차(휴면 D-일수, 기본 7). */ + private int alertD2 = 7; + /** 사전알림 3차(D-Day, 기본 0). */ + private int alertD3 = 0; + /** 휴면/탈퇴 후 삭제 보존기간(일). */ + private int deletePreserveDay = 365; + /** 배치 주기(DAILY/WEEKLY/MONTHLY). */ + private String batchCycle = "DAILY"; + /** 사전알림 관리자 수신 이메일. */ + private String adminEmail; + private String updId; + private String updDt; + + public int getPolicyId() { return policyId; } + public void setPolicyId(int policyId) { this.policyId = policyId; } + public int getDormantDay() { return dormantDay; } + public void setDormantDay(int dormantDay) { this.dormantDay = dormantDay; } + public int getAlertD1() { return alertD1; } + public void setAlertD1(int alertD1) { this.alertD1 = alertD1; } + public int getAlertD2() { return alertD2; } + public void setAlertD2(int alertD2) { this.alertD2 = alertD2; } + public int getAlertD3() { return alertD3; } + public void setAlertD3(int alertD3) { this.alertD3 = alertD3; } + public int getDeletePreserveDay() { return deletePreserveDay; } + public void setDeletePreserveDay(int deletePreserveDay) { this.deletePreserveDay = deletePreserveDay; } + public String getBatchCycle() { return batchCycle; } + public void setBatchCycle(String batchCycle) { this.batchCycle = batchCycle; } + public String getAdminEmail() { return adminEmail; } + public void setAdminEmail(String adminEmail) { this.adminEmail = adminEmail; } + public String getUpdId() { return updId; } + public void setUpdId(String updId) { this.updId = updId; } + public String getUpdDt() { return updDt; } + public void setUpdDt(String updDt) { this.updDt = updDt; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusService.java b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusService.java new file mode 100644 index 0000000..68727b8 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusService.java @@ -0,0 +1,15 @@ +package egovframework.com.cmm.privacy.account; + +import java.util.List; + +/** + * 장기 미사용 계정 현황 조회 서비스 (#11). + */ +public interface DormantAccountStatusService { + + /** 현황 목록 조회(페이징, totalRecordCount 세팅). */ + List selectDormantStatusList(DormantAccountStatusVO searchVO) throws Exception; + + /** 현황 전체 건수. */ + int selectDormantStatusTotCnt(DormantAccountStatusVO searchVO) throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusServiceImpl.java new file mode 100644 index 0000000..02594b0 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusServiceImpl.java @@ -0,0 +1,33 @@ +package egovframework.com.cmm.privacy.account; + +import java.util.List; + +import javax.annotation.Resource; + +import org.springframework.stereotype.Service; + +/** + * 장기 미사용 계정 현황 조회 서비스 구현 (#11). + */ +@Service("dormantAccountStatusService") +public class DormantAccountStatusServiceImpl implements DormantAccountStatusService { + + @Resource(name = "accountMngDAO") + private AccountMngDAO accountMngDAO; + + @Override + public List selectDormantStatusList(DormantAccountStatusVO searchVO) throws Exception { + int pageIndex = searchVO.getPageIndex() < 1 ? 1 : searchVO.getPageIndex(); + int perPage = searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage(); + searchVO.setFirstIndex((pageIndex - 1) * perPage); + searchVO.setRecordCountPerPage(perPage); + + searchVO.setTotalRecordCount(accountMngDAO.selectDormantStatusListCount(searchVO)); + return accountMngDAO.selectDormantStatusList(searchVO); + } + + @Override + public int selectDormantStatusTotCnt(DormantAccountStatusVO searchVO) throws Exception { + return accountMngDAO.selectDormantStatusListCount(searchVO); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusVO.java b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusVO.java new file mode 100644 index 0000000..c8f1b2b --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusVO.java @@ -0,0 +1,66 @@ +package egovframework.com.cmm.privacy.account; + +import java.io.Serializable; + +/** + * 장기 미사용 계정 현황 VO — 조회 결과 + 검색조건(#11) + 사전알림 대상(#10). + * + *

개인정보보호 기능개선 사업(SFR-002) 개발항목 #11(현황 조회) / #10(사전알림). + * 계정ID·성명·소속·최종로그인일·미접속일수·상태·휴면예정일.

+ */ +public class DormantAccountStatusVO implements Serializable { + + private static final long serialVersionUID = 1L; + + // ---- 조회 결과 ---- + private String accountId; // 계정ID(EMPLYR_ID) + private String userNm; // 성명(USER_NM) + private String orgnztId; // 소속 조직ID(ORGNZT_ID) + private String ofcpsNm; // 직위명(OFCPS_NM) + private String esntlId; // 고유ID(ESNTL_ID) + private String lastLoginDt; // 최종로그인일(yyyy-MM-dd) + private int noConnectDays; // 미접속일수 + private String sttusCode; // 상태코드(EMPLYR_STTUS_CODE) + private String dormantExpectDt; // 휴면예정일(yyyy-MM-dd) + private String mberTyCode; // 회원유형(EMP/GNR) + + // ---- 검색조건(#11) ---- + private int minNoConnectDays = 0; // 미접속일수 하한 필터 + + // ---- 페이징 ---- + private int pageIndex = 1; + private int recordCountPerPage = 10; + private int firstIndex = 0; + private int totalRecordCount = 0; + + public String getAccountId() { return accountId; } + public void setAccountId(String accountId) { this.accountId = accountId; } + public String getUserNm() { return userNm; } + public void setUserNm(String userNm) { this.userNm = userNm; } + public String getOrgnztId() { return orgnztId; } + public void setOrgnztId(String orgnztId) { this.orgnztId = orgnztId; } + public String getOfcpsNm() { return ofcpsNm; } + public void setOfcpsNm(String ofcpsNm) { this.ofcpsNm = ofcpsNm; } + public String getEsntlId() { return esntlId; } + public void setEsntlId(String esntlId) { this.esntlId = esntlId; } + public String getLastLoginDt() { return lastLoginDt; } + public void setLastLoginDt(String lastLoginDt) { this.lastLoginDt = lastLoginDt; } + public int getNoConnectDays() { return noConnectDays; } + public void setNoConnectDays(int noConnectDays) { this.noConnectDays = noConnectDays; } + public String getSttusCode() { return sttusCode; } + public void setSttusCode(String sttusCode) { this.sttusCode = sttusCode; } + public String getDormantExpectDt() { return dormantExpectDt; } + public void setDormantExpectDt(String dormantExpectDt) { this.dormantExpectDt = dormantExpectDt; } + public String getMberTyCode() { return mberTyCode; } + public void setMberTyCode(String mberTyCode) { this.mberTyCode = mberTyCode; } + public int getMinNoConnectDays() { return minNoConnectDays; } + public void setMinNoConnectDays(int minNoConnectDays) { this.minNoConnectDays = minNoConnectDays; } + public int getPageIndex() { return pageIndex; } + public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; } + public int getRecordCountPerPage() { return recordCountPerPage; } + public void setRecordCountPerPage(int recordCountPerPage) { this.recordCountPerPage = recordCountPerPage; } + public int getFirstIndex() { return firstIndex; } + public void setFirstIndex(int firstIndex) { this.firstIndex = firstIndex; } + public int getTotalRecordCount() { return totalRecordCount; } + public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/account/DormantAlertService.java b/src/main/java/egovframework/com/cmm/privacy/account/DormantAlertService.java new file mode 100644 index 0000000..c09b4a9 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/account/DormantAlertService.java @@ -0,0 +1,19 @@ +package egovframework.com.cmm.privacy.account; + +/** + * 휴면 전 사전 알림 서비스 (#10). + * + *

기존 본인 메일 2단계(유예/확정)에 더해, 휴면 예정 D-30 / D-7 / D-Day 3단계 + * 사전 알림을 본인 + 관리자에게 발송한다. 알림 시점(D-일수)·관리자 수신주소는 + * ACCOUNT_MNG_POLICY(#13)에서 조회한다.

+ */ +public interface DormantAlertService { + + /** + * 정책(ALERT_D1/D2/D3)에 해당하는 휴면 사전알림 대상을 조회해 본인·관리자에게 발송한다. + * (배치에서 호출; 개별 발송 실패는 로깅 후 계속 — fail-safe) + * + * @return 발송 건수 + */ + int sendDormantPreAlerts() throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/account/DormantAlertServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/account/DormantAlertServiceImpl.java new file mode 100644 index 0000000..b32a9ad --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/account/DormantAlertServiceImpl.java @@ -0,0 +1,112 @@ +package egovframework.com.cmm.privacy.account; + +import java.util.ArrayList; +import java.util.List; + +import javax.annotation.Resource; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import egovframework.com.cop.ems.service.EgovSndngMailRegistService; +import egovframework.com.cop.ems.service.SndngMailVO; + +/** + * 휴면 전 사전 알림 서비스 구현 (#10, D-30/D-7/D-Day + 관리자 알림). + * + *

정책(ACCOUNT_MNG_POLICY)의 ALERT_D1/D2/D3(기본 30/7/0)일 전 대상을 조회해 + * 본인과 관리자(ADMIN_EMAIL)에게 사전 알림 메일을 발송한다. 기존 휴면 배치의 + * 유예/확정 본인 메일(EgovLoginServiceImpl.processDiapauseList)과 독립적으로 동작하며, + * 발송 실패가 배치 본류를 막지 않도록 전 구간 fail-safe.

+ */ +@Service("dormantAlertService") +public class DormantAlertServiceImpl implements DormantAlertService { + + private static final Logger LOG = LoggerFactory.getLogger(DormantAlertServiceImpl.class); + + @Resource(name = "accountMngDAO") + private AccountMngDAO accountMngDAO; + + @Resource(name = "accountMngPolicyService") + private AccountMngPolicyService accountMngPolicyService; + + @Resource(name = "sndngMailRegistService") + private EgovSndngMailRegistService sndngMailRegistService; + + @Override + public int sendDormantPreAlerts() throws Exception { + AccountMngPolicyVO policy = accountMngPolicyService.selectPolicy(); + + // 정책 알림 시점 3단계(중복 제거). D-Day(0) 포함. + List stages = new ArrayList(); + addStage(stages, policy.getAlertD1()); + addStage(stages, policy.getAlertD2()); + addStage(stages, policy.getAlertD3()); + + String adminEmail = policy.getAdminEmail(); + int sent = 0; + + for (Integer before : stages) { + List targets; + try { + targets = accountMngDAO.selectDormantAlertTargets(before.intValue()); + } catch (Exception e) { + LOG.error("[개인정보/#10] 사전알림 대상 조회 오류(D-{}) — 건너뜀: {}", before, e.getMessage()); + continue; + } + if (targets == null) { + continue; + } + for (DormantAccountStatusVO t : targets) { + sent += sendOne(t, before.intValue(), adminEmail); + } + } + LOG.info("[개인정보/#10] 휴면 사전알림 발송 완료 — {}건", Integer.valueOf(sent)); + return sent; + } + + private void addStage(List stages, int before) { + Integer v = Integer.valueOf(before); + if (before >= 0 && !stages.contains(v)) { + stages.add(v); + } + } + + /** 본인 + 관리자 발송(각각 fail-safe). 발송 성공 건수 반환. */ + private int sendOne(DormantAccountStatusVO t, int before, String adminEmail) { + int cnt = 0; + String dLabel = before == 0 ? "D-Day" : ("D-" + before); + String subject = "[문화품앗이] 장기 미접속 계정 휴면 전환 사전 안내 (" + dLabel + ")"; + String body = (t.getUserNm() == null ? "" : t.getUserNm()) + "(" + t.getAccountId() + + ") 계정이 미접속 " + t.getNoConnectDays() + "일로 곧 휴면 전환됩니다. 로그인 시 유지됩니다."; + + // 본인 발송(대상 이메일이 조회 VO에 없으면 관리자에게만 — 취급자 메일 컬럼 미보유 방어) + cnt += sendMail(t.getAccountId(), subject, body); + // 관리자 발송(#10 핵심 — 관리자 알림 추가) + if (adminEmail != null && adminEmail.trim().length() > 0) { + cnt += sendMail(adminEmail, subject + " - 관리자통지", body); + } + return cnt; + } + + private int sendMail(String recipient, String subject, String body) { + if (recipient == null || recipient.trim().length() == 0) { + return 0; + } + try { + SndngMailVO vo = new SndngMailVO(); + vo.setDsptchPerson("webmaster"); + vo.setRecptnPerson(recipient); + vo.setSj(subject); + vo.setEmailCn(body); + vo.setAtchFileId(""); + sndngMailRegistService.insertSndngMail(vo); + return 1; + } catch (Exception e) { + // 개별 발송 실패는 로깅 후 계속(배치 본류 비차단) + LOG.error("[개인정보/#10] 사전알림 발송 실패 — 계속: {}", e.getMessage()); + return 0; + } + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessDetectionDAO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessDetectionDAO.java new file mode 100644 index 0000000..8bb331c --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessDetectionDAO.java @@ -0,0 +1,52 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 비정상 접근 탐지 5유형 집계 DAO (#35). + * + *

PERSONAL_DATA_ACCESS_LOG 표준 컬럼만 SELECT(별도 탐지 테이블 없음, 위·변조 방지). + * statement는 이미 등록된 {@code PrivacyAccessLog_SQL_Mysql.xml} + * (namespace {@code PersonalDataAccessLogDAO})에 정의 — 신규 sqlmap 등록 불요.

+ * + * @author arko-privacy-auditlog (Fork B - 6차) + * @since 2026-07-21 + */ +@Repository("abnormalAccessDetectionDAO") +public class AbnormalAccessDetectionDAO extends EgovComAbstractDAO { + + /** ① 비인가접근시도(처리결과 F) 총건수. */ + public int countUnauthorized(Map param) { + Object o = selectByPk("PersonalDataAccessLogDAO.detectUnauthorized", param); + return o == null ? 0 : ((Integer) o).intValue(); + } + + /** ② 대량조회 의심 접속자 목록({conectId, cnt}). */ + @SuppressWarnings("unchecked") + public List> selectMassQuery(Map param) { + return list("PersonalDataAccessLogDAO.detectMassQuery", param); + } + + /** ③ 비정상시간대접속 총건수. */ + public int countOffHours(Map param) { + Object o = selectByPk("PersonalDataAccessLogDAO.detectOffHours", param); + return o == null ? 0 : ((Integer) o).intValue(); + } + + /** ④ 계정공유의심 접속자 목록({conectId, ipCnt}). */ + @SuppressWarnings("unchecked") + public List> selectAccountSharing(Map param) { + return list("PersonalDataAccessLogDAO.detectAccountSharing", param); + } + + /** ⑤ 인증실패반복 접속자 목록({conectId, cnt}). */ + @SuppressWarnings("unchecked") + public List> selectRepeatedAuthFailure(Map param) { + return list("PersonalDataAccessLogDAO.detectRepeatedAuthFailure", param); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessDetectionService.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessDetectionService.java new file mode 100644 index 0000000..2151e0c --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessDetectionService.java @@ -0,0 +1,35 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.List; + +/** + * 비정상 접근 탐지 서비스 (#35, SER-002). + * + *

기간 내 접속기록을 5유형(비인가/대량조회/비정상시간대/계정공유/인증실패반복)으로 탐지한다. + * 정기점검 리포트(#43)의 이상징후 집계와 비정상접근 알림함(#49)에서 사용한다.

+ * + * @author arko-privacy-auditlog (Fork B - 6차) + * @since 2026-07-21 + */ +public interface AbnormalAccessDetectionService { + + /** + * 기간 내 5유형 탐지 결과 목록. + * + * @param fromDt 시작일시(yyyy-MM-dd HH:mm:ss) + * @param toDt 종료일시(yyyy-MM-dd HH:mm:ss) + * @return 5유형 탐지 결과(유형별 건수·상세) + * @throws Exception 조회 오류 + */ + List detect(String fromDt, String toDt) throws Exception; + + /** + * 기간 내 탐지 결과를 리포트용 한 줄 요약 문자열로 반환한다(#43 이상징후 집계). + * + * @param fromDt 시작일시 + * @param toDt 종료일시 + * @return 요약 문자열(예: "비인가접근시도 3건; 대량조회 2명; ...") + * @throws Exception 조회 오류 + */ + String summarize(String fromDt, String toDt) throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessDetectionServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessDetectionServiceImpl.java new file mode 100644 index 0000000..448e936 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessDetectionServiceImpl.java @@ -0,0 +1,120 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Resource; + +import org.springframework.stereotype.Service; + +/** + * 비정상 접근 탐지 서비스 구현 (#35). + * + *

임계치는 {@link AbnormalAccessPolicy}(기본값 + 정책 주입점)에서 취득한다. + * 탐지는 PERSONAL_DATA_ACCESS_LOG 표준 컬럼 집계로만 수행하며 개인정보 본문을 읽지 않는다.

+ * + * @author arko-privacy-auditlog (Fork B - 6차) + * @since 2026-07-21 + */ +@Service("abnormalAccessDetectionService") +public class AbnormalAccessDetectionServiceImpl implements AbnormalAccessDetectionService { + + @Resource(name = "abnormalAccessDetectionDAO") + private AbnormalAccessDetectionDAO abnormalAccessDetectionDAO; + + /** 탐지 임계치 정책(기본값). TODO: 정책 테이블/관리자 설정(#13) 연동 시 주입 교체. */ + private AbnormalAccessPolicy policy = new AbnormalAccessPolicy(); + + public void setPolicy(AbnormalAccessPolicy policy) { + this.policy = policy; + } + + @Override + public List detect(String fromDt, String toDt) throws Exception { + Map p = new HashMap(); + p.put("fromDt", fromDt); + p.put("toDt", toDt); + p.put("massThreshold", Integer.valueOf(policy.getMassQueryThreshold())); + p.put("offHoursStart", Integer.valueOf(policy.getOffHoursStart())); + p.put("offHoursEnd", Integer.valueOf(policy.getOffHoursEnd())); + p.put("sharingThreshold", Integer.valueOf(policy.getSharingIpThreshold())); + p.put("authFailThreshold", Integer.valueOf(policy.getAuthFailThreshold())); + + List results = new ArrayList(); + + // ① 비인가접근시도 + int unauth = abnormalAccessDetectionDAO.countUnauthorized(p); + results.add(new AbnormalAccessResultVO(AbnormalAccessType.UNAUTHORIZED_ACCESS, unauth, + unauth + "건")); + + // ② 대량조회(의심 접속자 목록) + List> mass = abnormalAccessDetectionDAO.selectMassQuery(p); + results.add(new AbnormalAccessResultVO(AbnormalAccessType.MASS_QUERY, size(mass), + offenders(mass, "cnt", "건"))); + + // ③ 비정상시간대접속 + int off = abnormalAccessDetectionDAO.countOffHours(p); + results.add(new AbnormalAccessResultVO(AbnormalAccessType.OFF_HOURS_ACCESS, off, off + "건")); + + // ④ 계정공유의심(의심 접속자 목록) + List> sharing = abnormalAccessDetectionDAO.selectAccountSharing(p); + results.add(new AbnormalAccessResultVO(AbnormalAccessType.ACCOUNT_SHARING, size(sharing), + offenders(sharing, "ipCnt", "IP"))); + + // ⑤ 인증실패반복(의심 접속자 목록) + List> authFail = abnormalAccessDetectionDAO.selectRepeatedAuthFailure(p); + results.add(new AbnormalAccessResultVO(AbnormalAccessType.REPEATED_AUTH_FAILURE, size(authFail), + offenders(authFail, "cnt", "회"))); + + return results; + } + + @Override + public String summarize(String fromDt, String toDt) throws Exception { + List results = detect(fromDt, toDt); + StringBuilder sb = new StringBuilder(); + for (AbnormalAccessResultVO r : results) { + if (sb.length() > 0) { + sb.append("; "); + } + sb.append(r.getType().getLabel()).append(" ").append(r.getCount()); + // 건수형(비인가/시간대)은 '건', 대상자형은 '명' + if (r.getType() == AbnormalAccessType.UNAUTHORIZED_ACCESS + || r.getType() == AbnormalAccessType.OFF_HOURS_ACCESS) { + sb.append("건"); + } else { + sb.append("명"); + } + } + return sb.length() == 0 ? "(이상징후 없음)" : sb.toString(); + } + + private static int size(List> list) { + return list == null ? 0 : list.size(); + } + + /** 상위 몇 명의 접속자ID + 값을 요약(개인정보 본문 미포함, 최대 5명). */ + private static String offenders(List> list, String valKey, String unit) { + if (list == null || list.isEmpty()) { + return "0명"; + } + StringBuilder sb = new StringBuilder(); + int shown = 0; + for (Map m : list) { + if (shown >= 5) { + sb.append(" 외 ").append(list.size() - 5).append("명"); + break; + } + if (sb.length() > 0) { + sb.append(", "); + } + Object id = m.get("conectId"); + Object val = m.get(valKey); + sb.append(id).append("(").append(val).append(unit).append(")"); + shown++; + } + return sb.toString(); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessPolicy.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessPolicy.java new file mode 100644 index 0000000..8aee7c5 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessPolicy.java @@ -0,0 +1,44 @@ +package egovframework.com.cmm.privacy.auditlog; + +/** + * 비정상 접근 탐지 임계치 정책 (#35). + * + *

대량조회 임계·업무시간 범위·계정공유 IP수·인증실패 반복 임계를 담는다. + * 기본값을 제공하되, 관리자 설정 화면/정책 테이블 연동 시 이 값을 주입·교체한다. + * (임계치 하드코딩 금지 원칙 — 현재는 기본값 + setter 주입점.)

+ * + * @author arko-privacy-auditlog (Fork B - 6차) + * @since 2026-07-21 + */ +public class AbnormalAccessPolicy { + + /** 대량조회 임계(단위기간 조회 건수 초과 시 의심). 기본 1000건. */ + private int massQueryThreshold = 1000; + + /** 업무 시작시(포함). 기본 09시. */ + private int offHoursStart = 9; + + /** 업무 종료시(이 시각 이상은 업무외). 기본 18시. */ + private int offHoursEnd = 18; + + /** 계정공유의심 IP수 임계(초과 시 의심). 기본 1(=2개 이상 IP면 의심). */ + private int sharingIpThreshold = 1; + + /** 인증실패반복 임계(이상이면 의심). 기본 5회(#24 계정잠금 정책과 정합). */ + private int authFailThreshold = 5; + + public int getMassQueryThreshold() { return massQueryThreshold; } + public void setMassQueryThreshold(int v) { this.massQueryThreshold = v; } + + public int getOffHoursStart() { return offHoursStart; } + public void setOffHoursStart(int v) { this.offHoursStart = v; } + + public int getOffHoursEnd() { return offHoursEnd; } + public void setOffHoursEnd(int v) { this.offHoursEnd = v; } + + public int getSharingIpThreshold() { return sharingIpThreshold; } + public void setSharingIpThreshold(int v) { this.sharingIpThreshold = v; } + + public int getAuthFailThreshold() { return authFailThreshold; } + public void setAuthFailThreshold(int v) { this.authFailThreshold = v; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessResultVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessResultVO.java new file mode 100644 index 0000000..e532051 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessResultVO.java @@ -0,0 +1,41 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.io.Serializable; + +/** + * 비정상 접근 탐지 결과 1유형 (#35). + * + * @author arko-privacy-auditlog (Fork B - 6차) + * @since 2026-07-21 + */ +public class AbnormalAccessResultVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 탐지 유형. */ + private AbnormalAccessType type; + + /** 탐지 건수(유형별 발생 건수 또는 의심 대상자 수). */ + private int count; + + /** 상세 요약(의심 대상자 목록 등, 개인정보 본문 미포함). */ + private String detail; + + public AbnormalAccessResultVO() { + } + + public AbnormalAccessResultVO(AbnormalAccessType type, int count, String detail) { + this.type = type; + this.count = count; + this.detail = detail; + } + + public AbnormalAccessType getType() { return type; } + public void setType(AbnormalAccessType type) { this.type = type; } + + public int getCount() { return count; } + public void setCount(int count) { this.count = count; } + + public String getDetail() { return detail; } + public void setDetail(String detail) { this.detail = detail; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessType.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessType.java new file mode 100644 index 0000000..eccab47 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AbnormalAccessType.java @@ -0,0 +1,39 @@ +package egovframework.com.cmm.privacy.auditlog; + +/** + * 비정상 접근 탐지 5유형 (#35, SER-002). + * + *

개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 표준 컬럼 집계로 탐지한다. + * 각 유형의 임계치는 하드코딩하지 않고 정책값({@link AbnormalAccessPolicy})으로 주입한다.

+ * + * @author arko-privacy-auditlog (Fork B - 6차) + * @since 2026-07-21 + */ +public enum AbnormalAccessType { + + /** ① 비인가접근시도 — 처리결과 F(실패·차단). */ + UNAUTHORIZED_ACCESS("비인가접근시도"), + + /** ② 대량조회 — 단위기간 조회(S) 건수 임계 초과. */ + MASS_QUERY("대량조회"), + + /** ③ 비정상시간대접속 — 업무시간 외 처리. */ + OFF_HOURS_ACCESS("비정상시간대접속"), + + /** ④ 계정공유의심 — 동일 계정 상이 IP 다수. */ + ACCOUNT_SHARING("계정공유의심"), + + /** ⑤ 인증실패반복 — 접속자별 실패(F) 반복. */ + REPEATED_AUTH_FAILURE("인증실패반복"); + + private final String label; + + AbnormalAccessType(String label) { + this.label = label; + } + + /** 한글 표기(리포트·알림함 노출용). */ + public String getLabel() { + return label; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadHistoryDAO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadHistoryDAO.java new file mode 100644 index 0000000..889cd13 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadHistoryDAO.java @@ -0,0 +1,39 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 개인정보 접속기록 다운로드 이력 DAO (#42). + * + *

INSERT/SELECT 전용(위·변조 방지). statement는 이미 sql-map-config에 등록된 + * {@code PrivacyAccessLog_SQL_Mysql.xml}(namespace {@code PersonalDataAccessLogDAO})에 + * 함께 정의되어 있다 — 신규 sqlmap 등록 불요.

+ * + * @author arko-privacy-auditlog (Fork B - 6차) + * @since 2026-07-21 + */ +@Repository("accessLogDownloadHistoryDAO") +public class AccessLogDownloadHistoryDAO extends EgovComAbstractDAO { + + /** 다운로드 이력 1행 적재. */ + public void insertDownloadHistory(AccessLogDownloadHistoryVO vo) { + insert("PersonalDataAccessLogDAO.insertDownloadHistory", vo); + } + + /** 기간 내 다운로드 건수(정기점검 리포트 #43 연계). */ + public int countByPeriod(Map param) { + Object cnt = selectByPk("PersonalDataAccessLogDAO.countDownloadByPeriod", param); + return cnt == null ? 0 : ((Integer) cnt).intValue(); + } + + /** 기간 내 다운로드 이력 목록. */ + @SuppressWarnings("unchecked") + public List selectByPeriod(Map param) { + return list("PersonalDataAccessLogDAO.selectDownloadHistoryByPeriod", param); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadHistoryVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadHistoryVO.java new file mode 100644 index 0000000..db25b51 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadHistoryVO.java @@ -0,0 +1,59 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.io.Serializable; + +/** + * 개인정보 접속기록 다운로드 이력 VO (#42, SER-002). + * + *

다운로드 사유(필수)·다운로드자·일시·IP·건수 이력. 개인정보 본문 미보관 + * (검색조건 요약만). PDAL_DOWNLOAD_HISTORY 매핑.

+ * + * @author arko-privacy-auditlog (Fork B - 6차) + * @since 2026-07-21 + */ +public class AccessLogDownloadHistoryVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 다운로드 이력 일련번호(PK). */ + private long dlId; + + /** 다운로드자 계정ID. */ + private String downloaderId; + + /** 다운로드 일시(yyyy-MM-dd HH:mm:ss). */ + private String downloadDt; + + /** 다운로드자 접속 IP. */ + private String downloaderIp; + + /** 다운로드 사유(필수). */ + private String dlReason; + + /** 다운로드 검색조건 요약(개인정보 본문 미포함). */ + private String searchCond; + + /** 다운로드 건수. */ + private int resultCnt; + + public long getDlId() { return dlId; } + public void setDlId(long dlId) { this.dlId = dlId; } + + public String getDownloaderId() { return downloaderId; } + public void setDownloaderId(String downloaderId) { this.downloaderId = downloaderId; } + + public String getDownloadDt() { return downloadDt; } + public void setDownloadDt(String downloadDt) { this.downloadDt = downloadDt; } + + public String getDownloaderIp() { return downloaderIp; } + public void setDownloaderIp(String downloaderIp) { this.downloaderIp = downloaderIp; } + + public String getDlReason() { return dlReason; } + public void setDlReason(String dlReason) { this.dlReason = dlReason; } + + public String getSearchCond() { return searchCond; } + public void setSearchCond(String searchCond) { this.searchCond = searchCond; } + + public int getResultCnt() { return resultCnt; } + public void setResultCnt(int resultCnt) { this.resultCnt = resultCnt; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadService.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadService.java new file mode 100644 index 0000000..ec0013f --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadService.java @@ -0,0 +1,28 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.List; + +/** + * 개인정보 접속기록 다운로드 서비스 (#42, SER-002). + * + *

다운로드 시 사유 입력이 필수이며(미입력 시 차단), 다운로드자·일시·IP·사유·건수를 + * 이력(PDAL_DOWNLOAD_HISTORY)에 적재한다. 반환된 목록은 화면/CSV 생성에 사용한다.

+ * + * @author arko-privacy-auditlog (Fork B - 6차) + * @since 2026-07-21 + */ +public interface AccessLogDownloadService { + + /** + * 검색조건에 해당하는 접속기록을 다운로드용으로 조회하고 다운로드 이력을 적재한다. + * + * @param downloaderId 다운로드자 계정ID + * @param downloaderIp 다운로드자 접속 IP + * @param reason 다운로드 사유(필수 — 공백 시 {@link IllegalArgumentException}) + * @param searchVO 검색조건 + * @return 다운로드 대상 접속기록 목록 + * @throws Exception 조회/적재 오류 + */ + List downloadAccessLog(String downloaderId, String downloaderIp, + String reason, PersonalDataAccessLogSearchVO searchVO) throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadServiceImpl.java new file mode 100644 index 0000000..e94e080 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadServiceImpl.java @@ -0,0 +1,83 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.List; + +import javax.annotation.Resource; + +import org.springframework.stereotype.Service; + +/** + * 개인정보 접속기록 다운로드 서비스 구현 (#42). + * + *

사유 필수 검증 → 조회 → 다운로드 이력 적재 순으로 처리한다. + * 사유 미입력 시 예외로 다운로드 자체를 차단한다.

+ * + * @author arko-privacy-auditlog (Fork B - 6차) + * @since 2026-07-21 + */ +@Service("accessLogDownloadService") +public class AccessLogDownloadServiceImpl implements AccessLogDownloadService { + + /** 다운로드 최대 건수(과도한 대량 다운로드 방어). TODO: 정책 테이블화(#13). */ + private static final int MAX_DOWNLOAD_ROWS = 100000; + + @Resource(name = "personalDataAccessLogSearchService") + private PersonalDataAccessLogSearchService searchService; + + @Resource(name = "accessLogDownloadHistoryDAO") + private AccessLogDownloadHistoryDAO downloadHistoryDAO; + + @Override + public List downloadAccessLog(String downloaderId, String downloaderIp, + String reason, PersonalDataAccessLogSearchVO searchVO) throws Exception { + + // 1. 사유 필수 — 미입력 시 다운로드 차단 + if (reason == null || reason.trim().length() == 0) { + throw new IllegalArgumentException("다운로드 사유는 필수입니다."); + } + if (searchVO == null) { + searchVO = new PersonalDataAccessLogSearchVO(); + } + + // 2. 다운로드는 페이징 없이 조건 전체 — 전체 건수만큼 조회(상한 방어) + int totCnt = searchService.selectAccessLogListTotCnt(searchVO); + int fetch = totCnt <= 0 ? 1 : Math.min(totCnt, MAX_DOWNLOAD_ROWS); + searchVO.setPageIndex(1); + searchVO.setRecordCountPerPage(fetch); + List result = searchService.selectAccessLogList(searchVO); + + // 3. 다운로드 이력 적재(다운로드자·일시·IP·사유·건수). 개인정보 본문 미보관 — 검색조건 요약만. + AccessLogDownloadHistoryVO hist = new AccessLogDownloadHistoryVO(); + hist.setDownloaderId(downloaderId); + hist.setDownloaderIp(downloaderIp); + hist.setDlReason(reason.trim()); + hist.setSearchCond(summarizeCond(searchVO)); + hist.setResultCnt(result == null ? 0 : result.size()); + downloadHistoryDAO.insertDownloadHistory(hist); + + return result; + } + + /** 검색조건을 개인정보 본문 없이 요약 문자열로. */ + private String summarizeCond(PersonalDataAccessLogSearchVO vo) { + StringBuilder sb = new StringBuilder(); + appendCond(sb, "conectId", vo.getConectId()); + appendCond(sb, "jobSeCode", vo.getJobSeCode()); + appendCond(sb, "trgetId", vo.getTrgetId()); + appendCond(sb, "conectIp", vo.getConectIp()); + appendCond(sb, "dmlGubun", vo.getDmlGubun()); + appendCond(sb, "processResult", vo.getProcessResult()); + appendCond(sb, "fromDt", vo.getFromDt()); + appendCond(sb, "toDt", vo.getToDt()); + return sb.length() == 0 ? "(전체)" : sb.toString(); + } + + private void appendCond(StringBuilder sb, String key, String val) { + if (val != null && val.trim().length() > 0) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(key).append("=").append(val.trim()); + } + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogQueue.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogQueue.java new file mode 100644 index 0000000..806b08a --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogQueue.java @@ -0,0 +1,146 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.annotation.Resource; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.stereotype.Component; + +/** + * 개인정보 접속기록 비동기 적재 큐 (개발항목 #33, SFR-004 / DBR-001). + * + *

목적: 접속기록 적재(직전 해시 조회 → 봉인 → INSERT)를 업무 트랜잭션과 분리해 + * 개인정보 처리 서비스의 응답시간에 영향을 주지 않게 한다({@link PersonalDataLogAspect}는 + * {@link #enqueue}만 호출하고 즉시 반환).

+ * + *

순번·해시체인 무결성(#39): 컨슈머는 단일 스레드다. 큐에서 하나씩 꺼내 + * {@link PersonalDataLogService#persistNow}를 순차 호출하므로 {@code PREV_HASH_VAL}이 항상 + * 직전 레코드를 정확히 가리킨다. 병렬 컨슈머를 두지 않는다(전체 순번 보장).

+ * + *

드롭 금지·백프레셔: 큐가 가득 차면 레코드를 버리지 않고 {@code offer(timeout)}로 + * 대기(백프레셔)한다. enqueue는 업무 트랜잭션 커밋 이후(Aspect의 finally) 호출되므로 짧은 + * 대기가 업무 데이터 정합성을 해치지 않는다.

+ * + *

종료 flush: 컨테이너 종료 시 잔여 큐를 모두 적재한 뒤 스레드를 정리한다(유실 방지).

+ * + * @author arko-privacy-auditlog (Fork B) + * @since 2026-07-21 + */ +@Component("accessLogQueue") +public class AccessLogQueue { + + private static final Log LOG = LogFactory.getLog(AccessLogQueue.class); + + /** 큐 용량(백프레셔 경계). 초과 시 드롭하지 않고 대기한다. */ + private static final int CAPACITY = 10000; + /** enqueue 대기 1회 최대 시간(초). 초과하면 경고 로깅 후 재대기(무한 유실 방지). */ + private static final long OFFER_WAIT_SEC = 5L; + /** 종료 시 컨슈머 스레드 join 최대 대기(ms). */ + private static final long SHUTDOWN_JOIN_MS = 10000L; + + private final BlockingQueue queue = + new LinkedBlockingQueue(CAPACITY); + + @Resource(name = "personalDataLogService") + private PersonalDataLogService personalDataLogService; + + private volatile boolean running = false; + private Thread consumer; + + @PostConstruct + public void start() { + running = true; + consumer = new Thread(new Runnable() { + public void run() { + consumeLoop(); + } + }, "pdal-accesslog-consumer"); + consumer.setDaemon(true); + consumer.start(); + LOG.info("[개인정보/#33] 접속기록 비동기 컨슈머 기동 — 단일 스레드, capacity=" + CAPACITY); + } + + /** + * 접속기록 1건을 큐에 적재한다(비동기, 드롭 금지). + * 큐 포화 시 백프레셔로 대기한다. 종료 진행 중이면 인라인 적재로 유실을 방지한다. + */ + public void enqueue(PersonalDataAccessLogVO vo) { + if (vo == null) { + return; + } + try { + boolean queued = false; + while (running && !queued) { + queued = queue.offer(vo, OFFER_WAIT_SEC, TimeUnit.SECONDS); + if (!queued) { + LOG.warn("[개인정보/#33] 접속기록 큐 포화(size=" + queue.size() + + ") — 컨슈머 지연. 드롭하지 않고 계속 대기."); + } + } + if (!queued) { + // 종료 중(running=false) — 컨슈머가 이미 멈췄으므로 단일 라이터 보장 하에 인라인 적재. + personalDataLogService.persistNow(vo); + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + LOG.error("[개인정보/#33] 접속기록 enqueue 인터럽트 — 인라인 적재 시도.", ie); + try { + personalDataLogService.persistNow(vo); + } catch (Exception e) { + LOG.error("[개인정보/#33] 인라인 적재 실패", e); + } + } + } + + /** 단일 컨슈머 루프 — 큐에서 하나씩 꺼내 순차 적재(순번 보장). */ + private void consumeLoop() { + while (running || !queue.isEmpty()) { + try { + PersonalDataAccessLogVO vo = queue.poll(1, TimeUnit.SECONDS); + if (vo != null) { + personalDataLogService.persistNow(vo); + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + // 종료 신호 — 남은 큐를 마저 비우고 루프 조건에서 빠져나간다. + } catch (Exception e) { + LOG.error("[개인정보/#33] 접속기록 적재 처리 실패(로깅 후 계속)", e); + } + } + LOG.info("[개인정보/#33] 접속기록 비동기 컨슈머 종료 — 잔여 큐 flush 완료."); + } + + @PreDestroy + public void stop() { + running = false; + if (consumer != null) { + consumer.interrupt(); + try { + consumer.join(SHUTDOWN_JOIN_MS); // 잔여 flush 대기 + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + // join 타임아웃으로 미처리분이 남았다면 인라인 flush(유실 방지). + List remaining = new ArrayList(); + queue.drainTo(remaining); + for (int i = 0; i < remaining.size(); i++) { + try { + personalDataLogService.persistNow(remaining.get(i)); + } catch (Exception e) { + LOG.error("[개인정보/#33] 종료 flush 적재 실패", e); + } + } + if (!remaining.isEmpty()) { + LOG.info("[개인정보/#33] 종료 시 잔여 " + remaining.size() + "건 인라인 flush 완료."); + } + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/ClientIpResolver.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/ClientIpResolver.java new file mode 100644 index 0000000..69c3845 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/ClientIpResolver.java @@ -0,0 +1,205 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.servlet.http.HttpServletRequest; + +/** + * 클라이언트 실제 IP 취득 보정 유틸리티. + * + *

문화품앗이 개인정보보호 기능개선 사업(SFR-004 / SER-002) — 개발항목 #34.

+ * + *

AS-IS 격차: 기존 {@code IpObtainInterceptor}는 {@code request.getRemoteAddr()}만 + * 사용하고, 그나마 인증된 사용자(loginVO != null)일 때만 IP를 세팅한다. + * Apache+L4 이중구성에서는 전 레코드가 L4 IP로 고정되고, 비인가 접근 시도의 IP는 남지 않는다.

+ * + *

보정 규칙: 프록시 계층이 전달하는 헤더를 우선순위대로 확인하되, + * 직접 접속지({@code getRemoteAddr()})가 신뢰 프록시 대역일 때만 헤더를 신뢰한다 + * (헤더 위조 방지). 신뢰 프록시가 아니면 헤더를 무시하고 원격 주소를 사용한다. + * 인증 여부와 무관하게 취득한다.

+ * + *
+ * 확인 순서: X-Forwarded-For → Proxy-Client-IP → WL-Proxy-Client-IP
+ *          → HTTP_CLIENT_IP → HTTP_X_FORWARDED_FOR → getRemoteAddr()
+ * 
+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.07.21    URP     최초 생성 (개인정보보호 기능개선 사업 #34)
+ * 
+ */ +public final class ClientIpResolver { + + private ClientIpResolver() { } + + /** 프록시 전달 IP 헤더 확인 순서. */ + private static final String[] IP_HEADER_CANDIDATES = { + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_CLIENT_IP", + "HTTP_X_FORWARDED_FOR" + }; + + private static final String UNKNOWN = "unknown"; + /** IPv6 루프백 정규화 대상. */ + private static final String IPV6_LOOPBACK = "0:0:0:0:0:0:0:1"; + private static final String IPV4_LOOPBACK = "127.0.0.1"; + + /** + * 신뢰 프록시 대역(CIDR). getRemoteAddr()가 이 대역에 속할 때만 전달 헤더를 신뢰한다. + * TODO: 운영/발주기관 확인 — Apache+L4 구성의 실제 프록시 대역으로 확정하고 정책 테이블 연동. + * (기본값은 사설망 + 루프백. 운영 프록시 대역 미확정 상태의 안전측 기본값) + */ + private static volatile List trustedProxyCidrs = new ArrayList(Arrays.asList( + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16" + )); + + /** + * 신뢰 프록시 대역을 정책값으로 주입한다(관리자 설정/정책 테이블 연동 지점). + * @param cidrs CIDR 표기 대역 목록(예: "10.0.0.0/8"). null/빈 목록이면 무시. + */ + public static void setTrustedProxyCidrs(List cidrs) { + if (cidrs != null && !cidrs.isEmpty()) { + trustedProxyCidrs = new ArrayList(cidrs); + } + } + + /** + * 요청에서 클라이언트 실제 IP를 보정 취득한다. + * + * @param request HTTP 요청(널이면 UNKNOWN) + * @return 보정된 클라이언트 IP. 확정 불가 시 원격 주소, 그마저 없으면 "unknown". + */ + public static String getClientIp(HttpServletRequest request) { + if (request == null) { + return UNKNOWN; + } + + String remoteAddr = normalize(request.getRemoteAddr()); + boolean trustedHop = isTrustedProxy(remoteAddr); + + // 직접 접속지가 신뢰 프록시일 때만 전달 헤더를 신뢰한다(위조 방지). + if (trustedHop) { + for (String header : IP_HEADER_CANDIDATES) { + String value = request.getHeader(header); + String picked = pickClientIp(value); + if (picked != null) { + return normalize(picked); + } + } + } + + // 헤더 미신뢰/부재 → 원격 주소(현행 동작) 폴백. + return (remoteAddr == null || remoteAddr.length() == 0) ? UNKNOWN : remoteAddr; + } + + /** + * 헤더 값에서 최좌측(최초 클라이언트) IP를 선별한다. + * X-Forwarded-For: client, proxy1, proxy2 → client. + * @return 유효 IP 또는 null(빈 값/"unknown"). + */ + private static String pickClientIp(String headerValue) { + if (isBlank(headerValue) || UNKNOWN.equalsIgnoreCase(headerValue.trim())) { + return null; + } + String first = headerValue.contains(",") ? headerValue.split(",")[0] : headerValue; + first = first.trim(); + if (first.length() == 0 || UNKNOWN.equalsIgnoreCase(first)) { + return null; + } + return first; + } + + /** IPv6 루프백을 IPv4 루프백으로 정규화(적재값 일관성). */ + private static String normalize(String ip) { + if (ip == null) { + return null; + } + String trimmed = ip.trim(); + if (IPV6_LOOPBACK.equals(trimmed) || "::1".equals(trimmed)) { + return IPV4_LOOPBACK; + } + return trimmed; + } + + private static boolean isBlank(String s) { + return s == null || s.trim().length() == 0; + } + + /** 원격 주소가 신뢰 프록시 대역(CIDR)에 속하는지 판정(IPv4 기준, IPv6는 루프백만 신뢰). */ + private static boolean isTrustedProxy(String remoteAddr) { + if (remoteAddr == null || remoteAddr.length() == 0) { + return false; + } + if (IPV4_LOOPBACK.equals(remoteAddr)) { + return true; + } + for (String cidr : trustedProxyCidrs) { + if (ipv4InCidr(remoteAddr, cidr)) { + return true; + } + } + return false; + } + + /** + * IPv4 주소의 CIDR 포함 여부. 파싱 불가(IPv6 등)면 false. + */ + private static boolean ipv4InCidr(String ip, String cidr) { + try { + int slash = cidr.indexOf('/'); + if (slash < 0) { + return false; + } + String network = cidr.substring(0, slash); + int prefix = Integer.parseInt(cidr.substring(slash + 1)); + long ipLong = ipv4ToLong(ip); + long netLong = ipv4ToLong(network); + if (ipLong < 0 || netLong < 0) { + return false; + } + long mask = (prefix == 0) ? 0L : (0xFFFFFFFFL << (32 - prefix)) & 0xFFFFFFFFL; + return (ipLong & mask) == (netLong & mask); + } catch (RuntimeException e) { + return false; + } + } + + /** 점 표기 IPv4를 부호없는 long으로. 형식 오류(IPv6 포함) 시 -1. */ + private static long ipv4ToLong(String ip) { + String[] octets = ip.split("\\."); + if (octets.length != 4) { + return -1L; + } + long result = 0L; + for (String octet : octets) { + int v = Integer.parseInt(octet); + if (v < 0 || v > 255) { + return -1L; + } + result = (result << 8) | v; + } + return result & 0xFFFFFFFFL; + } + + /** + * 단위 검증 예시(서블릿 컨테이너 없이 CIDR 판정 로직만 확인). + * javac 후 {@code java egovframework.com.cmm.privacy.auditlog.ClientIpResolver} 로 실행. + */ + public static void main(String[] args) { + System.out.println("127.0.0.1 in 127.0.0.0/8 = " + ipv4InCidr("127.0.0.1", "127.0.0.0/8")); // true + System.out.println("10.20.30.40 in 10.0.0.0/8 = " + ipv4InCidr("10.20.30.40", "10.0.0.0/8")); // true + System.out.println("8.8.8.8 in 10.0.0.0/8 = " + ipv4InCidr("8.8.8.8", "10.0.0.0/8")); // false + System.out.println("172.20.0.1 in 172.16.0.0/12= " + ipv4InCidr("172.20.0.1", "172.16.0.0/12"));// true + System.out.println("pickClientIp('1.2.3.4, 10.0.0.1') = " + pickClientIp("1.2.3.4, 10.0.0.1")); // 1.2.3.4 + System.out.println("normalize('0:0:0:0:0:0:0:1') = " + normalize("0:0:0:0:0:0:0:1")); // 127.0.0.1 + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/HashChainService.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/HashChainService.java new file mode 100644 index 0000000..99368ba --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/HashChainService.java @@ -0,0 +1,164 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; + +/** + * 개인정보 접속기록 위·변조 방지 SHA-256 해시체인 서비스. + * + *

문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #39 (DBR-001). + * 각 레코드 적재 시 직전 레코드의 해시({@code PREV_HASH_VAL})를 결합해 현재 레코드 + * 해시({@code HASH_VAL})를 생성함으로써, 임의의 레코드 위·변조/삭제 시 이후 체인이 + * 전부 불일치하도록 만든다.

+ * + *

해시 결합 규칙 (DDL 컬럼과 정확히 일치)

+ *
+ *   payload = CONECT_ID | CONECT_DT | CONECT_IP | JOB_SE_CODE | TRGET_ID | DML_GUBUN | PREV_HASH_VAL
+ *   HASH_VAL = SHA-256(payload) 의 소문자 Hex
+ * 
+ *
    + *
  • 구분자는 파이프({@code |}) 고정. 각 필드 null 은 빈 문자열로 치환.
  • + *
  • 필수 5항목(ID/일시/IP/업무유형/처리대상) + 처리구분 + 직전 해시를 결합한다.
  • + *
  • {@code CONECT_DT}는 DB 적재 문자열과 동일 형식이어야 재검증 시 해시가 일치한다.
  • + *
+ * + *

순번 보장 전제

+ * 비동기 큐 컨슈머가 단일 스레드로 순차 INSERT해야 {@code PREV_HASH_VAL}이 정확히 + * 직전 레코드를 가리킨다. 병렬 적재가 필요하면 {@code CONECT_ID} 등 파티션 단위 체인으로 + * 설계를 조정한다. + * + *

본 클래스는 외부 라이브러리 의존 없이 {@link java.security.MessageDigest} 만 사용한다. + * 무상태(stateless)이며 스레드-세이프하다.

+ * + * @author arko-privacy-auditlog (Fork B) + * @since 2026-07-21 + */ +public class HashChainService { + + /** 체인의 첫 레코드(직전 해시 없음)에 사용하는 제네시스 값. */ + public static final String GENESIS_HASH = ""; + + private static final char FIELD_DELIM = '|'; + + /** + * 접속기록 한 건의 해시를 계산한다. + * + * @param entry 해시 대상 레코드 (필수 필드 세팅되어 있어야 함) + * @param prevHash 직전 레코드의 HASH_VAL. 체인 첫 레코드면 null 또는 {@link #GENESIS_HASH} + * @return 소문자 SHA-256 Hex (64자) + */ + public String computeHash(PersonalDataAccessLogVO entry, String prevHash) { + if (entry == null) { + throw new IllegalArgumentException("접속기록 VO는 null일 수 없습니다."); + } + StringBuilder payload = new StringBuilder() + .append(nvl(entry.getConectId())).append(FIELD_DELIM) + .append(nvl(entry.getConectDt())).append(FIELD_DELIM) + .append(nvl(entry.getConectIp())).append(FIELD_DELIM) + .append(nvl(entry.getJobSeCode())).append(FIELD_DELIM) + .append(nvl(entry.getTrgetId())).append(FIELD_DELIM) + .append(nvl(entry.getDmlGubun())).append(FIELD_DELIM) + .append(nvl(prevHash)); + return sha256Hex(payload.toString()); + } + + /** + * 레코드에 해시체인 값(PREV_HASH_VAL, HASH_VAL)을 채워 넣는다. + * + * @param entry 적재 대상 레코드 + * @param prevHash 직전 레코드 해시 (첫 레코드면 null/{@link #GENESIS_HASH}) + * @return 계산된 현재 레코드 해시(HASH_VAL) + */ + public String linkAndSeal(PersonalDataAccessLogVO entry, String prevHash) { + String prev = nvl(prevHash); + entry.setPrevHashVal(prev.isEmpty() ? null : prev); + String hash = computeHash(entry, prev); + entry.setHashVal(hash); + return hash; + } + + /** + * 순차 정렬된 레코드 목록(오래된→최신)의 해시체인을 처음부터 재계산해 검증한다. + * 무결성 검증 배치(월 1회)에서 사용한다. + * + * @param orderedChain 적재 순서(LOG_ID/순번 오름차순)로 정렬된 레코드 목록 + * @return 최초 불일치가 발견된 인덱스(0-base). 전부 정상이면 -1 + */ + public int verifyChain(List orderedChain) { + if (orderedChain == null || orderedChain.isEmpty()) { + return -1; + } + String prevHash = GENESIS_HASH; + for (int i = 0; i < orderedChain.size(); i++) { + PersonalDataAccessLogVO cur = orderedChain.get(i); + + // ① 저장된 PREV_HASH_VAL 이 실제 직전 해시와 일치하는지 (연결 무결성) + String storedPrev = nvl(cur.getPrevHashVal()); + if (!storedPrev.equals(prevHash)) { + return i; + } + // ② 저장된 HASH_VAL 이 재계산 결과와 일치하는지 (내용 무결성) + String recomputed = computeHash(cur, prevHash); + if (!recomputed.equalsIgnoreCase(nvl(cur.getHashVal()))) { + return i; + } + prevHash = nvl(cur.getHashVal()); + } + return -1; + } + + /** 문자열의 SHA-256 소문자 Hex(64자)를 반환한다. */ + public String sha256Hex(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)); + hex.append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + // SHA-256은 모든 JVM 표준 제공 알고리즘 — 실무상 발생 불가 + throw new IllegalStateException("SHA-256 알고리즘을 사용할 수 없습니다.", e); + } + } + + private static String nvl(String s) { + return s == null ? "" : s; + } + + /** + * 단위 검증용 데모. 정상 체인 검증 → 중간 레코드 변조 후 재검증 시 해당 인덱스 탐지 확인. + *
+     *   javac HashChainService.java PersonalDataAccessLogVO.java
+     *   java  egovframework.com.cmm.privacy.auditlog.HashChainService
+     * 
+ */ + public static void main(String[] args) { + HashChainService svc = new HashChainService(); + java.util.List chain = new java.util.ArrayList(); + + String prev = GENESIS_HASH; + for (int i = 1; i <= 3; i++) { + PersonalDataAccessLogVO vo = new PersonalDataAccessLogVO(); + vo.setLogId(i); + vo.setConectId("admin0" + i); + vo.setConectDt("2026-07-21 09:0" + i + ":00"); + vo.setConectIp("10.0.0." + i); + vo.setJobSeCode("MBER_SEARCH"); + vo.setTrgetId("USR-100" + i); + vo.setDmlGubun("S"); + prev = svc.linkAndSeal(vo, prev); + chain.add(vo); + } + + System.out.println("[정상 체인] 불일치 인덱스(-1=정상) = " + svc.verifyChain(chain)); + + // 2번째 레코드 처리대상 위변조 (해시 재봉인 없이 필드만 변경) + chain.get(1).setTrgetId("USR-9999"); + System.out.println("[변조 후] 불일치 인덱스(기대 1) = " + svc.verifyChain(chain)); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingDAO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingDAO.java new file mode 100644 index 0000000..86e0246 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingDAO.java @@ -0,0 +1,28 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 개인정보 처리 업무유형 매핑(PDAL_JOB_MAPPING) 조회 DAO. + * + *

문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #32.

+ * + * @author arko-privacy-auditlog (Fork B) + * @since 2026-07-21 + */ +@Repository("jobMappingDAO") +public class JobMappingDAO extends EgovComAbstractDAO { + + /** + * 사용중(USE_AT='Y')인 업무유형 매핑 전체를 우선순위(SORT_ORDR) 오름차순으로 조회한다. + * (Resolver가 기동 시 1회 로드해 메모리 캐시) + */ + @SuppressWarnings("unchecked") + public List selectJobMappingList() { + return list("JobMappingDAO.selectJobMappingList", null); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingResolver.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingResolver.java new file mode 100644 index 0000000..45df757 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingResolver.java @@ -0,0 +1,112 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.ArrayList; +import java.util.List; + +import javax.annotation.Resource; + +import org.springframework.stereotype.Component; + +/** + * 업무유형 매핑 해석기 (개발항목 #32). + * + *

PDAL_JOB_MAPPING 테이블을 메모리에 캐시하고, 접속기록 Aspect(#31)가 가로챈 + * (대상클래스, 메서드명) 조합을 업무코드/업무명으로 해석한다.

+ * + *

매칭 규칙: USE_AT='Y' 매핑을 SORT_ORDR 오름차순으로 순회하여, 대상 클래스가 + * 일치(단순명 또는 FQN)하고 메서드명이 METHOD_PTTRN으로 시작(또는 '*')하는 첫 매핑을 반환. + * 구체 규칙(낮은 SORT_ORDR)이 폴백('*', 높은 SORT_ORDR)보다 우선한다.

+ * + * @author arko-privacy-auditlog (Fork B) + * @since 2026-07-21 + */ +@Component("jobMappingResolver") +public class JobMappingResolver { + + @Resource(name = "jobMappingDAO") + private JobMappingDAO jobMappingDAO; + + /** 캐시(불변 스냅샷 참조 교체 방식). */ + private volatile List cache; + + /** + * (대상클래스 단순명, 메서드명)에 매칭되는 업무유형 매핑을 반환한다. + * 매칭이 없으면 null (호출측이 메서드명 기반 기본값으로 폴백). + */ + public JobMappingVO resolve(String targetSimpleClassName, String methodName) { + if (targetSimpleClassName == null || methodName == null) { + return null; + } + List list = getCache(); + for (int i = 0; i < list.size(); i++) { + JobMappingVO m = list.get(i); + if (classMatches(m.getTargetClass(), targetSimpleClassName) + && methodMatches(m.getMethodPttrn(), methodName)) { + return m; + } + } + return null; + } + + /** 매핑이 변경되면(관리자 화면 등) 호출해 캐시를 무효화한다. */ + public void refresh() { + this.cache = null; + } + + private List getCache() { + List local = this.cache; + if (local == null) { + synchronized (this) { + if (this.cache == null) { + List loaded = null; + try { + loaded = jobMappingDAO.selectJobMappingList(); + } catch (Exception e) { + // 매핑 로드 실패는 접속기록 수집 자체를 막지 않는다(메서드명 폴백으로 동작). + loaded = null; + } + this.cache = (loaded == null) ? new ArrayList() : loaded; + } + local = this.cache; + } + } + return local; + } + + private boolean classMatches(String pattern, String simpleName) { + if (pattern == null) { + return false; + } + // 단순명 정확 일치, 또는 FQN 등록 시 '.단순명'으로 끝나는지 + return pattern.equals(simpleName) || pattern.endsWith("." + simpleName); + } + + private boolean methodMatches(String pattern, String methodName) { + if (pattern == null || "*".equals(pattern)) { + return true; + } + return methodName.startsWith(pattern); + } + + /** + * 메서드명 접두로 처리구분(DML_GUBUN)을 유도한다. + * insert/regist/creat/add/save→I, update/modify/chang→U, delete/remov→D, 그 외 S(조회). + */ + public static String deriveDmlGubun(String methodName) { + if (methodName == null) { + return "S"; + } + String m = methodName.toLowerCase(); + if (m.startsWith("insert") || m.startsWith("regist") || m.startsWith("creat") + || m.startsWith("add") || m.startsWith("save")) { + return "I"; + } + if (m.startsWith("update") || m.startsWith("modify") || m.startsWith("chang")) { + return "U"; + } + if (m.startsWith("delete") || m.startsWith("remov")) { + return "D"; + } + return "S"; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java new file mode 100644 index 0000000..c240599 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java @@ -0,0 +1,62 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.io.Serializable; + +/** + * 개인정보 처리 업무유형 매핑(PDAL_JOB_MAPPING) VO. + * + *

문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #32 (SFR-004 / SER-002). + * 접속기록 수집({@link PersonalDataLogAspect})이 가로챈 서비스 메서드를 + * 업무코드/업무명으로 해석하는 매핑 한 행.

+ * + * @author arko-privacy-auditlog (Fork B) + * @since 2026-07-21 + */ +public class JobMappingVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private long mappingId; + /** 대상 서비스 impl 단순 클래스명 또는 FQN */ + private String targetClass; + /** 메서드명 접두 매칭(또는 '*' 전체) */ + private String methodPttrn; + /** 수행업무 유형 코드 */ + private String jobSeCode; + /** 수행업무 유형(화면·기능명) */ + private String jobSeNm; + /** 처리구분 강제값 S/I/U/D (null이면 메서드명으로 유도) */ + private String dmlGubun; + /** 매칭 우선순위(낮을수록 우선) */ + private int sortOrdr; + /** 사용여부 Y/N */ + private String useAt; + private String rm; + + public long getMappingId() { return mappingId; } + public void setMappingId(long mappingId) { this.mappingId = mappingId; } + + public String getTargetClass() { return targetClass; } + public void setTargetClass(String targetClass) { this.targetClass = targetClass; } + + public String getMethodPttrn() { return methodPttrn; } + public void setMethodPttrn(String methodPttrn) { this.methodPttrn = methodPttrn; } + + public String getJobSeCode() { return jobSeCode; } + public void setJobSeCode(String jobSeCode) { this.jobSeCode = jobSeCode; } + + public String getJobSeNm() { return jobSeNm; } + public void setJobSeNm(String jobSeNm) { this.jobSeNm = jobSeNm; } + + public String getDmlGubun() { return dmlGubun; } + public void setDmlGubun(String dmlGubun) { this.dmlGubun = dmlGubun; } + + public int getSortOrdr() { return sortOrdr; } + public void setSortOrdr(int sortOrdr) { this.sortOrdr = sortOrdr; } + + public String getUseAt() { return useAt; } + public void setUseAt(String useAt) { this.useAt = useAt; } + + public String getRm() { return rm; } + public void setRm(String rm) { this.rm = rm; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogDAO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogDAO.java new file mode 100644 index 0000000..6e23c98 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogDAO.java @@ -0,0 +1,34 @@ +package egovframework.com.cmm.privacy.auditlog; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 적재 DAO. + * + *

문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #31 (SFR-004 / DBR-001 / SER-002). + * 애플리케이션 계층에는 INSERT/SELECT만 정의한다 — UPDATE/DELETE statement는 두지 않는다 + * (유일한 삭제 경로는 #37 파티션 DROP 배치). 위·변조 방지 원칙(DBR-001).

+ * + * @author arko-privacy-auditlog (Fork B) + * @since 2026-07-21 + */ +@Repository("personalDataAccessLogDAO") +public class PersonalDataAccessLogDAO extends EgovComAbstractDAO { + + /** 접속기록 1건 적재. */ + public void insertAccessLog(PersonalDataAccessLogVO vo) { + insert("PersonalDataAccessLogDAO.insertAccessLog", vo); + } + + /** + * 해시체인 연결을 위한 직전(최신) 레코드의 HASH_VAL을 조회한다. + * 아직 레코드가 없으면 null(제네시스). + * + *

순번 보장 전제: 단일 스레드 순차 적재(서비스 recordAccess 동기화). #33 비동기 단일컨슈머 큐 대체 예정.

+ */ + public String selectLastHashVal() { + return (String) selectByPk("PersonalDataAccessLogDAO.selectLastHashVal", null); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchDAO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchDAO.java new file mode 100644 index 0000000..1aec1fa --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchDAO.java @@ -0,0 +1,33 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 조회·검색 DAO (#41). + * + *

SELECT 전용(위·변조 방지 원칙: 이 테이블에 UPDATE/DELETE statement 미정의). + * statement는 이미 sql-map-config에 등록된 {@code PrivacyAccessLog_SQL_Mysql.xml} + * (namespace {@code PersonalDataAccessLogDAO})에 추가되어 있다 — 신규 sqlmap 등록 불요.

+ * + * @author arko-privacy-auditlog (Fork B - 5차) + * @since 2026-07-21 + */ +@Repository("personalDataAccessLogSearchDAO") +public class PersonalDataAccessLogSearchDAO extends EgovComAbstractDAO { + + /** 복합 검색조건 + 페이징 목록 조회. */ + @SuppressWarnings("unchecked") + public List selectAccessLogList(PersonalDataAccessLogSearchVO searchVO) { + return list("PersonalDataAccessLogDAO.selectAccessLogList", searchVO); + } + + /** 복합 검색조건 전체 건수(페이징 total). */ + public int selectAccessLogListCount(PersonalDataAccessLogSearchVO searchVO) { + Object cnt = selectByPk("PersonalDataAccessLogDAO.selectAccessLogListCount", searchVO); + return cnt == null ? 0 : ((Integer) cnt).intValue(); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchService.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchService.java new file mode 100644 index 0000000..d59b5e9 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchService.java @@ -0,0 +1,34 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.List; + +/** + * 개인정보 접속기록 조회·검색 서비스 (#41). + * + *

문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002). + * 접속자 ID·업무유형·처리대상·IP·처리구분·처리결과·기간 복합 검색 + 페이징 조회를 제공한다.

+ * + * @author arko-privacy-auditlog (Fork B - 5차) + * @since 2026-07-21 + */ +public interface PersonalDataAccessLogSearchService { + + /** + * 검색조건에 맞는 접속기록 목록을 페이징 조회한다. + * (검색 결과 총 건수는 {@link PersonalDataAccessLogSearchVO#getTotalRecordCount()}에 세팅) + * + * @param searchVO 검색조건·페이징 + * @return 접속기록 목록 + * @throws Exception 조회 오류 + */ + List selectAccessLogList(PersonalDataAccessLogSearchVO searchVO) throws Exception; + + /** + * 검색조건에 맞는 접속기록 전체 건수. + * + * @param searchVO 검색조건 + * @return 총 건수 + * @throws Exception 조회 오류 + */ + int selectAccessLogListTotCnt(PersonalDataAccessLogSearchVO searchVO) throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchServiceImpl.java new file mode 100644 index 0000000..f4bd912 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchServiceImpl.java @@ -0,0 +1,39 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.util.List; + +import javax.annotation.Resource; + +import org.springframework.stereotype.Service; + +/** + * 개인정보 접속기록 조회·검색 서비스 구현 (#41). + * + * @author arko-privacy-auditlog (Fork B - 5차) + * @since 2026-07-21 + */ +@Service("personalDataAccessLogSearchService") +public class PersonalDataAccessLogSearchServiceImpl implements PersonalDataAccessLogSearchService { + + @Resource(name = "personalDataAccessLogSearchDAO") + private PersonalDataAccessLogSearchDAO personalDataAccessLogSearchDAO; + + @Override + public List selectAccessLogList(PersonalDataAccessLogSearchVO searchVO) throws Exception { + // 페이징 offset 계산(1-base 페이지 → 0-base offset) + int pageIndex = searchVO.getPageIndex() < 1 ? 1 : searchVO.getPageIndex(); + int perPage = searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage(); + searchVO.setFirstIndex((pageIndex - 1) * perPage); + searchVO.setRecordCountPerPage(perPage); + + int totCnt = personalDataAccessLogSearchDAO.selectAccessLogListCount(searchVO); + searchVO.setTotalRecordCount(totCnt); + + return personalDataAccessLogSearchDAO.selectAccessLogList(searchVO); + } + + @Override + public int selectAccessLogListTotCnt(PersonalDataAccessLogSearchVO searchVO) throws Exception { + return personalDataAccessLogSearchDAO.selectAccessLogListCount(searchVO); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchVO.java new file mode 100644 index 0000000..14bd81f --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchVO.java @@ -0,0 +1,84 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.io.Serializable; + +/** + * 개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 조회·검색 조건 VO (#41). + * + *

문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002) — 조회·검색 개선. + * 현행 접속기록 조회는 검색조건이 빈약(수단 LIKE + 날짜뿐)하여 접속자 ID 검색이 불가했다. + * 본 VO는 접속자 ID·업무유형·처리대상·IP·처리구분·처리결과·기간을 복합 검색조건으로 제공하며, + * {@code IDX_PDAL_01(CONECT_ID, CONECT_DT)} 인덱스로 접속자 ID 검색 결함을 해소한다.

+ * + *

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

+ * + * @author arko-privacy-auditlog (Fork B - 5차) + * @since 2026-07-21 + */ +public class PersonalDataAccessLogSearchVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 접속자 ID(부분/정확 일치) — 현행 결함(ID 검색 불가) 해소 */ + private String conectId; + /** 수행업무 유형 코드 */ + private String jobSeCode; + /** 처리대상 식별자 */ + private String trgetId; + /** 접속지 IP */ + private String conectIp; + /** 처리구분 S:조회 I:등록 U:수정 D:삭제 */ + private String dmlGubun; + /** 처리결과 S:성공 F:실패/차단(이상징후 포함) */ + private String processResult; + /** 조회 시작일시(yyyy-MM-dd 또는 yyyy-MM-dd HH:mm:ss) */ + private String fromDt; + /** 조회 종료일시 */ + private String toDt; + + // ---- 페이징 ---- + /** 현재 페이지 번호(1-base) */ + private int pageIndex = 1; + /** 페이지당 레코드 수 */ + private int recordCountPerPage = 10; + /** 조회 시작 offset(0-base) = (pageIndex-1) * recordCountPerPage */ + private int firstIndex = 0; + /** 전체 레코드 수(조회 후 세팅) */ + private int totalRecordCount = 0; + + public String getConectId() { return conectId; } + public void setConectId(String conectId) { this.conectId = conectId; } + + public String getJobSeCode() { return jobSeCode; } + public void setJobSeCode(String jobSeCode) { this.jobSeCode = jobSeCode; } + + public String getTrgetId() { return trgetId; } + public void setTrgetId(String trgetId) { this.trgetId = trgetId; } + + public String getConectIp() { return conectIp; } + public void setConectIp(String conectIp) { this.conectIp = conectIp; } + + public String getDmlGubun() { return dmlGubun; } + public void setDmlGubun(String dmlGubun) { this.dmlGubun = dmlGubun; } + + public String getProcessResult() { return processResult; } + public void setProcessResult(String processResult) { this.processResult = processResult; } + + 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; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogVO.java new file mode 100644 index 0000000..9d859b0 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogVO.java @@ -0,0 +1,86 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.io.Serializable; + +/** + * 개인정보 접속기록 표준 테이블(PERSONAL_DATA_ACCESS_LOG) VO. + * + *

문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #30/#39 (SFR-004 / DBR-001 / SER-002). + * 해시체인({@link HashChainService})의 해시 대상 필드를 정의한다.

+ * + *

주의: {@code conectDt}는 DB에 적재되는 문자열 표현과 동일한 형식 + * (기본 {@code yyyy-MM-dd HH:mm:ss})으로 세팅해야 무결성 재검증 시 해시가 일치한다.

+ * + * @author arko-privacy-auditlog (Fork B) + * @since 2026-07-21 + */ +public class PersonalDataAccessLogVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 로그 일련번호 (DB AUTO_INCREMENT) */ + private long logId; + /** ① 사용자 식별자(ID) */ + private String conectId; + /** ② 접속일시 (DB 적재 문자열과 동일 형식) */ + private String conectDt; + /** ③ 접속지 IP */ + private String conectIp; + /** ④ 수행업무 유형 코드 */ + private String jobSeCode; + /** ④ 수행업무 유형 명 */ + private String jobSeNm; + /** ⑤ 처리대상 식별자 */ + private String trgetId; + /** ⑤ 처리대상 건수/범위 */ + private String trgetCn; + /** 처리구분 S:조회 I:등록 U:수정 D:삭제 */ + private String dmlGubun; + /** 처리결과 S:성공 F:실패/차단 */ + private String processResult; + /** 처리자 구분 */ + private String userSe; + /** 위·변조 방지 해시(SHA-256 Hex) */ + private String hashVal; + /** 직전 레코드 해시(해시체인) */ + private String prevHashVal; + + public long getLogId() { return logId; } + public void setLogId(long logId) { this.logId = logId; } + + public String getConectId() { return conectId; } + public void setConectId(String conectId) { this.conectId = conectId; } + + public String getConectDt() { return conectDt; } + public void setConectDt(String conectDt) { this.conectDt = conectDt; } + + public String getConectIp() { return conectIp; } + public void setConectIp(String conectIp) { this.conectIp = conectIp; } + + public String getJobSeCode() { return jobSeCode; } + public void setJobSeCode(String jobSeCode) { this.jobSeCode = jobSeCode; } + + public String getJobSeNm() { return jobSeNm; } + public void setJobSeNm(String jobSeNm) { this.jobSeNm = jobSeNm; } + + public String getTrgetId() { return trgetId; } + public void setTrgetId(String trgetId) { this.trgetId = trgetId; } + + public String getTrgetCn() { return trgetCn; } + public void setTrgetCn(String trgetCn) { this.trgetCn = trgetCn; } + + public String getDmlGubun() { return dmlGubun; } + public void setDmlGubun(String dmlGubun) { this.dmlGubun = dmlGubun; } + + public String getProcessResult() { return processResult; } + public void setProcessResult(String processResult) { this.processResult = processResult; } + + public String getUserSe() { return userSe; } + public void setUserSe(String userSe) { this.userSe = userSe; } + + public String getHashVal() { return hashVal; } + public void setHashVal(String hashVal) { this.hashVal = hashVal; } + + public String getPrevHashVal() { return prevHashVal; } + public void setPrevHashVal(String prevHashVal) { this.prevHashVal = prevHashVal; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataLogAspect.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataLogAspect.java new file mode 100644 index 0000000..609c2d4 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataLogAspect.java @@ -0,0 +1,256 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.lang.reflect.Method; +import java.text.SimpleDateFormat; +import java.util.Collection; +import java.util.Date; +import java.util.Map; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.aspectj.lang.ProceedingJoinPoint; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import egovframework.com.cmm.LoginVO; + +/** + * 개인정보 접속기록 수집 Aspect (개발항목 #31, SFR-004 / DBR-001 / SER-002). + * + *

한정 수집: {@code COMTNSYSLOG}의 select* 전 서비스 대상 방식이 아니라, + * 개인정보 처리 서비스로 한정한 pointcut(스키마 기반 {@code }, context-syslogaop.xml)에만 + * 적용된다. 가로챈 메서드는 업무유형 매핑 테이블({@link JobMappingResolver}, #32)로 업무코드를 얻고, + * 접속 IP({@link ClientIpResolver}, #34)와 해시체인({@link HashChainService}, #39)을 결합해 + * {@code PERSONAL_DATA_ACCESS_LOG}에 적재한다.

+ * + *

본문 미적재 원칙: 처리 파라미터(개인정보 본문)는 로그에 담지 않는다. 식별자(TRGET_ID)만 + * 베스트에포트로 취득한다(#36).

+ * + *

수집 범위(#36): 회원·업체 2개 지점 한정에서 전 개인정보 처리 서비스로 확장. + * pointcut 대상은 {@code context-syslogaop.xml}에 개인정보 취급 서비스 impl만 열거(과수집 방지). + * 신규 화면 추가 시에는 이 pointcut(또는 대상 서비스에 편입)과 업무유형 매핑({@link JobMappingResolver})만 + * 갱신하면 되고 컨트롤러 수동 호출은 불요하다.

+ * + *

TRGET_ID 취득 규약(#36): 가로챈 메서드 인자에서 식별자만 베스트에포트로 추출한다. + * ① 인자가 VO/도메인 빈이면 우선순위 getter({@code getEsntlId→getUniqId→getMberId→getEmplyrId→ + * getEntrprsId→getGroupId→getUserId→getId→getSn}) 중 첫 non-blank 값. ② 인자가 {@code Map}이면 + * 동일 우선순위 키. ③ 원시 {@code String}/{@code Number} 인자는 식별자·검색어 구분이 불가하므로 + * 스킵(검색어·개인정보 본문 오적재 방지). 어느 것도 못 얻으면 {@code null}(fail-safe). + * 처리대상 건수(TRGET_CN)는 반환값이 {@code Collection}이면 "N건"으로 기록한다.

+ * + *

스키마 기반 AOP(@AspectJ 미사용)라 {@code aspectj-autoproxy}를 전역 활성화하지 않는다 — + * 기존 프로젝트 프록시 동작에 영향을 주지 않기 위함.

+ * + * @author arko-privacy-auditlog (Fork B) + * @since 2026-07-21 + */ +@Component("personalDataLogAspect") +public class PersonalDataLogAspect { + + private static final Log LOG = LogFactory.getLog(PersonalDataLogAspect.class); + + private static final String DT_FORMAT = "yyyy-MM-dd HH:mm:ss"; + private static final String ANONYMOUS = "ANONYMOUS"; + + @Resource(name = "jobMappingResolver") + private JobMappingResolver jobMappingResolver; + + /** #33 비동기 적재 큐 — Aspect는 enqueue만 하고 즉시 반환(업무 응답시간 무영향). */ + @Resource(name = "accessLogQueue") + private AccessLogQueue accessLogQueue; + + /** + * 스키마 기반 {@code } 대상 메서드. + * 업무는 정상 수행하고, 성공/예외 여부와 무관하게 접속기록을 남긴다(기록 실패는 업무에 영향 없음). + */ + public Object around(ProceedingJoinPoint pjp) throws Throwable { + String processResult = "S"; + Object result = null; + try { + result = pjp.proceed(); + return result; + } catch (Throwable t) { + processResult = "F"; + throw t; + } finally { + try { + writeAccessLog(pjp, processResult, result); + } catch (Exception ignore) { + // 접속기록 수집은 업무 흐름을 절대 방해하지 않는다. + LOG.error("[개인정보/#31] 접속기록 수집 중 예외(무시)", ignore); + } + } + } + + private void writeAccessLog(ProceedingJoinPoint pjp, String processResult, Object result) { + HttpServletRequest request = currentRequest(); + // 웹 요청 컨텍스트가 없으면(배치/스케줄러 등) 접속기록 대상이 아니다. + if (request == null) { + return; + } + + String targetSimpleName = pjp.getTarget().getClass().getSimpleName(); + String methodName = pjp.getSignature().getName(); + + JobMappingVO mapping = jobMappingResolver.resolve(targetSimpleName, methodName); + + String jobSeCode; + String jobSeNm; + String dmlGubun; + if (mapping != null) { + jobSeCode = mapping.getJobSeCode(); + jobSeNm = mapping.getJobSeNm(); + dmlGubun = (mapping.getDmlGubun() != null && mapping.getDmlGubun().length() > 0) + ? mapping.getDmlGubun() + : JobMappingResolver.deriveDmlGubun(methodName); + } else { + // pointcut 범위 안이지만 매핑 미등록 → 메서드명 기반 기본값(누락 없이 수집) + jobSeCode = targetSimpleName; + jobSeNm = targetSimpleName + "." + methodName; + dmlGubun = JobMappingResolver.deriveDmlGubun(methodName); + } + + LoginVO login = currentLogin(request); + + PersonalDataAccessLogVO vo = new PersonalDataAccessLogVO(); + vo.setConectId(login != null && notBlank(login.getId()) ? login.getId() : ANONYMOUS); + vo.setConectDt(new SimpleDateFormat(DT_FORMAT).format(new Date())); + vo.setConectIp(safeIp(request)); + vo.setJobSeCode(jobSeCode); + vo.setJobSeNm(jobSeNm); + // #36 처리대상 식별자(본문 아님)·건수 베스트에포트 취득 — 규약은 클래스 Javadoc 참조. + vo.setTrgetId(extractTargetId(pjp.getArgs())); + vo.setTrgetCn(extractTargetCount(result)); + vo.setDmlGubun(dmlGubun); + vo.setProcessResult(processResult); + vo.setUserSe(login != null ? login.getUserSe() : null); + + // #33 비동기 큐에 적재만 하고 즉시 반환 — 봉인+INSERT는 큐 단일 컨슈머가 순차 수행(순번 보장). + accessLogQueue.enqueue(vo); + } + + private HttpServletRequest currentRequest() { + RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); + if (attrs instanceof ServletRequestAttributes) { + return ((ServletRequestAttributes) attrs).getRequest(); + } + return null; + } + + private LoginVO currentLogin(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return null; + } + Object o = session.getAttribute("loginVO"); + return (o instanceof LoginVO) ? (LoginVO) o : null; + } + + private String safeIp(HttpServletRequest request) { + try { + String ip = ClientIpResolver.getClientIp(request); + return notBlank(ip) ? ip : "-"; + } catch (Exception e) { + return "-"; + } + } + + private static boolean notBlank(String s) { + return s != null && s.trim().length() > 0; + } + + // ===================== #36 처리대상 식별자/건수 취득 ===================== + + /** 우선순위 식별자 getter(개인정보 본문이 아닌 식별자만). */ + private static final String[] ID_GETTERS = { + "getEsntlId", "getUniqId", "getMberId", "getEmplyrId", "getEntrprsId", + "getGroupId", "getUserId", "getId", "getSn" + }; + /** Map 파라미터용 우선순위 식별자 키(ID_GETTERS와 동일 순서). */ + private static final String[] ID_KEYS = { + "esntlId", "uniqId", "mberId", "emplyrId", "entrprsId", + "groupId", "userId", "id", "sn" + }; + /** TRGET_ID 컬럼 길이(VARCHAR(50)) 보호. */ + private static final int TRGET_ID_MAX = 50; + + /** + * 인자에서 처리대상 식별자를 베스트에포트로 추출한다(규약: 클래스 Javadoc). + * VO는 우선순위 getter, Map은 우선순위 키. 원시 String/Number 인자는 스킵(검색어 오적재 방지). + * 실패 시 null(fail-safe) — 접속기록 자체는 계속 적재된다. + */ + private String extractTargetId(Object[] args) { + if (args == null) { + return null; + } + for (Object arg : args) { + if (arg == null) { + continue; + } + String id = null; + if (arg instanceof Map) { + id = fromMap((Map) arg); + } else if (isDomainBean(arg)) { + id = fromBean(arg); + } + // 원시 String/Number/Collection/배열 인자는 식별자 확정 불가 → 스킵(본문·검색어 오적재 방지) + if (notBlank(id)) { + return id.trim().length() > TRGET_ID_MAX ? id.trim().substring(0, TRGET_ID_MAX) : id.trim(); + } + } + return null; + } + + /** 반환값이 컬렉션이면 처리대상 건수(범위)를 "N건"으로 기록한다. */ + private String extractTargetCount(Object result) { + if (result instanceof Collection) { + return ((Collection) result).size() + "건"; + } + return null; + } + + /** VO/도메인 빈 판정 — String/래퍼/Collection/Map/배열은 제외. */ + private boolean isDomainBean(Object arg) { + if (arg instanceof String || arg instanceof Number || arg instanceof Boolean + || arg instanceof Character || arg instanceof Collection || arg instanceof Map + || arg.getClass().isArray()) { + return false; + } + return true; + } + + private String fromBean(Object bean) { + for (String getter : ID_GETTERS) { + try { + Method m = bean.getClass().getMethod(getter); + Object v = m.invoke(bean); + if (v != null && notBlank(String.valueOf(v))) { + return String.valueOf(v); + } + } catch (Exception ignore) { + // 해당 getter 없음/접근 불가 → 다음 후보 + } + } + return null; + } + + private String fromMap(Map map) { + for (String key : ID_KEYS) { + try { + Object v = map.get(key); + if (v != null && notBlank(String.valueOf(v))) { + return String.valueOf(v); + } + } catch (Exception ignore) { + // 일부 Map 구현의 키 타입 불일치 등 → 다음 후보 + } + } + return null; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataLogService.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataLogService.java new file mode 100644 index 0000000..46bd756 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataLogService.java @@ -0,0 +1,32 @@ +package egovframework.com.cmm.privacy.auditlog; + +/** + * 개인정보 접속기록 적재 서비스 (개발항목 #31 / 비동기화 #33). + * + * @author arko-privacy-auditlog (Fork B) + * @since 2026-07-21 + */ +public interface PersonalDataLogService { + + /** + * 접속기록 1건을 적재한다. + * + * @deprecated #33 비동기화 이후 표준 적재 경로는 {@link AccessLogQueue#enqueue}이다. + * 이 동기 메서드는 큐를 우회하므로 비동기 컨슈머와 동시에 호출하면 + * 해시체인 순번(단일 라이터 전제)이 깨질 수 있다. 호환용으로만 남긴다. + * @param vo 적재 대상(해시 제외 필드 세팅되어 있어야 함) + */ + @Deprecated + void recordAccess(PersonalDataAccessLogVO vo); + + /** + * 접속기록 1건을 해시체인으로 봉인해 실제로 DB에 적재한다(직전 해시 조회→봉인→INSERT). + * + *

단일 라이터 전제: 이 메서드는 반드시 접속기록 비동기 큐의 + * 단일 컨슈머 스레드에서만 호출되어야 {@code PREV_HASH_VAL}이 정확히 직전 + * 레코드를 가리킨다(#33/#39). 종료 시 잔여 큐 flush도 컨슈머 정지 후 단일 스레드로 수행된다.

+ * + * @param vo 적재 대상(해시 제외 필드 세팅되어 있어야 함) + */ + void persistNow(PersonalDataAccessLogVO vo); +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataLogServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataLogServiceImpl.java new file mode 100644 index 0000000..cc2982c --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataLogServiceImpl.java @@ -0,0 +1,60 @@ +package egovframework.com.cmm.privacy.auditlog; + +import javax.annotation.Resource; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.stereotype.Service; + +/** + * 개인정보 접속기록 적재 서비스 구현 (개발항목 #31 / 해시체인 #39 / 비동기화 #33). + * + *

순번 보장(#33): 실제 적재({@link #persistNow})는 접속기록 비동기 큐 + * ({@link AccessLogQueue})의 단일 컨슈머 스레드에서만 호출된다. 단일 라이터이므로 + * "직전 해시 조회 → 봉인 → INSERT"가 순서를 지켜 해시체인({@link HashChainService})의 + * {@code PREV_HASH_VAL}이 정확히 직전 레코드를 가리킨다. 방어적으로 {@code synchronized}를 + * 유지해 종료 시 flush 등 예외적 동시 호출에도 순번이 깨지지 않게 한다.

+ * + *

업무 트랜잭션과 분리(#33): Aspect는 큐에 enqueue만 하고 즉시 반환하므로 개인정보 처리 + * 서비스의 응답시간에 적재 I/O가 영향을 주지 않는다.

+ * + * @author arko-privacy-auditlog (Fork B) + * @since 2026-07-21 + */ +@Service("personalDataLogService") +public class PersonalDataLogServiceImpl implements PersonalDataLogService { + + private static final Log LOG = LogFactory.getLog(PersonalDataLogServiceImpl.class); + + @Resource(name = "personalDataAccessLogDAO") + private PersonalDataAccessLogDAO accessLogDAO; + + /** 무상태·스레드세이프 유틸 — 빈 미등록이므로 직접 인스턴스화(2차 산출물 규약과 동일). */ + private final HashChainService hashChainService = new HashChainService(); + + /** + * @deprecated #33 이후 표준 경로는 큐 enqueue다. 호환용으로 남기며 즉시 동기 적재로 위임한다. + * 비동기 컨슈머 가동 중 직접 호출하면 단일 라이터 전제가 깨질 수 있으니 사용을 지양한다. + */ + @Override + @Deprecated + public void recordAccess(PersonalDataAccessLogVO vo) { + persistNow(vo); + } + + @Override + public synchronized void persistNow(PersonalDataAccessLogVO vo) { + if (vo == null) { + return; + } + try { + String prevHash = accessLogDAO.selectLastHashVal(); // null이면 제네시스 + hashChainService.linkAndSeal(vo, prevHash); // PREV_HASH_VAL/HASH_VAL 세팅 + accessLogDAO.insertAccessLog(vo); + } catch (Exception e) { + // 접속기록 적재 실패가 업무 트랜잭션을 깨뜨리지 않도록 흡수하고 에러 로깅만 한다. + LOG.error("[개인정보/#31] 접속기록 적재 실패 — conectId=" + vo.getConectId() + + ", job=" + vo.getJobSeCode(), e); + } + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorChangeHistoryVO.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorChangeHistoryVO.java new file mode 100644 index 0000000..fd7c1d4 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorChangeHistoryVO.java @@ -0,0 +1,93 @@ +package egovframework.com.cmm.privacy.author; + +import java.io.Serializable; + +/** + * 권한 변경 이력 VO — AUTHOR_CHANGE_HISTORY(#4) 매핑 + 조회·검색(#6). + * 실제 DDL 컬럼(CHG_SE_CODE/CHG_RESN/CHANGER_UNIQ_ID 등)에 정합. + * + *
+ * 개정이력
+ *  2026.07.21  URP(Fork A 6차)  최초 생성(#3 승인 이력 적재 / #6 조회·다운로드)
+ * 
+ */ +public class AuthorChangeHistoryVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private long histId; + /** 변경 대상자 고유식별자 */ + private String trgetUniqId; + /** 변경 대상자 계정ID */ + private String trgetId; + /** 변경구분 G:부여 R:회수 M:변경 */ + private String chgSeCode; + private String beforeAuthor; + private String afterAuthor; + /** 변경 사유 */ + private String chgResn; + /** 승인자 계정ID */ + private String aprovlId; + /** 변경자(처리자) 고유식별자 */ + private String changerUniqId; + /** 변경자 계정ID */ + private String changerId; + /** 변경자 접속 IP */ + private String changerIp; + /** 변경 일시 */ + private String chgDt; + + // --- 검색(#6 대상자·변경자 양방향) / 페이징 --- + private String searchTrgetId; + private String searchChangerId; + private String searchChgSeCode; + private String fromDt; + private String toDt; + private int pageIndex = 1; + private int recordCountPerPage = 10; + private int firstIndex = 0; + private int totalRecordCount = 0; + + public long getHistId() { return histId; } + public void setHistId(long histId) { this.histId = histId; } + public String getTrgetUniqId() { return trgetUniqId; } + public void setTrgetUniqId(String trgetUniqId) { this.trgetUniqId = trgetUniqId; } + public String getTrgetId() { return trgetId; } + public void setTrgetId(String trgetId) { this.trgetId = trgetId; } + public String getChgSeCode() { return chgSeCode; } + public void setChgSeCode(String chgSeCode) { this.chgSeCode = chgSeCode; } + public String getBeforeAuthor() { return beforeAuthor; } + public void setBeforeAuthor(String beforeAuthor) { this.beforeAuthor = beforeAuthor; } + public String getAfterAuthor() { return afterAuthor; } + public void setAfterAuthor(String afterAuthor) { this.afterAuthor = afterAuthor; } + public String getChgResn() { return chgResn; } + public void setChgResn(String chgResn) { this.chgResn = chgResn; } + public String getAprovlId() { return aprovlId; } + public void setAprovlId(String aprovlId) { this.aprovlId = aprovlId; } + public String getChangerUniqId() { return changerUniqId; } + public void setChangerUniqId(String changerUniqId) { this.changerUniqId = changerUniqId; } + public String getChangerId() { return changerId; } + public void setChangerId(String changerId) { this.changerId = changerId; } + public String getChangerIp() { return changerIp; } + public void setChangerIp(String changerIp) { this.changerIp = changerIp; } + public String getChgDt() { return chgDt; } + public void setChgDt(String chgDt) { this.chgDt = chgDt; } + public String getSearchTrgetId() { return searchTrgetId; } + public void setSearchTrgetId(String searchTrgetId) { this.searchTrgetId = searchTrgetId; } + public String getSearchChangerId() { return searchChangerId; } + public void setSearchChangerId(String searchChangerId) { this.searchChangerId = searchChangerId; } + public String getSearchChgSeCode() { return searchChgSeCode; } + public void setSearchChgSeCode(String searchChgSeCode) { this.searchChgSeCode = searchChgSeCode; } + 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; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorChangeRequestVO.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorChangeRequestVO.java new file mode 100644 index 0000000..a284af3 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorChangeRequestVO.java @@ -0,0 +1,98 @@ +package egovframework.com.cmm.privacy.author; + +import java.io.Serializable; + +/** + * 권한 변경 신청·승인 워크플로 VO (개인정보보호 SFR-001 / #3). + * + *
+ * 개정이력
+ *  2026.07.21  URP(Fork A 6차)  최초 생성
+ * 
+ */ +public class AuthorChangeRequestVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 신청 일련번호(PK) */ + private long requestId; + /** 권한변경 대상자 고유식별자 */ + private String trgetUniqId; + /** 권한변경 대상자 계정ID */ + private String trgetId; + /** 신청구분 N:신설 C:변경 R:회수 */ + private String requestSe; + /** 신청 시점 현재 권한 스냅샷 */ + private String beforeAuthor; + /** 신청 목표 권한 */ + private String afterAuthor; + /** 신청 사유(필수) */ + private String requestResn; + /** 신청자 계정ID */ + private String reqsterId; + /** 신청자 고유식별자 */ + private String reqsterUniqId; + /** 신청 일시 */ + private String requestDt; + /** 처리상태 R:신청 A:승인 J:반려 */ + private String confmSttus; + /** 처리자 계정ID */ + private String confmId; + /** 처리자 고유식별자 */ + private String confmUniqId; + /** 처리 일시 */ + private String confmDt; + /** 반려 사유 */ + private String confmResn; + + // --- 검색/페이징 --- + private String searchSttus; + private String searchTrgetId; + private int pageIndex = 1; + private int recordCountPerPage = 10; + private int firstIndex = 0; + private int totalRecordCount = 0; + + public long getRequestId() { return requestId; } + public void setRequestId(long requestId) { this.requestId = requestId; } + public String getTrgetUniqId() { return trgetUniqId; } + public void setTrgetUniqId(String trgetUniqId) { this.trgetUniqId = trgetUniqId; } + public String getTrgetId() { return trgetId; } + public void setTrgetId(String trgetId) { this.trgetId = trgetId; } + public String getRequestSe() { return requestSe; } + public void setRequestSe(String requestSe) { this.requestSe = requestSe; } + public String getBeforeAuthor() { return beforeAuthor; } + public void setBeforeAuthor(String beforeAuthor) { this.beforeAuthor = beforeAuthor; } + public String getAfterAuthor() { return afterAuthor; } + public void setAfterAuthor(String afterAuthor) { this.afterAuthor = afterAuthor; } + public String getRequestResn() { return requestResn; } + public void setRequestResn(String requestResn) { this.requestResn = requestResn; } + public String getReqsterId() { return reqsterId; } + public void setReqsterId(String reqsterId) { this.reqsterId = reqsterId; } + public String getReqsterUniqId() { return reqsterUniqId; } + public void setReqsterUniqId(String reqsterUniqId) { this.reqsterUniqId = reqsterUniqId; } + public String getRequestDt() { return requestDt; } + public void setRequestDt(String requestDt) { this.requestDt = requestDt; } + public String getConfmSttus() { return confmSttus; } + public void setConfmSttus(String confmSttus) { this.confmSttus = confmSttus; } + public String getConfmId() { return confmId; } + public void setConfmId(String confmId) { this.confmId = confmId; } + public String getConfmUniqId() { return confmUniqId; } + public void setConfmUniqId(String confmUniqId) { this.confmUniqId = confmUniqId; } + public String getConfmDt() { return confmDt; } + public void setConfmDt(String confmDt) { this.confmDt = confmDt; } + public String getConfmResn() { return confmResn; } + public void setConfmResn(String confmResn) { this.confmResn = confmResn; } + public String getSearchSttus() { return searchSttus; } + public void setSearchSttus(String searchSttus) { this.searchSttus = searchSttus; } + public String getSearchTrgetId() { return searchTrgetId; } + public void setSearchTrgetId(String searchTrgetId) { this.searchTrgetId = searchTrgetId; } + public int getPageIndex() { return pageIndex; } + public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; } + public int getRecordCountPerPage() { return recordCountPerPage; } + public void setRecordCountPerPage(int recordCountPerPage) { this.recordCountPerPage = recordCountPerPage; } + public int getFirstIndex() { return firstIndex; } + public void setFirstIndex(int firstIndex) { this.firstIndex = firstIndex; } + public int getTotalRecordCount() { return totalRecordCount; } + public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadVO.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadVO.java new file mode 100644 index 0000000..2389af4 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadVO.java @@ -0,0 +1,40 @@ +package egovframework.com.cmm.privacy.author; + +import java.io.Serializable; + +/** + * 권한이력 다운로드 증빙 이력 VO (개인정보보호 SFR-001 / #6). + * 다운로드 사유 필수 + 다운로드자·일시·IP·건수 적재. + */ +public class AuthorHistDownloadVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private long dwldId; + private String downloaderId; + private String downloaderUniqId; + private String downloaderIp; + /** 다운로드 사유(필수) */ + private String dwldResn; + /** 검색조건 스냅샷 */ + private String searchCond; + private int dwldCnt; + private String dwldDt; + + public long getDwldId() { return dwldId; } + public void setDwldId(long dwldId) { this.dwldId = dwldId; } + public String getDownloaderId() { return downloaderId; } + public void setDownloaderId(String downloaderId) { this.downloaderId = downloaderId; } + public String getDownloaderUniqId() { return downloaderUniqId; } + public void setDownloaderUniqId(String downloaderUniqId) { this.downloaderUniqId = downloaderUniqId; } + public String getDownloaderIp() { return downloaderIp; } + public void setDownloaderIp(String downloaderIp) { this.downloaderIp = downloaderIp; } + public String getDwldResn() { return dwldResn; } + public void setDwldResn(String dwldResn) { this.dwldResn = dwldResn; } + public String getSearchCond() { return searchCond; } + public void setSearchCond(String searchCond) { this.searchCond = searchCond; } + public int getDwldCnt() { return dwldCnt; } + public void setDwldCnt(int dwldCnt) { this.dwldCnt = dwldCnt; } + public String getDwldDt() { return dwldDt; } + public void setDwldDt(String dwldDt) { this.dwldDt = dwldDt; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryDAO.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryDAO.java new file mode 100644 index 0000000..7a328bb --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryDAO.java @@ -0,0 +1,34 @@ +package egovframework.com.cmm.privacy.author; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 권한 변경 이력 조회·다운로드 DAO (개인정보보호 SFR-001 / #6). + * + *

AUTHOR_CHANGE_HISTORY 는 SELECT 전용(UPDATE/DELETE 미정의, 위·변조 억제 — + * 유일 삭제는 #5 보관기간 경과 파기 배치). 다운로드 증빙만 INSERT.

+ */ +@Repository("authorHistoryDAO") +public class AuthorHistoryDAO extends EgovComAbstractDAO { + + /** 권한 변경 이력 목록(대상자·변경자 양방향 검색 + 기간/구분 + 페이징) */ + @SuppressWarnings("unchecked") + public List selectHistoryList(AuthorChangeHistoryVO vo) { + return list("AuthorHistoryDAO.selectHistoryList", vo); + } + + /** 권한 변경 이력 전체 건수 */ + public int selectHistoryListCount(AuthorChangeHistoryVO vo) { + Object cnt = selectByPk("AuthorHistoryDAO.selectHistoryListCount", vo); + return cnt == null ? 0 : ((Integer) cnt).intValue(); + } + + /** 다운로드 증빙 이력 적재(사유 필수) */ + public void insertDownloadHistory(AuthorHistDownloadVO vo) { + insert("AuthorHistoryDAO.insertDownloadHistory", vo); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryService.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryService.java new file mode 100644 index 0000000..e4e1083 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryService.java @@ -0,0 +1,24 @@ +package egovframework.com.cmm.privacy.author; + +import java.util.List; + +/** + * 권한 변경 이력 조회·다운로드 서비스 (개인정보보호 SFR-001 / #6). + */ +public interface AuthorHistoryService { + + /** 이력 목록 조회(대상자·변경자 양방향 검색 + 페이징) */ + List selectHistoryList(AuthorChangeHistoryVO vo) throws Exception; + + /** 이력 목록 건수 */ + int selectHistoryListCount(AuthorChangeHistoryVO vo) throws Exception; + + /** + * 이력 다운로드용 데이터 추출 + 다운로드 이력 적재. + *

다운로드 사유(dwldResn)는 필수 — 미입력 시 예외로 차단한다.

+ * @param searchVo 검색조건(다운로드 대상 범위) + * @param download 다운로드 증빙(다운로드자·IP·사유) + * @return 추출된 이력 목록(파일 생성은 화면/컨트롤러 계층에서) + */ + List downloadHistory(AuthorChangeHistoryVO searchVo, AuthorHistDownloadVO download) throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryServiceImpl.java new file mode 100644 index 0000000..a74f68b --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryServiceImpl.java @@ -0,0 +1,51 @@ +package egovframework.com.cmm.privacy.author; + +import java.util.List; + +import javax.annotation.Resource; + +import org.springframework.stereotype.Service; + +/** + * 권한 변경 이력 조회·다운로드 서비스 구현 (개인정보보호 SFR-001 / #6). + */ +@Service("authorHistoryService") +public class AuthorHistoryServiceImpl implements AuthorHistoryService { + + @Resource(name = "authorHistoryDAO") + private AuthorHistoryDAO authorHistoryDAO; + + @Override + public List selectHistoryList(AuthorChangeHistoryVO vo) throws Exception { + int pageIndex = vo.getPageIndex() < 1 ? 1 : vo.getPageIndex(); + int perPage = vo.getRecordCountPerPage() < 1 ? 10 : vo.getRecordCountPerPage(); + vo.setFirstIndex((pageIndex - 1) * perPage); + vo.setRecordCountPerPage(perPage); + vo.setTotalRecordCount(authorHistoryDAO.selectHistoryListCount(vo)); + return authorHistoryDAO.selectHistoryList(vo); + } + + @Override + public int selectHistoryListCount(AuthorChangeHistoryVO vo) throws Exception { + return authorHistoryDAO.selectHistoryListCount(vo); + } + + @Override + public List downloadHistory(AuthorChangeHistoryVO searchVo, AuthorHistDownloadVO download) throws Exception { + // 다운로드 사유 필수 — 미입력 시 차단 + if (download == null || download.getDwldResn() == null || download.getDwldResn().trim().length() == 0) { + throw new IllegalArgumentException("다운로드 사유는 필수입니다."); + } + + // 다운로드 대상 전량 추출(페이징 없이) — 화면 계층이 파일로 변환 + searchVo.setFirstIndex(0); + searchVo.setRecordCountPerPage(Integer.MAX_VALUE); + List list = authorHistoryDAO.selectHistoryList(searchVo); + + // 다운로드 증빙 이력 적재(다운로드자·일시·IP·사유·건수) + download.setDwldCnt(list == null ? 0 : list.size()); + authorHistoryDAO.insertDownloadHistory(download); + + return list; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java new file mode 100644 index 0000000..bf4407b --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java @@ -0,0 +1,130 @@ +package egovframework.com.cmm.privacy.author; + +import java.util.List; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.RequestMapping; + +import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.privacy.auditlog.ClientIpResolver; + +/** + * 권한 신청·승인 워크플로 + 권한이력 조회·다운로드 컨트롤러 + * (개인정보보호 SFR-001 / #3·#6). + * + *

취급자 영역(/mnt) 백엔드 엔드포인트. 응답은 jsonView(화면 JSP는 #46 후속). + * 처리자/신청자 식별자와 IP는 세션 loginVO·ClientIpResolver(#34)에서 취득한다.

+ */ +@Controller +public class AuthorWorkflowController { + + protected static final String JSON = "jsonView"; + + @Resource(name = "authorWorkflowService") + private AuthorWorkflowService authorWorkflowService; + + @Resource(name = "authorHistoryService") + private AuthorHistoryService authorHistoryService; + + private LoginVO loginUser(HttpServletRequest request) { + Object o = request.getSession().getAttribute("loginVO"); + return (o instanceof LoginVO) ? (LoginVO) o : null; + } + + /** 권한 변경 신청 목록 */ + @RequestMapping(value = "/mnt/sec/authorRequest/list.do") + public String requestList(AuthorChangeRequestVO searchVO, ModelMap model) throws Exception { + List list = authorWorkflowService.selectRequestList(searchVO); + model.addAttribute("resultList", list); + model.addAttribute("totalCount", searchVO.getTotalRecordCount()); + return JSON; + } + + /** 권한 변경 신청 등록 */ + @RequestMapping(value = "/mnt/sec/authorRequest/submit.do") + public String submit(AuthorChangeRequestVO vo, HttpServletRequest request, ModelMap model) throws Exception { + LoginVO user = loginUser(request); + if (user != null) { + vo.setReqsterId(user.getId()); + vo.setReqsterUniqId(user.getUniqId()); + } + try { + authorWorkflowService.submitRequest(vo); + model.addAttribute("result", "success"); + } catch (IllegalArgumentException e) { + model.addAttribute("result", "fail"); + model.addAttribute("message", e.getMessage()); + } + return JSON; + } + + /** 승인 — 권한 반영 + 이력 적재 단일 트랜잭션 */ + @RequestMapping(value = "/mnt/sec/authorRequest/approve.do") + public String approve(@org.springframework.web.bind.annotation.RequestParam("requestId") long requestId, + HttpServletRequest request, ModelMap model) throws Exception { + LoginVO user = loginUser(request); + String confmId = user != null ? user.getId() : null; + String confmUniqId = user != null ? user.getUniqId() : null; + String ip = ClientIpResolver.getClientIp(request); + try { + authorWorkflowService.approveRequest(requestId, confmId, confmUniqId, ip); + model.addAttribute("result", "success"); + } catch (Exception e) { + model.addAttribute("result", "fail"); + model.addAttribute("message", e.getMessage()); + } + return JSON; + } + + /** 반려 — 사유 기록 */ + @RequestMapping(value = "/mnt/sec/authorRequest/reject.do") + public String reject(@org.springframework.web.bind.annotation.RequestParam("requestId") long requestId, + @org.springframework.web.bind.annotation.RequestParam(value = "confmResn", required = false) String confmResn, + HttpServletRequest request, ModelMap model) throws Exception { + LoginVO user = loginUser(request); + String confmId = user != null ? user.getId() : null; + authorWorkflowService.rejectRequest(requestId, confmId, confmResn); + model.addAttribute("result", "success"); + return JSON; + } + + /** 권한 변경 이력 조회(대상자·변경자 양방향 검색 + 페이징) */ + @RequestMapping(value = "/mnt/sec/authorHistory/list.do") + public String historyList(AuthorChangeHistoryVO searchVO, ModelMap model) throws Exception { + List list = authorHistoryService.selectHistoryList(searchVO); + model.addAttribute("resultList", list); + model.addAttribute("totalCount", searchVO.getTotalRecordCount()); + return JSON; + } + + /** 권한 변경 이력 다운로드 — 사유 필수 + 다운로드 이력 적재 */ + @RequestMapping(value = "/mnt/sec/authorHistory/download.do") + public String historyDownload(AuthorChangeHistoryVO searchVO, + @org.springframework.web.bind.annotation.RequestParam(value = "dwldResn", required = false) String dwldResn, + HttpServletRequest request, ModelMap model) throws Exception { + LoginVO user = loginUser(request); + AuthorHistDownloadVO dl = new AuthorHistDownloadVO(); + dl.setDwldResn(dwldResn); + if (user != null) { + dl.setDownloaderId(user.getId()); + dl.setDownloaderUniqId(user.getUniqId()); + } + dl.setDownloaderIp(ClientIpResolver.getClientIp(request)); + dl.setSearchCond("trget=" + searchVO.getSearchTrgetId() + ",changer=" + searchVO.getSearchChangerId() + + ",se=" + searchVO.getSearchChgSeCode() + ",from=" + searchVO.getFromDt() + ",to=" + searchVO.getToDt()); + try { + List list = authorHistoryService.downloadHistory(searchVO, dl); + model.addAttribute("resultList", list); + model.addAttribute("result", "success"); + // TODO: 화면 #46 — 실제 파일(CSV/XLS) 스트리밍은 프론트/뷰 계층에서 처리 + } catch (IllegalArgumentException e) { + model.addAttribute("result", "fail"); + model.addAttribute("message", e.getMessage()); // 사유 미입력 차단 + } + return JSON; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowDAO.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowDAO.java new file mode 100644 index 0000000..879e83f --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowDAO.java @@ -0,0 +1,69 @@ +package egovframework.com.cmm.privacy.author; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 권한 신청·승인 워크플로 DAO (개인정보보호 SFR-001 / #3). + * + *

iBatis 2. 권한 반영(COMTNEMPLYRSCRTYESTBS)과 이력 적재(AUTHOR_CHANGE_HISTORY)는 + * 서비스 단일 트랜잭션(egovframework.com..*Impl AOP tx)에서 함께 호출된다.

+ * + *
+ * 개정이력
+ *  2026.07.21  URP(Fork A 6차)  최초 생성
+ * 
+ */ +@Repository("authorWorkflowDAO") +public class AuthorWorkflowDAO extends EgovComAbstractDAO { + + /** 신청 등록 */ + public void insertRequest(AuthorChangeRequestVO vo) { + insert("AuthorWorkflowDAO.insertRequest", vo); + } + + /** 신청 단건 조회 */ + public AuthorChangeRequestVO selectRequest(long requestId) { + return (AuthorChangeRequestVO) selectByPk("AuthorWorkflowDAO.selectRequest", Long.valueOf(requestId)); + } + + /** 신청 목록 조회(상태/대상 검색 + 페이징) */ + @SuppressWarnings("unchecked") + public List selectRequestList(AuthorChangeRequestVO vo) { + return list("AuthorWorkflowDAO.selectRequestList", vo); + } + + /** 신청 목록 전체 건수 */ + public int selectRequestListCount(AuthorChangeRequestVO vo) { + Object cnt = selectByPk("AuthorWorkflowDAO.selectRequestListCount", vo); + return cnt == null ? 0 : ((Integer) cnt).intValue(); + } + + /** 신청 상태 갱신(승인 A / 반려 J) */ + public void updateRequestStatus(AuthorChangeRequestVO vo) { + update("AuthorWorkflowDAO.updateRequestStatus", vo); + } + + /** 대상자 현재 권한(CSV) 스냅샷 — 회수/변경 반영 전 캡처 */ + public String selectCurrentAuthors(String trgetUniqId) { + return (String) selectByPk("AuthorWorkflowDAO.selectCurrentAuthors", trgetUniqId); + } + + /** 권한 반영 — COMTNEMPLYRSCRTYESTBS 부여(insert) */ + public void insertEmplyrAuthor(AuthorChangeRequestVO vo) { + insert("AuthorWorkflowDAO.insertEmplyrAuthor", vo); + } + + /** 권한 반영 — COMTNEMPLYRSCRTYESTBS 회수(delete) */ + public void deleteEmplyrAuthor(AuthorChangeRequestVO vo) { + delete("AuthorWorkflowDAO.deleteEmplyrAuthor", vo); + } + + /** 변경 이력 적재(AUTHOR_CHANGE_HISTORY, #4) */ + public void insertChangeHistory(AuthorChangeHistoryVO vo) { + insert("AuthorWorkflowDAO.insertChangeHistory", vo); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowService.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowService.java new file mode 100644 index 0000000..d752f5e --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowService.java @@ -0,0 +1,31 @@ +package egovframework.com.cmm.privacy.author; + +import java.util.List; + +/** + * 권한 신청·승인 워크플로 서비스 (개인정보보호 SFR-001 / #3). + */ +public interface AuthorWorkflowService { + + /** 권한 변경 신청 등록(상태 R) */ + void submitRequest(AuthorChangeRequestVO vo) throws Exception; + + /** 신청 목록 조회 */ + List selectRequestList(AuthorChangeRequestVO vo) throws Exception; + + /** 신청 목록 건수 */ + int selectRequestListCount(AuthorChangeRequestVO vo) throws Exception; + + /** + * 승인(A) — 권한 반영(COMTNEMPLYRSCRTYESTBS)과 이력 적재(AUTHOR_CHANGE_HISTORY)를 + * 단일 트랜잭션으로 처리한다. + * @param requestId 신청 일련번호 + * @param confmId 승인자 계정ID + * @param confmUniqId 승인자 고유식별자 + * @param confmIp 승인자 IP + */ + void approveRequest(long requestId, String confmId, String confmUniqId, String confmIp) throws Exception; + + /** 반려(J) — 반려 사유 기록 */ + void rejectRequest(long requestId, String confmId, String confmResn) throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowServiceImpl.java new file mode 100644 index 0000000..b6dfd93 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowServiceImpl.java @@ -0,0 +1,119 @@ +package egovframework.com.cmm.privacy.author; + +import java.util.List; + +import javax.annotation.Resource; + +import org.springframework.stereotype.Service; + +/** + * 권한 신청·승인 워크플로 서비스 구현 (개인정보보호 SFR-001 / #3). + * + *

단일 트랜잭션: 이 클래스는 {@code egovframework.com..*Impl} 패턴에 걸리는 + * {@code context-transaction.xml}의 tx advisor(REQUIRED, rollback-for=Exception) 대상이다. + * 따라서 {@link #approveRequest}는 별도 애노테이션 없이도 권한 반영과 이력 적재가 + * 하나의 트랜잭션으로 처리되며, 어느 단계든 Exception 발생 시 전체 롤백된다.

+ */ +@Service("authorWorkflowService") +public class AuthorWorkflowServiceImpl implements AuthorWorkflowService { + + @Resource(name = "authorWorkflowDAO") + private AuthorWorkflowDAO authorWorkflowDAO; + + @Override + public void submitRequest(AuthorChangeRequestVO vo) throws Exception { + if (vo.getRequestResn() == null || vo.getRequestResn().trim().length() == 0) { + throw new IllegalArgumentException("신청 사유는 필수입니다."); + } + // 신청 시점 현재 권한 스냅샷 캡처 + vo.setBeforeAuthor(authorWorkflowDAO.selectCurrentAuthors(vo.getTrgetUniqId())); + vo.setConfmSttus("R"); + authorWorkflowDAO.insertRequest(vo); + } + + @Override + public List selectRequestList(AuthorChangeRequestVO vo) throws Exception { + int pageIndex = vo.getPageIndex() < 1 ? 1 : vo.getPageIndex(); + int perPage = vo.getRecordCountPerPage() < 1 ? 10 : vo.getRecordCountPerPage(); + vo.setFirstIndex((pageIndex - 1) * perPage); + vo.setRecordCountPerPage(perPage); + vo.setTotalRecordCount(authorWorkflowDAO.selectRequestListCount(vo)); + return authorWorkflowDAO.selectRequestList(vo); + } + + @Override + public int selectRequestListCount(AuthorChangeRequestVO vo) throws Exception { + return authorWorkflowDAO.selectRequestListCount(vo); + } + + @Override + public void approveRequest(long requestId, String confmId, String confmUniqId, String confmIp) throws Exception { + AuthorChangeRequestVO req = authorWorkflowDAO.selectRequest(requestId); + if (req == null) { + throw new IllegalArgumentException("존재하지 않는 신청입니다: " + requestId); + } + if (!"R".equals(req.getConfmSttus())) { + throw new IllegalStateException("이미 처리된 신청입니다: " + req.getConfmSttus()); + } + + // 1) 회수/변경 반영 전, 현재 권한을 다시 캡처(신청~승인 사이 변동 대비) + String beforeAuthor = authorWorkflowDAO.selectCurrentAuthors(req.getTrgetUniqId()); + + // 2) 권한 반영 (N:신설=부여 / C:변경=회수 후 부여 / R:회수) + String se = req.getRequestSe(); + if ("R".equals(se)) { + authorWorkflowDAO.deleteEmplyrAuthor(req); + } else if ("C".equals(se)) { + authorWorkflowDAO.deleteEmplyrAuthor(req); + authorWorkflowDAO.insertEmplyrAuthor(req); + } else { // N + authorWorkflowDAO.insertEmplyrAuthor(req); + } + + // 3) 신청 상태 승인(A) + req.setConfmSttus("A"); + req.setConfmId(confmId); + req.setConfmUniqId(confmUniqId); + authorWorkflowDAO.updateRequestStatus(req); + + // 4) 변경 이력 적재(AUTHOR_CHANGE_HISTORY, #4) — 위 3단계와 동일 트랜잭션 + AuthorChangeHistoryVO hist = new AuthorChangeHistoryVO(); + hist.setTrgetUniqId(req.getTrgetUniqId()); + hist.setTrgetId(req.getTrgetId()); + hist.setChgSeCode(mapChgSeCode(se)); // N→G, C→M, R→R + hist.setBeforeAuthor(beforeAuthor); + hist.setAfterAuthor("R".equals(se) ? null : req.getAfterAuthor()); + hist.setChgResn(req.getRequestResn()); + hist.setAprovlId(confmId); + hist.setChangerUniqId(confmUniqId); + hist.setChangerId(confmId); + hist.setChangerIp(confmIp); + authorWorkflowDAO.insertChangeHistory(hist); + } + + @Override + public void rejectRequest(long requestId, String confmId, String confmResn) throws Exception { + AuthorChangeRequestVO req = authorWorkflowDAO.selectRequest(requestId); + if (req == null) { + throw new IllegalArgumentException("존재하지 않는 신청입니다: " + requestId); + } + if (!"R".equals(req.getConfmSttus())) { + throw new IllegalStateException("이미 처리된 신청입니다: " + req.getConfmSttus()); + } + req.setConfmSttus("J"); + req.setConfmId(confmId); + req.setConfmResn(confmResn); + authorWorkflowDAO.updateRequestStatus(req); + } + + /** 신청구분(N/C/R) → 이력 변경구분(G:부여/M:변경/R:회수) */ + private String mapChgSeCode(String requestSe) { + if ("R".equals(requestSe)) { + return "R"; + } + if ("C".equals(requestSe)) { + return "M"; + } + return "G"; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/AccessLogIntegrityDAO.java b/src/main/java/egovframework/com/cmm/privacy/batch/AccessLogIntegrityDAO.java new file mode 100644 index 0000000..102af2b --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/batch/AccessLogIntegrityDAO.java @@ -0,0 +1,33 @@ +package egovframework.com.cmm.privacy.batch; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogVO; +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 무결성 검증 조회 DAO (#40). + * + *

해시체인 재계산을 위해 적재 순서(LOG_ID 오름차순)대로 레코드를 조회한다. + * 이 테이블에는 UPDATE/DELETE statement를 정의하지 않는다(위·변조 방지).

+ * + * @author arko-privacy (Fork A - 2차) + * @since 2026-07-21 + */ +@Repository("accessLogIntegrityDAO") +public class AccessLogIntegrityDAO extends EgovComAbstractDAO { + + /** + * 해시체인 검증용 전체 레코드를 LOG_ID 오름차순(적재 순서)으로 조회한다. + * CONECT_DT는 해시 계산 시점과 동일한 문자열 형식(yyyy-MM-dd HH:mm:ss)으로 반환한다. + * + * @return 순차 정렬된 접속기록 목록 + * @throws Exception 조회 오류 + */ + @SuppressWarnings("unchecked") + public List selectOrderedChain() throws Exception { + return list("AccessLogIntegrityDAO.selectOrderedChain", null); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/AccessLogPartitionDAO.java b/src/main/java/egovframework/com/cmm/privacy/batch/AccessLogPartitionDAO.java new file mode 100644 index 0000000..6d8b655 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/batch/AccessLogPartitionDAO.java @@ -0,0 +1,57 @@ +package egovframework.com.cmm.privacy.batch; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 파티션 유지보수 DAO (#37). + * + *

보관정책(730일) 파기 배치가 사용하는 스키마 관리(DDL) 전용 DAO다. + * 로그 행에 대한 UPDATE/DELETE 는 정의하지 않는다(위·변조 방지). 파기는 개별 DELETE 가 아닌 + * 파티션 DROP 으로만 수행하며, 미래 월 파티션은 선행 ADD 로 확보한다.

+ * + *

파티션명/경계값은 배치가 Java 에서 안전하게 계산(사용자 입력 아님)해 전달하므로 + * 식별자 치환(iBatis {@code $...$})을 사용한다.

+ * + * @author arko-privacy-auditlog (Fork B) + * @since 2026-07-21 + */ +@Repository("accessLogPartitionDAO") +public class AccessLogPartitionDAO extends EgovComAbstractDAO { + + /** + * 대상 테이블의 현재 파티션 목록을 조회한다(information_schema). + * 각 행: partitionName, partitionDesc(LESS THAN 경계 리터럴), ordinalPosition. + * + * @param tableName 대상 테이블명 + */ + @SuppressWarnings("unchecked") + public List> selectPartitions(String tableName) throws Exception { + return list("PersonalDataAccessLogDAO.selectPartitions", tableName); + } + + /** 만료 파티션을 DROP 한다(개별 DELETE 미사용). */ + public int dropPartition(String partitionName) throws Exception { + return update("PersonalDataAccessLogDAO.dropPartition", partitionName); + } + + /** + * 미래 월 파티션을 ADD 한다. + * @param param partitionName, boundaryDt(예: 2026-09-01) + */ + public int addPartition(Map param) throws Exception { + return update("PersonalDataAccessLogDAO.addPartition", param); + } + + /** + * 파기·증설 이력을 기록한다. + * @param param partitionName, boundaryDt, purgeType(DROP/ADD), note + */ + public void insertPurgeHistory(Map param) throws Exception { + insert("PersonalDataAccessLogDAO.insertPurgeHistory", param); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/AuthorHistoryPurgeDAO.java b/src/main/java/egovframework/com/cmm/privacy/batch/AuthorHistoryPurgeDAO.java new file mode 100644 index 0000000..eba5561 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/batch/AuthorHistoryPurgeDAO.java @@ -0,0 +1,29 @@ +package egovframework.com.cmm.privacy.batch; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 권한 변경 이력(AUTHOR_CHANGE_HISTORY) 파기 DAO (#5). + * + *

보관기간 경과 이력을 파기하는 단일 DELETE만 제공한다. + * 개별 이력 삭제 statement는 정의하지 않는다(파기 배치가 유일한 삭제 경로).

+ * + * @author arko-privacy (Fork A - 2차) + * @since 2026-07-21 + */ +@Repository("authorHistoryPurgeDAO") +public class AuthorHistoryPurgeDAO extends EgovComAbstractDAO { + + /** + * 기준일시(cutoff) 이전(CHG_DT < cutoff)의 권한 변경 이력을 파기한다. + * + * @param cutoffDt 파기 기준일시 문자열(yyyy-MM-dd HH:mm:ss). 이 시각 이전 이력이 대상. + * @return 파기된 건수 + * @throws Exception 파기 오류 + */ + public int deleteExpired(String cutoffDt) throws Exception { + return delete("AuthorHistoryPurgeDAO.deleteExpired", cutoffDt); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/HashChainVerifyResultVO.java b/src/main/java/egovframework/com/cmm/privacy/batch/HashChainVerifyResultVO.java new file mode 100644 index 0000000..25f4c9e --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/batch/HashChainVerifyResultVO.java @@ -0,0 +1,42 @@ +package egovframework.com.cmm.privacy.batch; + +import java.io.Serializable; + +/** + * 접속기록 해시체인 무결성 검증(#40) 결과. + * + *

정기점검 리포트(#43)와 관리자 통보에 사용한다.

+ * + * @author arko-privacy (Fork A - 2차) + * @since 2026-07-21 + */ +public class HashChainVerifyResultVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 검증 대상 총 레코드 수 */ + private int totalCount; + /** 최초 불일치 인덱스(0-base). 전부 정상이면 -1 */ + private int firstMismatchIndex = -1; + /** 최초 불일치 레코드의 LOG_ID. 정상이면 0 */ + private long mismatchLogId; + /** 검증 수행 일시(yyyy-MM-dd HH:mm:ss) */ + private String verifiedAt; + + /** 무결성 정상 여부 */ + public boolean isOk() { + return firstMismatchIndex < 0; + } + + public int getTotalCount() { return totalCount; } + public void setTotalCount(int totalCount) { this.totalCount = totalCount; } + + public int getFirstMismatchIndex() { return firstMismatchIndex; } + public void setFirstMismatchIndex(int firstMismatchIndex) { this.firstMismatchIndex = firstMismatchIndex; } + + public long getMismatchLogId() { return mismatchLogId; } + public void setMismatchLogId(long mismatchLogId) { this.mismatchLogId = mismatchLogId; } + + public String getVerifiedAt() { return verifiedAt; } + public void setVerifiedAt(String verifiedAt) { this.verifiedAt = verifiedAt; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/InspectionReportDAO.java b/src/main/java/egovframework/com/cmm/privacy/batch/InspectionReportDAO.java new file mode 100644 index 0000000..8e47c76 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/batch/InspectionReportDAO.java @@ -0,0 +1,85 @@ +package egovframework.com.cmm.privacy.batch; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 개인정보 접속기록 정기점검 리포트 DAO (#43). + * + *

전월분 접속기록을 집계(총건수/실패건수/취급자별 처리현황)하고 리포트 1행을 적재한다. + * 집계 대상 statement는 이미 sql-map-config에 등록된 {@code PrivacyAccessLog_SQL_Mysql.xml} + * (namespace {@code PersonalDataAccessLogDAO})에 함께 정의되어 있다 — 신규 sqlmap 등록 불요. + * PERSONAL_DATA_ACCESS_LOG 에는 SELECT 만 수행(위·변조 방지), 적재는 PDAL_INSPECTION_REPORT 대상.

+ * + * @author arko-privacy-auditlog (Fork B - 5차) + * @since 2026-07-21 + */ +@Repository("inspectionReportDAO") +public class InspectionReportDAO extends EgovComAbstractDAO { + + /** + * 기간 내 총 접속기록 건수·실패(F) 건수를 집계한다. + * + * @param param fromDt/toDt 를 담은 Map + * @return {totalCnt, failCnt} Map (null 없음) + */ + @SuppressWarnings("unchecked") + public Map summarizeAccessLogByPeriod(Map param) { + return (Map) selectByPk("PersonalDataAccessLogDAO.summarizeAccessLogByPeriod", param); + } + + /** + * 기간 내 취급자별(CONECT_ID·JOB_SE_CODE) 처리현황을 집계한다. + * + * @param param fromDt/toDt 를 담은 Map + * @return {conectId, jobSeCode, procCnt} 목록 + */ + @SuppressWarnings("unchecked") + public List> summarizeByHandler(Map param) { + return list("PersonalDataAccessLogDAO.summarizeByHandler", param); + } + + /** + * 정기점검 리포트 1행을 적재한다(PDAL_INSPECTION_REPORT). + * + * @param report 집계 결과 VO + */ + public void insertInspectionReport(InspectionReportVO report) { + insert("PersonalDataAccessLogDAO.insertInspectionReport", report); + } + + /** + * 정기점검 리포트 월별 목록 조회 (#44). + * + * @param param rptYmPrefix(연도 등)·inspectedYn(Y/N) 검색조건 Map + * @return 리포트 목록(연월 역순) + */ + @SuppressWarnings("unchecked") + public List selectInspectionReportList(Map param) { + return list("PersonalDataAccessLogDAO.selectInspectionReportList", param); + } + + /** + * 정기점검 리포트 상세 조회 (#44). + * + * @param rptId 리포트 일련번호(PK) + * @return 리포트 단건(없으면 null) + */ + public InspectionReportVO selectInspectionReport(long rptId) { + return (InspectionReportVO) selectByPk("PersonalDataAccessLogDAO.selectInspectionReport", Long.valueOf(rptId)); + } + + /** + * 점검결과 입력·보관 (#44). 점검자·결과·조치사항 + 점검일시(NOW) 갱신. + * + * @param report rptId/inspectorId/inspectResult/actnMatter 를 담은 VO + * @return 갱신 건수(리포트 미존재 시 0) + */ + public int updateInspectionResult(InspectionReportVO report) { + return update("PersonalDataAccessLogDAO.updateInspectionResult", report); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/InspectionReportVO.java b/src/main/java/egovframework/com/cmm/privacy/batch/InspectionReportVO.java new file mode 100644 index 0000000..2304a5b --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/batch/InspectionReportVO.java @@ -0,0 +1,103 @@ +package egovframework.com.cmm.privacy.batch; + +import java.io.Serializable; + +/** + * 개인정보 접속기록 정기점검 리포트 VO (#43). + * + *

월 1회 정기점검 배치가 전월분을 집계해 {@code PDAL_INSPECTION_REPORT} 1행으로 적재한다. + * INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER 는 배치가 아닌 점검자가 화면(#48)에서 입력한다.

+ * + * @author arko-privacy-auditlog (Fork B - 5차) + * @since 2026-07-21 + */ +public class InspectionReportVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 리포트 일련번호(PK) */ + private long rptId; + /** 점검 대상 연월(전월, yyyyMM) */ + private String rptYm; + /** 집계 시작일시(yyyy-MM-dd HH:mm:ss) */ + private String periodFrom; + /** 집계 종료일시(yyyy-MM-dd HH:mm:ss) */ + private String periodTo; + /** 총 접속기록 건수 */ + private int totalLogCnt; + /** 처리결과 F(실패/차단) 건수 */ + private int failCnt; + /** 이상징후 5유형 요약(#35 연계 전 자리표시) */ + private String abnormalSummary; + /** 해시체인 무결성 결과: OK / VIOLATION */ + private String integrityResult; + /** 최초 불일치 LOG_ID(위반 시) */ + private Long integrityMismatchId; + /** 무결성 검증 대상 건수 */ + private int integrityTotalCnt; + /** 취급자별 처리현황 요약 */ + private String handlerSummary; + /** 다운로드 이력 건수(#42 연계 전 0) */ + private int downloadCnt; + /** 점검자 계정ID(화면 입력) */ + private String inspectorId; + /** 점검결과(화면 입력) */ + private String inspectResult; + /** 조치사항(화면 입력) */ + private String actnMatter; + /** 점검일시(점검결과 입력 시각, #44 감사증빙) */ + private String inspectDt; + /** 리포트 생성일시(조회용) */ + private String regDt; + + public long getRptId() { return rptId; } + public void setRptId(long rptId) { this.rptId = rptId; } + + public String getRptYm() { return rptYm; } + public void setRptYm(String rptYm) { this.rptYm = rptYm; } + + public String getPeriodFrom() { return periodFrom; } + public void setPeriodFrom(String periodFrom) { this.periodFrom = periodFrom; } + + public String getPeriodTo() { return periodTo; } + public void setPeriodTo(String periodTo) { this.periodTo = periodTo; } + + public int getTotalLogCnt() { return totalLogCnt; } + public void setTotalLogCnt(int totalLogCnt) { this.totalLogCnt = totalLogCnt; } + + public int getFailCnt() { return failCnt; } + public void setFailCnt(int failCnt) { this.failCnt = failCnt; } + + public String getAbnormalSummary() { return abnormalSummary; } + public void setAbnormalSummary(String abnormalSummary) { this.abnormalSummary = abnormalSummary; } + + public String getIntegrityResult() { return integrityResult; } + public void setIntegrityResult(String integrityResult) { this.integrityResult = integrityResult; } + + public Long getIntegrityMismatchId() { return integrityMismatchId; } + public void setIntegrityMismatchId(Long integrityMismatchId) { this.integrityMismatchId = integrityMismatchId; } + + public int getIntegrityTotalCnt() { return integrityTotalCnt; } + public void setIntegrityTotalCnt(int integrityTotalCnt) { this.integrityTotalCnt = integrityTotalCnt; } + + public String getHandlerSummary() { return handlerSummary; } + public void setHandlerSummary(String handlerSummary) { this.handlerSummary = handlerSummary; } + + public int getDownloadCnt() { return downloadCnt; } + public void setDownloadCnt(int downloadCnt) { this.downloadCnt = downloadCnt; } + + public String getInspectorId() { return inspectorId; } + public void setInspectorId(String inspectorId) { this.inspectorId = inspectorId; } + + public String getInspectResult() { return inspectResult; } + public void setInspectResult(String inspectResult) { this.inspectResult = inspectResult; } + + public String getActnMatter() { return actnMatter; } + public void setActnMatter(String actnMatter) { this.actnMatter = actnMatter; } + + public String getInspectDt() { return inspectDt; } + public void setInspectDt(String inspectDt) { this.inspectDt = inspectDt; } + + public String getRegDt() { return regDt; } + public void setRegDt(String regDt) { this.regDt = regDt; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/InspectionResultController.java b/src/main/java/egovframework/com/cmm/privacy/batch/InspectionResultController.java new file mode 100644 index 0000000..e4647a6 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/batch/InspectionResultController.java @@ -0,0 +1,79 @@ +package egovframework.com.cmm.privacy.batch; + +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.RequestMapping; + +import egovframework.com.cmm.LoginVO; + +/** + * 정기점검 결과 입력·보관 컨트롤러 (#44, SFR-004/SER-002). + * + *

취급자 영역(/mnt) 백엔드 엔드포인트. 응답은 jsonView(화면 JSP는 #48 대시보드 후속). + * 점검자 식별자는 세션 loginVO 에서 취득한다.

+ * + * @author arko-privacy-auditlog (Fork C - 7차) + * @since 2026-07-21 + */ +@Controller +public class InspectionResultController { + + protected static final String JSON = "jsonView"; + + @Resource(name = "inspectionResultService") + private InspectionResultService inspectionResultService; + + private LoginVO loginUser(HttpServletRequest request) { + Object o = request.getSession().getAttribute("loginVO"); + return (o instanceof LoginVO) ? (LoginVO) o : 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, + @org.springframework.web.bind.annotation.RequestParam(value = "inspectedYn", required = false) String inspectedYn, + ModelMap model) throws Exception { + 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()); + return JSON; + } + + /** 정기점검 리포트 상세 */ + @RequestMapping(value = "/mnt/sec/inspection/detail.do") + public String detail(@org.springframework.web.bind.annotation.RequestParam("rptId") long rptId, + ModelMap model) throws Exception { + model.addAttribute("report", inspectionResultService.selectReport(rptId)); + return JSON; + } + + /** 점검결과 입력·보관 저장 */ + @RequestMapping(value = "/mnt/sec/inspection/saveResult.do") + public String saveResult(InspectionReportVO vo, HttpServletRequest request, ModelMap model) throws Exception { + LoginVO user = loginUser(request); + if (user != null) { + vo.setInspectorId(user.getId()); + } + try { + int cnt = inspectionResultService.saveInspectionResult(vo); + model.addAttribute("result", cnt > 0 ? "success" : "fail"); + if (cnt == 0) { + model.addAttribute("message", "저장 대상이 없습니다."); + } + } catch (IllegalArgumentException e) { + model.addAttribute("result", "fail"); + model.addAttribute("message", e.getMessage()); + } + return JSON; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/InspectionResultService.java b/src/main/java/egovframework/com/cmm/privacy/batch/InspectionResultService.java new file mode 100644 index 0000000..fb498ee --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/batch/InspectionResultService.java @@ -0,0 +1,34 @@ +package egovframework.com.cmm.privacy.batch; + +import java.util.List; +import java.util.Map; + +/** + * 정기점검 결과 입력·보관 서비스 (#44, SFR-004/SER-002). + * + *

월 1회 배치(#43)가 생성한 {@code PDAL_INSPECTION_REPORT} 리포트에 대해 + * 점검자가 점검결과·조치사항을 입력·보관해 "월 1회 이상 점검 내역"을 증빙한다. + * 리포트 집계값(배치 적재분)은 수정하지 않고, 점검자 입력 필드 + * (INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER/INSPECT_DT)만 갱신한다.

+ * + * @author arko-privacy-auditlog (Fork C - 7차) + * @since 2026-07-21 + */ +public interface InspectionResultService { + + /** 정기점검 리포트 월별 목록 조회(연월 역순). param: rptYmPrefix/inspectedYn */ + List selectReportList(Map param) throws Exception; + + /** 정기점검 리포트 상세 조회(RPT_ID). 없으면 null */ + InspectionReportVO selectReport(long rptId) throws Exception; + + /** + * 점검결과 입력·보관. 점검자(inspectorId)·점검결과(inspectResult)·조치사항(actnMatter) + + * 점검일시(서버 NOW) 저장. + * + * @param vo rptId/inspectorId/inspectResult/actnMatter + * @return 갱신 건수(대상 리포트 미존재 시 0) + * @throws IllegalArgumentException 점검결과 미입력 등 유효성 위반 + */ + int saveInspectionResult(InspectionReportVO vo) throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/InspectionResultServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/batch/InspectionResultServiceImpl.java new file mode 100644 index 0000000..e54a30c --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/batch/InspectionResultServiceImpl.java @@ -0,0 +1,50 @@ +package egovframework.com.cmm.privacy.batch; + +import java.util.List; +import java.util.Map; + +import javax.annotation.Resource; + +import org.springframework.stereotype.Service; + +/** + * 정기점검 결과 입력·보관 서비스 구현 (#44). + * + *

{@code *Impl} + {@code throws Exception} 이므로 context-transaction.xml 의 + * AOP advisor(REQUIRED·rollback-for=Exception)가 트랜잭션을 적용한다.

+ * + * @author arko-privacy-auditlog (Fork C - 7차) + * @since 2026-07-21 + */ +@Service("inspectionResultService") +public class InspectionResultServiceImpl implements InspectionResultService { + + @Resource(name = "inspectionReportDAO") + private InspectionReportDAO inspectionReportDAO; + + @Override + public List selectReportList(Map param) throws Exception { + return inspectionReportDAO.selectInspectionReportList(param); + } + + @Override + public InspectionReportVO selectReport(long rptId) throws Exception { + return inspectionReportDAO.selectInspectionReport(rptId); + } + + @Override + public int saveInspectionResult(InspectionReportVO vo) throws Exception { + if (vo == null || vo.getRptId() <= 0L) { + throw new IllegalArgumentException("점검 대상 리포트가 지정되지 않았습니다."); + } + if (vo.getInspectResult() == null || vo.getInspectResult().trim().length() == 0) { + throw new IllegalArgumentException("점검결과는 필수 입력입니다."); + } + // 대상 리포트 존재 확인(배치 생성분만 점검 가능) + InspectionReportVO target = inspectionReportDAO.selectInspectionReport(vo.getRptId()); + if (target == null) { + throw new IllegalArgumentException("대상 정기점검 리포트가 존재하지 않습니다. RPT_ID=" + vo.getRptId()); + } + return inspectionReportDAO.updateInspectionResult(vo); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyBatchService.java b/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyBatchService.java new file mode 100644 index 0000000..ab8c958 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyBatchService.java @@ -0,0 +1,66 @@ +package egovframework.com.cmm.privacy.batch; + +/** + * 개인정보보호 정기 배치 서비스. + * + *

문화품앗이 개인정보보호 기능개선 사업 — 월 1회 정기 배치 진입점.

+ *
    + *
  • #5 권한 변경 이력(AUTHOR_CHANGE_HISTORY) 보관기간 경과분 파기 (SFR-001)
  • + *
  • #40 개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 해시체인 무결성 검증 (DBR-001)
  • + *
  • #37 개인정보 접속기록 보관정책(730일) 파티션 파기 + 미래 파티션 선행 증설 (SFR-004/DBR-001)
  • + *
+ * + *

실제 스케줄 트리거는 {@code kr.or.arko.cmm.batch.BatchScheduler}의 {@code @Scheduled} + * 메서드가 담당하며, 이 서비스는 트랜잭션 경계·로깅·관리자 통보를 책임진다.

+ * + * @author arko-privacy (Fork A - 2차) + * @since 2026-07-21 + */ +public interface PrivacyBatchService { + + /** + * 권한 변경 이력 파기 배치 (#5). + * 보관기간(기본 1,095일=3년) 경과 이력만 선별 파기한다. 개별 삭제 API는 제공하지 않으며 + * 이 배치가 유일한 삭제 경로다. + * + * @return 파기된 이력 건수 + * @throws Exception 파기 처리 오류 + */ + int purgeExpiredAuthorChangeHistory() throws Exception; + + /** + * 접속기록 해시체인 무결성 검증 배치 (#40). + * 적재 순서(LOG_ID 오름차순)대로 재계산해 저장된 해시와 대조, 최초 불일치를 탐지한다. + * 불일치 발견 시 즉시 관리자에게 통보하고, 결과는 정기점검 리포트(#43)에 반영한다. + * + * @return 검증 결과(총 건수·최초 불일치 위치 등) + * @throws Exception 검증 처리 오류 + */ + HashChainVerifyResultVO verifyAccessLogHashChain() throws Exception; + + /** + * 접속기록 보관정책(730일) 파티션 파기 + 미래 파티션 선행 증설 배치 (#37). + * + *

보관기간(기본 730일) 경과 파티션을 {@code ALTER TABLE ... DROP PARTITION} 으로 파기하고 + * (개별 DELETE 미사용), 적재 누락 방지를 위해 미래 월 파티션을 선행 증설한다. 수행 내역은 + * {@code PDAL_PURGE_HISTORY} 에 파기 증빙으로 기록한다. {@code COMTNSYSLOG} 의 7일 삭제정책은 + * 이 테이블에 절대 적용하지 않는다(분리 원칙 #38).

+ * + * @return 파기(DROP)된 파티션 수 + * @throws Exception 파기/증설 오류 + */ + int purgeExpiredAccessLogPartitions() throws Exception; + + /** + * 정기점검 리포트 생성 배치 (#43). + * + *

전월분 접속기록을 집계해 {@code PDAL_INSPECTION_REPORT} 1행으로 적재한다. + * 총건수·실패(F)건수, 해시체인 무결성 검증결과(#40 재사용), 취급자별 처리현황을 담는다. + * 이상징후 5유형(#35)·다운로드 이력(#42)은 미구현이라 자리표시(요약/0)로 적재하며, + * 점검자·점검결과·조치사항은 점검자가 화면(#48)에서 별도 입력한다.

+ * + * @return 생성된 리포트 VO(적재값 스냅샷) + * @throws Exception 집계/적재 오류 + */ + InspectionReportVO generateMonthlyInspectionReport() throws Exception; +} diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyBatchServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyBatchServiceImpl.java new file mode 100644 index 0000000..3e70717 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyBatchServiceImpl.java @@ -0,0 +1,369 @@ +package egovframework.com.cmm.privacy.batch; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.annotation.Resource; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.stereotype.Service; + +import egovframework.com.cmm.privacy.auditlog.AbnormalAccessDetectionService; +import egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryDAO; +import egovframework.com.cmm.privacy.auditlog.HashChainService; +import egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogVO; + +/** + * 개인정보보호 정기 배치 서비스 구현체. + * + * @author arko-privacy (Fork A - 2차) + * @since 2026-07-21 + */ +@Service("privacyBatchService") +public class PrivacyBatchServiceImpl implements PrivacyBatchService { + + private static final Log LOG = LogFactory.getLog(PrivacyBatchServiceImpl.class); + + private static final String DT_FORMAT = "yyyy-MM-dd HH:mm:ss"; + + /** + * 권한 변경 이력 보관기간(일). 개인정보 보호법상 3년(1,095일) 이상 보관 요건. + * TODO: 계정 관리 정책 설정 화면(#13)/정책 테이블 도입 시 조회값으로 대체. + */ + private static final int AUTHOR_HISTORY_RETENTION_DAYS = 1095; + + /** + * 개인정보 접속기록 보관기간(일). 기본 730일(2년). + * TODO: 계정/로그 관리 정책 설정 화면(#13)/정책 테이블 도입 시 조회값으로 대체. + */ + private static final int ACCESS_LOG_RETENTION_DAYS = 730; + + /** 미래 파티션 선행 확보 개월 수(당월 포함). 적재 시 파티션 누락 방지. */ + private static final int PARTITION_MONTHS_AHEAD = 2; + + private static final String PDAL_TABLE = "PERSONAL_DATA_ACCESS_LOG"; + + @Resource(name = "authorHistoryPurgeDAO") + private AuthorHistoryPurgeDAO authorHistoryPurgeDAO; + + @Resource(name = "accessLogIntegrityDAO") + private AccessLogIntegrityDAO accessLogIntegrityDAO; + + @Resource(name = "accessLogPartitionDAO") + private AccessLogPartitionDAO accessLogPartitionDAO; + + @Resource(name = "inspectionReportDAO") + private InspectionReportDAO inspectionReportDAO; + + /** #35 비정상 접근 탐지(리포트 이상징후 실집계). */ + @Resource(name = "abnormalAccessDetectionService") + private AbnormalAccessDetectionService abnormalAccessDetectionService; + + /** #42 다운로드 이력(리포트 다운로드 건수 실집계). */ + @Resource(name = "accessLogDownloadHistoryDAO") + private AccessLogDownloadHistoryDAO accessLogDownloadHistoryDAO; + + /** 정기점검 리포트 취급자별 처리현황 요약 최대 표기 행 수. */ + private static final int HANDLER_SUMMARY_TOP = 20; + + /** 해시체인 계산기(무상태·스레드세이프, 외부 라이브러리 의존 없음). */ + private final HashChainService hashChainService = new HashChainService(); + + @Override + public int purgeExpiredAuthorChangeHistory() throws Exception { + String cutoffDt = formatDate(minusDays(new Date(), AUTHOR_HISTORY_RETENTION_DAYS)); + int purged = authorHistoryPurgeDAO.deleteExpired(cutoffDt); + LOG.info("[개인정보/#5] 권한변경이력 파기 완료 — 기준일시(" + cutoffDt + + ") 이전 " + purged + "건 파기(보관 " + AUTHOR_HISTORY_RETENTION_DAYS + "일)."); + return purged; + } + + @Override + public HashChainVerifyResultVO verifyAccessLogHashChain() throws Exception { + List chain = accessLogIntegrityDAO.selectOrderedChain(); + + HashChainVerifyResultVO result = new HashChainVerifyResultVO(); + result.setVerifiedAt(formatDate(new Date())); + result.setTotalCount(chain == null ? 0 : chain.size()); + + int mismatchIdx = hashChainService.verifyChain(chain); + result.setFirstMismatchIndex(mismatchIdx); + + if (mismatchIdx >= 0 && chain != null) { + long mismatchLogId = chain.get(mismatchIdx).getLogId(); + result.setMismatchLogId(mismatchLogId); + // 위·변조 탐지 — 발견 즉시 관리자 통보(정기점검 리포트 #43에도 반영) + LOG.error("[개인정보/#40] 접속기록 해시체인 무결성 위반 탐지! index=" + mismatchIdx + + ", LOG_ID=" + mismatchLogId + ", 총 " + result.getTotalCount() + "건 검증."); + notifyAdmin(result); + } else { + LOG.info("[개인정보/#40] 접속기록 해시체인 무결성 정상 — 총 " + result.getTotalCount() + "건 검증."); + } + return result; + } + + /** + * 무결성 위반 즉시 관리자 통보. + * TODO: 실제 통보 채널(관리자 메일/SMS/알림함 #49) 연동. 현재는 에러 로그로 남긴다. + */ + private void notifyAdmin(HashChainVerifyResultVO result) { + LOG.error("[개인정보/#40] 관리자 통보 필요 — 무결성 위반 LOG_ID=" + result.getMismatchLogId() + + " (검증일시 " + result.getVerifiedAt() + "). TODO: 메일/SMS/알림함 연동."); + } + + @Override + public InspectionReportVO generateMonthlyInspectionReport() throws Exception { + // 집계 기간 = 전월 1일 00:00:00 ~ 전월 말일 23:59:59 + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.MONTH, -1); + cal.set(Calendar.DAY_OF_MONTH, 1); + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + Date from = cal.getTime(); + String rptYm = new SimpleDateFormat("yyyyMM").format(from); + + cal.set(Calendar.DAY_OF_MONTH, cal.getActualMaximum(Calendar.DAY_OF_MONTH)); + cal.set(Calendar.HOUR_OF_DAY, 23); + cal.set(Calendar.MINUTE, 59); + cal.set(Calendar.SECOND, 59); + Date to = cal.getTime(); + + Map param = new HashMap(); + param.put("fromDt", formatDate(from)); + param.put("toDt", formatDate(to)); + + // 1) 총건수·실패(F)건수 + Map agg = inspectionReportDAO.summarizeAccessLogByPeriod(param); + int totalCnt = asInt(agg == null ? null : agg.get("totalCnt")); + int failCnt = asInt(agg == null ? null : agg.get("failCnt")); + + // 2) 해시체인 무결성 검증(#40 재사용 — 중복 구현 안 함) + HashChainVerifyResultVO integrity = verifyAccessLogHashChain(); + + // 3) 취급자별(접속자ID·업무유형) 처리현황 요약 + List> handlers = inspectionReportDAO.summarizeByHandler(param); + StringBuilder sb = new StringBuilder(); + int shown = 0; + if (handlers != null) { + for (Map h : handlers) { + if (shown >= HANDLER_SUMMARY_TOP) { + sb.append(" 외 ").append(handlers.size() - HANDLER_SUMMARY_TOP).append("건"); + break; + } + if (sb.length() > 0) { + sb.append("; "); + } + sb.append(asString(h.get("conectId"))).append("/").append(asString(h.get("jobSeCode"))) + .append(":").append(asInt(h.get("procCnt"))); + shown++; + } + } + + InspectionReportVO report = new InspectionReportVO(); + report.setRptYm(rptYm); + report.setPeriodFrom(formatDate(from)); + report.setPeriodTo(formatDate(to)); + report.setTotalLogCnt(totalCnt); + report.setFailCnt(failCnt); + // 이상징후 5유형 실집계(#35 연계) — 비인가/대량조회/비정상시간대/계정공유/인증실패반복 + String abnormalSummary; + try { + abnormalSummary = abnormalAccessDetectionService.summarize(param.get("fromDt").toString(), + param.get("toDt").toString()); + } catch (Exception e) { + // 탐지 집계 실패가 리포트 생성을 막지 않도록 fail-safe + abnormalSummary = "이상징후 집계 오류(처리실패 " + failCnt + "건 기준)"; + LOG.error("[개인정보/#35] 이상징후 집계 오류 - 리포트 계속: " + e.getMessage()); + } + report.setAbnormalSummary(abnormalSummary); + report.setIntegrityResult(integrity.isOk() ? "OK" : "VIOLATION"); + report.setIntegrityMismatchId(integrity.isOk() ? null : Long.valueOf(integrity.getMismatchLogId())); + report.setIntegrityTotalCnt(integrity.getTotalCount()); + report.setHandlerSummary(sb.length() == 0 ? "(처리내역 없음)" : sb.toString()); + // #42 다운로드 이력 실집계 — 전월 다운로드 건수 + int downloadCnt = 0; + try { + downloadCnt = accessLogDownloadHistoryDAO.countByPeriod(param); + } catch (Exception e) { + LOG.error("[개인정보/#42] 다운로드 이력 집계 오류 - 리포트 계속: " + e.getMessage()); + } + report.setDownloadCnt(downloadCnt); + + inspectionReportDAO.insertInspectionReport(report); + LOG.info("[개인정보/#43] 정기점검 리포트 생성 — " + rptYm + " 총 " + totalCnt + "건(실패 " + failCnt + + "), 무결성=" + report.getIntegrityResult() + ", 취급자 " + + (handlers == null ? 0 : handlers.size()) + "종."); + return report; + } + + /** Map 집계값(Long/BigDecimal 등) → int 변환. */ + private static int asInt(Object o) { + if (o == null) { + return 0; + } + if (o instanceof Number) { + return ((Number) o).intValue(); + } + try { + return Integer.parseInt(o.toString().trim()); + } catch (NumberFormatException e) { + return 0; + } + } + + @Override + public int purgeExpiredAccessLogPartitions() throws Exception { + // 1) 미래 월 파티션 선행 확보 — 적재 시 파티션 누락으로 INSERT 실패하는 것을 방지. + ensureFuturePartitions(); + + // 2) 보관기간(730일) 경과 파티션 파기. 파티션 상한(경계) <= 파기기준일이면 파티션 전체가 만료. + Date cutoff = minusDays(new Date(), ACCESS_LOG_RETENTION_DAYS); + List> partitions = accessLogPartitionDAO.selectPartitions(PDAL_TABLE); + int dropped = 0; + if (partitions != null) { + for (int i = 0; i < partitions.size(); i++) { + Map p = partitions.get(i); + String name = asString(p.get("partitionName")); + String boundaryStr = stripQuotes(asString(p.get("partitionDesc"))); + Date boundary = parseBoundary(boundaryStr); + if (name == null || boundary == null) { + continue; // MAXVALUE 등 파싱 불가 파티션은 건너뜀(파기 대상 아님) + } + if (!boundary.after(cutoff)) { // boundary <= cutoff → 파티션 전체가 보관기간 경과 + accessLogPartitionDAO.dropPartition(name); + recordPurge(name, boundaryStr, "DROP", + "보관기간(" + ACCESS_LOG_RETENTION_DAYS + "일) 경과 파티션 파기"); + dropped++; + LOG.info("[개인정보/#37] 접속기록 파티션 파기 — " + name + " (경계 " + boundaryStr + ")"); + } + } + } + LOG.info("[개인정보/#37] 접속기록 파기 배치 완료 — 파티션 " + dropped + + "개 DROP(기준일시 " + formatDate(cutoff) + ", 보관 " + ACCESS_LOG_RETENTION_DAYS + "일)."); + return dropped; + } + + /** + * 당월부터 {@link #PARTITION_MONTHS_AHEAD}개월 앞까지 월 파티션을 선행 생성한다. + * RANGE 파티션은 기존 최대 경계보다 큰 경우에만 ADD 할 수 있으므로 미래 파티션만 추가한다. + */ + private void ensureFuturePartitions() throws Exception { + List> partitions = accessLogPartitionDAO.selectPartitions(PDAL_TABLE); + Set names = new HashSet(); + Date maxBoundary = null; + if (partitions != null) { + for (int i = 0; i < partitions.size(); i++) { + Map p = partitions.get(i); + String nm = asString(p.get("partitionName")); + if (nm != null) { + names.add(nm); + } + Date b = parseBoundary(stripQuotes(asString(p.get("partitionDesc")))); + if (b != null && (maxBoundary == null || b.after(maxBoundary))) { + maxBoundary = b; + } + } + } + + Calendar month = Calendar.getInstance(); + month.setTime(new Date()); + truncateToMonthStart(month); + + SimpleDateFormat nameFmt = new SimpleDateFormat("yyyyMM"); + SimpleDateFormat boundaryFmt = new SimpleDateFormat("yyyy-MM-dd"); + + for (int i = 0; i <= PARTITION_MONTHS_AHEAD; i++) { + Calendar m = (Calendar) month.clone(); + m.add(Calendar.MONTH, i); + String name = "p" + nameFmt.format(m.getTime()); + + Calendar next = (Calendar) m.clone(); + next.add(Calendar.MONTH, 1); + String boundaryStr = boundaryFmt.format(next.getTime()); + Date boundaryDate = parseBoundary(boundaryStr); + + // 이름 중복이 없고 기존 최대 경계보다 미래인 경우에만 ADD(RANGE 증가 제약 충족). + if (!names.contains(name) && (maxBoundary == null || boundaryDate.after(maxBoundary))) { + Map param = new HashMap(); + param.put("partitionName", name); + param.put("boundaryDt", boundaryStr); + accessLogPartitionDAO.addPartition(param); + recordPurge(name, boundaryStr, "ADD", "월 파티션 선행 생성"); + names.add(name); + maxBoundary = boundaryDate; + LOG.info("[개인정보/#37] 접속기록 파티션 선행 생성 — " + name + " (경계 " + boundaryStr + ")"); + } + } + } + + /** 파기·증설 이력 기록(실패해도 배치 본류를 막지 않는다). */ + private void recordPurge(String partitionName, String boundaryDt, String type, String note) { + try { + Map m = new HashMap(); + m.put("partitionName", partitionName); + m.put("boundaryDt", boundaryDt); + m.put("purgeType", type); + m.put("note", note); + accessLogPartitionDAO.insertPurgeHistory(m); + } catch (Exception e) { + LOG.error("[개인정보/#37] 파기이력 기록 실패 — " + partitionName, e); + } + } + + private static String asString(Object o) { + return o == null ? null : String.valueOf(o); + } + + /** RANGE COLUMNS 경계 리터럴의 따옴표를 제거한다(예: '2026-08-01' → 2026-08-01). */ + private static String stripQuotes(String s) { + if (s == null) { + return null; + } + String v = s.trim(); + if (v.length() >= 2 && v.charAt(0) == '\'' && v.charAt(v.length() - 1) == '\'') { + v = v.substring(1, v.length() - 1); + } + return v; + } + + /** 경계값(yyyy-MM-dd, 시각이 붙어도 앞 10자리)만 파싱. 파싱 불가(MAXVALUE 등)면 null. */ + private static Date parseBoundary(String boundary) { + if (boundary == null || boundary.length() < 10) { + return null; + } + try { + return new SimpleDateFormat("yyyy-MM-dd").parse(boundary.substring(0, 10)); + } catch (ParseException e) { + return null; + } + } + + private static void truncateToMonthStart(Calendar cal) { + cal.set(Calendar.DAY_OF_MONTH, 1); + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + } + + private static Date minusDays(Date base, int days) { + Calendar cal = Calendar.getInstance(); + cal.setTime(base); + cal.add(Calendar.DAY_OF_MONTH, -days); + return cal.getTime(); + } + + private static String formatDate(Date date) { + return new SimpleDateFormat(DT_FORMAT).format(date); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/interceptor/AuthorityInterceptor.java b/src/main/java/egovframework/com/cmm/privacy/interceptor/AuthorityInterceptor.java new file mode 100644 index 0000000..2448580 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/interceptor/AuthorityInterceptor.java @@ -0,0 +1,289 @@ +package egovframework.com.cmm.privacy.interceptor; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; + +import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.util.EgovUserDetailsHelper; + +/** + * 권한(롤) 판정 인터셉터 — 개인정보보호 기능개선 사업(SFR-001 요건4, 역할별 차등 접근, 개발항목 #2). + * + *

목적: "메뉴 노출 = URL 접근 허용"을 일치시킨다. 관리자 메뉴가 롤별로 다르게 보이는데 + * (admin은 많이, 시군구는 적게), 메뉴에 안 보이는 화면을 URL 직접입력으로 들어가는 것을 차단한다.

+ * + *

판정 데이터(기존 arko 메뉴권한 DB): {@link PrivacyMenuAuthDAO}가 기존 3테이블 + * (COMTNMENUCREATDTLS·COMTNMENUINFO·COMTNPROGRMLIST) 조인으로 "롤 → 허용 /mnt/ URL"을 전량 로드하고, + * 이를 최초 요청 시 1회 캐싱(TTL 경과 시 갱신)한다(요청마다 조인 금지).

+ * + *

preHandle 판정 순서: + *

    + *
  1. {@code /mnt/} 외 · 제외패턴 · 미인증(loginVO==null) → 통과(기존 유지)
  2. + *
  3. 명시 매핑({@code urlRoleMapping}) 우선 — 매칭 시 해당 롤 필요(신규 privacy 화면 보호 유지)
  4. + *
  5. 바이패스 롤({@code bypassRoles}, 기본 ROLE_ADMIN/ROLE_ADMINS) 보유자 → 통과(관리자 잠금 방지)
  6. + *
  7. 메뉴권한 판정 — 요청 path(쿼리 제외): + *
      + *
    • 사용자 롤들의 허용집합에 있으면 → 통과
    • + *
    • 없지만 전체 메뉴경로 합집합(다른 롤엔 있는 화면)에 있으면 → 403 차단
    • + *
    • 전체 메뉴경로에도 없으면(메뉴 아닌 하위액션/ajax) → 통과(하위액션 우선 허용 정책)
    • + *
    + *
  8. + *
+ * + *

운영 안전: ① 바이패스 롤(ROLE_ADMIN)로 관리자는 항상 통과. ② DAO/캐시 로드 실패 시 + * 예외를 삼키고 통과(fail-open) + ERROR 로깅 — 사이트 잠금 방지.

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.07.21    URP     최초 생성 (개인정보보호 기능개선 사업 SFR-001 / #2)
+ *  2026.07.22    URP     기존 메뉴권한 DB 기반 URL 접근제어로 개편(요건4) — 캐시/TTL/바이패스/fail-open
+ * 
+ */ +public class AuthorityInterceptor extends HandlerInterceptorAdapter { + + private static final Logger LOG = LoggerFactory.getLogger(AuthorityInterceptor.class); + + /** 적용 경로 접두(개인정보취급자 영역). 기본 /mnt/ */ + private String applyPathPrefix = "/mnt/"; + + /** 적용 제외 정규식 패턴(로그인/프레임/공통 등). contextPath 제외 URI 대상 */ + private List excludePatterns; + + /** + * URL(정규식) → 필요 권한코드 명시 매핑. 메뉴권한 판정보다 우선한다. + * 신규 privacy 화면(/mnt/privacy/·/mnt/sec/ → ROLE_ADMIN) 보호용. + */ + private Map urlRoleMapping; + + /** 메뉴권한 판정을 건너뛰고 항상 통과시킬 롤(관리자 잠금 방지). 기본 ROLE_ADMIN/ROLE_ADMINS */ + private Set bypassRoles; + + /** 캐시 유효시간(초). 기본 600초. */ + private int cacheTtlSeconds = 600; + + /** 메뉴권한 조회 DAO(루트 컨텍스트 @Repository, 자식→부모 가시성으로 주입) */ + private PrivacyMenuAuthDAO menuAuthDAO; + + // --- 캐시(최초 요청 시 로드, TTL 경과 시 갱신) --- + /** 롤 → 허용 path 집합('?' 앞 경로) */ + private volatile Map> allowedByRole; + /** 모든 롤의 메뉴 path 합집합 */ + private volatile Set allMenuPaths; + /** 캐시 적재 시각(ms) */ + private volatile long cacheLoadedAt = 0L; + + public void setApplyPathPrefix(String applyPathPrefix) { this.applyPathPrefix = applyPathPrefix; } + public void setExcludePatterns(List excludePatterns) { this.excludePatterns = excludePatterns; } + public void setUrlRoleMapping(Map urlRoleMapping) { this.urlRoleMapping = urlRoleMapping; } + public void setBypassRoles(Set bypassRoles) { this.bypassRoles = bypassRoles; } + public void setCacheTtlSeconds(int cacheTtlSeconds) { this.cacheTtlSeconds = cacheTtlSeconds; } + public void setMenuAuthDAO(PrivacyMenuAuthDAO menuAuthDAO) { this.menuAuthDAO = menuAuthDAO; } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + + String ctx = request.getContextPath(); + String uri = request.getRequestURI(); + String path = (ctx != null && uri.startsWith(ctx)) ? uri.substring(ctx.length()) : uri; + + // 1) 취급자 영역(/mnt/) 외 요청은 통과 + if (applyPathPrefix != null && !path.startsWith(applyPathPrefix)) { + return true; + } + + // 2) 제외 패턴 통과 + if (isExcluded(path)) { + return true; + } + + // 3) 미인증은 통과(기존 인증판정 체계가 처리) + LoginVO loginVO = (LoginVO) EgovUserDetailsHelper.getAuthenticatedUser(); + if (loginVO == null) { + return true; + } + + // 사용자 롤 목록(#1 getAuthorities 활용) + List roles = EgovUserDetailsHelper.getAuthorities(); + + // 4) 명시 매핑 우선 — 신규 privacy 화면(/mnt/privacy/·/mnt/sec/ → ROLE_ADMIN) 보호 + String requiredRole = resolveRequiredRole(path); + if (requiredRole != null) { + if (roles != null && roles.contains(requiredRole)) { + return true; + } + LOG.warn("[개인정보/#2] 권한 미허용 차단(403): user={}, path={}, requiredRole={}, roles={}", + new Object[] { loginVO.getId(), path, requiredRole, roles }); + response.sendError(HttpServletResponse.SC_FORBIDDEN); + return false; + } + + // 5) 바이패스 롤(관리자 등) 보유자는 통과 — 관리자 잠금 방지 + if (hasBypassRole(roles)) { + return true; + } + + // 6) 메뉴권한 판정 — 캐시(롤→허용 path) 대조 + Map> byRole; + Set allPaths; + try { + ensureCache(); + byRole = this.allowedByRole; + allPaths = this.allMenuPaths; + } catch (Exception e) { + // fail-open: 캐시 로드 실패 시 통과(사이트 잠금 방지) + LOG.error("[개인정보/#2] 메뉴권한 캐시 로드 실패 — fail-open 통과: path=" + path, e); + return true; + } + if (byRole == null || allPaths == null) { + // 캐시가 아직 없음(로드 실패 포함) → fail-open 통과 + LOG.error("[개인정보/#2] 메뉴권한 캐시 미적재 — fail-open 통과: path={}", path); + return true; + } + + // 요청 path 는 쿼리 없음(getRequestURI). 방어적으로 '?' 앞만 취함. + String reqPath = stripQuery(path); + + // 6-1) 사용자 롤들의 허용 path 합집합에 있으면 통과 + if (roles != null) { + for (int i = 0; i < roles.size(); i++) { + Set allowed = byRole.get(roles.get(i)); + if (allowed != null && allowed.contains(reqPath)) { + return true; + } + } + } + + // 6-2) 다른 롤엔 배정된 메뉴 화면인데 내 롤엔 없음 → 403 차단 + if (allPaths.contains(reqPath)) { + LOG.warn("[개인정보/#2] 권한 미허용 차단(403): user={}, path={}, requiredRole=menu-not-allowed, roles={}", + new Object[] { loginVO.getId(), reqPath, roles }); + response.sendError(HttpServletResponse.SC_FORBIDDEN); + return false; + } + + // 6-3) 메뉴 화면이 아닌 하위 액션/ajax → 통과(정책) + return true; + } + + /** 명시 매핑에서 필요 권한 조회. 매핑 없으면 null. */ + private String resolveRequiredRole(String path) { + if (urlRoleMapping == null || urlRoleMapping.isEmpty()) { + return null; + } + for (Map.Entry e : urlRoleMapping.entrySet()) { + if (e.getKey() != null && Pattern.matches(e.getKey(), path)) { + return e.getValue(); + } + } + return null; + } + + /** 바이패스 롤 보유 여부. */ + private boolean hasBypassRole(List roles) { + if (roles == null || bypassRoles == null || bypassRoles.isEmpty()) { + return false; + } + for (int i = 0; i < roles.size(); i++) { + if (bypassRoles.contains(roles.get(i))) { + return true; + } + } + return false; + } + + private boolean isExcluded(String path) { + if (excludePatterns == null) { + return false; + } + for (String p : excludePatterns) { + if (p != null && p.length() > 0 && Pattern.matches(p, path)) { + return true; + } + } + return false; + } + + /** 쿼리스트링('?' 이후) 제거. */ + private String stripQuery(String url) { + if (url == null) { + return null; + } + int q = url.indexOf('?'); + return (q >= 0) ? url.substring(0, q) : url; + } + + /** + * 캐시가 없거나 TTL 경과 시 메뉴권한을 재로드한다(더블체크 락). + * 로드 실패 시 예외를 던지지 않고 기존 캐시를 유지(호출부에서 fail-open 처리). + */ + private void ensureCache() { + long now = System.currentTimeMillis(); + long ttlMs = (long) cacheTtlSeconds * 1000L; + if (allowedByRole != null && (now - cacheLoadedAt) < ttlMs) { + return; // 유효 캐시 + } + synchronized (this) { + now = System.currentTimeMillis(); + if (allowedByRole != null && (now - cacheLoadedAt) < ttlMs) { + return; + } + loadCache(); + } + } + + /** 메뉴권한 전량 로드 후 캐시 갱신. 실패 시 기존 캐시 보존(로그만). */ + private void loadCache() { + if (menuAuthDAO == null) { + LOG.error("[개인정보/#2] menuAuthDAO 미주입 — 메뉴권한 캐시 로드 불가(fail-open)"); + return; + } + try { + List rows = menuAuthDAO.selectMenuUrlRoles(); + Map> byRole = new HashMap>(); + Set allPaths = new HashSet(); + if (rows != null) { + for (int i = 0; i < rows.size(); i++) { + MenuAuthVO row = rows.get(i); + if (row == null) { + continue; + } + String role = row.getAuthorCode(); + String p = stripQuery(row.getUrl()); + if (role == null || p == null || p.length() == 0) { + continue; + } + Set set = byRole.get(role); + if (set == null) { + set = new HashSet(); + byRole.put(role, set); + } + set.add(p); + allPaths.add(p); + } + } + this.allowedByRole = byRole; + this.allMenuPaths = allPaths; + this.cacheLoadedAt = System.currentTimeMillis(); + if (LOG.isInfoEnabled()) { + LOG.info("[개인정보/#2] 메뉴권한 캐시 로드 완료: roles={}, totalMenuPaths={}", + new Object[] { Integer.valueOf(byRole.size()), Integer.valueOf(allPaths.size()) }); + } + } catch (Exception e) { + // 기존 캐시 유지(있으면). 없으면 호출부에서 fail-open. + LOG.error("[개인정보/#2] 메뉴권한 캐시 로드 실패(기존 캐시 유지)", e); + } + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/interceptor/MenuAuthVO.java b/src/main/java/egovframework/com/cmm/privacy/interceptor/MenuAuthVO.java new file mode 100644 index 0000000..2596486 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/interceptor/MenuAuthVO.java @@ -0,0 +1,50 @@ +package egovframework.com.cmm.privacy.interceptor; + +import java.io.Serializable; + +/** + * 메뉴권한 → 허용 URL 매핑 행(row) VO. + * + *

개인정보보호 기능개선 사업(SFR-001 요건4, 역할별 차등 접근 / #2) — {@link AuthorityInterceptor}가 + * 기존 arko 메뉴권한 데이터(COMTNMENUCREATDTLS + COMTNMENUINFO + COMTNPROGRMLIST 조인)로 + * "롤 → 허용 /mnt/ URL"을 캐싱하기 위한 단일 행 표현.

+ * + *
    + *
  • authorCode : 권한코드(AUTHOR_CODE, 예: ROLE_ADMIN, ROLE_GUGUN_ADMIN)
  • + *
  • url : 프로그램 URL(COMTNPROGRMLIST.URL). 쿼리스트링 포함 가능 → + * 인터셉터가 '?' 앞 경로만 취해 캐시에 적재
  • + *
+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.07.22    URP     최초 생성 (개인정보보호 기능개선 사업 SFR-001 요건4 / #2)
+ * 
+ */ +public class MenuAuthVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 권한코드(AUTHOR_CODE) */ + private String authorCode; + + /** 프로그램 URL(쿼리스트링 포함 가능) */ + private String url; + + public String getAuthorCode() { + return authorCode; + } + + public void setAuthorCode(String authorCode) { + this.authorCode = authorCode; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/interceptor/PrivacyMenuAuthDAO.java b/src/main/java/egovframework/com/cmm/privacy/interceptor/PrivacyMenuAuthDAO.java new file mode 100644 index 0000000..2e77987 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/interceptor/PrivacyMenuAuthDAO.java @@ -0,0 +1,40 @@ +package egovframework.com.cmm.privacy.interceptor; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 메뉴권한 기반 "롤 → 허용 /mnt/ URL" 목록 조회 DAO. + * + *

개인정보보호 기능개선 사업(SFR-001 요건4, 역할별 차등 접근 / #2) — {@link AuthorityInterceptor}가 + * 최초 요청 시 1회 전량 로드해 캐싱한다(요청마다 조인 금지). 신규 테이블 없음 — + * 기존 arko 메뉴권한 3테이블(COMTNMENUCREATDTLS·COMTNMENUINFO·COMTNPROGRMLIST) 조인.

+ * + *

루트 컨텍스트 component-scan(egovframework, {@code @Repository} 포함)에 등록되며, + * 자식(servlet) 컨텍스트의 {@code authorityInterceptor} 빈이 자식→부모 가시성으로 참조한다.

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.07.22    URP     최초 생성 (개인정보보호 기능개선 사업 SFR-001 요건4 / #2)
+ * 
+ */ +@Repository("privacyMenuAuthDAO") +public class PrivacyMenuAuthDAO extends EgovComAbstractDAO { + + /** + * 메뉴에 배정된 (권한코드, 프로그램 URL) 전체 목록을 조회한다(파라미터 없음). + * URL은 '/mnt/%' 로 한정한다. URL에는 쿼리스트링이 포함될 수 있다. + * + * @return (authorCode, url) 목록. 없으면 빈 목록. + * @throws Exception 조회 오류 + */ + @SuppressWarnings("unchecked") + public List selectMenuUrlRoles() throws Exception { + return list("PrivacyMenuAuthDAO.selectMenuUrlRoles", null); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/interceptor/SecondFactorInterceptor.java b/src/main/java/egovframework/com/cmm/privacy/interceptor/SecondFactorInterceptor.java new file mode 100644 index 0000000..c305222 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/interceptor/SecondFactorInterceptor.java @@ -0,0 +1,266 @@ +package egovframework.com.cmm.privacy.interceptor; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Pattern; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; + +import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.privacy.auditlog.ClientIpResolver; +import egovframework.com.cmm.util.EgovUserDetailsHelper; +import egovframework.com.uat.uia.service.AuthMethodResolver; +import egovframework.com.uat.uia.service.AuthMethodService; + +/** + * 2단계(추가) 인증 판정 인터셉터 — 개인정보보호 기능개선 사업(SFR-003, 개발항목 #14). + * + *

개인정보취급자 영역(기본 {@code /mnt/})에 한해, 세션의 2차 인증 완료 표식 + * ({@code SECOND_FACTOR_DONE})이 없으면 2차 인증 화면으로 유도한다. + * 인터셉터 등록 순서상 {@link egovframework.com.cmm.privacy.interceptor.AuthorityInterceptor} + * 보다 먼저 실행되어야 한다(3단 순서: 인증판정 → SecondFactor → Authority, #26).

+ * + *

운영 안전(사이트 잠금 방지): 본 프로젝트는 전용 {@code } 경로매핑이 + * 아니라 {@code selectAnnotaionMapper}가 {@code /**}/*.do 전체에 인터셉터를 적용하므로, + * 적용 범위 판정을 이 클래스 내부에서 수행한다(그 외 경로는 통과). 또한 2차 인증 화면(#52)과 + * 인증수단 구현(#18~20)이 완료되기 전에는 리다이렉트 대상이 없어 취급자가 잠길 수 있으므로, + * {@code enforce} 스위치를 기본 {@code false}로 두어 등록만 하고 강제하지 않는다. + * 화면·인증수단 완료 후 {@code enforce=true} + {@code authMethodResolver} 빈 주입으로 활성화한다.

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.07.21    URP     최초 생성 (개인정보보호 기능개선 사업 SFR-003 / #14)
+ * 
+ */ +public class SecondFactorInterceptor extends HandlerInterceptorAdapter { + + private static final Logger LOG = LoggerFactory.getLogger(SecondFactorInterceptor.class); + + /** 세션 2차 인증 완료 표식 (SessionRegenerator.onSecondFactorSuccess 와 동일 키, #16) */ + public static final String ATTR_SECOND_FACTOR_DONE = "SECOND_FACTOR_DONE"; + + /** 강제 여부. 기본 false(등록만·미강제) — 2차 인증 화면(#52)·인증수단(#18~20) 완료 후 true 로 전환 */ + private boolean enforce = false; + + /** 적용 경로 접두(개인정보취급자 영역). 기본 /mnt/ */ + private String applyPathPrefix = "/mnt/"; + + /** 2차 인증 입력 화면 URL(리다이렉트 대상, contextPath 제외) */ + private String secondFactorUrl = "/uat/uia/secondFactor.do"; + + /** 적용 제외 정규식 패턴(로그인/공통/프레임 등 — 리다이렉트 루프 방지). contextPath 제외 URI 대상 */ + private List excludePatterns; + + /** 취급자별 인증수단 조회·분기(#21). enforce=true 시에만 사용, 빈 미주입이면 null 허용 */ + private AuthMethodResolver authMethodResolver; + + // ── 적용대상·경로 판정(#15) ────────────────────────────────────────────── + /** + * 내부접속 시 2차 인증 적용 여부(정책 설정, 기본 적용=true). + * 외부접속은 이 값과 무관하게 항상 필수(설정변경 불가) — 판정 로직에서 강제. + */ + private boolean applyToInternal = true; + + /** + * 내부(사내)접속 판정용 IP 대역(CIDR). 요청 클라이언트 IP가 이 대역이면 내부접속. + * TODO: 운영/발주기관 확인 — 실제 사내/전용선 대역으로 확정하고 정책 테이블 연동(#34 신뢰대역과 별개일 수 있음). + */ + private List internalIpCidrs = new ArrayList(Arrays.asList( + "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")); + + /** + * 개인정보취급자로 간주할 사용자구분(userSe) 코드 집합. 비어 있으면(기본) + * 취급자 영역(/mnt/)의 인증 사용자를 모두 취급자로 간주한다(fail-safe: 과소적용 방지). + */ + private List handlerUserSeCodes = new ArrayList(); + + public void setEnforce(boolean enforce) { this.enforce = enforce; } + public void setApplyPathPrefix(String applyPathPrefix) { this.applyPathPrefix = applyPathPrefix; } + public void setSecondFactorUrl(String secondFactorUrl) { this.secondFactorUrl = secondFactorUrl; } + public void setExcludePatterns(List excludePatterns) { this.excludePatterns = excludePatterns; } + public void setAuthMethodResolver(AuthMethodResolver authMethodResolver) { this.authMethodResolver = authMethodResolver; } + public void setApplyToInternal(boolean applyToInternal) { this.applyToInternal = applyToInternal; } + public void setInternalIpCidrs(List internalIpCidrs) { + if (internalIpCidrs != null && !internalIpCidrs.isEmpty()) { + this.internalIpCidrs = new ArrayList(internalIpCidrs); + } + } + public void setHandlerUserSeCodes(List handlerUserSeCodes) { + if (handlerUserSeCodes != null) { + this.handlerUserSeCodes = new ArrayList(handlerUserSeCodes); + } + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + + String ctx = request.getContextPath(); + String uri = request.getRequestURI(); + String path = (ctx != null && uri.startsWith(ctx)) ? uri.substring(ctx.length()) : uri; + + // 1) 취급자 영역(/mnt/) 외 요청은 통과 — 공개 프론트 잠금 방지 + if (applyPathPrefix != null && !path.startsWith(applyPathPrefix)) { + return true; + } + + // 2) 제외 패턴(로그인/프레임/2차인증화면 자신 등) 통과 — 리다이렉트 루프 방지 + if (isExcluded(path)) { + return true; + } + + // 3) 미인증은 통과(기존 인증판정 체계가 처리 — 여기서 인증을 새로 강제하지 않음) + LoginVO loginVO = (LoginVO) EgovUserDetailsHelper.getAuthenticatedUser(); + if (loginVO == null) { + return true; + } + + // 3-1) 적용대상 판정(#15): 개인정보취급자가 아니면 2차 인증 대상 아님 → 통과 + if (!isPrivacyHandler(loginVO)) { + if (LOG.isDebugEnabled()) { + LOG.debug("[개인정보/#15] 비취급자(userSe={}) — 2차 인증 미적용 통과: {}", loginVO.getUserSe(), path); + } + return true; + } + + // 3-2) 경로(접속위치) 판정(#15): 외부접속은 2차 인증 필수(설정변경 불가), + // 내부접속은 정책(applyToInternal, 기본 적용). 필수가 아니면 통과. + boolean external = !isInternalAccess(request); + boolean required = external || applyToInternal; + if (!required) { + LOG.info("[개인정보/#15] 내부접속·정책상 2차 인증 미적용(applyToInternal=false) 통과: user={}, path={}", + loginVO.getId(), path); + return true; + } + + // 4) 미강제(기본) — 판정상 2차 인증 대상이어도 enforce=false면 등록만 하고 통과. + // (판정 결과는 로깅해 활성화 전 검증에 활용. 활성화는 2차인증 화면(#52)·인증수단 완료 후.) + if (!enforce) { + if (LOG.isDebugEnabled()) { + LOG.debug("[개인정보/#14·#15] 2차 인증 대상(external={}, applyToInternal={}) — 단, enforce=false 통과: user={}, path={}", + new Object[] { Boolean.valueOf(external), Boolean.valueOf(applyToInternal), loginVO.getId(), path }); + } + return true; + } + + // 5) 이미 2차 인증 완료된 세션은 통과 + HttpSession session = request.getSession(false); + if (session != null && Boolean.TRUE.equals(session.getAttribute(ATTR_SECOND_FACTOR_DONE))) { + return true; + } + + // 6) 사용 가능한 인증수단이 없으면 잠금 대신 통과(취급자가 2FA 미설정 상태에서 잠기는 것 방지) + 로깅 + if (authMethodResolver != null) { + try { + AuthMethodService svc = authMethodResolver.resolve(loginVO.getUniqId()); + if (svc == null || !svc.isAvailable(loginVO.getUniqId())) { + LOG.warn("[개인정보/#14] 2차 인증수단 미설정/미가용 — 통과 처리(설정 유도 필요): user={}, path={}", + loginVO.getId(), path); + return true; + } + } catch (Exception e) { + // 인증수단 구현(#18~20) 미완 등으로 판정 불가 시 잠금 대신 통과 + 로깅 + LOG.warn("[개인정보/#14] 2차 인증수단 판정 불가 — 통과 처리: user={}, path={}, cause={}", + new Object[] { loginVO.getId(), path, e.toString() }); + return true; + } + } + + // 7) 2차 인증 미완료 → 2차 인증 화면으로 유도 + LOG.info("[개인정보/#14] 2차 인증 미완료 — 인증 화면 유도: user={}, path={}", loginVO.getId(), path); + response.sendRedirect(ctx + secondFactorUrl); + return false; + } + + private boolean isExcluded(String path) { + if (excludePatterns == null) { + return false; + } + for (String p : excludePatterns) { + if (p != null && p.length() > 0 && Pattern.matches(p, path)) { + return true; + } + } + return false; + } + + /** + * 개인정보취급자 여부 판정(#15). {@code handlerUserSeCodes}가 비어 있으면 취급자 영역(/mnt/)의 + * 인증 사용자를 모두 취급자로 간주한다(fail-safe: 과소적용 방지). 코드가 설정되면 해당 userSe만 취급자. + */ + private boolean isPrivacyHandler(LoginVO loginVO) { + if (handlerUserSeCodes == null || handlerUserSeCodes.isEmpty()) { + return true; + } + String userSe = loginVO.getUserSe(); + return userSe != null && handlerUserSeCodes.contains(userSe); + } + + /** + * 내부(사내)접속 여부 판정(#15). 클라이언트 실제 IP(#34 {@link ClientIpResolver} 재사용)가 + * {@code internalIpCidrs} 대역에 속하면 내부접속. 판정 불가/예외 시 외부접속으로 간주 + * (안전측: 외부는 2차 인증 필수라 과소적용을 피한다). + */ + private boolean isInternalAccess(HttpServletRequest request) { + try { + String ip = ClientIpResolver.getClientIp(request); + if (ip == null) { + return false; + } + for (String cidr : internalIpCidrs) { + if (ipv4InCidr(ip, cidr)) { + return true; + } + } + return false; + } catch (RuntimeException e) { + LOG.warn("[개인정보/#15] 내부/외부 접속 판정 실패 — 외부접속(2차 인증 필수)로 간주: {}", e.toString()); + return false; + } + } + + /** IPv4 주소의 CIDR 포함 여부. 파싱 불가(IPv6 등)면 false. */ + private static boolean ipv4InCidr(String ip, String cidr) { + try { + int slash = cidr.indexOf('/'); + if (slash < 0) { + return false; + } + int prefix = Integer.parseInt(cidr.substring(slash + 1)); + long ipLong = ipv4ToLong(ip); + long netLong = ipv4ToLong(cidr.substring(0, slash)); + if (ipLong < 0 || netLong < 0) { + return false; + } + long mask = (prefix == 0) ? 0L : (0xFFFFFFFFL << (32 - prefix)) & 0xFFFFFFFFL; + return (ipLong & mask) == (netLong & mask); + } catch (RuntimeException e) { + return false; + } + } + + /** 점 표기 IPv4를 부호없는 long으로. 형식 오류(IPv6 포함) 시 -1. */ + private static long ipv4ToLong(String ip) { + String[] octets = ip.split("\\."); + if (octets.length != 4) { + return -1L; + } + long result = 0L; + for (String octet : octets) { + int v = Integer.parseInt(octet.trim()); + if (v < 0 || v > 255) { + return -1L; + } + result = (result << 8) | v; + } + return result & 0xFFFFFFFFL; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/lock/AccountUnlockHistoryDAO.java b/src/main/java/egovframework/com/cmm/privacy/lock/AccountUnlockHistoryDAO.java new file mode 100644 index 0000000..1e0aef9 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/lock/AccountUnlockHistoryDAO.java @@ -0,0 +1,35 @@ +package egovframework.com.cmm.privacy.lock; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 계정 잠금 해제 이력 DAO — 개인정보보호 기능개선 사업(SER-001 / #24). + * + *

iBatis 2 기반. 잠금 해제 시 이력 적재(insert)와 조회(select)만 제공한다. + * UPDATE/DELETE statement는 정의하지 않는다(이력 위·변조 억제).

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.07.21    URP     최초 생성 (개인정보보호 SER-001 #24)
+ * 
+ */ +@Repository("accountUnlockHistoryDAO") +public class AccountUnlockHistoryDAO extends EgovComAbstractDAO { + + /** 잠금 해제 이력 1건 적재 */ + public void insertUnlockHistory(AccountUnlockHistoryVO vo) { + insert("AccountUnlockHistoryDAO.insertUnlockHistory", vo); + } + + /** 잠금 해제 이력 조회(대상/해제자/기간 검색 — 화면 후속) */ + @SuppressWarnings("unchecked") + public List selectUnlockHistoryList(AccountUnlockHistoryVO vo) { + return list("AccountUnlockHistoryDAO.selectUnlockHistoryList", vo); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/lock/AccountUnlockHistoryVO.java b/src/main/java/egovframework/com/cmm/privacy/lock/AccountUnlockHistoryVO.java new file mode 100644 index 0000000..ae212dd --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/lock/AccountUnlockHistoryVO.java @@ -0,0 +1,57 @@ +package egovframework.com.cmm.privacy.lock; + +import java.io.Serializable; + +/** + * 계정 잠금 해제 이력 VO — 개인정보보호 기능개선 사업(SER-001 / #24). + * + *

관리자가 잠긴 계정을 해제할 때마다 해제자·해제일시·대상·사유를 적재한다 + * (DB 직접조작 대신 관리자 화면 해제 + 이력화).

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.07.21    URP     최초 생성 (개인정보보호 SER-001 #24)
+ * 
+ */ +public class AccountUnlockHistoryVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 해제 이력 일련번호(PK) */ + private long unlockHistId; + /** 해제 대상 계정 ID */ + private String trgetId; + /** 해제 대상 회원구분(GNR/GRO/ENT/USR) */ + private String trgetSe; + /** 해제 유형 I:개별 A:전체 */ + private String unlockTy; + /** 해제 처리자(관리자) ID */ + private String unlockUserId; + /** 해제 사유 */ + private String unlockResn; + /** 해제자 접속 IP */ + private String unlockIp; + + public long getUnlockHistId() { return unlockHistId; } + public void setUnlockHistId(long unlockHistId) { this.unlockHistId = unlockHistId; } + + public String getTrgetId() { return trgetId; } + public void setTrgetId(String trgetId) { this.trgetId = trgetId; } + + public String getTrgetSe() { return trgetSe; } + public void setTrgetSe(String trgetSe) { this.trgetSe = trgetSe; } + + public String getUnlockTy() { return unlockTy; } + public void setUnlockTy(String unlockTy) { this.unlockTy = unlockTy; } + + public String getUnlockUserId() { return unlockUserId; } + public void setUnlockUserId(String unlockUserId) { this.unlockUserId = unlockUserId; } + + public String getUnlockResn() { return unlockResn; } + public void setUnlockResn(String unlockResn) { this.unlockResn = unlockResn; } + + public String getUnlockIp() { return unlockIp; } + public void setUnlockIp(String unlockIp) { this.unlockIp = unlockIp; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/security/SessionRegenerator.java b/src/main/java/egovframework/com/cmm/privacy/security/SessionRegenerator.java new file mode 100644 index 0000000..eff6746 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/security/SessionRegenerator.java @@ -0,0 +1,93 @@ +package egovframework.com.cmm.privacy.security; + +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +/** + * 세션 재발급(세션 고정 방지) 유틸리티. + * + *

문화품앗이 개인정보보호 기능개선 사업(SFR-003 / SER-001) — 개발항목 #16.

+ * + *

2차 인증 성공 등 권한 상승 시점에 기존 세션 속성을 보존한 채 + * 세션을 무효화({@code invalidate})하고 새 세션을 재발급한다. 이로써 로그인 이전에 + * 발급된 세션 ID를 그대로 재사용하는 세션 고정(Session Fixation) 공격을 차단한다.

+ * + *

사용 지점(후속): {@code SecondFactorInterceptor}(#14) 2차 인증 성공 콜백에서 호출. + * 인터셉터가 아직 없는 현재는 재사용 가능한 static 유틸로만 제공한다.

+ * + *
+ * 흐름: 보존 속성 복사 → oldSession.invalidate() → newSession = getSession(true) → 속성 재적재
+ * 
+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.07.21    URP     최초 생성 (개인정보보호 기능개선 사업 #16)
+ * 
+ */ +public final class SessionRegenerator { + + private SessionRegenerator() { } + + /** 2차 인증 완료 표식 세션 키(SecondFactorInterceptor와 공유). */ + public static final String SECOND_FACTOR_DONE = "SECOND_FACTOR_DONE"; + /** 로그인 정보 세션 키. */ + public static final String LOGIN_VO = "loginVO"; + + /** + * 기존 세션의 모든 속성을 보존한 채 세션을 재발급한다. + * + * @param request HTTP 요청(널이면 아무 동작 없이 null 반환) + * @return 재발급된 새 세션. 기존 세션이 없으면 새로 생성한 세션. + */ + public static HttpSession regenerate(HttpServletRequest request) { + if (request == null) { + return null; + } + + HttpSession oldSession = request.getSession(false); + + // 기존 세션이 없으면 고정 위험이 없으므로 새 세션만 생성해 반환. + if (oldSession == null) { + return request.getSession(true); + } + + // 1) 보존 속성 복사(무효화 전에 반드시 캡처). + Map preserved = new HashMap(); + Enumeration names = oldSession.getAttributeNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + preserved.put(name, oldSession.getAttribute(name)); + } + + // 2) 기존 세션 무효화(세션 ID 폐기). + oldSession.invalidate(); + + // 3) 새 세션 재발급 + 속성 재적재. + HttpSession newSession = request.getSession(true); + for (Map.Entry entry : preserved.entrySet()) { + newSession.setAttribute(entry.getKey(), entry.getValue()); + } + return newSession; + } + + /** + * 2차 인증 성공 시점 처리: 세션을 재발급하고 2차 인증 완료 표식을 세팅한다. + * (authctrl-impl §7 {@code onSecondFactorSuccess} 패턴을 전 속성 보존 방식으로 일반화) + * + * @param request HTTP 요청 + * @return 재발급된 새 세션(2차 인증 완료 표식 포함) + */ + public static HttpSession onSecondFactorSuccess(HttpServletRequest request) { + HttpSession newSession = regenerate(request); + if (newSession != null) { + newSession.setAttribute(SECOND_FACTOR_DONE, Boolean.TRUE); + } + return newSession; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/util/MaskingUtil.java b/src/main/java/egovframework/com/cmm/privacy/util/MaskingUtil.java new file mode 100644 index 0000000..46df5e9 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/util/MaskingUtil.java @@ -0,0 +1,204 @@ +/** + * Class Name : MaskingUtil.java + * Description : 개인정보 표시용 마스킹 유틸리티 + * (문화품앗이 개인정보보호 기능개선 사업 — #29 / SER-003 인증정보 보호) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.21 Fork C(개인정보보호) 최초 생성 — 휴대폰/이메일/이름/일반 마스킹 static 메서드 + * + * @since 2026. 07. 21 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.util; + +/** + * 개인정보 표시(display) 전용 마스킹 유틸리티. + * + *

2차 인증 화면(#52)·마이페이지·접속기록 조회 등에서 화면에 개인정보를 노출할 때 + * 사용한다. JSP EL/스크립틀릿에서 바로 호출할 수 있도록 모든 메서드를 {@code static}으로 + * 제공한다(예: {@code ${egovframework.com.cmm.privacy.util.MaskingUtil.maskPhone(vo.moblphonNo)}}).

+ * + *

주의: 이 유틸은 표시값만 변환한다 — 원본은 저장·전송·해시체인 적재에 그대로 사용하고, + * 마스킹된 문자열을 다시 저장하거나 로그로 남기지 않는다(마스킹 결과의 역산은 불가).

+ * + *

null/빈값/형식 이상(하이픈 유무, 길이 부족)은 예외 없이 방어적으로 처리한다.

+ */ +public final class MaskingUtil { + + private MaskingUtil() { + } + + private static final String EMPTY = ""; + + /** + * 휴대폰/전화번호의 가운데 국번을 마스킹한다. (개인정보 보호 관행: 가운데 4자리) + * + *
+     *  "01012341234"   → "010-****-1234"
+     *  "010-1234-1234" → "010-****-1234"
+     *  "0212345678"    → "02-****-5678"
+     *  null/짧은값      → 원본 그대로(빈값은 빈문자열)
+     * 
+ * + * @param phone 원본 번호(하이픈 유무 무관) + * @return 마스킹된 번호 + */ + public static String maskPhone(String phone) { + if (phone == null) { + return EMPTY; + } + String digits = phone.replaceAll("[^0-9]", ""); + int len = digits.length(); + if (len < 7) { + // 마스킹 기준 미달 — 원본 노출 대신 뒤 절반만 남기고 가림 + return maskTail(phone); + } + // 뒤 4자리는 노출, 앞 자리(지역/식별번호)는 노출, 그 사이(가운데)를 가림 + String last = digits.substring(len - 4); + String first = digits.substring(0, len - 4 - 4 >= 0 ? len - 8 : len - 4 - Math.min(4, len - 4)); + // 가운데는 항상 4개의 *로 표기(형식 일관) + return first + "-****-" + last; + } + + /** + * 이메일 로컬파트를 마스킹한다. (앞 1~2자만 노출) + * + *
+     *  "hong@arko.or.kr"  → "ho**@arko.or.kr"
+     *  "a@b.com"          → "a*@b.com"
+     *  도메인/@ 없는 값     → 앞 일부만 노출
+     * 
+ * + * @param email 원본 이메일 + * @return 마스킹된 이메일 + */ + public static String maskEmail(String email) { + if (email == null) { + return EMPTY; + } + int at = email.indexOf('@'); + if (at <= 0) { + return maskTail(email); + } + String local = email.substring(0, at); + String domain = email.substring(at); // '@' 포함 + int keep = local.length() <= 2 ? 1 : 2; + StringBuilder sb = new StringBuilder(); + sb.append(local.substring(0, Math.min(keep, local.length()))); + int stars = Math.max(1, local.length() - keep); + for (int i = 0; i < stars; i++) { + sb.append('*'); + } + sb.append(domain); + return sb.toString(); + } + + /** + * 성명을 마스킹한다. (가운데 글자를 가림) + * + *
+     *  "홍길동"   → "홍*동"
+     *  "김철수영" → "김**영"
+     *  "이순"     → "이*"
+     *  1글자      → "*"
+     * 
+ * + * @param name 원본 성명 + * @return 마스킹된 성명 + */ + public static String maskName(String name) { + if (name == null) { + return EMPTY; + } + String n = name.trim(); + int len = n.length(); + if (len == 0) { + return EMPTY; + } + if (len == 1) { + return "*"; + } + if (len == 2) { + return n.charAt(0) + "*"; + } + StringBuilder sb = new StringBuilder(); + sb.append(n.charAt(0)); + for (int i = 1; i < len - 1; i++) { + sb.append('*'); + } + sb.append(n.charAt(len - 1)); + return sb.toString(); + } + + /** + * 임의 문자열의 앞 {@code keepHead}자, 뒤 {@code keepTail}자만 남기고 가운데를 가리는 범용 마스킹. + * + * @param value 원본 + * @param keepHead 앞에서 노출할 글자 수(0 이상) + * @param keepTail 뒤에서 노출할 글자 수(0 이상) + * @return 마스킹 결과(길이 부족 시 전부 가림) + */ + public static String mask(String value, int keepHead, int keepTail) { + if (value == null) { + return EMPTY; + } + int len = value.length(); + int head = Math.max(0, keepHead); + int tail = Math.max(0, keepTail); + if (head + tail >= len) { + // 노출 요청이 전체 길이 이상이면 안전측으로 전부 가림 + return repeat('*', len); + } + StringBuilder sb = new StringBuilder(); + sb.append(value, 0, head); + sb.append(repeat('*', len - head - tail)); + sb.append(value.substring(len - tail)); + return sb.toString(); + } + + /** 뒤 절반만 노출하고 앞을 가리는 짧은값 폴백. */ + private static String maskTail(String value) { + if (value == null) { + return EMPTY; + } + int len = value.length(); + if (len <= 1) { + return repeat('*', len); + } + int keep = len / 2; + return repeat('*', len - keep) + value.substring(len - keep); + } + + private static String repeat(char c, int n) { + if (n <= 0) { + return EMPTY; + } + char[] arr = new char[n]; + for (int i = 0; i < n; i++) { + arr[i] = c; + } + return new String(arr); + } + + // 간이 자체검증 (운영 미포함, 필요 시 `java ... MaskingUtil` 로 확인) + public static void main(String[] args) { + assertEq("010-****-1234", maskPhone("01012341234")); + assertEq("010-****-1234", maskPhone("010-1234-1234")); + assertEq("ho**@arko.or.kr", maskEmail("hong@arko.or.kr")); + assertEq("a*@b.com", maskEmail("a@b.com")); + assertEq("홍*동", maskName("홍길동")); + assertEq("김**영", maskName("김철수영")); + assertEq("이*", maskName("이순")); + assertEq("", maskPhone(null)); + System.out.println("MaskingUtil self-check OK"); + } + + private static void assertEq(String expected, String actual) { + if (expected == null ? actual != null : !expected.equals(actual)) { + throw new AssertionError("expected=[" + expected + "] actual=[" + actual + "]"); + } + } +} diff --git a/src/main/java/egovframework/com/cmm/service/impl/EgovUserAuthorityDAO.java b/src/main/java/egovframework/com/cmm/service/impl/EgovUserAuthorityDAO.java new file mode 100644 index 0000000..b21a997 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/service/impl/EgovUserAuthorityDAO.java @@ -0,0 +1,35 @@ +package egovframework.com.cmm.service.impl; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +/** + * 인증된 사용자의 권한(롤) 목록 조회 DAO. + * + *

개인정보보호 기능개선 사업(SFR-001, 역할별 차등 접근) — {@code getAuthorities()} 구현용. + * 신규 권한 테이블을 만들지 않고 기존 표준 권한 매핑 테이블(COMTNEMPLYRSCRTYESTBS)을 + * 사용자 고유식별자(uniqId = ESNTL_ID = SCRTY_DTRMN_TRGET_ID)로 조회한다.

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.07.21    URP     최초 생성 (개인정보보호 기능개선 사업 SFR-001)
+ * 
+ */ +@Repository("egovUserAuthorityDAO") +public class EgovUserAuthorityDAO extends EgovComAbstractDAO { + + /** + * 사용자 고유식별자(uniqId)로 부여된 권한코드(AUTHOR_CODE) 목록을 조회한다. + * + * @param uniqId 사용자 고유식별자(ESNTL_ID) + * @return 권한코드 목록 (예: ROLE_ADMIN, ROLE_USER). 없으면 빈 목록. + * @throws Exception 조회 오류 + */ + @SuppressWarnings("unchecked") + public List selectAuthoritiesByUniqId(String uniqId) throws Exception { + return list("EgovUserAuthorityDAO.selectAuthoritiesByUniqId", uniqId); + } +} diff --git a/src/main/java/egovframework/com/cmm/service/impl/EgovUserDetailsSessionServiceImpl.java b/src/main/java/egovframework/com/cmm/service/impl/EgovUserDetailsSessionServiceImpl.java index ef50abd..5cb162a 100644 --- a/src/main/java/egovframework/com/cmm/service/impl/EgovUserDetailsSessionServiceImpl.java +++ b/src/main/java/egovframework/com/cmm/service/impl/EgovUserDetailsSessionServiceImpl.java @@ -6,6 +6,7 @@ import java.util.List; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; +import egovframework.com.cmm.LoginVO; import egovframework.com.cmm.service.EgovUserDetailsService; import egovframework.rte.fdl.cmmn.AbstractServiceImpl; @@ -29,19 +30,59 @@ import egovframework.rte.fdl.cmmn.AbstractServiceImpl; public class EgovUserDetailsSessionServiceImpl extends AbstractServiceImpl implements EgovUserDetailsService { + /** 사용자 권한(롤) 조회 DAO — 개인정보보호 기능개선 사업(SFR-001) */ + private EgovUserAuthorityDAO userAuthorityDAO; + + public void setUserAuthorityDAO(EgovUserAuthorityDAO userAuthorityDAO) { + this.userAuthorityDAO = userAuthorityDAO; + } + public Object getAuthenticatedUser() { - + return RequestContextHolder.getRequestAttributes().getAttribute("loginVO", RequestAttributes.SCOPE_SESSION); } + /** + * 인증된 사용자의 권한(롤) 목록을 반환한다. + * + *

개인정보보호 기능개선 사업(SFR-001, 역할별 차등 접근) 구현. + * 세션의 로그인 정보(LoginVO)에서 고유식별자(uniqId)를 얻어 기존 표준 권한 매핑 + * 테이블(COMTNEMPLYRSCRTYESTBS)을 조회한다 — 신규 권한 테이블 없음. + * 미인증/미조회 시 빈 목록을 반환한다(fail-safe: 권한 없음).

+ */ public List getAuthorities() { // 권한 설정을 리턴한다. List listAuth = new ArrayList(); + // 요청 컨텍스트/세션이 없으면 권한 없음 + if (RequestContextHolder.getRequestAttributes() == null) { + return listAuth; + } + + Object user = getAuthenticatedUser(); + if (!(user instanceof LoginVO)) { + return listAuth; + } + + String uniqId = ((LoginVO) user).getUniqId(); + if (uniqId == null || uniqId.trim().length() == 0) { + return listAuth; + } + + try { + List roleList = userAuthorityDAO.selectAuthoritiesByUniqId(uniqId); + if (roleList != null) { + listAuth.addAll(roleList); + } + } catch (Exception e) { + // 조회 실패 시 안전측(권한 없음)으로 처리한다. + // TODO: 공통 로깅 체계(EgovLogger 등) 연동 + } + return listAuth; } diff --git a/src/main/java/egovframework/com/sec/security/filter/EgovSpringSecurityLoginFilter.java b/src/main/java/egovframework/com/sec/security/filter/EgovSpringSecurityLoginFilter.java index ce8f259..66c8366 100644 --- a/src/main/java/egovframework/com/sec/security/filter/EgovSpringSecurityLoginFilter.java +++ b/src/main/java/egovframework/com/sec/security/filter/EgovSpringSecurityLoginFilter.java @@ -267,9 +267,12 @@ private FilterConfig config; allowList.add("106.241.248.119"); allowList.add("106.241.248.125"); allowList.add("60.197.123.147"); - // 2025.07.21 나혁제 urp IP 등록처리 - allowList.add("220.118.247.143"); + // 2025.07.21 나혁제 urp IP 등록처리 + allowList.add("220.118.247.143"); allowList.add("175.198.62.216"); + // 2026.06.18 로컬 개발 접속 허용 (localhost) + allowList.add("127.0.0.1"); + allowList.add("0:0:0:0:0:0:0:1"); for (String ip : allowList) { diff --git a/src/main/java/egovframework/com/uat/uia/service/AuthMethodPolicyProvider.java b/src/main/java/egovframework/com/uat/uia/service/AuthMethodPolicyProvider.java new file mode 100644 index 0000000..b2fd2d5 --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/service/AuthMethodPolicyProvider.java @@ -0,0 +1,34 @@ +/** + * Class Name : AuthMethodPolicyProvider.java + * Description : 취급자별 활성 인증수단 정책 조회 추상화 + * (문화품앗이 개인정보보호 기능개선 사업 — Ⅲ-3 인증체계 / IAR-001 #21) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.21 Fork C(개인정보보호) 최초 생성 — 정책조회 추상화(정책 테이블 연동은 후속) + * + * @since 2026. 07. 21 + * @version 1.0 + */ +package egovframework.com.uat.uia.service; + +/** + * 개인정보취급자별로 지정된 추가 인증수단 코드를 조회하는 정책 제공자. + * + *

{@link AuthMethodResolver}가 이 인터페이스를 통해 취급자별 인증수단을 얻는다. + * 정책 테이블(예: {@code AUTH_METHOD_POLICY}) 연동 구현은 후속(#21 후속)이며, + * 그때 이 인터페이스의 구현체 1개만 교체하면 되도록 리졸버와 분리한다.

+ */ +public interface AuthMethodPolicyProvider { + + /** + * 취급자 고유식별자로 지정 인증수단 코드를 조회한다. + * + * @param uniqId 개인정보취급자 고유식별자(ESNTL_ID) + * @return 인증수단 코드(OTP/SMS/CERT). 미지정 시 null 가능(리졸버가 기본수단으로 폴백). + * @throws Exception 조회 오류 + */ + String resolveAuthMethodCode(String uniqId) throws Exception; +} diff --git a/src/main/java/egovframework/com/uat/uia/service/AuthMethodResolver.java b/src/main/java/egovframework/com/uat/uia/service/AuthMethodResolver.java new file mode 100644 index 0000000..d730569 --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/service/AuthMethodResolver.java @@ -0,0 +1,84 @@ +/** + * Class Name : AuthMethodResolver.java + * Description : 취급자별 추가 인증수단(AuthMethodService) 분기 리졸버 + * (문화품앗이 개인정보보호 기능개선 사업 — Ⅲ-3 인증체계 / IAR-001 #21) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.21 Fork C(개인정보보호) 최초 생성 — 정책조회→구현체 분기 + 사용가능 폴백 + * + * @since 2026. 07. 21 + * @version 1.0 + */ +package egovframework.com.uat.uia.service; + +import java.util.Map; + +/** + * 개인정보취급자별 활성 인증수단을 판정해 적절한 {@link AuthMethodService} 구현체로 분기한다. + * + *

분기 방식: {@link AuthMethodPolicyProvider}가 반환한 인증수단 코드(OTP/SMS/CERT)를 + * 주입된 {@code serviceMap}(코드 → 구현체)으로 매핑한다. 신규 인증수단 추가 시 + * 구현체 1개 + 맵 항목 1개만 추가하면 되며 이 리졸버 소스는 수정하지 않는다.

+ * + *

폴백: 정책상 지정 수단이 해당 취급자에게 사용 불가({@code isAvailable == false})하면 + * 우선순위(OTP→SMS→CERT) 순으로 사용 가능한 첫 수단을 선택한다.

+ * + *

주의: 인증수단 구현체(#18~20)가 스텁 상태인 동안 {@code isAvailable}은 + * {@code UnsupportedOperationException}을 던진다 — 리졸버 로직 자체는 완성이며, + * 구현체 완료 후 정상 동작한다.

+ */ +public class AuthMethodResolver { + + /** 인증수단 코드("OTP"/"SMS"/"CERT") → 구현체. Spring 설정에서 주입. */ + private Map serviceMap; + + /** 취급자별 인증수단 정책 조회기. */ + private AuthMethodPolicyProvider policyProvider; + + public void setServiceMap(Map serviceMap) { + this.serviceMap = serviceMap; + } + + public void setPolicyProvider(AuthMethodPolicyProvider policyProvider) { + this.policyProvider = policyProvider; + } + + /** + * 취급자에게 적용할 인증수단 구현체를 반환한다. + * + * @param uniqId 개인정보취급자 고유식별자(ESNTL_ID) + * @return 적용 인증수단 구현체 + * @throws Exception 정책조회·사용가능 판정 중 오류 + * @throws IllegalStateException 사용 가능한 인증수단이 하나도 없는 경우 + */ + public AuthMethodService resolve(String uniqId) throws Exception { + String code = (policyProvider != null) ? policyProvider.resolveAuthMethodCode(uniqId) : null; + AuthMethodType preferred = AuthMethodType.fromCode(code); // null·미정의 → OTP + + // 1) 정책 지정 수단이 사용 가능하면 그대로 사용 + AuthMethodService svc = getService(preferred); + if (svc != null && svc.isAvailable(uniqId)) { + return svc; + } + + // 2) 폴백 — 우선순위(OTP→SMS→CERT) 중 사용 가능한 첫 수단 + for (AuthMethodType type : AuthMethodType.priorityOrder()) { + if (type == preferred) { + continue; // 이미 위에서 확인 + } + AuthMethodService candidate = getService(type); + if (candidate != null && candidate.isAvailable(uniqId)) { + return candidate; + } + } + + throw new IllegalStateException("사용 가능한 추가 인증수단이 없습니다: uniqId=" + uniqId); + } + + private AuthMethodService getService(AuthMethodType type) { + return (serviceMap == null || type == null) ? null : serviceMap.get(type.code()); + } +} diff --git a/src/main/java/egovframework/com/uat/uia/service/AuthMethodService.java b/src/main/java/egovframework/com/uat/uia/service/AuthMethodService.java new file mode 100644 index 0000000..553a1b8 --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/service/AuthMethodService.java @@ -0,0 +1,62 @@ +/** + * Class Name : AuthMethodService.java + * Description : 개인정보취급자 2단계(추가) 인증수단 공통 인터페이스 + * (문화품앗이 개인정보보호 기능개선 사업 — Ⅲ-3 인증체계 / IAR-001) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.21 Fork C(개인정보보호) 최초 생성 — 인증수단 3종(OTP/SMS/공동인증서) 공통 계약 정의 + * + * @since 2026. 07. 21 + * @version 1.0 + */ +package egovframework.com.uat.uia.service; + +/** + * 추가 인증수단(2단계 인증) 공통 서비스 인터페이스. + * + *

신규 인증수단 도입 시 이 인터페이스의 구현체 1개만 추가하면 되도록 설계한다 + * (소스 수정 범위를 신규 클래스 1개로 한정). 취급자별 활성 인증수단은 정책 테이블에서 + * 조회하여 {@code AuthMethodResolver}가 적절한 구현체로 분기한다.

+ * + *

구현체(적용 순서): {@code OtpAuthService}(1차, TOTP 자체구현) → + * {@code SmsAuthService}(2차, KT-TCS/MCSAgent3.0 연계) → + * {@code CertAuthService}(3차, GPKI 기존 자산 재활용).

+ * + *

인증정보 보호 원칙(SER-003): OTP 비밀키는 암호화 저장하고 화면·로그에 노출하지 않는다. + * SMS 인증번호는 저장하지 않고 유효시간(3분) 내 검증 후 폐기한다. + * 공동인증서 본문은 저장하지 않고 식별값만 매핑한다.

+ */ +public interface AuthMethodService { + + /** + * 인증수단을 발급/발송한다. + * (OTP: 비밀키 생성·프로비저닝, SMS: 인증번호 발송, 공동인증서: 챌린지 발급 등) + * + * @param uniqId 개인정보취급자 고유식별자(COMTNEMPLYRINFO.ESNTL_ID 등) + * @throws Exception 발급/발송 처리 중 오류 + */ + void issue(String uniqId) throws Exception; + + /** + * 사용자가 제출한 인증코드를 검증한다. + * + * @param uniqId 개인정보취급자 고유식별자 + * @param code 사용자 제출 인증코드(OTP 6자리 / SMS 인증번호 / 인증서 서명값 등) + * @return 검증 성공 여부(true: 인증 성공) + * @throws Exception 검증 처리 중 오류 + */ + boolean verify(String uniqId, String code) throws Exception; + + /** + * 해당 사용자에게 이 인증수단이 사용 가능(활성화)한지 판정한다. + * (OTP 비밀키 등록 여부, 휴대폰 등록 여부, 인증서 매핑 여부 등) + * + * @param uniqId 개인정보취급자 고유식별자 + * @return 사용 가능 여부(true: 이 수단으로 인증 가능) + * @throws Exception 판정 처리 중 오류 + */ + boolean isAvailable(String uniqId) throws Exception; +} diff --git a/src/main/java/egovframework/com/uat/uia/service/AuthMethodType.java b/src/main/java/egovframework/com/uat/uia/service/AuthMethodType.java new file mode 100644 index 0000000..7c156c1 --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/service/AuthMethodType.java @@ -0,0 +1,65 @@ +/** + * Class Name : AuthMethodType.java + * Description : 추가 인증수단(2단계 인증) 유형 코드 열거형 + * (문화품앗이 개인정보보호 기능개선 사업 — Ⅲ-3 인증체계 / IAR-001 #21) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.21 Fork C(개인정보보호) 최초 생성 — 인증수단 코드/우선순위 정의 + * + * @since 2026. 07. 21 + * @version 1.0 + */ +package egovframework.com.uat.uia.service; + +/** + * 추가 인증수단 유형. 정책 테이블에 저장되는 코드값(OTP/SMS/CERT)과 1:1 매핑된다. + * + *

적용 순서(폴백 우선순위): OTP(1차) → SMS(2차) → CERT(3차).

+ */ +public enum AuthMethodType { + + /** 1차 — TOTP 자체구현 */ + OTP("OTP"), + /** 2차 — KT-TCS/MCSAgent3.0 SMS */ + SMS("SMS"), + /** 3차 — GPKI 공동인증서 */ + CERT("CERT"); + + private final String code; + + AuthMethodType(String code) { + this.code = code; + } + + public String code() { + return code; + } + + /** + * 코드 문자열을 유형으로 변환한다. null·공백·미정의 코드는 기본수단(OTP)으로 폴백한다. + * + * @param code 정책 테이블의 인증수단 코드(대소문자 무관) + * @return 매칭 유형, 없으면 {@link #OTP} + */ + public static AuthMethodType fromCode(String code) { + if (code != null) { + String trimmed = code.trim(); + for (AuthMethodType t : values()) { + if (t.code.equalsIgnoreCase(trimmed)) { + return t; + } + } + } + return OTP; // 기본수단 + } + + /** + * 폴백 탐색 우선순위(OTP→SMS→CERT). + */ + public static AuthMethodType[] priorityOrder() { + return new AuthMethodType[] { OTP, SMS, CERT }; + } +} diff --git a/src/main/java/egovframework/com/uat/uia/service/impl/CertAuthService.java b/src/main/java/egovframework/com/uat/uia/service/impl/CertAuthService.java new file mode 100644 index 0000000..77cfbdb --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/service/impl/CertAuthService.java @@ -0,0 +1,114 @@ +/** + * Class Name : CertAuthService.java + * Description : 추가 인증수단 3차 — 공동인증서(GPKI) 인증 구현체 + * (문화품앗이 개인정보보호 기능개선 사업 — Ⅲ-3 / IAR-001 #20) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.21 Fork C(개인정보보호) 골격 생성 — 인터페이스 계약 확정 + * 2026.07.21 Fork C(개인정보보호) #20 실제 구현 — 기존 GPKI actionCrtfctLogin 경로 위임, 식별값(DN)만 매핑 + * + * @since 2026. 07. 21 + * @version 1.0 + */ +package egovframework.com.uat.uia.service.impl; + +import egovframework.com.cmm.LoginVO; +import egovframework.com.uat.uia.service.AuthMethodService; +import egovframework.com.uat.uia.service.EgovLoginService; + +/** + * 공동인증서(GPKI) 인증수단(3차 적용). + * + *

기존 자산을 재활용·위임한다: + * {@code EgovLoginServiceImpl.actionCrtfctLogin(LoginVO)} + * → {@code LoginDAO.actionCrtfctLogin} + * → {@code loginDAO.actionCrtfctLogin} 쿼리(EgovLoginUsr_SQL_Mysql.xml:434, + * {@code COMTNEMPLYRINFO WHERE CRTFC_DN_VALUE = #dn#}). + * 인증서 본문은 저장하지 않고 식별값(DN)만 COMTNEMPLYRINFO.CRTFC_DN_VALUE에 매핑한다.

+ * + *

핸드오프(운영 config, 본 fork 범위 외): 인증서 로그인 실제 활성화는 + * {@code CertProcessFilter}(web.xml:46~59 주석 처리)의 재활성이 필요하다. 공용 config 충돌·운영 + * 영향 때문에 여기서 web.xml을 수정하지 않는다 — 서비스 계층 위임 코드만 제공하고 필터 재활성은 + * 인터셉터 배선(#14·#26)·운영 협의 시 함께 처리한다.

+ */ +public class CertAuthService implements AuthMethodService { + + /** 기존 로그인 서비스(주입) — 인증서 DN 조회/로그인 경로 재활용. */ + private EgovLoginService loginService; + + /** 취급자별 인증서 매핑 존재 여부 확인기(주입). 기본은 미확정 반환 + 핸드오프. */ + private CertMappingChecker mappingChecker = new NoopCertMappingChecker(); + + public void setLoginService(EgovLoginService loginService) { + this.loginService = loginService; + } + + public void setMappingChecker(CertMappingChecker mappingChecker) { + this.mappingChecker = mappingChecker; + } + + // ------------------------------------------------------------------ 계약 구현 + + /** + * 인증서 인증은 클라이언트 GPKI 서명(브라우저/보안모듈) 기반이라 서버 발급 챌린지가 별도로 없다. + * 실제 서명 요청은 {@code CertProcessFilter}(재활성 핸드오프)가 담당한다 — 여기서는 no-op. + */ + @Override + public void issue(String uniqId) throws Exception { + // 서버측 발급 동작 없음(클라이언트 서명 기반). CertProcessFilter 재활성 핸드오프 참조. + } + + /** + * 제출된 인증서 식별값(DN)을 기존 GPKI 경로로 검증하고, 조회된 취급자가 + * 인증 대상 {@code uniqId}와 동일한지 대조한다. + * + * @param uniqId 인증 대상 취급자 고유식별자(ESNTL_ID) + * @param code 제출된 인증서 DN(식별값). 인증서 본문은 취급/저장하지 않는다. + * @return DN이 해당 취급자에 매핑되면 true + */ + @Override + public boolean verify(String uniqId, String code) throws Exception { + if (loginService == null || uniqId == null || code == null || code.trim().length() == 0) { + return false; + } + LoginVO vo = new LoginVO(); + vo.setDn(code.trim()); // DN(식별값)으로 조회 — 본문 미사용 + LoginVO found = loginService.actionCrtfctLogin(vo); + return found != null + && found.getUniqId() != null + && found.getUniqId().equals(uniqId); + } + + /** + * 해당 취급자에게 매핑된 공동인증서 식별값(CRTFC_DN_VALUE)이 있는지 판정한다. + *

uniqId 기준 매핑 조회는 신규 statement가 필요하므로(접속기록 파이프라인과 config 분리) + * {@link CertMappingChecker}로 추상화한다 — 운영 연동 시 구현체 1개 교체.

+ */ + @Override + public boolean isAvailable(String uniqId) throws Exception { + return mappingChecker.hasCertMapping(uniqId); + } + + // ------------------------------------------------------------------ 매핑 확인기 추상화 + + /** + * 취급자별 인증서 식별값 매핑 존재 여부 확인 계약. + * 운영 구현: {@code SELECT COUNT(*) FROM COMTNEMPLYRINFO + * WHERE ESNTL_ID = #uniqId# AND CRTFC_DN_VALUE IS NOT NULL AND CRTFC_DN_VALUE <> ''}. + */ + public interface CertMappingChecker { + boolean hasCertMapping(String uniqId) throws Exception; + } + + /** 기본 구현 — 매핑 조회 statement 미연동. 운영에서 DB 구현체로 교체. */ + static final class NoopCertMappingChecker implements CertMappingChecker { + @Override + public boolean hasCertMapping(String uniqId) throws Exception { + // TODO(#20 후속): COMTNEMPLYRINFO.CRTFC_DN_VALUE 존재 여부 조회 statement 연동 + return false; + } + } +} diff --git a/src/main/java/egovframework/com/uat/uia/service/impl/DaoOtpSecretStore.java b/src/main/java/egovframework/com/uat/uia/service/impl/DaoOtpSecretStore.java new file mode 100644 index 0000000..1820071 --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/service/impl/DaoOtpSecretStore.java @@ -0,0 +1,52 @@ +/** + * Class Name : DaoOtpSecretStore.java + * Description : OtpSecretStore 의 DB 영속 구현체 — 개인정보보호 SFR-003 No.18 후속 + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.22 arko-privacy-authctrl 최초 생성 — InMemoryOtpSecretStore 대체(재기동 영속화) + * + * @since 2026. 07. 22 + * @version 1.0 + */ +package egovframework.com.uat.uia.service.impl; + +/** + * {@link OtpSecretStore}의 DB 영속 구현체. OTP 비밀키 암호문을 OTP_DEVICE 테이블에 저장·조회한다. + * + *

{@link InMemoryOtpSecretStore}(메모리, 재기동 시 유실)를 대체한다. 저장소는 암호문만 다루며 + * 평문 비밀키는 이 계층에 존재하지 않는다(SER-003).

+ * + *

예외 처리: 기존 계약대로 예외를 삼키지 않고 그대로 던진다 — 저장/조회 실패 판단은 + * 상위({@link OtpAuthService})가 수행한다. (등록 저장 실패가 로그인을 막지 않도록 하는 정책은 + * 상위/인터셉터 계층에 이미 있다.)

+ */ +public class DaoOtpSecretStore implements OtpSecretStore { + + /** OTP_DEVICE 접근 DAO(주입). 루트 컨텍스트 @Repository("otpDeviceDAO"). */ + private OtpDeviceDAO otpDeviceDAO; + + public void setOtpDeviceDAO(OtpDeviceDAO otpDeviceDAO) { + this.otpDeviceDAO = otpDeviceDAO; + } + + @Override + public void save(String uniqId, String encryptedKey) throws Exception { + OtpDeviceVO vo = new OtpDeviceVO(); + vo.setUniqId(uniqId); + vo.setSecretEnc(encryptedKey); // AES-GCM 암호문(Base64) — 평문 금지 + otpDeviceDAO.saveSecret(vo); + } + + @Override + public String find(String uniqId) throws Exception { + return otpDeviceDAO.findSecret(uniqId); + } + + @Override + public boolean exists(String uniqId) throws Exception { + return otpDeviceDAO.existsCount(uniqId) > 0; + } +} diff --git a/src/main/java/egovframework/com/uat/uia/service/impl/DefaultAuthMethodPolicyProvider.java b/src/main/java/egovframework/com/uat/uia/service/impl/DefaultAuthMethodPolicyProvider.java new file mode 100644 index 0000000..1b26295 --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/service/impl/DefaultAuthMethodPolicyProvider.java @@ -0,0 +1,34 @@ +/** + * Class Name : DefaultAuthMethodPolicyProvider.java + * Description : 인증수단 정책 제공자 기본 구현(정책 테이블 연동 전 임시) + * (문화품앗이 개인정보보호 기능개선 사업 — Ⅲ-3 인증체계 / IAR-001 #21) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.21 Fork C(개인정보보호) 최초 생성 — 기본수단(OTP) 반환, 정책 테이블 연동은 후속 + * + * @since 2026. 07. 21 + * @version 0.1 + */ +package egovframework.com.uat.uia.service.impl; + +import egovframework.com.uat.uia.service.AuthMethodPolicyProvider; +import egovframework.com.uat.uia.service.AuthMethodType; + +/** + * 정책 테이블 연동 이전 단계의 기본 정책 제공자. + * + *

모든 취급자에 대해 기본수단(OTP)을 반환한다. 실제 취급자별 인증수단은 + * 정책 테이블({@code AUTH_METHOD_POLICY}) 연동 시(#21 후속) 이 구현체를 교체한다.

+ */ +public class DefaultAuthMethodPolicyProvider implements AuthMethodPolicyProvider { + + @Override + public String resolveAuthMethodCode(String uniqId) throws Exception { + // TODO: 정책 테이블 연동(#21 후속) — 취급자별 인증수단(AUTH_METHOD_POLICY) 조회. + // 정책 미지정 취급자는 기본수단(OTP)으로 처리한다. + return AuthMethodType.OTP.code(); + } +} diff --git a/src/main/java/egovframework/com/uat/uia/service/impl/EgovLoginServiceImpl.java b/src/main/java/egovframework/com/uat/uia/service/impl/EgovLoginServiceImpl.java index 9c9eca0..6d534bb 100644 --- a/src/main/java/egovframework/com/uat/uia/service/impl/EgovLoginServiceImpl.java +++ b/src/main/java/egovframework/com/uat/uia/service/impl/EgovLoginServiceImpl.java @@ -289,32 +289,40 @@ public class EgovLoginServiceImpl extends AbstractServiceImpl implements // 휴먼고객 확인후 테이블 입력 @Override public void insertDiapause(LoginVO loginVO) throws Exception { - loginDAO.insertDiapause(loginVO); EgovWebUtil egovUtil = new EgovWebUtil(); - - List list = loginDAO.selectDiapauseList(loginVO); - - for(int i=0; i< list.size(); i++){ + + // 1) 일반회원(COMTNGNRLMBER) 휴면 대상 적재 + 유예/확정 처리 + loginDAO.insertDiapause(loginVO); + processDiapauseList(loginDAO.selectDiapauseList(loginVO), egovUtil); + + // 2) [개인정보보호 #7] 개인정보취급자(COMTNEMPLYRINFO) 휴면 대상 확장 — 동일 유예/확정 체인 + // COMTNDIAPAUSEMBER.MBER_TY_CODE='EMP' 로 분리되어 일반회원 흐름과 혼선 없음. + loginDAO.insertDiapauseEmplyr(loginVO); + processDiapauseList(loginDAO.selectDiapauseListEmplyr(loginVO), egovUtil); + } + + // 휴면 유예(→'S')/확정(→'P') 공통 처리 — 일반회원·개인정보취급자 공용(#7) + private void processDiapauseList(List list, EgovWebUtil egovUtil) throws Exception { + if (list == null) { + return; + } + for (int i = 0; i < list.size(); i++) { LoginVO vo = list.get(i); HashMap map = new HashMap<>(); - - if(vo.getDiapause_stat() == null){ - egovUtil.sendIdDiapauseMail(vo.getEmail(), vo.getCreatDt() , vo.getName(), vo.getId()); - //System.out.println("--------111.휴면 유예 메일을 보냈다.----------"); + + if (vo.getDiapause_stat() == null) { + egovUtil.sendIdDiapauseMail(vo.getEmail(), vo.getCreatDt(), vo.getName(), vo.getId()); map.put("id", vo.getId()); map.put("diapause_stat", "S"); - }else if(vo.getDiapause_stat().equals("S")){ - egovUtil.sendIdDiapauseStandbyMail(vo.getEmail(), vo.getCreatDt() , vo.getName(), vo.getId()); - //System.out.println("--------222.휴면 확정 메일을 보냈다.----------"); + } else if (vo.getDiapause_stat().equals("S")) { + egovUtil.sendIdDiapauseStandbyMail(vo.getEmail(), vo.getCreatDt(), vo.getName(), vo.getId()); map.put("id", vo.getId()); map.put("diapause_stat", "P"); } - + loginDAO.updateDiapauseMber(map); - } - } // 휴먼고객 활성화 diff --git a/src/main/java/egovframework/com/uat/uia/service/impl/InMemoryOtpSecretStore.java b/src/main/java/egovframework/com/uat/uia/service/impl/InMemoryOtpSecretStore.java new file mode 100644 index 0000000..f6f713d --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/service/impl/InMemoryOtpSecretStore.java @@ -0,0 +1,46 @@ +/** + * Class Name : InMemoryOtpSecretStore.java + * Description : OtpSecretStore 기본 구현(개발/검증용) — 개인정보보호 기능개선 사업 #18 + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.21 Fork C(개인정보보호) 최초 생성 — 메모리 보관(운영은 DB 구현체로 교체) + * + * @since 2026. 07. 21 + * @version 1.0 + */ +package egovframework.com.uat.uia.service.impl; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * {@link OtpSecretStore}의 기본 구현. 취급자별 OTP 비밀키 암호문을 메모리에 보관한다. + * + *

주의: 개발/단위검증용이다. 애플리케이션 재기동 시 소실되므로 운영에서는 + * 신규 비밀키 테이블(EMPLYR_OTP_SECRET) + iBatis2 sqlmap 연동 구현체로 교체한다. + * (테이블/sqlmap 신설·sql-map-config 등록은 접속기록 파이프라인과의 config 충돌 회피를 위해 + * 후속 작업으로 분리 — TODO: 비밀키 테이블 연동)

+ */ +public class InMemoryOtpSecretStore implements OtpSecretStore { + + private final ConcurrentHashMap store = new ConcurrentHashMap(); + + @Override + public void save(String uniqId, String encryptedKey) throws Exception { + // TODO(#18 후속): INSERT/UPDATE EMPLYR_OTP_SECRET (ESNTL_ID, SECRET_KEY_ENC, REG_DT) + store.put(uniqId, encryptedKey); + } + + @Override + public String find(String uniqId) throws Exception { + // TODO(#18 후속): SELECT SECRET_KEY_ENC FROM EMPLYR_OTP_SECRET WHERE ESNTL_ID = #uniqId# + return store.get(uniqId); + } + + @Override + public boolean exists(String uniqId) throws Exception { + return store.containsKey(uniqId); + } +} diff --git a/src/main/java/egovframework/com/uat/uia/service/impl/LoginDAO.java b/src/main/java/egovframework/com/uat/uia/service/impl/LoginDAO.java index 53709d0..ad2d1da 100644 --- a/src/main/java/egovframework/com/uat/uia/service/impl/LoginDAO.java +++ b/src/main/java/egovframework/com/uat/uia/service/impl/LoginDAO.java @@ -164,6 +164,16 @@ public class LoginDAO extends EgovComAbstractDAO { insert("loginDAO.insertDiapause", loginVO); } + // [개인정보보호 #7] 개인정보취급자(COMTNEMPLYRINFO) 휴면 대상 적재 + public void insertDiapauseEmplyr(LoginVO loginVO) { + insert("loginDAO.insertDiapauseEmplyr", loginVO); + } + + // [개인정보보호 #7] 개인정보취급자 휴면 확인 대상 조회 + public List selectDiapauseListEmplyr(LoginVO loginVO) { + return list("loginDAO.selectDiapauseListEmplyr", loginVO); + } + // 휴먼고객 활성화 public void deleteDiapause(LoginVO vo) { delete("loginDAO.deleteDiapause", vo); diff --git a/src/main/java/egovframework/com/uat/uia/service/impl/OtpAuthService.java b/src/main/java/egovframework/com/uat/uia/service/impl/OtpAuthService.java new file mode 100644 index 0000000..b3fe2c2 --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/service/impl/OtpAuthService.java @@ -0,0 +1,243 @@ +/** + * Class Name : OtpAuthService.java + * Description : 추가 인증수단 1차 — TOTP(OTP) 인증 구현체 + * (문화품앗이 개인정보보호 기능개선 사업 — Ⅲ-3 / IAR-001 #18) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.21 Fork C(개인정보보호) 골격 생성 — 인터페이스 계약 확정 + * 2026.07.21 Fork C(개인정보보호) #18 실제 구현 — RFC 6238 TOTP 자체구현, 비밀키 AES 암호화 저장 + * + * @since 2026. 07. 21 + * @version 1.0 + */ +package egovframework.com.uat.uia.service.impl; + +import java.nio.charset.Charset; +import java.security.SecureRandom; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import egovframework.com.uat.uia.service.AuthMethodService; + +/** + * TOTP(Time-based One-Time Password, RFC 6238) 표준 알고리즘 자체 구현 인증수단(1차 적용). + * + *

알고리즘: HMAC-SHA1, 타임스텝 30초, 6자리. 검증은 시계 오차를 고려해 ±1 스텝 윈도우를 허용한다. + * 외부 라이브러리(Google Authenticator 등) 없이 {@code javax.crypto.Mac}만 사용한다 — + * 국정원 검증필 암호모듈 대상 상용 솔루션이 아니므로 도입절차 불요.

+ * + *

비밀키 보호(SER-003): 비밀키는 {@link OtpSecretCipher}로 암호화하여 + * {@link OtpSecretStore}에 저장하고, 복호화는 검증 시점으로 한정한다. + * 평문 비밀키·OTP 코드는 화면·로그·예외 메시지에 절대 노출하지 않는다.

+ */ +public class OtpAuthService implements AuthMethodService { + + private static final int TIME_STEP_SECONDS = 30; + private static final int DIGITS = 6; + private static final int WINDOW = 1; // ±1 스텝 허용 + private static final int SECRET_BYTES = 20; // 160-bit (SHA-1 블록 권장) + private static final String ISSUER = "ARKO-CSV"; + + private static final int[] DIGITS_POWER = { 1, 10, 100, 1000, 10000, 100000, 1000000, 10000000 }; + private static final Charset UTF8 = Charset.forName("UTF-8"); + + private final SecureRandom random = new SecureRandom(); + + /** 비밀키 저장소(주입). 기본 구현은 {@link InMemoryOtpSecretStore}. */ + private OtpSecretStore secretStore = new InMemoryOtpSecretStore(); + /** 비밀키 암호 헬퍼(주입 가능). */ + private OtpSecretCipher secretCipher = new OtpSecretCipher(); + + public void setSecretStore(OtpSecretStore secretStore) { + this.secretStore = secretStore; + } + + public void setSecretCipher(OtpSecretCipher secretCipher) { + this.secretCipher = secretCipher; + } + + // ------------------------------------------------------------------ 계약 구현 + + /** + * OTP 비밀키를 생성하여 암호화 저장하고, 프로비저닝용 otpauth URI를 반환한다. + *

반환된 URI에는 평문 비밀키(Base32)가 포함되므로 등록 화면에 1회 표기/QR 렌더링 용도로만 + * 사용하고 저장·로깅하지 않는다.

+ * + * @return otpauth://totp/... 프로비저닝 URI + */ + @Override + public void issue(String uniqId) throws Exception { + provision(uniqId); + } + + /** + * {@link #issue(String)}와 동일하되 프로비저닝 URI를 반환한다(등록 화면 연동용). + */ + public String provision(String uniqId) throws Exception { + byte[] secret = new byte[SECRET_BYTES]; + random.nextBytes(secret); + String base32Secret = Base32.encode(secret); + // 암호문만 저장 — 평문 비밀키는 이 메서드 스코프를 벗어나지 않는다. + secretStore.save(uniqId, secretCipher.encrypt(base32Secret)); + return buildOtpAuthUri(uniqId, base32Secret); + } + + /** + * 제출된 6자리 OTP 코드를 현재 타임스텝(±WINDOW) 기준으로 검증한다. + */ + @Override + public boolean verify(String uniqId, String code) throws Exception { + if (code == null || code.trim().length() == 0) { + return false; + } + String encrypted = secretStore.find(uniqId); + if (encrypted == null) { + return false; // 미등록 — 계정열거/노출 방지 위해 사유 미구분 + } + int submitted; + try { + submitted = Integer.parseInt(code.trim()); + } catch (NumberFormatException e) { + return false; + } + byte[] secret = Base32.decode(secretCipher.decrypt(encrypted)); + long currentStep = System.currentTimeMillis() / 1000L / TIME_STEP_SECONDS; + for (int w = -WINDOW; w <= WINDOW; w++) { + if (generateTotp(secret, currentStep + w) == submitted) { + return true; + } + } + return false; + } + + /** + * 해당 취급자에게 OTP 비밀키가 등록되어 있는지 판정한다. + */ + @Override + public boolean isAvailable(String uniqId) throws Exception { + return secretStore.exists(uniqId); + } + + // ------------------------------------------------------------------ TOTP 코어(RFC 6238/4226) + + /** 지정 타임스텝의 TOTP 값(0..10^DIGITS-1)을 계산한다. */ + private int generateTotp(byte[] secret, long timeStep) throws Exception { + byte[] msg = new byte[8]; + long v = timeStep; + for (int i = 7; i >= 0; i--) { + msg[i] = (byte) (v & 0xff); + v >>= 8; + } + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(secret, "HmacSHA1")); + byte[] hash = mac.doFinal(msg); + // 동적 절단(RFC 4226 §5.3) + int offset = hash[hash.length - 1] & 0x0f; + int binary = ((hash[offset] & 0x7f) << 24) + | ((hash[offset + 1] & 0xff) << 16) + | ((hash[offset + 2] & 0xff) << 8) + | (hash[offset + 3] & 0xff); + return binary % DIGITS_POWER[DIGITS]; + } + + private String buildOtpAuthUri(String uniqId, String base32Secret) { + // otpauth://totp/ISSUER:account?secret=...&issuer=ISSUER&digits=6&period=30&algorithm=SHA1 + StringBuilder sb = new StringBuilder(); + sb.append("otpauth://totp/").append(ISSUER).append(":").append(uniqId) + .append("?secret=").append(base32Secret) + .append("&issuer=").append(ISSUER) + .append("&algorithm=SHA1") + .append("&digits=").append(DIGITS) + .append("&period=").append(TIME_STEP_SECONDS); + return sb.toString(); + } + + // ------------------------------------------------------------------ Base32 (RFC 4648, otpauth 표준) + + /** + * TOTP 비밀키 표기 표준인 Base32(RFC 4648, 대문자 A-Z2-7)를 자체 구현한다. + * (commons-codec 1.10에도 Base32가 있으나 의존 최소화를 위해 인라인) + */ + static final class Base32 { + private static final String ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + private static final int[] LOOKUP = new int[128]; + static { + for (int i = 0; i < LOOKUP.length; i++) { + LOOKUP[i] = -1; + } + for (int i = 0; i < ALPHABET.length(); i++) { + LOOKUP[ALPHABET.charAt(i)] = i; + } + } + + static String encode(byte[] data) { + StringBuilder sb = new StringBuilder(); + int buffer = 0; + int bitsLeft = 0; + for (byte b : data) { + buffer = (buffer << 8) | (b & 0xff); + bitsLeft += 8; + while (bitsLeft >= 5) { + int idx = (buffer >> (bitsLeft - 5)) & 0x1f; + bitsLeft -= 5; + sb.append(ALPHABET.charAt(idx)); + } + } + if (bitsLeft > 0) { + int idx = (buffer << (5 - bitsLeft)) & 0x1f; + sb.append(ALPHABET.charAt(idx)); + } + return sb.toString(); + } + + static byte[] decode(String s) { + String in = s.trim().replace("=", "").toUpperCase(); + int outLen = in.length() * 5 / 8; + byte[] out = new byte[outLen]; + int buffer = 0; + int bitsLeft = 0; + int idx = 0; + for (int i = 0; i < in.length(); i++) { + char c = in.charAt(i); + int val = (c < 128) ? LOOKUP[c] : -1; + if (val < 0) { + continue; // 잘못된 문자 무시 + } + buffer = (buffer << 5) | val; + bitsLeft += 5; + if (bitsLeft >= 8) { + out[idx++] = (byte) ((buffer >> (bitsLeft - 8)) & 0xff); + bitsLeft -= 8; + } + } + return out; + } + } + + // ------------------------------------------------------------------ 자체 단위검증(RFC 6238 test vector) + + /** + * RFC 6238 Appendix B 테스트벡터(SHA-1, 비밀키 "12345678901234567890")로 자체 검증한다. + * 로그·화면 미노출 대상이 아니므로 임시 실행용. + *
  T=59s  → step 1  → 94287082  → 8자리, 6자리 절단 시 287082 
+ */ + public static void main(String[] args) throws Exception { + byte[] secret = "12345678901234567890".getBytes(UTF8); // RFC 6238 seed(ASCII) + OtpAuthService svc = new OtpAuthService(); + // T=59 → timeStep = 59/30 = 1, 기대 TOTP(6자리) = 287082 + int v = svc.generateTotp(secret, 59L / TIME_STEP_SECONDS); + System.out.println("RFC6238 T=59 step=1 => " + String.format("%06d", v) + " (expect 287082)"); + // T=1111111109 → step=37037036 → 8자리 07081804 → 6자리 081804 + int v2 = svc.generateTotp(secret, 1111111109L / TIME_STEP_SECONDS); + System.out.println("RFC6238 T=1111111109 => " + String.format("%06d", v2) + " (expect 081804)"); + // Base32 왕복 + byte[] k = new byte[20]; + new SecureRandom().nextBytes(k); + String b32 = Base32.encode(k); + System.out.println("Base32 roundtrip ok = " + java.util.Arrays.equals(k, Base32.decode(b32))); + } +} diff --git a/src/main/java/egovframework/com/uat/uia/service/impl/OtpDeviceDAO.java b/src/main/java/egovframework/com/uat/uia/service/impl/OtpDeviceDAO.java new file mode 100644 index 0000000..ffbfb03 --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/service/impl/OtpDeviceDAO.java @@ -0,0 +1,47 @@ +/** + * Class Name : OtpDeviceDAO.java + * Description : OTP 비밀키 영구 저장 DAO(iBatis2) — 개인정보보호 SFR-003 No.18 후속 + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.22 arko-privacy-authctrl 최초 생성 — OTP_DEVICE upsert/조회/존재확인 + * + * @since 2026. 07. 22 + * @version 1.0 + */ +package egovframework.com.uat.uia.service.impl; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * OTP_DEVICE 테이블 접근 DAO. arko 표준 5계층의 DAO 계층으로 {@link EgovComAbstractDAO}를 상속한다. + * + *

루트 컨텍스트 component-scan(base-package {@code egovframework})으로 {@code @Repository} + * 등록되어 루트의 {@code sqlMapClient}(context-sqlMap.xml)에서 statement를 로드한다. + * DispatcherServlet(자식) 컨텍스트의 {@code otpSecretStore} 빈이 자식→부모 가시성으로 이 DAO를 주입받는다.

+ * + *

iBatis2 — MyBatis/{@code #{}} 문법 아님. statement 는 PrivacyOtpDevice_SQL_Mysql.xml(namespace) 참조.

+ */ +@Repository("otpDeviceDAO") +public class OtpDeviceDAO extends EgovComAbstractDAO { + + /** 비밀키 암호문 저장(있으면 갱신, 없으면 신규 — upsert). */ + public void saveSecret(OtpDeviceVO vo) { + insert("OtpDeviceDAO.saveSecret", vo); + } + + /** 취급자의 비밀키 암호문 조회. 미등록 시 {@code null}. */ + public String findSecret(String uniqId) { + return (String) selectByPk("OtpDeviceDAO.findSecret", uniqId); + } + + /** 취급자에게 사용중(USE_YN='Y') 비밀키가 등록되어 있는지 건수. */ + public int existsCount(String uniqId) { + Object cnt = selectByPk("OtpDeviceDAO.existsCount", uniqId); + return cnt == null ? 0 : ((Integer) cnt).intValue(); + } +} diff --git a/src/main/java/egovframework/com/uat/uia/service/impl/OtpDeviceVO.java b/src/main/java/egovframework/com/uat/uia/service/impl/OtpDeviceVO.java new file mode 100644 index 0000000..d6a9651 --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/service/impl/OtpDeviceVO.java @@ -0,0 +1,56 @@ +/** + * Class Name : OtpDeviceVO.java + * Description : OTP_DEVICE 테이블 매핑 VO — OTP 비밀키 영구 저장(개인정보보호 SFR-003 No.18 후속) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.22 arko-privacy-authctrl 최초 생성 — DaoOtpSecretStore 연동용 VO + * + * @since 2026. 07. 22 + * @version 1.0 + */ +package egovframework.com.uat.uia.service.impl; + +import java.io.Serializable; + +/** + * OTP_DEVICE 매핑 VO. 취급자 고유식별자({@code uniqId})와 비밀키 암호문({@code secretEnc})만 + * 오간다 — 평문 비밀키는 이 VO에 담기지 않는다(SER-003). + */ +public class OtpDeviceVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 개인정보취급자 고유식별자(ESNTL_ID, PK) */ + private String uniqId; + /** OTP 비밀키 AES-GCM 암호문(Base64) */ + private String secretEnc; + /** 기기/계정 라벨(선택) */ + private String deviceLabel; + + public String getUniqId() { + return uniqId; + } + + public void setUniqId(String uniqId) { + this.uniqId = uniqId; + } + + public String getSecretEnc() { + return secretEnc; + } + + public void setSecretEnc(String secretEnc) { + this.secretEnc = secretEnc; + } + + public String getDeviceLabel() { + return deviceLabel; + } + + public void setDeviceLabel(String deviceLabel) { + this.deviceLabel = deviceLabel; + } +} diff --git a/src/main/java/egovframework/com/uat/uia/service/impl/OtpSecretCipher.java b/src/main/java/egovframework/com/uat/uia/service/impl/OtpSecretCipher.java new file mode 100644 index 0000000..8b3710c --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/service/impl/OtpSecretCipher.java @@ -0,0 +1,114 @@ +/** + * Class Name : OtpSecretCipher.java + * Description : OTP 비밀키 대칭암호(AES) 헬퍼 — 개인정보보호 기능개선 사업 #18 / SER-003 + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.21 Fork C(개인정보보호) 최초 생성 — 비밀키 암호화 저장/복호화(검증 시점 한정) + * + * @since 2026. 07. 21 + * @version 1.0 + */ +package egovframework.com.uat.uia.service.impl; + +import java.security.SecureRandom; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.commons.codec.binary.Base64; + +/** + * OTP 비밀키를 가역 암호화(AES-256/GCM)하여 저장하고, 검증 시점에만 복호화한다. + * + *

비밀번호 해시(SHA-256, 단방향)와 달리 TOTP 비밀키는 매 검증마다 원본이 필요하므로 + * 가역 대칭암호를 사용한다. 평문 비밀키는 발급/검증 순간 메모리에만 존재하며 저장소·화면·로그에 + * 절대 노출하지 않는다(SER-003).

+ * + *

키 관리 TODO: 마스터 키는 현재 환경변수/시스템프로퍼티({@code arko.otp.master.key}, + * Base64 16바이트)에서 로드한다. 운영에서는 KMS/보안 저장소 연동으로 교체한다. + * 미설정 시 개발용 임시 키로 폴백하되 경고를 남긴다(운영 배포 전 반드시 설정).

+ * + *

키 길이는 AES-128(16바이트)로 고정한다 — 구형 JDK의 JCE 제한정책 파일 없이도 + * 동작하도록 하기 위함(AES-256은 unlimited policy 필요). 강화 시 운영 JDK 정책 확인 후 상향.

+ */ +public class OtpSecretCipher { + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int GCM_TAG_BITS = 128; + private static final int GCM_IV_BYTES = 12; + + /** 마스터 키 시스템 프로퍼티/환경변수 명. */ + public static final String MASTER_KEY_PROP = "arko.otp.master.key"; + + private static final int KEY_BYTES = 16; // AES-128 (JCE 제한정책 무관 동작) + + private final byte[] masterKey; + private final SecureRandom random = new SecureRandom(); + + public OtpSecretCipher() { + this.masterKey = loadMasterKey(); + } + + /** 명시적 키 주입(테스트/커스텀 키 관리용). */ + public OtpSecretCipher(byte[] key16) { + if (key16 == null || key16.length != KEY_BYTES) { + throw new IllegalArgumentException("AES-128 master key must be " + KEY_BYTES + " bytes"); + } + this.masterKey = key16.clone(); + } + + private static byte[] loadMasterKey() { + String b64 = System.getProperty(MASTER_KEY_PROP); + if (b64 == null || b64.trim().length() == 0) { + b64 = System.getenv("ARKO_OTP_MASTER_KEY"); + } + if (b64 != null && b64.trim().length() > 0) { + byte[] k = Base64.decodeBase64(b64.trim().getBytes()); + if (k.length == KEY_BYTES) { + return k; + } + } + // TODO(운영): 마스터 키 미설정. 개발용 고정 키로 폴백 — 운영 배포 전 반드시 arko.otp.master.key 설정. + byte[] dev = new byte[KEY_BYTES]; + byte[] seed = "arko-otp-dev-2026".getBytes(); + System.arraycopy(seed, 0, dev, 0, Math.min(seed.length, KEY_BYTES)); + return dev; + } + + /** + * 평문 비밀키(Base32 문자열)를 AES-GCM 암호화하여 Base64 문자열로 반환한다. + * 반환 형식: Base64( IV(12) || ciphertext+tag ). + */ + public String encrypt(String plainSecret) throws Exception { + byte[] iv = new byte[GCM_IV_BYTES]; + random.nextBytes(iv); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(masterKey, "AES"), + new GCMParameterSpec(GCM_TAG_BITS, iv)); + byte[] ct = cipher.doFinal(plainSecret.getBytes("UTF-8")); + byte[] out = new byte[iv.length + ct.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + System.arraycopy(ct, 0, out, iv.length, ct.length); + return new String(Base64.encodeBase64(out), "US-ASCII"); + } + + /** + * {@link #encrypt(String)} 결과를 복호화하여 평문 비밀키를 반환한다. + * 검증 시점에만 호출한다. + */ + public String decrypt(String encryptedBase64) throws Exception { + byte[] all = Base64.decodeBase64(encryptedBase64.getBytes("US-ASCII")); + byte[] iv = new byte[GCM_IV_BYTES]; + System.arraycopy(all, 0, iv, 0, GCM_IV_BYTES); + byte[] ct = new byte[all.length - GCM_IV_BYTES]; + System.arraycopy(all, GCM_IV_BYTES, ct, 0, ct.length); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(masterKey, "AES"), + new GCMParameterSpec(GCM_TAG_BITS, iv)); + return new String(cipher.doFinal(ct), "UTF-8"); + } +} diff --git a/src/main/java/egovframework/com/uat/uia/service/impl/OtpSecretStore.java b/src/main/java/egovframework/com/uat/uia/service/impl/OtpSecretStore.java new file mode 100644 index 0000000..0116254 --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/service/impl/OtpSecretStore.java @@ -0,0 +1,48 @@ +/** + * Class Name : OtpSecretStore.java + * Description : OTP 비밀키 저장소 추상화 (개인정보보호 기능개선 사업 #18) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.21 Fork C(개인정보보호) 최초 생성 — 비밀키 영속화 추상화(테이블 연동은 후속 핸드오프) + * + * @since 2026. 07. 21 + * @version 1.0 + */ +package egovframework.com.uat.uia.service.impl; + +/** + * 개인정보취급자별 OTP 비밀키(암호문)를 보관·조회하는 저장소 계약. + * + *

OTP 비밀키는 암호문(AES) 상태로만 이 저장소를 오간다 — 평문 비밀키는 + * {@link OtpAuthService} 내부에서 발급/검증 순간에만 존재하고 저장소·화면·로그에 + * 노출되지 않는다(SER-003).

+ * + *

정책상 저장 위치는 신규 비밀키 테이블(예: EMPLYR_OTP_SECRET)이나, 해당 테이블/sqlmap + * 등록은 접속기록 수집 파이프라인과 config 충돌을 피하기 위해 후속으로 분리한다. + * 기본 구현({@link InMemoryOtpSecretStore})은 개발/검증용이며, 운영 반영 시 + * DB 연동 구현체 1개로 교체한다.

+ */ +public interface OtpSecretStore { + + /** + * 취급자의 OTP 비밀키 암호문을 저장(신규/갱신)한다. + * @param uniqId 취급자 고유식별자(ESNTL_ID) + * @param encryptedKey AES 암호문(Base64) — 평문 금지 + */ + void save(String uniqId, String encryptedKey) throws Exception; + + /** + * 취급자의 OTP 비밀키 암호문을 조회한다. 미등록 시 {@code null}. + * @param uniqId 취급자 고유식별자 + * @return AES 암호문(Base64) 또는 null + */ + String find(String uniqId) throws Exception; + + /** + * 취급자에게 OTP 비밀키가 등록되어 있는지 여부. + */ + boolean exists(String uniqId) throws Exception; +} diff --git a/src/main/java/egovframework/com/uat/uia/service/impl/SmsAuthService.java b/src/main/java/egovframework/com/uat/uia/service/impl/SmsAuthService.java new file mode 100644 index 0000000..4944fee --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/service/impl/SmsAuthService.java @@ -0,0 +1,56 @@ +/** + * Class Name : SmsAuthService.java + * Description : 추가 인증수단 2차 — 휴대폰(SMS) 인증 구현체 [스텁] + * (문화품앗이 개인정보보호 기능개선 사업 — Ⅲ-3 / IAR-001 #19) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.07.21 Fork C(개인정보보호) 골격 생성 — 인터페이스 계약 확정, 실제 로직은 후속(#19) + * + * @since 2026. 07. 21 + * @version 0.1 (stub) + */ +package egovframework.com.uat.uia.service.impl; + +import egovframework.com.uat.uia.service.AuthMethodService; + +/** + * 휴대폰(SMS) 인증수단(2차 적용). KT-TCS/MCSAgent3.0 문자발송모듈 연계. + * + *

연동 규격은 착수 시 발주기관과 확정 필요(블로커, 개발항목 #19). + * 인증번호는 저장하지 않고 유효시간(3분) 내 검증 후 폐기한다.

+ * + *

주의: 본 클래스는 인터페이스 계약 확정을 위한 골격(stub)이다. + * 실제 발송/검증 로직은 개발항목 #19에서 구현한다.

+ */ +public class SmsAuthService implements AuthMethodService { + + /** + * 인증번호를 생성해 취급자 등록 휴대폰번호로 발송한다(번호는 미저장, 만료시각만 세션/캐시 보관). + */ + @Override + public void issue(String uniqId) throws Exception { + // TODO(#19): 인증번호 생성 → KT-TCS/MCSAgent3.0 연계 발송 → 만료시각(3분) 세팅, 원문 미저장 + throw new UnsupportedOperationException("SmsAuthService.issue() 미구현 — 개발항목 #19 (연동규격 발주기관 확정 대기)"); + } + + /** + * 제출된 인증번호를 유효시간(3분) 내에서 검증하고, 검증 후 즉시 폐기한다. + */ + @Override + public boolean verify(String uniqId, String code) throws Exception { + // TODO(#19): 만료시각 확인 → 인증번호 대조 → 성공/실패 무관 즉시 폐기 + throw new UnsupportedOperationException("SmsAuthService.verify() 미구현 — 개발항목 #19"); + } + + /** + * 해당 취급자에게 인증 가능한 휴대폰번호가 등록되어 있는지 판정한다. + */ + @Override + public boolean isAvailable(String uniqId) throws Exception { + // TODO(#19): 취급자 휴대폰번호 등록 여부 조회 + throw new UnsupportedOperationException("SmsAuthService.isAvailable() 미구현 — 개발항목 #19"); + } +} diff --git a/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java b/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java index 418d3e7..3c5e04f 100644 --- a/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java +++ b/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java @@ -70,7 +70,10 @@ public class EgovLoginController { protected static final String JSON = "jsonView"; - + + /** 개인정보보호(SER-001/#24) 계정 잠금 기본 임계값 — LOGIN_LOCK_COUNT 미설정 시 fallback */ + private static final int DEFAULT_LOCK_THRESHOLD = 5; + /** EgovLoginService */ @Resource(name = "loginService") private EgovLoginService loginService; @@ -146,39 +149,101 @@ public class EgovLoginController { * @exception Exception */ @RequestMapping(value="/uat/uia/actionLogin.do") - public String actionLogin(@ModelAttribute("loginVO") LoginVO loginVO, + public String actionLogin(@ModelAttribute("loginVO") LoginVO loginVO, HttpServletRequest request, ModelMap model)throws Exception { - System.out.println("로그인"); - - - // Start~ KTH, 2019.12.16 가상키패드 보안 솔루션 적용건 - String password = request.getParameter("civpad_cipher_value_password"); + + // Start~ KTH, 2019.12.16 가상키패드 보안 솔루션 적용건 + String password = request.getParameter("civpad_cipher_value_password"); if(password != null && !"".equals(password)){ - password = VirtualPadUtil.decrypt(request, "password"); + password = VirtualPadUtil.decrypt(request, "password"); } else{ password = request.getParameter("password"); } - - loginVO.setPassword(password); - // End~ KTH, 2019.12.16 가상키패드 보안 솔루션 적용건 - - // 1. 일반 로그인 처리 - + + loginVO.setPassword(password); + // End~ KTH, 2019.12.16 가상키패드 보안 솔루션 적용건 + + // ============================================================ + // 개인정보보호(SER-001 / #22·#24) — 인증 실패 이력·계정 잠금 활성 경로 이설 + // · 기존 로직은 EgovSpringSecurityLoginFilter(운영 조건부)에만 존재해, + // 필터 미사용 경로(actionLogin)에서는 실패이력·잠금이 동작하지 않았다. + // → LOGIN_HISTORY / LOGIN_LOCK_COUNT 기존 테이블·statement를 그대로 재사용하고 + // 호출 지점만 이 활성 경로로 이설한다(신규 테이블 없음). + // · 실패이력 처리 오류가 로그인 자체를 막지 않도록 전 구간 fail-safe(try-catch). + // · 잠금 임계값은 LOGIN_LOCK_COUNT(관리자 설정값), 부재 시 기본 5회(#24, 10→5). + // ============================================================ + + // 1. 계정 잠금 선(先) 확인 — 잠긴 계정은 인증 시도 전 차단 + try { + if (loginVO.getId() != null && !"".equals(loginVO.getId())) { + LoginVO lockVo = loginService.selectlockCheck(loginVO); + if (lockVo != null && "Y".equals(lockVo.getLockYn())) { + model.addAttribute("message", "계정이 잠겼습니다. 관리자에게 문의 바랍니다."); + return "egovframework/com/uat/uia/EgovLoginUsr"; + } + } + } catch (Exception e) { + LOG.error("[개인정보/#24] 계정 잠금 확인 오류 - 로그인 계속 진행: " + e.getMessage()); + } + + // 2. 일반 로그인 처리 LoginVO resultVO = loginService.actionLogin(loginVO); if (resultVO != null && resultVO.getId() != null && !resultVO.getId().equals("")) { - System.out.println("로그인시도1"); - // 2-1. 로그인 정보를 세션에 저장 + // 2-1. 성공 — 당일 실패 이력 정리 후 세션 저장 + try { + loginService.deleteLoginSuccess(loginVO); + } catch (Exception e) { + LOG.error("[개인정보/#22] 로그인 성공 후 실패이력 정리 오류: " + e.getMessage()); + } request.getSession().setAttribute("loginVO", resultVO); - return "redirect:/mnt/main.do"; - + // [개인정보보호 SFR-003 / #14·#52] 로그인 통합 2단계 인증: + // 비밀번호 인증 성공 직후 2차 인증 화면으로 유도한다(관리자 actionLogin 경로 전용). + // secondFactor.do가 등록자→OTP 검증 / 미등록자→등록 유도를 처리하고, + // 2차 인증 완료(SECOND_FACTOR_DONE) 후 returnUrl 기본값(/mnt/main.do)으로 이동한다. + // 미등록자도 '등록하기'로 진행 가능(잠금 아님). 일반회원은 diapauseLogin.do 경로라 미영향. + return "redirect:/uat/uia/secondFactor.do"; + } else { - System.out.println("로그인시도2"); + // 2-2. 실패 — 계정이 존재하는 경우에만 실패 이력 적재 + 임계 초과 시 잠금 + int currentFail = 0; + int threshold = DEFAULT_LOCK_THRESHOLD; + try { + HashMap map = new HashMap(); + map.put("id", loginVO.getId()); + map.put("userSe", loginVO.getUserSe()); + + if (loginService.selectUserInfoCheck(map) >= 1) { + // 임계값(관리자 설정, LOGIN_LOCK_COUNT) 조회 — 값 부재 시 기본 5회 + LoginVO totVo = loginService.selectFailTotalCount(new HashMap()); + if (totVo != null && totVo.getLockCount() > 0) { + threshold = totVo.getLockCount(); + } + // 실패 이력 적재(LOGIN_HISTORY) + loginService.insertLoginFail(loginVO); + // 당일 누적 실패 횟수 + currentFail = loginService.selectFailCount(map); + // 임계 초과 시 계정 잠금(LOCK_YN='Y') + if (currentFail >= threshold) { + loginService.updateFailLogin(loginVO); + } + } + } catch (Exception e) { + LOG.error("[개인정보/#22] 로그인 실패 이력 처리 오류: " + e.getMessage()); + } + + // ============================================================ + // 계정 열거 방지(SER-001 / #23) — 계정 존재 여부·실패 횟수와 무관하게 동일 메시지 반환 + // · 이전에는 존재 계정에만 "현재 N회" 문구를 부가해 계정 존재 여부가 노출되었다(계정 열거 취약). + // · "존재하지 않는 아이디"류 문구도 사용하지 않는다. + // · 실패 이력 적재·잠금(위 2-2)은 내부적으로 수행하되 응답 메시지에는 절대 노출하지 않는다. + // · 위 currentFail/threshold 는 잠금 판정(내부)용으로만 사용한다. + // ============================================================ model.addAttribute("message", egovMessageSource.getMessage("fail.common.login")); return "egovframework/com/uat/uia/EgovLoginUsr"; } - } + } /** * 일반(세션) 로그인2을 처리한다 diff --git a/src/main/java/egovframework/com/uat/uia/web/SecondFactorController.java b/src/main/java/egovframework/com/uat/uia/web/SecondFactorController.java new file mode 100644 index 0000000..97f6db2 --- /dev/null +++ b/src/main/java/egovframework/com/uat/uia/web/SecondFactorController.java @@ -0,0 +1,345 @@ +package egovframework.com.uat.uia.web; + +import java.io.ByteArrayOutputStream; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; +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.privacy.interceptor.SecondFactorInterceptor; +import egovframework.com.cmm.privacy.security.SessionRegenerator; +import egovframework.com.cmm.privacy.util.MaskingUtil; +import egovframework.com.uat.uia.service.AuthMethodResolver; +import egovframework.com.uat.uia.service.AuthMethodService; +import egovframework.com.uat.uia.service.impl.CertAuthService; +import egovframework.com.uat.uia.service.impl.OtpAuthService; +import egovframework.com.uat.uia.service.impl.SmsAuthService; + +/** + * 2단계(추가) 인증 화면·검증·최소 등록(enrollment) 컨트롤러 + * (문화품앗이 개인정보보호 기능개선 사업 — SFR-003 / 개발항목 #52·#14·#16·#18). + * + *

모든 엔드포인트는 {@code /uat/uia/} 하위에 둔다 — {@link SecondFactorInterceptor}의 + * excludePatterns({@code \A/uat/uia/.*\Z})에 걸려 2차 인증 판정에서 제외되므로 + * 리다이렉트 루프가 발생하지 않는다.

+ * + *

흐름: + *

    + *
  1. {@code /mnt/} 접근 → 인터셉터가 2차 인증 미완료 취급자를 {@code /uat/uia/secondFactor.do}로 유도.
  2. + *
  3. {@link #secondFactorView}(GET) — 배정 수단 안내 + 코드 입력 폼.
  4. + *
  5. {@link #secondFactorConfirm}(POST) — {@link AuthMethodService#verify} 성공 시 + * {@link SessionRegenerator#onSecondFactorSuccess}로 세션 재발급(고정공격 차단, #16) + + * {@code SECOND_FACTOR_DONE=true} 세팅 후 원 목적지(또는 {@code /mnt/main.do})로 이동.
  6. + *
  7. {@link #enrollView}/{@link #enrollConfirm} — OTP 비밀키 발급/최초검증(등록 경로). + * 등록이 없으면 아무도 {@code isAvailable=true}가 아니어서 2FA가 무력하므로 최소 포함.
  8. + *
+ *

+ * + *

운영 안전(관리자 잠금 방지): 이 컨트롤러는 취급자를 잠그지 않는다. + * 잠금 회피 판정은 {@link SecondFactorInterceptor}가 수행한다(수단 미설정 사용자는 통과). + * 본 컨트롤러는 인증수단이 준비된 사용자에게 검증 UI만 제공한다.

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.07.22    URP     최초 생성 (개인정보보호 SFR-003 / #52 — 2차 인증 화면·검증·OTP 등록)
+ * 
+ */ +@Controller +public class SecondFactorController { + + protected static final Log LOG = LogFactory.getLog(SecondFactorController.class); + + /** 2차 인증 실패 누적 세션 키 — 과도한 시도 억제(계정 잠금과 별개, 이 세션 한정). */ + private static final String ATTR_SF_ATTEMPT = "SF_ATTEMPT"; + /** 세션당 2차 인증 최대 시도 횟수. */ + private static final int MAX_ATTEMPT = 5; + /** 검증 성공 후 기본 이동 목적지. */ + private static final String DEFAULT_TARGET = "/mnt/main.do"; + /** 미인증 시 이동할 로그인 화면. */ + private static final String LOGIN_URL = "/uat/uia/egovLoginUsr.do"; + + /** 취급자별 인증수단 분기 리졸버(빈: authMethodResolver). */ + @Resource(name = "authMethodResolver") + private AuthMethodResolver authMethodResolver; + + /** OTP 인증 서비스(등록 경로에서 비밀키 발급/검증에 직접 사용). */ + @Resource(name = "otpAuthService") + private OtpAuthService otpAuthService; + + // ────────────────────────────────────────────────────────── 2차 인증 화면 + + /** + * 2차 인증 입력 화면(GET). 로그인 사용자에게 배정된 인증수단을 판정해 안내한다. + */ + @RequestMapping(value = "/uat/uia/secondFactor.do") + public String secondFactorView(HttpServletRequest request, ModelMap model, + @RequestParam(value = "returnUrl", required = false) String returnUrl) throws Exception { + + LoginVO user = loginUser(request); + if (user == null) { + return "redirect:" + LOGIN_URL; + } + + model.addAttribute("returnUrl", sanitizeTarget(returnUrl)); + + AuthMethodService svc = null; + try { + svc = authMethodResolver.resolve(user.getUniqId()); + } catch (Exception e) { + // 사용 가능한 수단이 하나도 없음 → 등록 유도(잠그지 않음). + LOG.info("[개인정보/#52] 2차 인증수단 미가용 — 등록 유도: user=" + user.getId() + ", cause=" + e.toString()); + } + + if (svc == null || !safeAvailable(svc, user.getUniqId())) { + model.addAttribute("needEnroll", Boolean.TRUE); + model.addAttribute("methodType", "NONE"); + return "egovframework/com/uat/uia/EgovSecondFactor"; + } + + String methodType = methodTypeOf(svc); + model.addAttribute("methodType", methodType); + + // SMS 수단이면 화면 진입 시 인증번호 발송(구현체 준비 후 동작). 실패해도 화면은 표시. + if ("SMS".equals(methodType)) { + try { + svc.issue(user.getUniqId()); + model.addAttribute("maskedPhone", MaskingUtil.maskPhone(user.getMbtlnum())); + } catch (Exception e) { + LOG.warn("[개인정보/#52] SMS 인증번호 발송 미가용(구현 대기): " + e.toString()); + model.addAttribute("issueFail", Boolean.TRUE); + } + } + + return "egovframework/com/uat/uia/EgovSecondFactor"; + } + + /** + * 2차 인증 코드 검증(POST). 성공 시 세션 재발급 + 완료표식 세팅 후 목적지 이동. + */ + @RequestMapping(value = "/uat/uia/secondFactorConfirm.do") + public String secondFactorConfirm(HttpServletRequest request, ModelMap model, + @RequestParam(value = "authCode", required = false) String authCode, + @RequestParam(value = "returnUrl", required = false) String returnUrl) throws Exception { + + LoginVO user = loginUser(request); + if (user == null) { + return "redirect:" + LOGIN_URL; + } + + String target = sanitizeTarget(returnUrl); + model.addAttribute("returnUrl", target); + + // 세션당 과도한 시도 억제 + HttpSession session = request.getSession(); + int attempt = attemptCount(session); + if (attempt >= MAX_ATTEMPT) { + model.addAttribute("methodType", "LOCKED"); + model.addAttribute("errorMsg", "인증 시도 횟수를 초과했습니다. 다시 로그인해 주세요."); + return "egovframework/com/uat/uia/EgovSecondFactor"; + } + + AuthMethodService svc = null; + try { + svc = authMethodResolver.resolve(user.getUniqId()); + } catch (Exception e) { + LOG.info("[개인정보/#52] 검증 단계 수단 미가용 — 등록 유도: user=" + user.getId()); + } + if (svc == null || !safeAvailable(svc, user.getUniqId())) { + model.addAttribute("needEnroll", Boolean.TRUE); + model.addAttribute("methodType", "NONE"); + return "egovframework/com/uat/uia/EgovSecondFactor"; + } + + String methodType = methodTypeOf(svc); + model.addAttribute("methodType", methodType); + + boolean ok = false; + try { + ok = svc.verify(user.getUniqId(), authCode); + } catch (Exception e) { + LOG.warn("[개인정보/#52] 2차 인증 검증 오류: " + e.toString()); + ok = false; + } + + if (ok) { + // 성공 — 세션 재발급(세션 고정 차단, #16) + 완료표식 세팅. attempt 카운터는 세션과 함께 폐기됨. + SessionRegenerator.onSecondFactorSuccess(request); + LOG.info("[개인정보/#14] 2차 인증 성공 — 세션 재발급, 목적지 이동: user=" + user.getId() + ", target=" + target); + return "redirect:" + target; + } + + // 실패 — 시도횟수 증가 후 화면 재표시(계정열거/수단 노출 없이 일관 메시지) + session.setAttribute(ATTR_SF_ATTEMPT, Integer.valueOf(attempt + 1)); + model.addAttribute("errorMsg", "인증코드가 올바르지 않습니다. 다시 시도해 주세요."); + return "egovframework/com/uat/uia/EgovSecondFactor"; + } + + // ────────────────────────────────────────────────────────── OTP 최소 등록(enrollment) + + /** + * OTP 등록 화면(GET). 신규 비밀키를 발급·암호화 저장하고 프로비저닝 정보(secret/otpauth URI)를 표시한다. + * + *

주의: 매 진입 시 새 비밀키가 발급되므로, 표시된 secret으로 인증앱을 설정한 뒤 + * 반드시 하단 최초검증까지 완료해야 한다. (기본 저장소 {@code InMemoryOtpSecretStore}는 + * 애플리케이션 재기동 시 유실되므로 운영에서는 DB 저장소 구현체로 교체한다.)

+ */ + @RequestMapping(value = "/uat/uia/secondFactorEnroll.do") + public String enrollView(HttpServletRequest request, ModelMap model) throws Exception { + LoginVO user = loginUser(request); + if (user == null) { + return "redirect:" + LOGIN_URL; + } + + String otpauthUri = otpAuthService.provision(user.getUniqId()); + model.addAttribute("otpauthUri", otpauthUri); + model.addAttribute("otpSecret", extractSecret(otpauthUri)); // 수동 입력용(QR 보조) Base32 secret + model.addAttribute("accountLabel", user.getId()); + // otpauth URI 를 스캔 가능한 QR(자립형 data URI PNG)로 렌더 — 외부 리소스/네트워크 없음(ZXing 서버측 생성). + try { + model.addAttribute("otpQrDataUri", buildQrDataUri(otpauthUri, 220)); + } catch (Exception e) { + // QR 생성 실패해도 수동 입력(secret)로 등록 가능하도록 화면은 계속 표시. + LOG.warn("[개인정보/#18] OTP QR 생성 실패 — 수동 입력으로 대체: " + e.toString()); + } + return "egovframework/com/uat/uia/EgovSecondFactorEnroll"; + } + + /** + * OTP 등록 최초검증(POST). 발급한 비밀키로 첫 코드를 검증한다. + * 성공 시 곧바로 2차 인증 완료로 간주하고 세션을 재발급한 뒤 취급자 영역으로 이동한다. + */ + @RequestMapping(value = "/uat/uia/secondFactorEnrollConfirm.do") + public String enrollConfirm(HttpServletRequest request, ModelMap model, + @RequestParam(value = "authCode", required = false) String authCode) throws Exception { + + LoginVO user = loginUser(request); + if (user == null) { + return "redirect:" + LOGIN_URL; + } + + boolean ok = false; + try { + ok = otpAuthService.verify(user.getUniqId(), authCode); + } catch (Exception e) { + LOG.warn("[개인정보/#18] OTP 등록 검증 오류: " + e.toString()); + ok = false; + } + + if (ok) { + SessionRegenerator.onSecondFactorSuccess(request); + LOG.info("[개인정보/#18] OTP 등록·최초검증 완료 — 세션 재발급: user=" + user.getId()); + return "redirect:" + DEFAULT_TARGET; + } + + model.addAttribute("enrollError", "인증코드가 올바르지 않습니다. 인증앱의 현재 코드로 다시 시도하거나 재발급하세요."); + return "egovframework/com/uat/uia/EgovSecondFactorEnroll"; + } + + // ────────────────────────────────────────────────────────── 내부 유틸 + + private LoginVO loginUser(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return null; + } + Object o = session.getAttribute("loginVO"); + return (o instanceof LoginVO) ? (LoginVO) o : null; + } + + private int attemptCount(HttpSession session) { + Object o = session.getAttribute(ATTR_SF_ATTEMPT); + return (o instanceof Integer) ? ((Integer) o).intValue() : 0; + } + + /** {@code isAvailable} 호출 시 구현체(스텁)가 던지는 예외를 흡수해 false로 취급. */ + private boolean safeAvailable(AuthMethodService svc, String uniqId) { + try { + return svc.isAvailable(uniqId); + } catch (Exception e) { + return false; + } + } + + private String methodTypeOf(AuthMethodService svc) { + if (svc instanceof OtpAuthService) { + return "OTP"; + } + if (svc instanceof SmsAuthService) { + return "SMS"; + } + if (svc instanceof CertAuthService) { + return "CERT"; + } + return "OTP"; + } + + /** + * 목적지 URL 화이트리스트 검증 — open-redirect 방지. + * 취급자 영역({@code /mnt/}) 내부 경로만 허용하고, 그 외/이상값은 기본 목적지로 대체한다. + */ + private String sanitizeTarget(String returnUrl) { + if (returnUrl == null) { + return DEFAULT_TARGET; + } + String t = returnUrl.trim(); + if (t.length() == 0) { + return DEFAULT_TARGET; + } + // 절대 URL/프로토콜 상대(//host)/역참조 차단 — 반드시 "/mnt/"로 시작하는 앱 내부 경로만 허용 + if (!t.startsWith("/mnt/") || t.startsWith("//") || t.indexOf('\\') >= 0 + || t.indexOf("://") >= 0 || t.indexOf("..") >= 0) { + return DEFAULT_TARGET; + } + return t; + } + + /** + * otpauth URI를 스캔 가능한 QR 코드로 인코딩해 자립형 data URI(PNG, Base64)로 반환한다. + *

외부 리소스/네트워크 없이 서버측(ZXing, 기존 의존성)에서 생성한다 — 폐쇄망 전제. + * 반환값은 {@code data:image/png;base64,...} 형식으로 {@code }에 그대로 바인딩된다.

+ */ + private String buildQrDataUri(String content, int size) throws Exception { + QRCodeWriter writer = new QRCodeWriter(); + java.util.Map hints = new java.util.HashMap(); + hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M); + hints.put(EncodeHintType.MARGIN, Integer.valueOf(1)); + hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); + BitMatrix matrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size, hints); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + MatrixToImageWriter.writeToStream(matrix, "png", baos); + String b64 = new String(Base64.encodeBase64(baos.toByteArray()), "US-ASCII"); + return "data:image/png;base64," + b64; + } + + /** otpauth URI에서 secret 파라미터(Base32)만 추출한다(수동 입력 안내용). */ + private String extractSecret(String otpauthUri) { + if (otpauthUri == null) { + return ""; + } + int i = otpauthUri.indexOf("secret="); + if (i < 0) { + return ""; + } + int start = i + "secret=".length(); + int amp = otpauthUri.indexOf('&', start); + return (amp < 0) ? otpauthUri.substring(start) : otpauthUri.substring(start, amp); + } +} diff --git a/src/main/java/egovframework/com/uss/umt/service/impl/EgovAccountUnlockManageServiceImpl.java b/src/main/java/egovframework/com/uss/umt/service/impl/EgovAccountUnlockManageServiceImpl.java index 79965a4..051d209 100644 --- a/src/main/java/egovframework/com/uss/umt/service/impl/EgovAccountUnlockManageServiceImpl.java +++ b/src/main/java/egovframework/com/uss/umt/service/impl/EgovAccountUnlockManageServiceImpl.java @@ -3,10 +3,17 @@ package egovframework.com.uss.umt.service.impl; import java.util.List; import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.privacy.auditlog.ClientIpResolver; +import egovframework.com.cmm.privacy.lock.AccountUnlockHistoryDAO; +import egovframework.com.cmm.privacy.lock.AccountUnlockHistoryVO; +import egovframework.com.cmm.util.EgovUserDetailsHelper; import egovframework.com.uat.uia.service.impl.LoginDAO; import egovframework.com.uss.umt.service.DiapauseManageVO; import egovframework.com.uss.umt.service.EgovAccountUnlockManageService; @@ -15,17 +22,21 @@ import egovframework.rte.fdl.cmmn.AbstractServiceImpl; @Service("egovAccountUnlockManageService") public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl implements EgovAccountUnlockManageService{ - + /** diapauseManageDAO */ @Resource(name="diapauseManageDAO") - private DiapauseManageDAO diapauseManageDAO; - + private DiapauseManageDAO diapauseManageDAO; + //@Resource(name = "loginService") - //protected EgovLoginService egovLoginService; - + //protected EgovLoginService egovLoginService; + @Resource(name="loginDAO") private LoginDAO loginDAO; + /** 개인정보보호(SER-001 / #24) 계정 잠금 해제 이력 DAO */ + @Resource(name="accountUnlockHistoryDAO") + private AccountUnlockHistoryDAO accountUnlockHistoryDAO; + @Override public List selectUserLockCheckList(UserDefaultVO userSearchVO) { return diapauseManageDAO.selectUserLockCheckList(userSearchVO); @@ -40,41 +51,88 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp public void updateUnlock(String user_id, String user_gubun) { String [] sUser_id = user_id.split(","); String [] sUser_gubun = user_gubun.split(","); - + DiapauseManageVO vo = new DiapauseManageVO(); LoginVO loginVo = new LoginVO(); - for (int i=0; i※ 스케줄 활성화 주의: 본 프로젝트는 {@code }이 + * context-common.xml/egov-com-servlet.xml 양쪽 모두 주석 처리되어 있어 현재 + * {@code @Scheduled}가 동작하지 않는다(배치는 OS crontab 운영). 활성화 시 기존 휴면/메일 등 + * 휴면 배치가 함께 기동되므로, 활성화 여부·경합은 운영과 협의해 결정한다.

+ * + * @throws Exception 파기 오류 + */ + @Scheduled(cron = "0 0 4 1 * ?") + public void purgeAuthorChangeHistoryScheduler() throws Exception { + int purged = privacyBatchService.purgeExpiredAuthorChangeHistory(); + log.info("[개인정보/#5] 권한변경이력 파기 배치 완료 — " + purged + "건"); + } + + /** + * [개인정보보호 #40] 개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 해시체인 무결성 검증 배치. + * 매월 1일 03:00, 적재 순서대로 재계산해 위·변조를 탐지하고 불일치 시 즉시 관리자 통보. + * (스케줄 활성화 주의는 {@link #purgeAuthorChangeHistoryScheduler()} 참조.) + * + * @throws Exception 검증 오류 + */ + @Scheduled(cron = "0 0 3 1 * ?") + public void verifyAccessLogHashChainScheduler() throws Exception { + HashChainVerifyResultVO result = privacyBatchService.verifyAccessLogHashChain(); + log.info("[개인정보/#40] 접속기록 무결성 검증 배치 완료 — 총 " + result.getTotalCount() + + "건, 결과=" + (result.isOk() ? "정상" : "위반(LOG_ID=" + result.getMismatchLogId() + ")")); + } + + /** + * [개인정보보호 #37] 개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 보관정책 파기 배치. + * 매월 1일 02:00(저부하 시간대), 보관기간(730일) 경과 파티션을 DROP(개별 DELETE 미사용)하고 + * 미래 월 파티션을 선행 증설한다. COMTNSYSLOG 7일 삭제정책은 이 테이블에 적용하지 않는다(#38). + * (스케줄 활성화 주의는 {@link #purgeAuthorChangeHistoryScheduler()} 참조 — 현재 전역 비활성.) + * + * @throws Exception 파기/증설 오류 + */ + @Scheduled(cron = "0 0 2 1 * ?") + public void purgeAccessLogPartitionScheduler() throws Exception { + int dropped = privacyBatchService.purgeExpiredAccessLogPartitions(); + log.info("[개인정보/#37] 접속기록 파티션 파기/증설 배치 완료 — 파기 " + dropped + "개 파티션"); + } + + /** + * [개인정보보호 #43] 개인정보 접속기록 정기점검 리포트 생성 배치. + * 매월 1일 05:00(기존 파기 02:00/무결성 03:00/이력파기 04:00 배치와 시각 분리). + * 전월분 접속기록을 집계(총건수·실패·무결성#40·취급자별 처리현황)해 PDAL_INSPECTION_REPORT 적재. + * + *

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

+ * + * @throws Exception 집계/적재 오류 + */ + @Scheduled(cron = "0 0 5 1 * ?") + public void generateInspectionReportScheduler() throws Exception { + InspectionReportVO report = privacyBatchService.generateMonthlyInspectionReport(); + log.info("[개인정보/#43] 정기점검 리포트 생성 배치 완료 — " + report.getRptYm() + + " 총 " + report.getTotalLogCnt() + "건, 무결성=" + report.getIntegrityResult()); + } + + /** + * [개인정보보호 #10] 휴면 전 사전 알림 배치 — D-30/D-7/D-Day 3단계 + 관리자 알림. + * 매일 06:00, 정책(ACCOUNT_MNG_POLICY)의 ALERT_D1/D2/D3 시점에 해당하는 휴면 예정 + * 계정을 본인·관리자에게 사전 통지한다. 발송 실패는 fail-safe(로깅 후 계속). + * + *

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

+ * + * @throws Exception 알림 처리 오류 + */ + @Scheduled(cron = "0 0 6 * * ?") + public void dormantPreAlertScheduler() throws Exception { + int sent = dormantAlertService.sendDormantPreAlerts(); + log.info("[개인정보/#10] 휴면 사전알림 배치 완료 — " + sent + "건 발송"); + } + } \ No newline at end of file diff --git a/src/main/resources/egovframework/spring/com/context-egovuserdetailshelper.xml b/src/main/resources/egovframework/spring/com/context-egovuserdetailshelper.xml index 9072897..e187388 100644 --- a/src/main/resources/egovframework/spring/com/context-egovuserdetailshelper.xml +++ b/src/main/resources/egovframework/spring/com/context-egovuserdetailshelper.xml @@ -32,8 +32,11 @@ - - + + + + + diff --git a/src/main/resources/egovframework/spring/com/context-syslogaop.xml b/src/main/resources/egovframework/spring/com/context-syslogaop.xml index 651340f..2b09386 100644 --- a/src/main/resources/egovframework/spring/com/context-syslogaop.xml +++ b/src/main/resources/egovframework/spring/com/context-syslogaop.xml @@ -24,4 +24,39 @@ --> + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/egovframework/sqlmap/com/uat/uia/EgovLoginUsr_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/com/uat/uia/EgovLoginUsr_SQL_Mysql.xml index 4bc609a..0c2aab6 100644 --- a/src/main/resources/egovframework/sqlmap/com/uat/uia/EgovLoginUsr_SQL_Mysql.xml +++ b/src/main/resources/egovframework/sqlmap/com/uat/uia/EgovLoginUsr_SQL_Mysql.xml @@ -185,6 +185,119 @@ ]]> + + + + + + + + + + + + + + (SELECT IFNULL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY) /* No.9 정책값화(미설정 시 365) */ + AND a.MBER_ID NOT IN (SELECT MBER_ID FROM COMTNDIAPAUSEMBER) + AND a.MBER_STTUS = 'P' + ]]> + + + + + (SELECT IFNULL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY) /* No.9 정책값화(미설정 시 365) */ + AND e.EMPLYR_ID NOT IN (SELECT MBER_ID FROM COMTNDIAPAUSEMBER) + AND e.EMPLYR_STTUS_CODE = 'P' + ]]> + + + + + + + + + + + UPDATE COMTNDIAPAUSEMBER SET DIAPAUSE_STAT = #diapause_stat# WHERE MBER_ID = #id# + + + + + + + + UPDATE COMTNDIAPAUSEMBER + SET TOKEN = #token# + , TOKEN_EXPDT = DATE_ADD(NOW(), INTERVAL 10 MINUTE) + WHERE MBER_ID = #mber_id# + AND ESNTL_ID = #esntl_id# + + 365 + WHERE NVL(SYSDATE - b.CREAT_DT,SYSDATE - a.SBSCRB_DE) > (SELECT NVL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY) /* No.9 정책값화 */ AND a.MBER_ID NOT IN (SELECT MBER_ID FROM COMTNDIAPAUSEMBER) AND a.MBER_STTUS = 'P' ]]> + + + + (SELECT NVL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY) /* No.9 정책값화 */ + AND e.EMPLYR_ID NOT IN (SELECT MBER_ID FROM COMTNDIAPAUSEMBER) + AND e.EMPLYR_STTUS_CODE = 'P' + ]]> + + + + = DIAPAUSE_DT) + WHERE A.MBER_TY_CODE = 'GNR' + AND (DIAPAUSE_STAT IS NULL OR (DIAPAUSE_STAT = 'S' AND SYSDATE >= DIAPAUSE_DT)) 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 a77b364..a2f6df5 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 @@ -99,4 +99,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLogIntegrity_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLogIntegrity_SQL_Mysql.xml new file mode 100644 index 0000000..1c49341 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLogIntegrity_SQL_Mysql.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLogIntegrity_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLogIntegrity_SQL_Oracle.xml new file mode 100644 index 0000000..4a8131c --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLogIntegrity_SQL_Oracle.xml @@ -0,0 +1,32 @@ + + + + + + + + + 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 new file mode 100644 index 0000000..919b0ae --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml @@ -0,0 +1,358 @@ + + + + + + + + + + + + + + + + + + + + + ALTER TABLE PERSONAL_DATA_ACCESS_LOG DROP PARTITION $value$ + + + + + ALTER TABLE PERSONAL_DATA_ACCESS_LOG + ADD PARTITION (PARTITION $partitionName$ VALUES LESS THAN ('$boundaryDt$')) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000..94842e7 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Oracle.xml @@ -0,0 +1,299 @@ + + + + + + + + + + + + + + + + + + ALTER TABLE PERSONAL_DATA_ACCESS_LOG DROP PARTITION $value$ + + + + ALTER TABLE PERSONAL_DATA_ACCESS_LOG + ADD PARTITION $partitionName$ VALUES LESS THAN (TO_DATE('$boundaryDt$', 'YYYY-MM-DD')) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Mysql.xml new file mode 100644 index 0000000..e40bd44 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Mysql.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Oracle.xml new file mode 100644 index 0000000..832df1a --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Oracle.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountUnlockHistory_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountUnlockHistory_SQL_Mysql.xml new file mode 100644 index 0000000..177d80a --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountUnlockHistory_SQL_Mysql.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountUnlockHistory_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountUnlockHistory_SQL_Oracle.xml new file mode 100644 index 0000000..cf467a6 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountUnlockHistory_SQL_Oracle.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistoryPurge_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistoryPurge_SQL_Mysql.xml new file mode 100644 index 0000000..ba425e9 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistoryPurge_SQL_Mysql.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistoryPurge_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistoryPurge_SQL_Oracle.xml new file mode 100644 index 0000000..497a74f --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistoryPurge_SQL_Oracle.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Mysql.xml new file mode 100644 index 0000000..a97303f --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Mysql.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Oracle.xml new file mode 100644 index 0000000..4017a81 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Oracle.xml @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthority_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthority_SQL_Mysql.xml new file mode 100644 index 0000000..e526198 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthority_SQL_Mysql.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthority_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthority_SQL_Oracle.xml new file mode 100644 index 0000000..50876ab --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthority_SQL_Oracle.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Mysql.xml new file mode 100644 index 0000000..ea3185c --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Mysql.xml @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Oracle.xml new file mode 100644 index 0000000..e771490 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Oracle.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMenuAuth_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMenuAuth_SQL_Mysql.xml new file mode 100644 index 0000000..5e7d89c --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMenuAuth_SQL_Mysql.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMenuAuth_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMenuAuth_SQL_Oracle.xml new file mode 100644 index 0000000..91edfcd --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMenuAuth_SQL_Oracle.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyOtpDevice_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyOtpDevice_SQL_Mysql.xml new file mode 100644 index 0000000..a5c682b --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyOtpDevice_SQL_Mysql.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyOtpDevice_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyOtpDevice_SQL_Oracle.xml new file mode 100644 index 0000000..8c68204 --- /dev/null +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyOtpDevice_SQL_Oracle.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml b/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml index b036377..0cf783e 100644 --- a/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml +++ b/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml @@ -64,11 +64,22 @@ - + + + + + + @@ -94,6 +105,107 @@ + + + + + + + + + + \A/mnt/(main|left|top|right|footer)\.do.*\Z + \A/mnt/(include)/.*\.do.*\Z + \A/uat/uia/.*\Z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \A/mnt/(main|left|top|right|footer)\.do.*\Z + \A/mnt/(include)/.*\.do.*\Z + \A/uat/uia/.*\Z + + + + + + + + + + + + + + + ROLE_ADMIN + ROLE_ADMINS + + + + + 30 diff --git a/src/script/mysql/ddl/privacy.account_mng_policy_create_mysql.sql b/src/script/mysql/ddl/privacy.account_mng_policy_create_mysql.sql new file mode 100644 index 0000000..8aa95ec --- /dev/null +++ b/src/script/mysql/ddl/privacy.account_mng_policy_create_mysql.sql @@ -0,0 +1,34 @@ +-- ===================================================================== +-- 계정 관리 정책 : ACCOUNT_MNG_POLICY (단일행 정책 테이블) +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-002, 장기 미사용 계정 관리) +-- 개발항목 #13(정책 설정) + #9(휴면 기준일수 정책값화) - 담당: arko-privacy-authctrl (Fork A) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [용도] 휴면 기준일수·사전알림 시점·삭제 보존기간·배치주기 등 계정 관리 정책을 +-- 하드코딩이 아닌 관리자 설정값으로 운영하기 위한 단일행 정책 테이블. +-- [#9] 휴면 select(EgovLoginUsr_SQL_Mysql.xml)의 365 하드코딩을 +-- (SELECT DORMANT_DAY FROM ACCOUNT_MNG_POLICY) 로 대체(미설정 시 365 fallback). +-- [#10] ALERT_D1/D2/D3(기본 30/7/0) = 휴면예정 D-30/D-7/D-Day 사전알림 시점. +-- ADMIN_EMAIL = 사전알림 관리자 수신 주소. +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS ACCOUNT_MNG_POLICY ( + POLICY_ID INT NOT NULL DEFAULT 1 COMMENT '정책 식별자(단일행=1)', + DORMANT_DAY INT NOT NULL DEFAULT 365 COMMENT '휴면 전환 기준 미접속 일수(#9)', + ALERT_D1 INT NOT NULL DEFAULT 30 COMMENT '사전알림 1차(휴면 D-일수)', + ALERT_D2 INT NOT NULL DEFAULT 7 COMMENT '사전알림 2차(휴면 D-일수)', + ALERT_D3 INT NOT NULL DEFAULT 0 COMMENT '사전알림 3차(D-Day, 0=당일)', + DELETE_PRESERVE_DAY INT NOT NULL DEFAULT 365 COMMENT '휴면/탈퇴 후 삭제 보존기간(일)', + BATCH_CYCLE VARCHAR(20) NOT NULL DEFAULT 'DAILY' COMMENT '배치 주기(DAILY/WEEKLY/MONTHLY)', + ADMIN_EMAIL VARCHAR(200) NULL COMMENT '사전알림 관리자 수신 이메일', + UPD_ID VARCHAR(20) NULL COMMENT '최종 수정자', + UPD_DT DATETIME NULL COMMENT '최종 수정일시', + PRIMARY KEY (POLICY_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='계정 관리 정책(개인정보보호 SFR-002, 단일행)'; + +-- 기본 정책 시드(없을 때만). 기준 365일·알림 D-30/7/0·보존 365일. +INSERT INTO ACCOUNT_MNG_POLICY (POLICY_ID, DORMANT_DAY, ALERT_D1, ALERT_D2, ALERT_D3, DELETE_PRESERVE_DAY, BATCH_CYCLE, UPD_ID, UPD_DT) +SELECT 1, 365, 30, 7, 0, 365, 'DAILY', 'SYSTEM', NOW() + FROM DUAL + WHERE NOT EXISTS (SELECT 1 FROM ACCOUNT_MNG_POLICY WHERE POLICY_ID = 1); diff --git a/src/script/mysql/ddl/privacy.account_unlock_history_create_mysql.sql b/src/script/mysql/ddl/privacy.account_unlock_history_create_mysql.sql new file mode 100644 index 0000000..b5ada4b --- /dev/null +++ b/src/script/mysql/ddl/privacy.account_unlock_history_create_mysql.sql @@ -0,0 +1,38 @@ +-- ===================================================================== +-- 계정 잠금 해제 이력 : ACCOUNT_UNLOCK_HISTORY +-- 문화품앗이 개인정보보호 기능개선 사업 (SER-001, 계정 잠금 정책 정비) +-- 개발항목 #24 - 담당: arko-privacy-authctrl (Fork A) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [목적] 잠긴 계정 해제를 DB 직접조작이 아니라 관리자 화면(EgovAccountUnlockManage) +-- 에서 처리하고, 해제자·해제일시·대상·사유를 이력으로 남긴다. +-- [무결성] 애플리케이션 계층에 UPDATE/DELETE statement 미정의(INSERT/SELECT 한정). +-- ===================================================================== + +CREATE TABLE ACCOUNT_UNLOCK_HISTORY ( + UNLOCK_HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '해제 이력 일련번호(PK)', + TRGET_ID VARCHAR(50) NULL COMMENT '해제 대상 계정 ID(전체해제 시 NULL 가능)', + TRGET_SE VARCHAR(10) NULL COMMENT '대상 회원구분 GNR/GRO/ENT/USR', + UNLOCK_TY CHAR(1) NOT NULL DEFAULT 'I' COMMENT '해제 유형 I:개별 A:전체', + UNLOCK_USER_ID VARCHAR(50) NULL COMMENT '해제 처리자(관리자) ID', + UNLOCK_RESN VARCHAR(500) NULL COMMENT '해제 사유', + UNLOCK_IP VARCHAR(45) NULL COMMENT '해제자 접속 IP(IPv6 대응)', + UNLOCK_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '해제 일시', + PRIMARY KEY (UNLOCK_HIST_ID), + KEY IX_AUH_TRGET (TRGET_ID, UNLOCK_DT), + KEY IX_AUH_USER (UNLOCK_USER_ID, UNLOCK_DT), + KEY IX_AUH_DT (UNLOCK_DT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='계정 잠금 해제 이력(개인정보보호 SER-001 #24)'; + + +-- ===================================================================== +-- 계정 잠금 임계값 10회 → 5회 (#24) +-- 임계값은 하드코딩이 아니라 LOGIN_LOCK_COUNT.LOCK_COUNT(단일행) 데이터에서 조회됨 +-- (loginDAO.selectFailTotalCount). 아래 UPDATE 로 운영 정책값을 5회로 조정한다. +-- ※ 소스(EgovLoginController)에는 값 부재 시 기본 5회 fallback 이 반영되어 있음. +-- ===================================================================== +UPDATE LOGIN_LOCK_COUNT SET LOCK_COUNT = 5; +-- 행이 없을 경우 대비(최초 세팅): +-- INSERT INTO LOGIN_LOCK_COUNT (LOCK_COUNT) SELECT 5 FROM DUAL +-- WHERE NOT EXISTS (SELECT 1 FROM LOGIN_LOCK_COUNT); diff --git a/src/script/mysql/ddl/privacy.author_change_history_create_mysql.sql b/src/script/mysql/ddl/privacy.author_change_history_create_mysql.sql new file mode 100644 index 0000000..3c49e77 --- /dev/null +++ b/src/script/mysql/ddl/privacy.author_change_history_create_mysql.sql @@ -0,0 +1,37 @@ +-- ===================================================================== +-- 권한 변경 이력 테이블 : AUTHOR_CHANGE_HISTORY +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-001, 역할별 차등 접근/권한 변경 추적) +-- 개발항목 #4 - 담당: arko-privacy-authctrl (Fork A → 오케스트레이터 완료) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [목적] 권한 신설·변경·회수(#1 getAuthorities 대상 COMTNEMPLYRSCRTYESTBS 등)의 +-- 모든 변경을 변경자·변경일시·변경전/후 권한과 함께 이력으로 적재한다. +-- 권한 신청·승인 워크플로(#3)의 승인 반영 시점에 권한반영과 단일 트랜잭션으로 적재. +-- +-- [보관·파기] 3년(1,095일) 이상 보관 전제. 월 1회 @Scheduled 파기 배치(#5)가 +-- 보관기간(1,095일) 경과 이력만 선별 파기한다. 개별 삭제 API 미제공. +-- 다운로드 시 사유 입력 필수 + 다운로드 이력 적재(#6)는 별도 처리. +-- +-- [무결성] 이력 위·변조 억제를 위해 변경전/후 권한을 함께 보관(사후 대사 가능). +-- 애플리케이션 계층에 UPDATE statement 미정의(INSERT/SELECT/파기 DELETE 한정). +-- ===================================================================== + +CREATE TABLE AUTHOR_CHANGE_HISTORY ( + HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호(PK)', + TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '변경 대상자 고유식별자(ESNTL_ID)', + TRGET_ID VARCHAR(20) NULL COMMENT '변경 대상자 계정ID(참고용)', + CHG_SE_CODE CHAR(1) NOT NULL COMMENT '변경구분 G:부여(grant) R:회수(revoke) M:변경(modify)', + BEFORE_AUTHOR VARCHAR(1000) NULL COMMENT '변경 전 권한(코드 CSV 또는 스냅샷)', + AFTER_AUTHOR VARCHAR(1000) NULL COMMENT '변경 후 권한(코드 CSV 또는 스냅샷)', + CHG_RESN VARCHAR(500) NULL COMMENT '변경 사유(신청/승인 워크플로 연계 #3)', + APROVL_ID VARCHAR(20) NULL COMMENT '승인자 계정ID(승인 워크플로 #3)', + CHANGER_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '변경자(처리자) 고유식별자', + CHANGER_ID VARCHAR(20) NULL COMMENT '변경자 계정ID(참고용)', + CHANGER_IP VARCHAR(45) NULL COMMENT '변경자 접속 IP(IPv6 대응)', + CHG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '변경 일시', + PRIMARY KEY (HIST_ID), + KEY IX_ACH_TRGET (TRGET_UNIQ_ID, CHG_DT), + KEY IX_ACH_CHANGER (CHANGER_UNIQ_ID, CHG_DT), + KEY IX_ACH_CHGDT (CHG_DT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='권한 변경 이력(개인정보보호 SFR-001, 3년 이상 보관)'; diff --git a/src/script/mysql/ddl/privacy.author_workflow_create_mysql.sql b/src/script/mysql/ddl/privacy.author_workflow_create_mysql.sql new file mode 100644 index 0000000..486e44b --- /dev/null +++ b/src/script/mysql/ddl/privacy.author_workflow_create_mysql.sql @@ -0,0 +1,47 @@ +-- ===================================================================== +-- 권한 신청·승인 워크플로 + 권한이력 다운로드 이력 +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-001, 권한 신설·변경·회수 승인통제) +-- 개발항목 #3(신청·승인 워크플로) / #6(권한이력 조회·다운로드) - 담당: arko-privacy-authctrl (Fork A 6차) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [#3] AUTHOR_CHANGE_REQUEST : 권한 변경 신청→승인/반려 워크플로. +-- 승인(A) 시 권한 반영(COMTNEMPLYRSCRTYESTBS)과 이력 적재(AUTHOR_CHANGE_HISTORY)를 +-- 단일 트랜잭션으로 처리한다(egovframework.com..*Impl AOP tx advisor). +-- [#6] AUTHOR_HIST_DOWNLOAD_HISTORY : 권한이력 다운로드 증빙(사유 필수). +-- ===================================================================== + +CREATE TABLE AUTHOR_CHANGE_REQUEST ( + REQUEST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '신청 일련번호(PK)', + TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '권한변경 대상자 고유식별자(ESNTL_ID)', + TRGET_ID VARCHAR(20) NULL COMMENT '권한변경 대상자 계정ID(참고용)', + REQUEST_SE CHAR(1) NOT NULL COMMENT '신청구분 N:신설(부여) C:변경 R:회수', + BEFORE_AUTHOR VARCHAR(1000) NULL COMMENT '신청 시점 현재 권한(스냅샷)', + AFTER_AUTHOR VARCHAR(1000) NULL COMMENT '신청 목표 권한(코드/CSV)', + REQUEST_RESN VARCHAR(500) NOT NULL COMMENT '신청 사유(필수)', + REQSTER_ID VARCHAR(20) NOT NULL COMMENT '신청자 계정ID', + REQSTER_UNIQ_ID VARCHAR(20) NULL COMMENT '신청자 고유식별자', + REQUEST_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '신청 일시', + CONFM_STTUS CHAR(1) NOT NULL DEFAULT 'R' COMMENT '처리상태 R:신청 A:승인 J:반려', + CONFM_ID VARCHAR(20) NULL COMMENT '승인/반려 처리자 계정ID', + CONFM_UNIQ_ID VARCHAR(20) NULL COMMENT '승인/반려 처리자 고유식별자', + CONFM_DT DATETIME NULL COMMENT '승인/반려 일시', + CONFM_RESN VARCHAR(500) NULL COMMENT '반려 사유', + PRIMARY KEY (REQUEST_ID), + KEY IX_ACR_TRGET (TRGET_UNIQ_ID, REQUEST_DT), + KEY IX_ACR_STTUS (CONFM_STTUS, REQUEST_DT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='권한 변경 신청·승인 워크플로(개인정보보호 SFR-001 #3)'; + +CREATE TABLE AUTHOR_HIST_DOWNLOAD_HISTORY ( + DWLD_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 일련번호(PK)', + DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID', + DOWNLOADER_UNIQ_ID VARCHAR(20) NULL COMMENT '다운로드자 고유식별자', + DOWNLOADER_IP VARCHAR(45) NULL COMMENT '다운로드자 접속 IP(IPv6 대응)', + DWLD_RESN VARCHAR(500) NOT NULL COMMENT '다운로드 사유(필수·미입력 차단)', + SEARCH_COND VARCHAR(1000) NULL COMMENT '다운로드 시 검색조건 스냅샷', + DWLD_CNT INT NOT NULL DEFAULT 0 COMMENT '다운로드 건수', + DWLD_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '다운로드 일시', + PRIMARY KEY (DWLD_ID), + KEY IX_AHD_DWLD (DOWNLOADER_ID, DWLD_DT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='권한이력 다운로드 증빙 이력(개인정보보호 SFR-001 #6)'; diff --git a/src/script/mysql/ddl/privacy.diapause_emplyr_extend_mysql.sql b/src/script/mysql/ddl/privacy.diapause_emplyr_extend_mysql.sql new file mode 100644 index 0000000..be142c2 --- /dev/null +++ b/src/script/mysql/ddl/privacy.diapause_emplyr_extend_mysql.sql @@ -0,0 +1,25 @@ +-- ===================================================================== +-- 휴면(장기 미사용) 대상 확장 : COMTNDIAPAUSEMBER 회원유형 구분 컬럼 추가 +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-002, 장기 미사용 계정 관리) +-- 개발항목 #7 - 담당: arko-privacy-authctrl (Fork A) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [배경] 기존 휴면 대상은 일반회원(COMTNGNRLMBER)뿐이며, 휴면 테이블 +-- COMTNDIAPAUSEMBER 에는 회원유형 구분 컬럼이 없다. 개인정보취급자 +-- (COMTNEMPLYRINFO)를 휴면 대상에 포함(#7)하면서 일반회원 휴면/해제 +-- 흐름과 혼선되지 않도록 회원유형 구분 컬럼을 신설한다. +-- +-- [안전] DEFAULT 'GNR'(일반회원)로 신설 → 기존 적재 행은 자동 'GNR' 처리되어 +-- 기존 selectDiapauseList(일반회원 join COMTNGNRLMBER) 흐름 무영향. +-- 취급자 휴면 행은 'EMP'로 적재하여 별도 select/notify 경로로 처리한다. +-- +-- [멱등] 재실행 안전을 위해 컬럼 존재 시 무시(수동 확인 후 실행). +-- ===================================================================== + +ALTER TABLE COMTNDIAPAUSEMBER + ADD COLUMN MBER_TY_CODE CHAR(3) NOT NULL DEFAULT 'GNR' + COMMENT '회원유형 GNR:일반회원 EMP:개인정보취급자'; + +-- 조회 성능(취급자/일반 분리 조회) 보조 인덱스 +ALTER TABLE COMTNDIAPAUSEMBER + ADD INDEX IX_DIAPAUSE_MBERTY (MBER_TY_CODE, DIAPAUSE_STAT); diff --git a/src/script/mysql/ddl/privacy.download_history_create_mysql.sql b/src/script/mysql/ddl/privacy.download_history_create_mysql.sql new file mode 100644 index 0000000..6ce6aa8 --- /dev/null +++ b/src/script/mysql/ddl/privacy.download_history_create_mysql.sql @@ -0,0 +1,30 @@ +-- ===================================================================== +-- 개인정보 접속기록 다운로드 이력 : PDAL_DOWNLOAD_HISTORY +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002) +-- 개발항목 #42 - 담당: arko-privacy-auditlog (Fork B) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [목적] 개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 조회결과 다운로드 시, +-- 다운로드 사유(필수)·다운로드자·일시·IP·건수를 이력으로 남긴다. +-- 사유 미입력 시 다운로드는 서비스 계층에서 차단(본 테이블 미적재). +-- [원칙] 개인정보 본문 미적재 — 다운로드 검색조건 요약(SEARCH_COND)만 보관. +-- 이 테이블은 INSERT/SELECT 만 수행(위·변조 방지, UPDATE/DELETE 미정의). +-- ===================================================================== + +CREATE TABLE PDAL_DOWNLOAD_HISTORY ( + DL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 이력 일련번호(PK)', + DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID', + DOWNLOAD_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '다운로드 일시', + DOWNLOADER_IP VARCHAR(45) NULL COMMENT '다운로드자 접속 IP(IPv6 대응)', + DL_REASON VARCHAR(500) NOT NULL COMMENT '다운로드 사유(필수 — 미입력 시 차단)', + SEARCH_COND VARCHAR(1000) NULL COMMENT '다운로드 검색조건 요약(개인정보 본문 미포함)', + RESULT_CNT INT NOT NULL DEFAULT 0 COMMENT '다운로드 건수', + REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '등록 일시', + PRIMARY KEY (DL_ID), + KEY IX_PDDH_DT (DOWNLOAD_DT), + KEY IX_PDDH_USER (DOWNLOADER_ID, DOWNLOAD_DT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='개인정보 접속기록 다운로드 이력(#42, SER-002)'; + +-- [Oracle] 시퀀스 필요: +-- CREATE SEQUENCE PDAL_DOWNLOAD_HISTORY_SEQ START WITH 1 INCREMENT BY 1 NOCACHE; diff --git a/src/script/mysql/ddl/privacy.inspection_report_create_mysql.sql b/src/script/mysql/ddl/privacy.inspection_report_create_mysql.sql new file mode 100644 index 0000000..2ae17c1 --- /dev/null +++ b/src/script/mysql/ddl/privacy.inspection_report_create_mysql.sql @@ -0,0 +1,37 @@ +-- ===================================================================== +-- 개인정보 접속기록 정기점검 리포트 : PDAL_INSPECTION_REPORT +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002) +-- 개발항목 #43 - 담당: arko-privacy-auditlog (Fork B) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [목적] 월 1회 정기점검 배치가 전월분 접속기록을 집계해 1행으로 적재한다. +-- "월 1회 이상 점검 내역"을 화면 출력으로 제출(개인정보보호위 점검 대응). +-- +-- [집계원] PERSONAL_DATA_ACCESS_LOG(#30) + 해시체인 무결성 검증(#40) 재사용. +-- [자리표시] 이상징후 5유형(#35)·다운로드 이력(#42)은 미구현 → 컬럼만 두고 배치가 0/요약만 적재. +-- [화면입력] INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER 는 배치가 아닌 점검자가 화면(#48)에서 입력. +-- ===================================================================== + +CREATE TABLE PDAL_INSPECTION_REPORT ( + RPT_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '리포트 일련번호(PK)', + RPT_YM CHAR(6) NOT NULL COMMENT '점검 대상 연월(전월, yyyyMM)', + PERIOD_FROM DATETIME NOT NULL COMMENT '집계 시작일시', + PERIOD_TO DATETIME NOT NULL COMMENT '집계 종료일시', + TOTAL_LOG_CNT INT NOT NULL DEFAULT 0 COMMENT '총 접속기록 건수', + FAIL_CNT INT NOT NULL DEFAULT 0 COMMENT '처리결과 F(실패/차단) 건수 = 이상징후 일부', + ABNORMAL_SUMMARY VARCHAR(2000) NULL COMMENT '이상징후 5유형 요약(#35 연계 전 자리표시)', + INTEGRITY_RESULT VARCHAR(20) NOT NULL DEFAULT 'OK' COMMENT '해시체인 무결성: OK / VIOLATION', + INTEGRITY_MISMATCH_ID BIGINT NULL COMMENT '최초 불일치 LOG_ID(위반 시)', + INTEGRITY_TOTAL_CNT INT NOT NULL DEFAULT 0 COMMENT '무결성 검증 대상 건수', + HANDLER_SUMMARY VARCHAR(4000) NULL COMMENT '취급자별 처리현황 요약', + DOWNLOAD_CNT INT NOT NULL DEFAULT 0 COMMENT '다운로드 이력 건수(#42 연계 전 0)', + INSPECTOR_ID VARCHAR(20) NULL COMMENT '점검자 계정ID(화면 입력, #44)', + INSPECT_RESULT VARCHAR(500) NULL COMMENT '점검결과(화면 입력, #44)', + ACTN_MATTER VARCHAR(1000) NULL COMMENT '조치사항(화면 입력, #44)', + INSPECT_DT DATETIME NULL COMMENT '점검일시(점검결과 입력 시각, #44 감사증빙)', + REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '리포트 생성일시', + PRIMARY KEY (RPT_ID), + UNIQUE KEY UX_PIR_YM (RPT_YM), + KEY IX_PIR_REGDT (REG_DT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='개인정보 접속기록 정기점검 리포트(#43, 월 1회)'; diff --git a/src/script/mysql/ddl/privacy.job_mapping_create_mysql.sql b/src/script/mysql/ddl/privacy.job_mapping_create_mysql.sql new file mode 100644 index 0000000..5a0b6f0 --- /dev/null +++ b/src/script/mysql/ddl/privacy.job_mapping_create_mysql.sql @@ -0,0 +1,53 @@ +-- ===================================================================== +-- 개인정보 처리 업무유형 매핑 테이블 : PDAL_JOB_MAPPING +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002) +-- 개발항목 #32 - 담당: arko-privacy-auditlog (Fork B) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [목적] 접속기록 수집(#31 PersonalDataLogAspect)이 가로챈 서비스 메서드를 +-- 업무코드(JOB_SE_CODE)·업무명(JOB_SE_NM)으로 매핑한다. +-- AOP pointcut 범위(개인정보 처리 서비스 패키지) 안에서 신규 화면이 추가되면 +-- 이 테이블에 행만 등록하면 수집되며, Aspect/pointcut 소스 수정이 불요하다. +-- +-- [매칭 규칙] Aspect가 (대상클래스 단순명, 메서드명)으로 조회. +-- - TARGET_CLASS : 서비스 impl 단순 클래스명(예: EgovMberManageServiceImpl) 또는 FQN +-- - METHOD_PTTRN : 메서드명 접두 매칭(예: 'select','insertMber') 또는 '*'(전체) +-- - DML_GUBUN : 지정 시 우선, NULL이면 메서드명 접두로 유도(insert*→I/update*→U/delete*→D/그외 S) +-- - SORT_ORDR : 다중 매칭 시 낮은 값 우선(구체 규칙을 위로) +-- +-- ※ URL 패턴 기반 수집이 필요하면(서블릿 필터형) 동일 테이블에 URL_PTTRN 컬럼을 추가해 확장. +-- ===================================================================== + +CREATE TABLE PDAL_JOB_MAPPING ( + MAPPING_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '매핑 일련번호(PK)', + TARGET_CLASS VARCHAR(200) NOT NULL COMMENT '대상 서비스 impl 단순 클래스명 또는 FQN', + METHOD_PTTRN VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '메서드명 접두 매칭(또는 * 전체)', + JOB_SE_CODE VARCHAR(30) NOT NULL COMMENT '수행업무 유형 코드', + JOB_SE_NM VARCHAR(100) NOT NULL COMMENT '수행업무 유형(화면·기능명)', + DML_GUBUN CHAR(1) NULL COMMENT '처리구분 강제값 S/I/U/D (NULL=메서드명 유도)', + SORT_ORDR INT NOT NULL DEFAULT 100 COMMENT '매칭 우선순위(낮을수록 우선)', + USE_AT CHAR(1) NOT NULL DEFAULT 'Y' COMMENT '사용여부 Y/N', + RM VARCHAR(300) NULL COMMENT '비고', + PRIMARY KEY (MAPPING_ID), + KEY IX_PJM_LOOKUP (USE_AT, TARGET_CLASS, SORT_ORDR) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='개인정보 처리 업무유형 매핑(접속기록 #31/#32)'; + +-- --------------------------------------------------------------------- +-- 초기 매핑 시드 : 현행 개인정보 처리 서비스(#31 pointcut 대상과 일치) +-- 신규 개인정보 화면 추가 시 아래 형식으로 행만 추가하면 수집된다. +-- --------------------------------------------------------------------- +INSERT INTO PDAL_JOB_MAPPING (TARGET_CLASS, METHOD_PTTRN, JOB_SE_CODE, JOB_SE_NM, DML_GUBUN, SORT_ORDR, RM) VALUES + ('EgovMberManageServiceImpl', 'select', 'MBER_VIEW', '회원관리-조회', 'S', 10, '회원 개인정보 조회'), + ('EgovMberManageServiceImpl', 'insert', 'MBER_REG', '회원관리-등록', 'I', 10, NULL), + ('EgovMberManageServiceImpl', 'update', 'MBER_MOD', '회원관리-수정', 'U', 10, NULL), + ('EgovMberManageServiceImpl', 'delete', 'MBER_DEL', '회원관리-삭제', 'D', 10, NULL), + ('EgovMberManageServiceImpl', '*', 'MBER_ETC', '회원관리-기타', NULL, 90, '미분류 폴백'), + ('EgovEntrprsManageServiceImpl', 'select', 'ENTRPRS_VIEW', '업체관리-조회', 'S', 10, '업체 담당자 개인정보 조회'), + ('EgovEntrprsManageServiceImpl', 'insert', 'ENTRPRS_REG', '업체관리-등록', 'I', 10, NULL), + ('EgovEntrprsManageServiceImpl', 'update', 'ENTRPRS_MOD', '업체관리-수정', 'U', 10, NULL), + ('EgovEntrprsManageServiceImpl', 'delete', 'ENTRPRS_DEL', '업체관리-삭제', 'D', 10, NULL), + ('EgovEntrprsManageServiceImpl', '*', 'ENTRPRS_ETC', '업체관리-기타', NULL, 90, '미분류 폴백'), + ('MberSsnServiceImpl', '*', 'MBER_SSN', '회원 고유식별정보 처리', NULL, 20, '주민등록번호 등 고유식별정보'), + ('BizMberMergeServiceImpl', '*', 'MBER_MERGE', '회원 통합/이관', NULL, 30, NULL), + ('BizTalentMberManageServiceImpl', '*', 'TALENT_MBER', '재능나눔 회원관리', NULL, 30, NULL); diff --git a/src/script/mysql/ddl/privacy.otp_device_create_mysql.sql b/src/script/mysql/ddl/privacy.otp_device_create_mysql.sql new file mode 100644 index 0000000..51142f6 --- /dev/null +++ b/src/script/mysql/ddl/privacy.otp_device_create_mysql.sql @@ -0,0 +1,24 @@ +-- ===================================================================== +-- OTP(2단계 인증) 비밀키 영구 저장 테이블 +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-003 / IAR-001, 추가 인증수단 1차 OTP) +-- 개발항목 No.18 후속 - 담당: arko-privacy-authctrl +-- 작성일: 2026-07-22 DB: MySQL 5.7/8 / MariaDB +-- --------------------------------------------------------------------- +-- [배경] OtpSecretStore 기본 구현이 InMemory(재기동 시 유실)라 이를 대체하는 +-- DB 영속 저장소(DaoOtpSecretStore)가 사용하는 테이블. +-- [보안] SECRET_ENC 는 OtpSecretCipher(AES-GCM) 암호문(Base64)만 저장한다. +-- 평문 비밀키/OTP 코드는 저장하지 않는다(SER-003). +-- [키] UNIQ_ID = 개인정보취급자 고유식별자(ESNTL_ID), 취급자당 1건(PK). +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS OTP_DEVICE ( + UNIQ_ID VARCHAR(20) NOT NULL COMMENT '개인정보취급자 고유식별자(ESNTL_ID, PK)', + SECRET_ENC VARCHAR(255) NOT NULL COMMENT 'OTP 비밀키 AES-GCM 암호문(Base64) - 평문 금지', + REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '등록(발급) 일시', + LAST_VERIFY_DT DATETIME NULL COMMENT '최종 검증 성공 일시(참고용)', + USE_CNT INT NOT NULL DEFAULT 0 COMMENT '검증 성공 누적 횟수(참고용)', + DEVICE_LABEL VARCHAR(100) NULL COMMENT '기기/계정 라벨(참고용)', + USE_YN CHAR(1) NOT NULL DEFAULT 'Y' COMMENT '사용여부 Y:사용 N:해지', + PRIMARY KEY (UNIQ_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='OTP 2단계 인증 비밀키 영구 저장(개인정보보호 SFR-003 No.18)'; diff --git a/src/script/mysql/ddl/privacy.pdal_create_mysql.sql b/src/script/mysql/ddl/privacy.pdal_create_mysql.sql new file mode 100644 index 0000000..bf487d7 --- /dev/null +++ b/src/script/mysql/ddl/privacy.pdal_create_mysql.sql @@ -0,0 +1,79 @@ +-- ===================================================================== +-- 개인정보 접속기록 표준 테이블 : PERSONAL_DATA_ACCESS_LOG +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / DBR-001 / SER-002) +-- 개발항목 #30 - 담당: arko-privacy-auditlog (Fork B) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [설계 원칙] 신규 스키마 설계가 아니라 기존 표준 PERSONAL_DATA_DML_HISTORY 를 +-- 표준 접속기록 테이블로 "승격" + 보관/무결성 컬럼 확장이다. +-- +-- [기존 PERSONAL_DATA_DML_HISTORY → PERSONAL_DATA_ACCESS_LOG 컬럼 매핑] +-- CHG_USER_ID → CONECT_ID (① 사용자 식별자) +-- CREATE_DATE → CONECT_DT (② 접속일시, SYSDATE()) +-- CHG_CONECT_IP → CONECT_IP (③ 접속지 IP) * VARCHAR(45) IPv6 대응 +-- UI_GUBUN → JOB_SE_CODE/NM (④ 수행업무 유형) * 업무유형 매핑 테이블로 코드/명 분리 +-- ESNTL_ID → TRGET_ID (⑤ 처리대상 식별자) +-- DML_GUBUN → DML_GUBUN (처리구분 S/I/U/D 그대로 계승) +-- CHG_USER_SE → USER_SE (처리자 구분) +-- [신규 확장 컬럼] +-- TRGET_CN - 처리대상 건수/범위 (⑤ 보강) +-- PROCESS_RESULT - 처리결과 S:성공 F:실패/차단 (비정상접근 탐지 5유형 적재 겸용) +-- HASH_VAL - 위·변조 방지 해시 (SHA-256, 개발항목 #39) +-- PREV_HASH_VAL - 직전 레코드 해시 (해시체인) +-- +-- [보관·파기] 730일 보관 전제. 월 단위 RANGE COLUMNS 파티셔닝으로 +-- 파기 배치가 만료 파티션을 ALTER TABLE ... DROP PARTITION 으로 제거 +-- (개별 DELETE 미사용). 애플리케이션 계층에 UPDATE/DELETE statement 미정의. +-- [분리 원칙] COMTNSYSLOG 의 7일 삭제정책은 본 테이블에 절대 적용하지 않는다. +-- ===================================================================== + +CREATE TABLE PERSONAL_DATA_ACCESS_LOG +( + LOG_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '로그 일련번호', + CONECT_ID VARCHAR(20) NOT NULL COMMENT '① 사용자 식별자(ID)', + CONECT_DT DATETIME NOT NULL COMMENT '② 접속일시(파티션 키)', + CONECT_IP VARCHAR(45) NOT NULL COMMENT '③ 접속지 정보(IP, IPv6 대응)', + JOB_SE_CODE VARCHAR(30) NOT NULL COMMENT '④ 수행업무 유형 코드', + JOB_SE_NM VARCHAR(100) NOT NULL COMMENT '④ 수행업무 유형 명(화면/기능명)', + TRGET_ID VARCHAR(50) NULL COMMENT '⑤ 처리대상 식별자', + TRGET_CN VARCHAR(200) NULL COMMENT '⑤ 처리대상 건수/범위', + DML_GUBUN CHAR(1) NOT NULL COMMENT '처리구분 S:조회 I:등록 U:수정 D:삭제', + PROCESS_RESULT CHAR(1) NOT NULL DEFAULT 'S' COMMENT '처리결과 S:성공 F:실패/차단', + USER_SE VARCHAR(10) NULL COMMENT '처리자 구분', + HASH_VAL VARCHAR(64) NOT NULL COMMENT '위·변조 방지 해시(SHA-256 Hex)', + PREV_HASH_VAL VARCHAR(64) NULL COMMENT '직전 레코드 해시(해시체인)', + PRIMARY KEY (LOG_ID, CONECT_DT), + KEY IDX_PDAL_01 (CONECT_ID, CONECT_DT), -- 접속자 ID 검색 (현행 CONECT_MTHD LIKE 결함 해소) + KEY IDX_PDAL_02 (CONECT_DT), -- 기간 검색 / 파티션 프루닝 + KEY IDX_PDAL_03 (TRGET_ID, CONECT_DT), -- 처리대상 추적 + KEY IDX_PDAL_04 (JOB_SE_CODE, CONECT_DT) -- 업무유형/이상탐지유형 집계 +) +ENGINE=InnoDB DEFAULT CHARSET=utf8 +COMMENT='개인정보 접속기록 표준 테이블(PERSONAL_DATA_DML_HISTORY 승격, 730일 보관)' +PARTITION BY RANGE COLUMNS(CONECT_DT) +( + -- 파티션 키(CONECT_DT)를 PK에 포함해야 RANGE COLUMNS 파티셔닝 성립 (MySQL/MariaDB 제약) + -- 월 단위 사전 생성. 유지보수 배치가 매월 다음 달 파티션을 ADD PARTITION 으로 선행 생성하고, + -- 파기 배치(월 1회)가 보관기간(기본 730일) 경과 파티션을 DROP PARTITION 으로 제거한다. + -- ※ MAXVALUE 파티션은 두지 않는다 — 만료 데이터가 catch-all 에 갇혀 DROP 불가해지는 것을 방지. + PARTITION p202607 VALUES LESS THAN ('2026-08-01'), + PARTITION p202608 VALUES LESS THAN ('2026-09-01'), + PARTITION p202609 VALUES LESS THAN ('2026-10-01'), + PARTITION p202610 VALUES LESS THAN ('2026-11-01'), + PARTITION p202611 VALUES LESS THAN ('2026-12-01'), + PARTITION p202612 VALUES LESS THAN ('2027-01-01'), + PARTITION p202701 VALUES LESS THAN ('2027-02-01'), + PARTITION p202702 VALUES LESS THAN ('2027-03-01') +) +; + +-- --------------------------------------------------------------------- +-- [유지보수 참고] 월별 파티션 추가/삭제 예시 (배치가 동적으로 수행) +-- +-- -- 다음 달 파티션 선행 생성 +-- ALTER TABLE PERSONAL_DATA_ACCESS_LOG +-- ADD PARTITION (PARTITION p202703 VALUES LESS THAN ('2027-04-01')); +-- +-- -- 보관기간(730일) 경과 파티션 파기 +-- ALTER TABLE PERSONAL_DATA_ACCESS_LOG DROP PARTITION p202607; +-- --------------------------------------------------------------------- diff --git a/src/script/mysql/ddl/privacy.pdal_purge_history_create_mysql.sql b/src/script/mysql/ddl/privacy.pdal_purge_history_create_mysql.sql new file mode 100644 index 0000000..cb430ef --- /dev/null +++ b/src/script/mysql/ddl/privacy.pdal_purge_history_create_mysql.sql @@ -0,0 +1,28 @@ +-- ===================================================================== +-- 개인정보 접속기록 파기·증설 이력 : PDAL_PURGE_HISTORY +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / DBR-001) +-- 개발항목 #37 - 담당: arko-privacy-auditlog (Fork B) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [목적] PERSONAL_DATA_ACCESS_LOG 의 보관정책(730일) 파기 배치가 수행한 +-- 파티션 DROP(파기)·ADD(선행 증설) 내역을 증빙으로 기록한다. +-- 개인정보 접속기록의 유일한 삭제 경로가 "파티션 DROP"임을 감사에서 입증하는 근거. +-- +-- [원칙] 접속기록 본문(PERSONAL_DATA_ACCESS_LOG)에는 UPDATE/DELETE 를 정의하지 않으며, +-- 파기는 개별 DELETE 가 아니라 파티션 단위 DROP 으로만 수행한다(개발항목 #37). +-- ===================================================================== + +CREATE TABLE PDAL_PURGE_HISTORY +( + PURGE_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호', + PARTITION_NAME VARCHAR(64) NOT NULL COMMENT '대상 파티션명(예: p202607)', + BOUNDARY_DT VARCHAR(30) NULL COMMENT '파티션 상한 경계값(LESS THAN, 예: 2026-08-01)', + PURGE_TYPE VARCHAR(10) NOT NULL COMMENT '유형 DROP:파기 ADD:선행증설', + PURGE_DT DATETIME NOT NULL COMMENT '수행 일시', + NOTE VARCHAR(300) NULL COMMENT '비고(보관기간·사유)', + PRIMARY KEY (PURGE_ID), + KEY IDX_PDAL_PURGE_01 (PURGE_DT), + KEY IDX_PDAL_PURGE_02 (PARTITION_NAME) +) +ENGINE=InnoDB DEFAULT CHARSET=utf8 +COMMENT='개인정보 접속기록 파티션 파기·증설 이력(개발항목 #37, 파기 증빙)'; diff --git a/src/script/mysql/privacy.install_mysql.sql b/src/script/mysql/privacy.install_mysql.sql new file mode 100644 index 0000000..ff0fe4a --- /dev/null +++ b/src/script/mysql/privacy.install_mysql.sql @@ -0,0 +1,509 @@ +-- ##################################################################### +-- arko_renual 설치용 통합 SQL (개인정보보호 기능개선 사업 · arko 대비 변동분) +-- 대상 DB : MySQL 5.7 / MariaDB (스키마 csv) +-- 구성 : [PART 1] DDL(테이블 생성·변경) → [PART 2] DML(기준/시드 데이터) +-- 실행순서: 반드시 PART 1 → PART 2 순서로 실행(테이블 생성 후 시드 적재) +-- 주의 : 신규 설치용. 기존 테이블이 있는 환경 재실행 시 CREATE 중복 오류 가능 +-- (IF NOT EXISTS 미지정 테이블). 그런 환경은 PART 2(DML)만 실행하세요. +-- ##################################################################### + + +-- ===================================================================== +-- PART 1. DDL (테이블 생성 / 컬럼 확장) ※ 시드 INSERT는 PART 2로 분리 +-- ===================================================================== + +-- ───────────────────────────────────────────────────────────── +-- [privacy.account_mng_policy_create_mysql.sql] +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 계정 관리 정책 : ACCOUNT_MNG_POLICY (단일행 정책 테이블) +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-002, 장기 미사용 계정 관리) +-- 개발항목 #13(정책 설정) + #9(휴면 기준일수 정책값화) - 담당: arko-privacy-authctrl (Fork A) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [용도] 휴면 기준일수·사전알림 시점·삭제 보존기간·배치주기 등 계정 관리 정책을 +-- 하드코딩이 아닌 관리자 설정값으로 운영하기 위한 단일행 정책 테이블. +-- [#9] 휴면 select(EgovLoginUsr_SQL_Mysql.xml)의 365 하드코딩을 +-- (SELECT DORMANT_DAY FROM ACCOUNT_MNG_POLICY) 로 대체(미설정 시 365 fallback). +-- [#10] ALERT_D1/D2/D3(기본 30/7/0) = 휴면예정 D-30/D-7/D-Day 사전알림 시점. +-- ADMIN_EMAIL = 사전알림 관리자 수신 주소. +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS ACCOUNT_MNG_POLICY ( + POLICY_ID INT NOT NULL DEFAULT 1 COMMENT '정책 식별자(단일행=1)', + DORMANT_DAY INT NOT NULL DEFAULT 365 COMMENT '휴면 전환 기준 미접속 일수(#9)', + ALERT_D1 INT NOT NULL DEFAULT 30 COMMENT '사전알림 1차(휴면 D-일수)', + ALERT_D2 INT NOT NULL DEFAULT 7 COMMENT '사전알림 2차(휴면 D-일수)', + ALERT_D3 INT NOT NULL DEFAULT 0 COMMENT '사전알림 3차(D-Day, 0=당일)', + DELETE_PRESERVE_DAY INT NOT NULL DEFAULT 365 COMMENT '휴면/탈퇴 후 삭제 보존기간(일)', + BATCH_CYCLE VARCHAR(20) NOT NULL DEFAULT 'DAILY' COMMENT '배치 주기(DAILY/WEEKLY/MONTHLY)', + ADMIN_EMAIL VARCHAR(200) NULL COMMENT '사전알림 관리자 수신 이메일', + UPD_ID VARCHAR(20) NULL COMMENT '최종 수정자', + UPD_DT DATETIME NULL COMMENT '최종 수정일시', + PRIMARY KEY (POLICY_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='계정 관리 정책(개인정보보호 SFR-002, 단일행)'; + +-- 기본 정책 시드(없을 때만). 기준 365일·알림 D-30/7/0·보존 365일. + +-- ───────────────────────────────────────────────────────────── +-- [privacy.account_unlock_history_create_mysql.sql] +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 계정 잠금 해제 이력 : ACCOUNT_UNLOCK_HISTORY +-- 문화품앗이 개인정보보호 기능개선 사업 (SER-001, 계정 잠금 정책 정비) +-- 개발항목 #24 - 담당: arko-privacy-authctrl (Fork A) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [목적] 잠긴 계정 해제를 DB 직접조작이 아니라 관리자 화면(EgovAccountUnlockManage) +-- 에서 처리하고, 해제자·해제일시·대상·사유를 이력으로 남긴다. +-- [무결성] 애플리케이션 계층에 UPDATE/DELETE statement 미정의(INSERT/SELECT 한정). +-- ===================================================================== + +CREATE TABLE ACCOUNT_UNLOCK_HISTORY ( + UNLOCK_HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '해제 이력 일련번호(PK)', + TRGET_ID VARCHAR(50) NULL COMMENT '해제 대상 계정 ID(전체해제 시 NULL 가능)', + TRGET_SE VARCHAR(10) NULL COMMENT '대상 회원구분 GNR/GRO/ENT/USR', + UNLOCK_TY CHAR(1) NOT NULL DEFAULT 'I' COMMENT '해제 유형 I:개별 A:전체', + UNLOCK_USER_ID VARCHAR(50) NULL COMMENT '해제 처리자(관리자) ID', + UNLOCK_RESN VARCHAR(500) NULL COMMENT '해제 사유', + UNLOCK_IP VARCHAR(45) NULL COMMENT '해제자 접속 IP(IPv6 대응)', + UNLOCK_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '해제 일시', + PRIMARY KEY (UNLOCK_HIST_ID), + KEY IX_AUH_TRGET (TRGET_ID, UNLOCK_DT), + KEY IX_AUH_USER (UNLOCK_USER_ID, UNLOCK_DT), + KEY IX_AUH_DT (UNLOCK_DT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='계정 잠금 해제 이력(개인정보보호 SER-001 #24)'; + + +-- ===================================================================== +-- 계정 잠금 임계값 10회 → 5회 (#24) +-- 임계값은 하드코딩이 아니라 LOGIN_LOCK_COUNT.LOCK_COUNT(단일행) 데이터에서 조회됨 +-- (loginDAO.selectFailTotalCount). 아래 UPDATE 로 운영 정책값을 5회로 조정한다. +-- ※ 소스(EgovLoginController)에는 값 부재 시 기본 5회 fallback 이 반영되어 있음. +-- ===================================================================== +UPDATE LOGIN_LOCK_COUNT SET LOCK_COUNT = 5; +-- 행이 없을 경우 대비(최초 세팅): +-- INSERT INTO LOGIN_LOCK_COUNT (LOCK_COUNT) SELECT 5 FROM DUAL +-- WHERE NOT EXISTS (SELECT 1 FROM LOGIN_LOCK_COUNT); + +-- ───────────────────────────────────────────────────────────── +-- [privacy.author_change_history_create_mysql.sql] +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 권한 변경 이력 테이블 : AUTHOR_CHANGE_HISTORY +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-001, 역할별 차등 접근/권한 변경 추적) +-- 개발항목 #4 - 담당: arko-privacy-authctrl (Fork A → 오케스트레이터 완료) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [목적] 권한 신설·변경·회수(#1 getAuthorities 대상 COMTNEMPLYRSCRTYESTBS 등)의 +-- 모든 변경을 변경자·변경일시·변경전/후 권한과 함께 이력으로 적재한다. +-- 권한 신청·승인 워크플로(#3)의 승인 반영 시점에 권한반영과 단일 트랜잭션으로 적재. +-- +-- [보관·파기] 3년(1,095일) 이상 보관 전제. 월 1회 @Scheduled 파기 배치(#5)가 +-- 보관기간(1,095일) 경과 이력만 선별 파기한다. 개별 삭제 API 미제공. +-- 다운로드 시 사유 입력 필수 + 다운로드 이력 적재(#6)는 별도 처리. +-- +-- [무결성] 이력 위·변조 억제를 위해 변경전/후 권한을 함께 보관(사후 대사 가능). +-- 애플리케이션 계층에 UPDATE statement 미정의(INSERT/SELECT/파기 DELETE 한정). +-- ===================================================================== + +CREATE TABLE AUTHOR_CHANGE_HISTORY ( + HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호(PK)', + TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '변경 대상자 고유식별자(ESNTL_ID)', + TRGET_ID VARCHAR(20) NULL COMMENT '변경 대상자 계정ID(참고용)', + CHG_SE_CODE CHAR(1) NOT NULL COMMENT '변경구분 G:부여(grant) R:회수(revoke) M:변경(modify)', + BEFORE_AUTHOR VARCHAR(1000) NULL COMMENT '변경 전 권한(코드 CSV 또는 스냅샷)', + AFTER_AUTHOR VARCHAR(1000) NULL COMMENT '변경 후 권한(코드 CSV 또는 스냅샷)', + CHG_RESN VARCHAR(500) NULL COMMENT '변경 사유(신청/승인 워크플로 연계 #3)', + APROVL_ID VARCHAR(20) NULL COMMENT '승인자 계정ID(승인 워크플로 #3)', + CHANGER_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '변경자(처리자) 고유식별자', + CHANGER_ID VARCHAR(20) NULL COMMENT '변경자 계정ID(참고용)', + CHANGER_IP VARCHAR(45) NULL COMMENT '변경자 접속 IP(IPv6 대응)', + CHG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '변경 일시', + PRIMARY KEY (HIST_ID), + KEY IX_ACH_TRGET (TRGET_UNIQ_ID, CHG_DT), + KEY IX_ACH_CHANGER (CHANGER_UNIQ_ID, CHG_DT), + KEY IX_ACH_CHGDT (CHG_DT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='권한 변경 이력(개인정보보호 SFR-001, 3년 이상 보관)'; + +-- ───────────────────────────────────────────────────────────── +-- [privacy.author_workflow_create_mysql.sql] +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 권한 신청·승인 워크플로 + 권한이력 다운로드 이력 +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-001, 권한 신설·변경·회수 승인통제) +-- 개발항목 #3(신청·승인 워크플로) / #6(권한이력 조회·다운로드) - 담당: arko-privacy-authctrl (Fork A 6차) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [#3] AUTHOR_CHANGE_REQUEST : 권한 변경 신청→승인/반려 워크플로. +-- 승인(A) 시 권한 반영(COMTNEMPLYRSCRTYESTBS)과 이력 적재(AUTHOR_CHANGE_HISTORY)를 +-- 단일 트랜잭션으로 처리한다(egovframework.com..*Impl AOP tx advisor). +-- [#6] AUTHOR_HIST_DOWNLOAD_HISTORY : 권한이력 다운로드 증빙(사유 필수). +-- ===================================================================== + +CREATE TABLE AUTHOR_CHANGE_REQUEST ( + REQUEST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '신청 일련번호(PK)', + TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '권한변경 대상자 고유식별자(ESNTL_ID)', + TRGET_ID VARCHAR(20) NULL COMMENT '권한변경 대상자 계정ID(참고용)', + REQUEST_SE CHAR(1) NOT NULL COMMENT '신청구분 N:신설(부여) C:변경 R:회수', + BEFORE_AUTHOR VARCHAR(1000) NULL COMMENT '신청 시점 현재 권한(스냅샷)', + AFTER_AUTHOR VARCHAR(1000) NULL COMMENT '신청 목표 권한(코드/CSV)', + REQUEST_RESN VARCHAR(500) NOT NULL COMMENT '신청 사유(필수)', + REQSTER_ID VARCHAR(20) NOT NULL COMMENT '신청자 계정ID', + REQSTER_UNIQ_ID VARCHAR(20) NULL COMMENT '신청자 고유식별자', + REQUEST_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '신청 일시', + CONFM_STTUS CHAR(1) NOT NULL DEFAULT 'R' COMMENT '처리상태 R:신청 A:승인 J:반려', + CONFM_ID VARCHAR(20) NULL COMMENT '승인/반려 처리자 계정ID', + CONFM_UNIQ_ID VARCHAR(20) NULL COMMENT '승인/반려 처리자 고유식별자', + CONFM_DT DATETIME NULL COMMENT '승인/반려 일시', + CONFM_RESN VARCHAR(500) NULL COMMENT '반려 사유', + PRIMARY KEY (REQUEST_ID), + KEY IX_ACR_TRGET (TRGET_UNIQ_ID, REQUEST_DT), + KEY IX_ACR_STTUS (CONFM_STTUS, REQUEST_DT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='권한 변경 신청·승인 워크플로(개인정보보호 SFR-001 #3)'; + +CREATE TABLE AUTHOR_HIST_DOWNLOAD_HISTORY ( + DWLD_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 일련번호(PK)', + DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID', + DOWNLOADER_UNIQ_ID VARCHAR(20) NULL COMMENT '다운로드자 고유식별자', + DOWNLOADER_IP VARCHAR(45) NULL COMMENT '다운로드자 접속 IP(IPv6 대응)', + DWLD_RESN VARCHAR(500) NOT NULL COMMENT '다운로드 사유(필수·미입력 차단)', + SEARCH_COND VARCHAR(1000) NULL COMMENT '다운로드 시 검색조건 스냅샷', + DWLD_CNT INT NOT NULL DEFAULT 0 COMMENT '다운로드 건수', + DWLD_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '다운로드 일시', + PRIMARY KEY (DWLD_ID), + KEY IX_AHD_DWLD (DOWNLOADER_ID, DWLD_DT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='권한이력 다운로드 증빙 이력(개인정보보호 SFR-001 #6)'; + +-- ───────────────────────────────────────────────────────────── +-- [privacy.job_mapping_create_mysql.sql] +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 개인정보 처리 업무유형 매핑 테이블 : PDAL_JOB_MAPPING +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002) +-- 개발항목 #32 - 담당: arko-privacy-auditlog (Fork B) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [목적] 접속기록 수집(#31 PersonalDataLogAspect)이 가로챈 서비스 메서드를 +-- 업무코드(JOB_SE_CODE)·업무명(JOB_SE_NM)으로 매핑한다. +-- AOP pointcut 범위(개인정보 처리 서비스 패키지) 안에서 신규 화면이 추가되면 +-- 이 테이블에 행만 등록하면 수집되며, Aspect/pointcut 소스 수정이 불요하다. +-- +-- [매칭 규칙] Aspect가 (대상클래스 단순명, 메서드명)으로 조회. +-- - TARGET_CLASS : 서비스 impl 단순 클래스명(예: EgovMberManageServiceImpl) 또는 FQN +-- - METHOD_PTTRN : 메서드명 접두 매칭(예: 'select','insertMber') 또는 '*'(전체) +-- - DML_GUBUN : 지정 시 우선, NULL이면 메서드명 접두로 유도(insert*→I/update*→U/delete*→D/그외 S) +-- - SORT_ORDR : 다중 매칭 시 낮은 값 우선(구체 규칙을 위로) +-- +-- ※ URL 패턴 기반 수집이 필요하면(서블릿 필터형) 동일 테이블에 URL_PTTRN 컬럼을 추가해 확장. +-- ===================================================================== + +CREATE TABLE PDAL_JOB_MAPPING ( + MAPPING_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '매핑 일련번호(PK)', + TARGET_CLASS VARCHAR(200) NOT NULL COMMENT '대상 서비스 impl 단순 클래스명 또는 FQN', + METHOD_PTTRN VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '메서드명 접두 매칭(또는 * 전체)', + JOB_SE_CODE VARCHAR(30) NOT NULL COMMENT '수행업무 유형 코드', + JOB_SE_NM VARCHAR(100) NOT NULL COMMENT '수행업무 유형(화면·기능명)', + DML_GUBUN CHAR(1) NULL COMMENT '처리구분 강제값 S/I/U/D (NULL=메서드명 유도)', + SORT_ORDR INT NOT NULL DEFAULT 100 COMMENT '매칭 우선순위(낮을수록 우선)', + USE_AT CHAR(1) NOT NULL DEFAULT 'Y' COMMENT '사용여부 Y/N', + RM VARCHAR(300) NULL COMMENT '비고', + PRIMARY KEY (MAPPING_ID), + KEY IX_PJM_LOOKUP (USE_AT, TARGET_CLASS, SORT_ORDR) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='개인정보 처리 업무유형 매핑(접속기록 #31/#32)'; + +-- --------------------------------------------------------------------- +-- 초기 매핑 시드 : 현행 개인정보 처리 서비스(#31 pointcut 대상과 일치) +-- 신규 개인정보 화면 추가 시 아래 형식으로 행만 추가하면 수집된다. +-- --------------------------------------------------------------------- + +-- ───────────────────────────────────────────────────────────── +-- [privacy.download_history_create_mysql.sql] +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 개인정보 접속기록 다운로드 이력 : PDAL_DOWNLOAD_HISTORY +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002) +-- 개발항목 #42 - 담당: arko-privacy-auditlog (Fork B) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [목적] 개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 조회결과 다운로드 시, +-- 다운로드 사유(필수)·다운로드자·일시·IP·건수를 이력으로 남긴다. +-- 사유 미입력 시 다운로드는 서비스 계층에서 차단(본 테이블 미적재). +-- [원칙] 개인정보 본문 미적재 — 다운로드 검색조건 요약(SEARCH_COND)만 보관. +-- 이 테이블은 INSERT/SELECT 만 수행(위·변조 방지, UPDATE/DELETE 미정의). +-- ===================================================================== + +CREATE TABLE PDAL_DOWNLOAD_HISTORY ( + DL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 이력 일련번호(PK)', + DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID', + DOWNLOAD_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '다운로드 일시', + DOWNLOADER_IP VARCHAR(45) NULL COMMENT '다운로드자 접속 IP(IPv6 대응)', + DL_REASON VARCHAR(500) NOT NULL COMMENT '다운로드 사유(필수 — 미입력 시 차단)', + SEARCH_COND VARCHAR(1000) NULL COMMENT '다운로드 검색조건 요약(개인정보 본문 미포함)', + RESULT_CNT INT NOT NULL DEFAULT 0 COMMENT '다운로드 건수', + REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '등록 일시', + PRIMARY KEY (DL_ID), + KEY IX_PDDH_DT (DOWNLOAD_DT), + KEY IX_PDDH_USER (DOWNLOADER_ID, DOWNLOAD_DT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='개인정보 접속기록 다운로드 이력(#42, SER-002)'; + +-- [Oracle] 시퀀스 필요: +-- CREATE SEQUENCE PDAL_DOWNLOAD_HISTORY_SEQ START WITH 1 INCREMENT BY 1 NOCACHE; + +-- ───────────────────────────────────────────────────────────── +-- [privacy.inspection_report_create_mysql.sql] +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 개인정보 접속기록 정기점검 리포트 : PDAL_INSPECTION_REPORT +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002) +-- 개발항목 #43 - 담당: arko-privacy-auditlog (Fork B) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [목적] 월 1회 정기점검 배치가 전월분 접속기록을 집계해 1행으로 적재한다. +-- "월 1회 이상 점검 내역"을 화면 출력으로 제출(개인정보보호위 점검 대응). +-- +-- [집계원] PERSONAL_DATA_ACCESS_LOG(#30) + 해시체인 무결성 검증(#40) 재사용. +-- [자리표시] 이상징후 5유형(#35)·다운로드 이력(#42)은 미구현 → 컬럼만 두고 배치가 0/요약만 적재. +-- [화면입력] INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER 는 배치가 아닌 점검자가 화면(#48)에서 입력. +-- ===================================================================== + +CREATE TABLE PDAL_INSPECTION_REPORT ( + RPT_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '리포트 일련번호(PK)', + RPT_YM CHAR(6) NOT NULL COMMENT '점검 대상 연월(전월, yyyyMM)', + PERIOD_FROM DATETIME NOT NULL COMMENT '집계 시작일시', + PERIOD_TO DATETIME NOT NULL COMMENT '집계 종료일시', + TOTAL_LOG_CNT INT NOT NULL DEFAULT 0 COMMENT '총 접속기록 건수', + FAIL_CNT INT NOT NULL DEFAULT 0 COMMENT '처리결과 F(실패/차단) 건수 = 이상징후 일부', + ABNORMAL_SUMMARY VARCHAR(2000) NULL COMMENT '이상징후 5유형 요약(#35 연계 전 자리표시)', + INTEGRITY_RESULT VARCHAR(20) NOT NULL DEFAULT 'OK' COMMENT '해시체인 무결성: OK / VIOLATION', + INTEGRITY_MISMATCH_ID BIGINT NULL COMMENT '최초 불일치 LOG_ID(위반 시)', + INTEGRITY_TOTAL_CNT INT NOT NULL DEFAULT 0 COMMENT '무결성 검증 대상 건수', + HANDLER_SUMMARY VARCHAR(4000) NULL COMMENT '취급자별 처리현황 요약', + DOWNLOAD_CNT INT NOT NULL DEFAULT 0 COMMENT '다운로드 이력 건수(#42 연계 전 0)', + INSPECTOR_ID VARCHAR(20) NULL COMMENT '점검자 계정ID(화면 입력, #44)', + INSPECT_RESULT VARCHAR(500) NULL COMMENT '점검결과(화면 입력, #44)', + ACTN_MATTER VARCHAR(1000) NULL COMMENT '조치사항(화면 입력, #44)', + INSPECT_DT DATETIME NULL COMMENT '점검일시(점검결과 입력 시각, #44 감사증빙)', + REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '리포트 생성일시', + PRIMARY KEY (RPT_ID), + UNIQUE KEY UX_PIR_YM (RPT_YM), + KEY IX_PIR_REGDT (REG_DT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='개인정보 접속기록 정기점검 리포트(#43, 월 1회)'; + +-- ───────────────────────────────────────────────────────────── +-- [privacy.pdal_create_mysql.sql] +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 개인정보 접속기록 표준 테이블 : PERSONAL_DATA_ACCESS_LOG +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / DBR-001 / SER-002) +-- 개발항목 #30 - 담당: arko-privacy-auditlog (Fork B) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [설계 원칙] 신규 스키마 설계가 아니라 기존 표준 PERSONAL_DATA_DML_HISTORY 를 +-- 표준 접속기록 테이블로 "승격" + 보관/무결성 컬럼 확장이다. +-- +-- [기존 PERSONAL_DATA_DML_HISTORY → PERSONAL_DATA_ACCESS_LOG 컬럼 매핑] +-- CHG_USER_ID → CONECT_ID (① 사용자 식별자) +-- CREATE_DATE → CONECT_DT (② 접속일시, SYSDATE()) +-- CHG_CONECT_IP → CONECT_IP (③ 접속지 IP) * VARCHAR(45) IPv6 대응 +-- UI_GUBUN → JOB_SE_CODE/NM (④ 수행업무 유형) * 업무유형 매핑 테이블로 코드/명 분리 +-- ESNTL_ID → TRGET_ID (⑤ 처리대상 식별자) +-- DML_GUBUN → DML_GUBUN (처리구분 S/I/U/D 그대로 계승) +-- CHG_USER_SE → USER_SE (처리자 구분) +-- [신규 확장 컬럼] +-- TRGET_CN - 처리대상 건수/범위 (⑤ 보강) +-- PROCESS_RESULT - 처리결과 S:성공 F:실패/차단 (비정상접근 탐지 5유형 적재 겸용) +-- HASH_VAL - 위·변조 방지 해시 (SHA-256, 개발항목 #39) +-- PREV_HASH_VAL - 직전 레코드 해시 (해시체인) +-- +-- [보관·파기] 730일 보관 전제. 월 단위 RANGE COLUMNS 파티셔닝으로 +-- 파기 배치가 만료 파티션을 ALTER TABLE ... DROP PARTITION 으로 제거 +-- (개별 DELETE 미사용). 애플리케이션 계층에 UPDATE/DELETE statement 미정의. +-- [분리 원칙] COMTNSYSLOG 의 7일 삭제정책은 본 테이블에 절대 적용하지 않는다. +-- ===================================================================== + +CREATE TABLE PERSONAL_DATA_ACCESS_LOG +( + LOG_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '로그 일련번호', + CONECT_ID VARCHAR(20) NOT NULL COMMENT '① 사용자 식별자(ID)', + CONECT_DT DATETIME NOT NULL COMMENT '② 접속일시(파티션 키)', + CONECT_IP VARCHAR(45) NOT NULL COMMENT '③ 접속지 정보(IP, IPv6 대응)', + JOB_SE_CODE VARCHAR(30) NOT NULL COMMENT '④ 수행업무 유형 코드', + JOB_SE_NM VARCHAR(100) NOT NULL COMMENT '④ 수행업무 유형 명(화면/기능명)', + TRGET_ID VARCHAR(50) NULL COMMENT '⑤ 처리대상 식별자', + TRGET_CN VARCHAR(200) NULL COMMENT '⑤ 처리대상 건수/범위', + DML_GUBUN CHAR(1) NOT NULL COMMENT '처리구분 S:조회 I:등록 U:수정 D:삭제', + PROCESS_RESULT CHAR(1) NOT NULL DEFAULT 'S' COMMENT '처리결과 S:성공 F:실패/차단', + USER_SE VARCHAR(10) NULL COMMENT '처리자 구분', + HASH_VAL VARCHAR(64) NOT NULL COMMENT '위·변조 방지 해시(SHA-256 Hex)', + PREV_HASH_VAL VARCHAR(64) NULL COMMENT '직전 레코드 해시(해시체인)', + PRIMARY KEY (LOG_ID, CONECT_DT), + KEY IDX_PDAL_01 (CONECT_ID, CONECT_DT), -- 접속자 ID 검색 (현행 CONECT_MTHD LIKE 결함 해소) + KEY IDX_PDAL_02 (CONECT_DT), -- 기간 검색 / 파티션 프루닝 + KEY IDX_PDAL_03 (TRGET_ID, CONECT_DT), -- 처리대상 추적 + KEY IDX_PDAL_04 (JOB_SE_CODE, CONECT_DT) -- 업무유형/이상탐지유형 집계 +) +ENGINE=InnoDB DEFAULT CHARSET=utf8 +COMMENT='개인정보 접속기록 표준 테이블(PERSONAL_DATA_DML_HISTORY 승격, 730일 보관)' +PARTITION BY RANGE COLUMNS(CONECT_DT) +( + -- 파티션 키(CONECT_DT)를 PK에 포함해야 RANGE COLUMNS 파티셔닝 성립 (MySQL/MariaDB 제약) + -- 월 단위 사전 생성. 유지보수 배치가 매월 다음 달 파티션을 ADD PARTITION 으로 선행 생성하고, + -- 파기 배치(월 1회)가 보관기간(기본 730일) 경과 파티션을 DROP PARTITION 으로 제거한다. + -- ※ MAXVALUE 파티션은 두지 않는다 — 만료 데이터가 catch-all 에 갇혀 DROP 불가해지는 것을 방지. + PARTITION p202607 VALUES LESS THAN ('2026-08-01'), + PARTITION p202608 VALUES LESS THAN ('2026-09-01'), + PARTITION p202609 VALUES LESS THAN ('2026-10-01'), + PARTITION p202610 VALUES LESS THAN ('2026-11-01'), + PARTITION p202611 VALUES LESS THAN ('2026-12-01'), + PARTITION p202612 VALUES LESS THAN ('2027-01-01'), + PARTITION p202701 VALUES LESS THAN ('2027-02-01'), + PARTITION p202702 VALUES LESS THAN ('2027-03-01') +) +; + +-- --------------------------------------------------------------------- +-- [유지보수 참고] 월별 파티션 추가/삭제 예시 (배치가 동적으로 수행) +-- +-- -- 다음 달 파티션 선행 생성 +-- ALTER TABLE PERSONAL_DATA_ACCESS_LOG +-- ADD PARTITION (PARTITION p202703 VALUES LESS THAN ('2027-04-01')); +-- +-- -- 보관기간(730일) 경과 파티션 파기 +-- ALTER TABLE PERSONAL_DATA_ACCESS_LOG DROP PARTITION p202607; +-- --------------------------------------------------------------------- + +-- ───────────────────────────────────────────────────────────── +-- [privacy.pdal_purge_history_create_mysql.sql] +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 개인정보 접속기록 파기·증설 이력 : PDAL_PURGE_HISTORY +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / DBR-001) +-- 개발항목 #37 - 담당: arko-privacy-auditlog (Fork B) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [목적] PERSONAL_DATA_ACCESS_LOG 의 보관정책(730일) 파기 배치가 수행한 +-- 파티션 DROP(파기)·ADD(선행 증설) 내역을 증빙으로 기록한다. +-- 개인정보 접속기록의 유일한 삭제 경로가 "파티션 DROP"임을 감사에서 입증하는 근거. +-- +-- [원칙] 접속기록 본문(PERSONAL_DATA_ACCESS_LOG)에는 UPDATE/DELETE 를 정의하지 않으며, +-- 파기는 개별 DELETE 가 아니라 파티션 단위 DROP 으로만 수행한다(개발항목 #37). +-- ===================================================================== + +CREATE TABLE PDAL_PURGE_HISTORY +( + PURGE_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호', + PARTITION_NAME VARCHAR(64) NOT NULL COMMENT '대상 파티션명(예: p202607)', + BOUNDARY_DT VARCHAR(30) NULL COMMENT '파티션 상한 경계값(LESS THAN, 예: 2026-08-01)', + PURGE_TYPE VARCHAR(10) NOT NULL COMMENT '유형 DROP:파기 ADD:선행증설', + PURGE_DT DATETIME NOT NULL COMMENT '수행 일시', + NOTE VARCHAR(300) NULL COMMENT '비고(보관기간·사유)', + PRIMARY KEY (PURGE_ID), + KEY IDX_PDAL_PURGE_01 (PURGE_DT), + KEY IDX_PDAL_PURGE_02 (PARTITION_NAME) +) +ENGINE=InnoDB DEFAULT CHARSET=utf8 +COMMENT='개인정보 접속기록 파티션 파기·증설 이력(개발항목 #37, 파기 증빙)'; + +-- ───────────────────────────────────────────────────────────── +-- [privacy.otp_device_create_mysql.sql] +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- OTP(2단계 인증) 비밀키 영구 저장 테이블 +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-003 / IAR-001, 추가 인증수단 1차 OTP) +-- 개발항목 No.18 후속 - 담당: arko-privacy-authctrl +-- 작성일: 2026-07-22 DB: MySQL 5.7/8 / MariaDB +-- --------------------------------------------------------------------- +-- [배경] OtpSecretStore 기본 구현이 InMemory(재기동 시 유실)라 이를 대체하는 +-- DB 영속 저장소(DaoOtpSecretStore)가 사용하는 테이블. +-- [보안] SECRET_ENC 는 OtpSecretCipher(AES-GCM) 암호문(Base64)만 저장한다. +-- 평문 비밀키/OTP 코드는 저장하지 않는다(SER-003). +-- [키] UNIQ_ID = 개인정보취급자 고유식별자(ESNTL_ID), 취급자당 1건(PK). +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS OTP_DEVICE ( + UNIQ_ID VARCHAR(20) NOT NULL COMMENT '개인정보취급자 고유식별자(ESNTL_ID, PK)', + SECRET_ENC VARCHAR(255) NOT NULL COMMENT 'OTP 비밀키 AES-GCM 암호문(Base64) - 평문 금지', + REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '등록(발급) 일시', + LAST_VERIFY_DT DATETIME NULL COMMENT '최종 검증 성공 일시(참고용)', + USE_CNT INT NOT NULL DEFAULT 0 COMMENT '검증 성공 누적 횟수(참고용)', + DEVICE_LABEL VARCHAR(100) NULL COMMENT '기기/계정 라벨(참고용)', + USE_YN CHAR(1) NOT NULL DEFAULT 'Y' COMMENT '사용여부 Y:사용 N:해지', + PRIMARY KEY (UNIQ_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='OTP 2단계 인증 비밀키 영구 저장(개인정보보호 SFR-003 No.18)'; + +-- ───────────────────────────────────────────────────────────── +-- [privacy.diapause_emplyr_extend_mysql.sql] +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 휴면(장기 미사용) 대상 확장 : COMTNDIAPAUSEMBER 회원유형 구분 컬럼 추가 +-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-002, 장기 미사용 계정 관리) +-- 개발항목 #7 - 담당: arko-privacy-authctrl (Fork A) +-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB +-- --------------------------------------------------------------------- +-- [배경] 기존 휴면 대상은 일반회원(COMTNGNRLMBER)뿐이며, 휴면 테이블 +-- COMTNDIAPAUSEMBER 에는 회원유형 구분 컬럼이 없다. 개인정보취급자 +-- (COMTNEMPLYRINFO)를 휴면 대상에 포함(#7)하면서 일반회원 휴면/해제 +-- 흐름과 혼선되지 않도록 회원유형 구분 컬럼을 신설한다. +-- +-- [안전] DEFAULT 'GNR'(일반회원)로 신설 → 기존 적재 행은 자동 'GNR' 처리되어 +-- 기존 selectDiapauseList(일반회원 join COMTNGNRLMBER) 흐름 무영향. +-- 취급자 휴면 행은 'EMP'로 적재하여 별도 select/notify 경로로 처리한다. +-- +-- [멱등] 재실행 안전을 위해 컬럼 존재 시 무시(수동 확인 후 실행). +-- ===================================================================== + +ALTER TABLE COMTNDIAPAUSEMBER + ADD COLUMN MBER_TY_CODE CHAR(3) NOT NULL DEFAULT 'GNR' + COMMENT '회원유형 GNR:일반회원 EMP:개인정보취급자'; + +-- 조회 성능(취급자/일반 분리 조회) 보조 인덱스 +ALTER TABLE COMTNDIAPAUSEMBER + ADD INDEX IX_DIAPAUSE_MBERTY (MBER_TY_CODE, DIAPAUSE_STAT); + + +-- ===================================================================== +-- PART 2. DML (기준/시드 데이터) ※ 멱등: NOT EXISTS 가드로 중복 적재 방지 +-- 수록: PDAL_JOB_MAPPING(업무유형 매핑 13행), ACCOUNT_MNG_POLICY(기본정책 1행) +-- 미수록: OTP_DEVICE / PERSONAL_DATA_ACCESS_LOG / *_HISTORY = 런타임 적재(시드 없음) +-- ===================================================================== + +-- 1) PDAL_JOB_MAPPING — 개인정보 처리 업무유형 매핑(접속기록 AOP 분류 기준, 필수) +INSERT INTO PDAL_JOB_MAPPING (TARGET_CLASS, METHOD_PTTRN, JOB_SE_CODE, JOB_SE_NM, DML_GUBUN, SORT_ORDR, RM) +SELECT * FROM ( + SELECT 'EgovMberManageServiceImpl' AS TARGET_CLASS, 'select' AS METHOD_PTTRN, 'MBER_VIEW' AS JOB_SE_CODE, '회원관리-조회' AS JOB_SE_NM, 'S' AS DML_GUBUN, 10 AS SORT_ORDR, '회원 개인정보 조회' AS RM UNION ALL + SELECT 'EgovMberManageServiceImpl', 'insert', 'MBER_REG', '회원관리-등록', 'I', 10, NULL UNION ALL + SELECT 'EgovMberManageServiceImpl', 'update', 'MBER_MOD', '회원관리-수정', 'U', 10, NULL UNION ALL + SELECT 'EgovMberManageServiceImpl', 'delete', 'MBER_DEL', '회원관리-삭제', 'D', 10, NULL UNION ALL + SELECT 'EgovMberManageServiceImpl', '*', 'MBER_ETC', '회원관리-기타', NULL, 90, '미분류 폴백' UNION ALL + SELECT 'EgovEntrprsManageServiceImpl', 'select', 'ENTRPRS_VIEW', '업체관리-조회', 'S', 10, '업체 담당자 개인정보 조회' UNION ALL + SELECT 'EgovEntrprsManageServiceImpl', 'insert', 'ENTRPRS_REG', '업체관리-등록', 'I', 10, NULL UNION ALL + SELECT 'EgovEntrprsManageServiceImpl', 'update', 'ENTRPRS_MOD', '업체관리-수정', 'U', 10, NULL UNION ALL + SELECT 'EgovEntrprsManageServiceImpl', 'delete', 'ENTRPRS_DEL', '업체관리-삭제', 'D', 10, NULL UNION ALL + SELECT 'EgovEntrprsManageServiceImpl', '*', 'ENTRPRS_ETC', '업체관리-기타', NULL, 90, '미분류 폴백' UNION ALL + SELECT 'MberSsnServiceImpl', '*', 'MBER_SSN', '회원 고유식별정보 처리', NULL, 20, '주민등록번호 등 고유식별정보' UNION ALL + SELECT 'BizMberMergeServiceImpl', '*', 'MBER_MERGE', '회원 통합/이관', NULL, 30, NULL UNION ALL + SELECT 'BizTalentMberManageServiceImpl', '*', 'TALENT_MBER', '재능나눔 회원관리', NULL, 30, NULL +) seed +WHERE NOT EXISTS (SELECT 1 FROM PDAL_JOB_MAPPING pjm WHERE pjm.JOB_SE_CODE = seed.JOB_SE_CODE); + +-- 2) ACCOUNT_MNG_POLICY — 장기 미사용 계정 관리 기본정책(단일행 POLICY_ID=1) +INSERT INTO ACCOUNT_MNG_POLICY (POLICY_ID, DORMANT_DAY, ALERT_D1, ALERT_D2, ALERT_D3, DELETE_PRESERVE_DAY, BATCH_CYCLE, UPD_ID, UPD_DT) +SELECT 1, 365, 30, 7, 0, 365, 'DAILY', 'SYSTEM', NOW() + FROM DUAL + WHERE NOT EXISTS (SELECT 1 FROM ACCOUNT_MNG_POLICY WHERE POLICY_ID = 1); + +-- (부록) LOGIN_LOCK_COUNT — 기존 arko 테이블(변동분 아님). 미존재 신규 환경에서만 사용(기본 5): +-- INSERT INTO LOGIN_LOCK_COUNT (LOCK_COUNT) +-- SELECT 5 FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM LOGIN_LOCK_COUNT); + +-- ############################## END ############################## diff --git a/개인정보보호_개발진행현황.md b/개인정보보호_개발진행현황.md new file mode 100644 index 0000000..6412069 --- /dev/null +++ b/개인정보보호_개발진행현황.md @@ -0,0 +1,348 @@ +# 문화품앗이 개인정보보호 기능개선 사업 — 개발 진행현황 + +> 발주기관: 한국문화원연합회 | 사업기간: 계약일~2026.12.18 | 대상: 문화품앗이(csv.culture.go.kr) +> 근거: 개인정보보호위원회 「고유식별정보 안전조치 관리실태 점검」 결과통보(2025.11.21.) +> 작성일: **2026-07-21** | 기준: `개인정보보호_개발항목.docx`(61개 개발항목) 대비 소스 실측 + +--- + +## 1. 종합 진행률 대시보드 + +| 단계 | 상태 | 비고 | +|------|:----:|------| +| 요구사항 분석·설계 | ✅ 완료 | 제안서 Ⅲ장 + 개발항목 61개 정리 + 디자인 브리프(10개 화면) | +| 자동화 하네스 구축 | ✅ 완료 | `arko-privacy-*` 에이전트 3종 + 스킬 4종 + 리뷰어 확장 | +| **코드 구현** | 🟡 **진행 (42 / 61, 69%)** | 2026-07-21 7파 각 6개 — 1~6차 36개 + 7차(#9·#10·#11·#13·#36·#44) — 아래 §1-1 | +| 시험·검증(Ⅲ-5) | ⬜ 대기 | 구현 후 진행 | +| 적용·안정화(Ⅲ-6) | ⬜ 대기 | 구현 후 진행 | + +**개발항목 61개 코드 구현 기준 진행률: 42개 완료 (2026-07-21 당일, 7파 각 6개 — 69%)** + +- 분석·설계 및 하네스는 완료되어 **바로 구현 단계 진입 가능**한 상태. +- 현재 워킹트리의 소스 변경(`EgovSpringSecurityLoginFilter.java`, `pom.xml`)은 **로컬 개발 IP 허용(127.0.0.1) 추가**로, 본 사업 산출물이 아님 — 진행률에서 제외. + +--- + +## 1-1. 오늘의 작업 (2026-07-21, ~17:00 목표) + +fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반 항목 6개**를 당일 완료 목표로 착수한다. +(발주기관 확정 대기 #12·#19, 운영환경 실측 선행 #55~58, 대규모 화면 #45~54는 오늘 범위에서 제외.) + +| Fork | 영역 | 대상 # | 산출물 | 상태 | +|:----:|------|:------:|--------|:----:| +| **A** | 권한관리 | #1 `getAuthorities()` 구현 | `EgovUserDetailsSessionServiceImpl` + 신규 sqlmap/DDL | ✅ 완료 | +| **A** | 권한관리 | #4 AUTHOR_CHANGE_HISTORY DDL 신설 | 신규 `.sql` DDL(3년 보관) | ✅ 완료 | +| **B** | 접속기록 | #30 PERSONAL_DATA_ACCESS_LOG DDL | 신규 `.sql`(무결성 컬럼+월 파티셔닝) | ✅ 완료 | +| **B** | 접속기록 | #39 SHA-256 해시체인 유틸 | 신규 util `.java`(HASH_VAL/PREV_HASH_VAL) | ✅ 완료 | +| **C** | 인증체계 | #17 AuthMethodService 인터페이스 설계 | 신규 인터페이스 + 3종 구현 스텁 | ✅ 완료 | +| **C** | 인증체계 | #27 비밀번호 해시 알고리즘 점검 | `EgovFileScrty.encryptPassword()` 점검노트 | ✅ 완료 | + +> 파일 충돌 방지: Fork A만 `sql-map-config`/기존 ServiceImpl 수정, Fork B·C는 신규 파일만 생성. 진행현황 MD 기록은 오케스트레이터 단독 수행. + +**착수 로그** +- 2026-07-21 착수: Fork A/B/C 병렬 투입, 대상 6개 항목 🟡 진행중 전환. +- 2026-07-21 **Fork C 완료** (#17, #27): 인증수단 인터페이스 4파일 + 비밀번호 해시 점검노트 생성. 실측 확인 후 ✅ 전환. 후속 발견: 무솔트·무반복 해시, 해시값 로그출력(#27 보완 권고), 인증수단 빈 등록(#21 후속). +- 2026-07-21 **Fork B 완료** (#30, #39): 접속기록 DDL(월 파티셔닝) + 해시체인 유틸(javac+실행 검증) 생성. 실측 확인 후 ✅ 전환. 후속 연계: 파티션 자동증설·730일 파기 배치(#37), 단일스레드 순차적재 전제(#33). +- 2026-07-21 **Fork A 완료** (#1, #4): #1 getAuthorities() 5계층 배선(ServiceImpl→DAO→sqlmap→config→빈주입) 완료·실측 정합 확인. Fork A가 #4 DDL 착수 직후 API 오류로 중단되어, #4 AUTHOR_CHANGE_HISTORY DDL은 **오케스트레이터가 직접 완료**(컬럼명 오타 CHG_RESN 정정 포함). → **1차 목표 6개 항목 전부 ✅.** + +### 2차 작업 (2026-07-21, 추가 6개 목표) + +1차 기반(#1·#4·#17·#30·#39) 위에 쌓이는 항목. 공용 config 수정은 Fork A에 단독 집중해 충돌 차단. + +| Fork | 영역 | 대상 # | 산출물 | 상태 | +|:----:|------|:------:|--------|:----:| +| **A** | 배치(config 단독소유) | #5 권한 이력 파기 배치 | AUTHOR_CHANGE_HISTORY DAO/sqlmap + @Scheduled(1,095일) | ✅ 완료 | +| **A** | 배치(config 단독소유) | #40 해시체인 무결성 검증 배치 | HashChainService.verifyChain 재계산 배치 + 관리자 통보 | ✅ 완료 | +| **B** | util(신규파일만) | #34 IP 취득 로직 보정 | ClientIpResolver(XFF→Proxy-Client-IP→getRemoteAddr) | ✅ 완료 | +| **B** | util(신규파일만) | #16 세션 재발급(세션고정 차단) | SessionRegenerator util | ✅ 완료 | +| **C** | resolver+분석 | #21 AuthMethodResolver 구현 | #17 인터페이스 기반 인증수단 분기 | ✅ 완료 | +| **C** | resolver+분석 | #38 COMTNSYSLOG 7일 삭제 분리 | 기존 7일 삭제 SQL 진단 + 접속기록 미적용 분리 노트 | ✅ 완료 | + +- 2026-07-21 2차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환. + +### 3차 작업 (2026-07-21, 추가 6개 목표 — 통합 단계) + +1·2차 산출물(#1·#16·#17·#21·#30·#34·#39)을 실제로 연결·활용. 공용 config는 fork별로 배타 소유(A=egov-com-servlet.xml, B=sql-map-config+AOP, C=impl java만). + +| Fork | 영역 | 대상 # | 산출물 | 상태 | +|:----:|------|:------:|--------|:----:| +| **A** | 인터셉터(servlet.xml 단독) | #2 AuthorityInterceptor 등록 | URL·메뉴 권한 대조 차단(#1 getAuthorities 활용) | ✅ 완료 | +| **A** | 인터셉터(servlet.xml 단독) | #14 SecondFactorInterceptor 등록 | 2차 인증 판정(#16 SessionRegenerator+#21 Resolver) | ✅ 완료 | +| **B** | 접속기록수집(sqlmap+AOP 단독) | #31 PersonalDataLogAspect | 개인정보 서비스 한정 AOP 수집(#34 IP+#39 해시) | ✅ 완료 | +| **B** | 접속기록수집(sqlmap+AOP 단독) | #32 업무유형 매핑 테이블 | URL패턴→업무코드 DDL+로더 | ✅ 완료 | +| **C** | 인증수단(impl java만) | #18 OtpAuthService(TOTP) | #17 스텁 채움, TOTP 자체구현·비밀키 암호화 | ✅ 완료 | +| **C** | 인증수단(impl java만) | #20 CertAuthService | #17 스텁 채움, GPKI 기존자산 위임 | ✅ 완료 | + +- 2026-07-21 3차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환. + +### 4차 작업 (2026-07-21, 추가 6개 목표) + +접근통제·계정잠금 + 접속기록 배치/비동기 + 세션/마스킹. 로그인 흐름은 Fork A 단독 소유로 충돌 차단. + +| Fork | 영역 | 대상 # | 산출물 | 상태 | +|:----:|------|:------:|--------|:----:| +| **A** | 로그인흐름(sql-map-config 단독) | #22 인증실패이력 활성경로 이설 | LOGIN_HISTORY/LOGIN_LOCK_COUNT 재사용, actionLogin 활성 | ✅ 완료 | +| **A** | 로그인흐름(sql-map-config 단독) | #24 계정 잠금 10→5회 | 임계값 5 + 해제 관리자화면 전환·해제이력 적재 | ✅ 완료 | +| **B** | 접속기록배치(자기sqlmap·스케줄러 단독) | #33 비동기 적재 큐 | 로그적재↔업무txn 분리(단일컨슈머 순번보장) | ✅ 완료 | +| **B** | 접속기록배치(자기sqlmap·스케줄러 단독) | #37 보관 730일 파기 배치 | 월1회 파티션 DROP(개별 DELETE 미사용) | ✅ 완료 | +| **C** | 세션·마스킹(web.xml+util 단독) | #25 세션 타임아웃 60→30분 | web.xml session-config + 정책 훅 | ✅ 완료 | +| **C** | 세션·마스킹(web.xml+util 단독) | #29 2차인증 화면 마스킹 | 휴대폰번호 마스킹 util | ✅ 완료 | + +- 2026-07-21 4차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환. + +### 5차 작업 (2026-07-21, 추가 6개 목표) + +미사용계정 휴면(첫 착수) + 접속기록 조회·점검 + 접근통제 마무리(#23 핸드오프 해소). 로그인/인터셉터는 Fork C 단독. + +| Fork | 영역 | 대상 # | 산출물 | 상태 | +|:----:|------|:------:|--------|:----:| +| **A** | 휴면계정(umt·휴면 sqlmap 단독) | #7 휴면 대상 취급자 확장 | 휴면 체인 S→P를 COMTNEMPLYRINFO까지 | ✅ 완료 | +| **A** | 휴면계정(umt·휴면 sqlmap 단독) | #8 MariaDB 휴면배치 statement 정비 | Oracle에만 있던 배치 statement MariaDB 추가 | ✅ 완료 | +| **B** | 접속기록조회·점검(자기sqlmap·스케줄러 단독) | #41 접속기록 조회·검색 개선 | 접속자ID 등 신규 검색조건(현행 LIKE+날짜뿐) | ✅ 완료 | +| **B** | 접속기록조회·점검(자기sqlmap·스케줄러 단독) | #43 정기점검 리포트 생성 배치 | 월1회 이상징후·무결성·처리현황·다운로드 집계 | ✅ 완료 | +| **C** | 접근통제마무리(로그인·servlet.xml 단독) | #23 계정 열거 방지 | 실패 메시지 존재여부 무관 일관화(#22 핸드오프 해소) | ✅ 완료 | +| **C** | 접근통제마무리(로그인·servlet.xml 단독) | #26 인터셉터 3단 순서 확정 | 인증→SecondFactor→Authority 순서 확정·검증 | ✅ 완료 | + +- 2026-07-21 5차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환. + +### 6차 작업 (2026-07-21, 추가 6개 목표 — 2개 영역 완결) + +권한관리(1~6)·접근통제(22~29) 완결 목표. 공용 config는 fork별 배타 소유(A=sql-map-config, B=BatchScheduler+등록된sqlmap, C=servlet.xml). + +| Fork | 영역 | 대상 # | 산출물 | 상태 | +|:----:|------|:------:|--------|:----:| +| **A** | 권한관리완결(sql-map-config 단독) | #3 권한 신청·승인 워크플로 | 신청R→승인A/반려J 테이블+서비스, 승인시 권한반영+#4이력 단일TX | ✅ 완료 | +| **A** | 권한관리완결(sql-map-config 단독) | #6 권한 이력 조회·검색·다운로드 | #4 이력 양방향검색 + 다운로드 사유필수·다운로드이력 | ✅ 완료 | +| **B** | 접속기록마무리(스케줄러·등록sqlmap 단독) | #35 비정상 접근 탐지 5유형 | 비인가/대량조회/비정상시간대/계정공유의심/인증실패반복 | ✅ 완료 | +| **B** | 접속기록마무리(스케줄러·등록sqlmap 단독) | #42 다운로드 사유·이력 관리 | 접속기록 다운로드 사유필수+다운로드자·일시·IP·건수 이력 | ✅ 완료 | +| **C** | 인증판정·정책(servlet.xml·인터셉터 단독) | #15 2차인증 적용대상·경로 판정 | 취급자 한정, 외부 필수/내부 정책(#14 확장) | ✅ 완료 | +| **C** | 인증판정·정책(servlet.xml·인터셉터 단독) | #28 신규 인증정보 저장정책 적용 | OTP암호화/인증번호미저장/인증서 식별값만 점검·확정 | ✅ 완료 | + +- 2026-07-21 6차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환. + +### 7차 작업 (2026-07-21, 추가 6개 목표 — 2개 영역 완결) + +미사용계정(SFR-002) 완결 + 접속기록(SFR-004) 15/15 완결. 미사용계정 4개는 공유파일 충돌로 Fork A 단독, 접속기록 2개는 파일 분리(B=aspect, C=report). + +| Fork | 영역 | 대상 # | 산출물 | 상태 | +|:----:|------|:------:|--------|:----:| +| **A** | 미사용계정완결(휴면/정책·sql-map-config 단독) | #9 휴면 기준일수 정책값화 | 365 하드코딩→정책테이블 | ✅ 완료 | +| **A** | 미사용계정완결 | #10 휴면 전 관리자 알림 D-30/D-7/D-Day | 현재 본인메일 2단계→3단계+관리자 | ✅ 완료 | +| **A** | 미사용계정완결 | #11 장기 미사용 계정 현황 조회(백엔드) | ID·성명·소속·최종로그인·미접속일수·상태·휴면예정일 | ✅ 완료 | +| **A** | 미사용계정완결 | #13 계정 관리 정책 설정(백엔드) | 기준일수·알림시점·삭제보존기간·배치주기 정책 CRUD | ✅ 완료 | +| **B** | 처리기록확장(aspect·AOP context 단독) | #36 처리기록 적재 범위 확장 | 2개 Controller→전 처리지점, TRGET_ID 규약(sqlmap 무변경) | ✅ 완료 | +| **C** | 점검결과보관(report·등록sqlmap 단독) | #44 점검결과 입력·보관(백엔드) | 점검자·일시·결과·조치사항 입력→PDAL_INSPECTION_REPORT | ✅ 완료 | + +- 2026-07-21 7차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환. +- 2026-07-21 **Fork B(7차) 완료** (#36): 처리기록 pointcut 5→16 서비스 확장 + TRGET_ID 식별자 추출 규약(본문 미적재, fail-safe). 실측 확인 후 ✅(XML well-formed·헬퍼 배선·sqlmap 미변경). +- 2026-07-21 **Fork C(7차) 완료** (#44): 점검결과 입력·보관(InspectionResult 3파일+INSPECT_DT+statement 3종). 실측 확인 후 ✅(미입력 차단·양쪽 XML well-formed·sql-map-config 미변경). **접속기록 영역(30~44) 15/15 완결.** 배포된 테이블엔 INSPECT_DT ALTER 필요(핸드오프). +- 2026-07-21 **Fork A(7차) 완료** (#9,#10,#11,#13): 정책테이블(ACCOUNT_MNG_POLICY)+정책값화+D-30/7/Day 알림+현황조회, 10파일·javac EXIT=0. 실측 확인 후 ✅(365→정책 서브쿼리·sql-map-config 등록·알림 스케줄러 확인). → **미사용계정 영역 6/7 완결(#12 발주대기만). 7차 6개 전부 ✅ (누적 42/61, 69%).** +- 2026-07-21 **Fork C(6차) 중단→완결** (#15, #28): Fork C가 #15(SecondFactorInterceptor 확장) 완료 후 #28 점검노트 착수 직전 API 오류 중단. #15는 실측 확인(외부필수/내부정책 판정·enforce=false·xml well-formed·중괄호 75/75)→✅. **#28 점검노트는 오케스트레이터가 직접 완결**(3종 저장정책 실측, 위반 0건, OTP URI 주의)→✅. → **접근통제 영역(22~29) 8/8 완결.** +- 2026-07-21 **Fork B(6차) 완료** (#35, #42): 비정상 탐지 5유형(6파일, 표준컬럼 집계) + 다운로드 사유필수·이력(DDL+4파일). 실측 확인 후 ✅(#43 리포트 자리표시→실집계 `summarize()`/`countByPeriod()` 교체, 양쪽 XML well-formed). +- 2026-07-21 **Fork A(6차) 완료** (#3, #6): 권한 신청·승인 워크플로(단일TX) + 권한이력 조회·다운로드(사유필수). 실측 확인 후 ✅(10파일·DDL·sql-map-config 등록·approveRequest 단일TX·다운로드 차단 확인). → **권한관리 영역(1~6) 6/6 완결. 6차 6개 전부 ✅ (누적 36/61, 59%).** +- 2026-07-21 **Fork C(5차) 완료** (#23, #26): 계정 열거 방지(조건부 문구 제거→단일 메시지) + 인터셉터 3단 순서 확정·문서화. 실측 확인 후 ✅(문구 제거·순서·xml well-formed 확인). **4차 #22 핸드오프(계정열거) 해소 완료.** ①authenticInterceptor 재활성은 운영 협의 후속. +- 2026-07-21 **Fork B(5차) 중단→분할 완결**: Fork B가 #41 Java 4파일까지 만들고 API 오류로 중단(누락 sqlmap·#43 미착수). #41은 **오케스트레이터가 누락 sqlmap statement(Mysql/Oracle) 완결**(DAO statement id 정합·XML well-formed 검증)→✅. #43은 전용 fork 재투입(진행중). +- 2026-07-21 **Fork A(5차) 완료** (#7, #8): 휴면 취급자 확장(MBER_TY_CODE 분리) + MariaDB 휴면 statement 5종 이설. 실측 확인 후 ✅ — **핵심 발견: MariaDB sqlmap에 휴면 statement 전무해 실운영 휴면배치가 불능이던 것을 복구**(#56 착수전 확인사항과 직결). DAO↔sqlmap id·javac EXIT=0 확인. +- 2026-07-21 **#43 재투입 fork 완료**: 정기점검 리포트 배치(PDAL_INSPECTION_REPORT+집계 3종+스케줄러). 실측 확인 후 ✅(파일·서비스·XML well-formed). #35 이상징후·#42 다운로드는 자리표시(후속 실집계 교체). → **5차 목표 6개 전부 ✅ (누적 30/61 — 절반 근접).** +- 2026-07-21 **Fork C(4차) 완료** (#25, #29): web.xml 세션타임아웃 30분 + MaskingUtil(휴대폰/이메일/이름). 실측 확인 후 ✅(web.xml:218=30, javac 통과). 후속: 세션타임아웃 관리자 설정화면. +- 2026-07-21 **Fork A(4차) 완료** (#22, #24): 운영 로그인 경로(`EgovLoginController.actionLogin`) 실패이력·잠금 이설 + ACCOUNT_UNLOCK_HISTORY 해제이력. 실측 확인 후 ✅ — **참조 서비스 7메서드·VO 3게터 인터페이스 실재 검증, fail-safe로 로그인 무손상.** 핸드오프: 필터 활성 시 이중카운트 정리, #23 실패메시지 완전통일(현재 존재계정에만 "현재 N회" 부가). +- 2026-07-21 **Fork B(4차) 완료** (#33, #37): 비동기 적재 큐(단일컨슈머 순번보장) + 730일 파티션 파기·선행증설 배치. 실측 확인 후 ✅(Aspect enqueue 전환·파티션 DROP/ADD statement·BatchScheduler 등록 확인). → **4차 목표 6개 전부 ✅ (누적 24/61).** +- 2026-07-21 **Fork A(3차) 완료** (#2, #14): 인터셉터 2종 신규+`egov-com-servlet.xml` 등록(순서 #26 반영), javac 통과·xml well-formed. **운영 안전 확인: SecondFactor enforce=false·Authority urlRoleMapping 공백=둘 다 fail-open이라 현재 사이트 무손상.** 실측 확인 후 ✅. 핵심 실측: 이 프로젝트는 `selectAnnotaionMapper`가 `/**/*.do` 전체 적용(mvc:interceptors 미사용)·기존 `authenticInterceptor` 비활성 → 스코프/잠금방지를 인터셉터 내부에서 처리. 후속: #52·#18~20 후 enforce=true, urlRoleMapping 운영 등록. +- 2026-07-21 **Fork C(3차) 완료** (#18, #20): OtpAuthService(TOTP RFC6238 자체구현, RFC 테스트벡터 일치) 5파일 + CertAuthService(기존 GPKI actionCrtfctLogin 위임). 스텁→실구현, javac+실행 검증. 실측 확인 후 ✅(UnsupportedOperationException 0건, SmsAuth#19 미변경 유지). 후속: OTP 비밀키 영속화 테이블, CertProcessFilter 재활성. +- 2026-07-21 **Fork B(3차) 완료** (#31, #32): PersonalDataLogAspect 수집 파이프라인 10클래스+DDL+sqlmap 6+AOP context, javac EXIT=0. 실측 확인 후 ✅(pointcut 개인정보 서비스 5개 한정 확인=과수집 방지, 기존 COMTNSYSLOG 광역 블록 주석 유지). 후속: #33 비동기 큐, #36 TRGET_ID 규약. → **3차 목표 6개 전부 ✅ (누적 18/61).** +- 2026-07-21 **Fork B(2차) 완료** (#34, #16): ClientIpResolver(신뢰CIDR 검증) + SessionRegenerator 생성, javac+단위검증 통과. 실측 확인 후 ✅. 후속: 신뢰대역 운영값(#57), #14 인터셉터 연동 배선. +- 2026-07-21 **Fork C(2차) 완료** (#21, #38): AuthMethodResolver 4종(javac 통과) + 시스템로그 삭제정책 분리노트. 실측 확인 후 ✅(7일 삭제 SQL `EgovSysLog_SQL_Mysql.xml:159` 실제와 정합, 스케줄러 비활성 확인). 후속: #18~20 구현체·빈등록. +- 2026-07-21 **Fork A(2차) 완료** (#5, #40): 파기·무결성검증 배치 2종(배치 클래스+DAO+sqlmap 6파일) + `BatchScheduler` @Scheduled 2메서드 + sql-map-config 등록. 실측 확인 후 ✅(파기 SQL이 실제 DDL 컬럼 CHG_DT 사용 정합). **핵심 핸드오프: 전역 `task:annotation-driven`이 양쪽 context에 주석 처리→@Scheduled 현재 무동작(기존 배치도 동일). 활성화·crontab 경합은 운영 협의 필요.** → **2차 목표 6개 전부 ✅ (누적 12/61).** + +--- + +## 2. 영역별 진행 요약 + +| 영역 | 요구사항 ID | 항목(구간) | 담당 | 완료 | 진행중 | 미착수 | +|------|-------------|:----------:|------|:----:|:------:|:------:| +| Ⅰ. 권한관리 ✅ | SFR-001 | 1~6 | arko-privacy-authctrl | 6 | 0 | 0 | +| Ⅰ. 미사용계정관리 | SFR-002 | 7~13 | arko-privacy-authctrl | 6 | 0 | 1 | +| Ⅱ. 인증체계 | SFR-003 / IAR-001 | 14~21 | arko-privacy-authctrl | 7 | 0 | 1 | +| Ⅱ. 접근통제·인증정보 ✅ | SER-001 / SER-003 | 22~29 | arko-privacy-authctrl | 8 | 0 | 0 | +| Ⅲ. 접속기록 ✅ | SFR-004 / DBR-001 / SER-002 | 30~44 | arko-privacy-auditlog | 15 | 0 | 0 | +| Ⅳ. 신규 화면(UI) | - | 45~54 | arko-frontend | 0 | 0 | 10 | +| Ⅴ. 착수전 확인 | - | 55~58 | arko-privacy-analyst | 0 | 0 | 4 | +| Ⅵ. 보안·품질 | SER-004 | 59~61 | arko-reviewer | 0 | 0 | 3 | +| **합계** | | **61** | | **42** | **0** | **19** | + +범례: ✅ 완료 · 🟡 진행중 · ⬜ 미착수 + +--- + +## 3. 항목별 상세 진행현황 + +### Ⅰ. 권한관리 (Ⅲ-2, SFR-001·SFR-002) — arko-privacy-authctrl + +#### 1) 권한 신설·변경·회수 및 역할별 차등 적용 (SFR-001) + +| # | 개발항목 | 상태 | 소스 실측 / 비고 | +|:--:|----------|:----:|------------------| +| 1 | `getAuthorities()` 구현 | ✅ 완료 | [Fork A] `EgovUserDetailsSessionServiceImpl` 세션 LoginVO.uniqId→`EgovUserAuthorityDAO.selectAuthoritiesByUniqId`→`PrivacyAuthority_SQL_Mysql.xml`(COMTNEMPLYRSCRTYESTBS 조회). sql-map-config 등록 + 빈 DAO 주입 배선 완료. 미인증 시 빈 목록(fail-safe) | +| 2 | AuthorityInterceptor 신규 등록 | ✅ 완료 | [Fork A] `cmm/privacy/interceptor/AuthorityInterceptor.java` + `egov-com-servlet.xml` 등록. `/mnt/` 한정, `EgovUserDetailsHelper.getAuthorities()`(#1) 대조 미허용 403. **urlRoleMapping 기본 비어있음=fail-open(전경로 통과)** → 운영에서 매핑 점진 등록 | +| 3 | 권한 신청·승인 워크플로 구현 | ✅ 완료 | [Fork A] `AUTHOR_CHANGE_REQUEST`(DDL)+5계층(`AuthorWorkflow*`). `approveRequest()` 승인 시 스냅샷→권한반영(COMTNEMPLYRSCRTYESTBS)→상태A→#4이력을 **단일TX**(context-transaction AOP, 예외 시 롤백). 화면 #46 후속 | +| 4 | AUTHOR_CHANGE_HISTORY 이력 테이블 신설 | ✅ 완료 | `src/script/mysql/ddl/privacy.author_change_history_create_mysql.sql`. 변경자/변경일시/변경전후권한(BEFORE/AFTER_AUTHOR)/변경구분/승인자/IP + 3년(1,095일) 보관 전제. Fork A 중단→오케스트레이터 완료 | +| 5 | 권한 이력 파기 배치 구현 | ✅ 완료 | [Fork A] `cmm/privacy/batch/`(Service/Impl/PurgeDAO) + `PrivacyAuthorHistoryPurge_SQL`(DELETE WHERE CHG_DT365`→`>(SELECT IFNULL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY)`(Mysql/Oracle). **미설정 시 365 fallback**, Java 시그니처 무변경 | +| 10 | 휴면 전 관리자 알림 확장(D-30/D-7/D-Day) | ✅ 완료 | [Fork A] `DormantAlertService` — 정책 ALERT_D1/2/3(기본 30/7/0)일 전 대상 조회→본인+관리자(ADMIN_EMAIL) 메일(EgovSndngMail 재사용, fail-safe). `BatchScheduler.dormantPreAlertScheduler()` @Scheduled(매일 06:00) | +| 11 | 장기 미사용 계정 현황 조회 화면 신규 | ✅ 완료 | [Fork A] `DormantAccountStatus`Service/DAO+VO. 취급자(COMTNEMPLYRINFO) 계정ID·성명·소속·직위·최종로그인·**미접속일수(DATEDIFF)**·상태·**휴면예정일** 복합검색+페이징. 일반회원 UNION·화면 후속 | +| 12 | 계정 삭제 기능 구현 | ⬜ 미착수 | ⚠ 보존기간 발주기관 확정 필요 | +| 13 | 계정 관리 정책 설정 화면 | ✅ 완료 | [Fork A] `ACCOUNT_MNG_POLICY`(DDL, DORMANT_DAY·ALERT_D1/2/3·DELETE_PRESERVE_DAY·BATCH_CYCLE·ADMIN_EMAIL 단일행+시드) + `AccountMngPolicy`Service/DAO/VO/Controller. 미설정 시 기본값 fallback. #9/#10이 이 테이블 참조. 화면 후속 | + +### Ⅱ. 인증체계 (Ⅲ-3, SFR-003·IAR-001·SER-001·SER-003) — arko-privacy-authctrl + +#### 3) 2단계 인증 삽입 (SFR-003) + +| # | 개발항목 | 상태 | 소스 실측 / 비고 | +|:--:|----------|:----:|------------------| +| 14 | SecondFactorInterceptor 신규 등록 | ✅ 완료 | [Fork A] `cmm/privacy/interceptor/SecondFactorInterceptor.java`, authority보다 먼저 등록(#26 순서 반영). `/mnt/` 한정 SECOND_FACTOR_DONE 검사. **enforce=false(미강제)** → #52 화면·#18~20 수단 완료 후 true+resolver 주입 활성화. javac 통과 | +| 15 | 2차 인증 적용대상·경로 판정 로직 | ✅ 완료 | [Fork C] `SecondFactorInterceptor` 확장 — `/mnt/` 취급자 한정, **외부접속 필수(설정불가)/내부접속 정책(applyToInternal, internalIpCidrs CIDR 판정)**. enforce=false 유지(잠금방지). 중괄호 75/75·xml well-formed | +| 16 | 세션 재발급 처리(세션고정 차단) | ✅ 완료 | [Fork B] `cmm/privacy/security/SessionRegenerator.java`. `regenerate()`=속성캡처→invalidate→getSession(true)→재적재, `onSecondFactorSuccess()` 표식. javac 통과. #14 SecondFactorInterceptor 연동 시 호출 배선 필요 | + +#### 4) 추가 인증수단 3종 (IAR-001) + +| # | 개발항목 | 상태 | 소스 실측 / 비고 | +|:--:|----------|:----:|------------------| +| 17 | AuthMethodService 인터페이스 설계 | ✅ 완료 | [Fork C] `com/uat/uia/service/AuthMethodService.java` + 스텁 3종(Otp/Sms/Cert) 생성. `issue(uniqId)`/`verify(uniqId,code)`/`isAvailable(uniqId)` throws Exception 확정. 빈 등록·실로직은 #18~21 후속 | +| 18 | OtpAuthService 구현 (1차 적용) | ✅ 완료 | [Fork C] `OtpAuthService`+`OtpSecretStore`/`InMemory`/`OtpSecretCipher`(5파일). TOTP RFC6238 자체구현(HmacSHA1/30초/6자리/±1스텝), 비밀키 AES-128/GCM 암호화 저장·미노출. **RFC 테스트벡터 일치(287082/081804)**. 영속화=EMPLYR_OTP_SECRET 테이블 후속(Fork B 소유 sqlmap) | +| 19 | SmsAuthService 구현 (2차 적용) | ⬜ 미착수 | ⚠ KT-TCS/MCSAgent3.0 연동규격 발주기관 확정 필요 | +| 20 | CertAuthService 구현 (3차 적용) | ✅ 완료 | [Fork C] `CertAuthService` 실구현. 기존 `EgovLoginServiceImpl.actionCrtfctLogin`(:108)→`COMTNEMPLYRINFO.CRTFC_DN_VALUE`(EgovLoginUsr_SQL_Mysql.xml:434) 위임, **인증서 본문 미저장·DN 식별값만 대조**. 핸드오프: `CertProcessFilter`(web.xml:46 주석) 재활성 운영협의 | +| 21 | AuthMethodResolver 구현 | ✅ 완료 | [Fork C] `AuthMethodResolver`+`AuthMethodType`(enum)+`AuthMethodPolicyProvider`+`DefaultAuthMethodPolicyProvider` 4종. Map 주입 분기, 폴백 OTP→SMS→CERT, `resolve(String) throws Exception`(1차 계약 정합). javac 통과. 실동작은 #18~20 구현체·빈등록 후 | + +#### 5) 인증 실패 이력 및 접근통제 정책 (SER-001) + +| # | 개발항목 | 상태 | 소스 실측 / 비고 | +|:--:|----------|:----:|------------------| +| 22 | 인증 실패 이력 활성 경로 이설 | ✅ 완료 | [Fork A] AS-IS: 실패이력/잠금이 `EgovSpringSecurityLoginFilter:466`(조건부)에만 존재. `EgovLoginController.actionLogin` 재작성(잠금선확인→성공시 이력정리→실패시 조건부 적재/잠금), **기존 LOGIN_HISTORY/LOGIN_LOCK_COUNT·서비스 7메서드 재사용(신규 statement 0)**, 전구간 fail-safe. 서비스/VO 메서드 실재 검증 | +| 23 | 계정 열거 방지 처리 | ✅ 완료 | [Fork C] `EgovLoginController:238` 조건부 "현재 N회" 문구 제거→존재/미존재·실패횟수 무관 단일 `fail.common.login`. 실패이력·잠금은 내부 유지, 응답 노출 제거. 잠금안내만 의도적 예외 | +| 24 | 계정 잠금 정책 정비(10회→5회) | ✅ 완료 | [Fork A] 임계값 `LOGIN_LOCK_COUNT` UPDATE=5 + 소스 fallback `DEFAULT_LOCK_THRESHOLD=5`. 해제화면 기존확인. **신규 `ACCOUNT_UNLOCK_HISTORY`**(DDL+VO+DAO+sqlmap, sql-map-config 등록)에 해제이력 훅(해제자+ClientIpResolver IP, fail-safe). 사유입력은 화면 후속 | +| 25 | 세션 타임아웃 조정(60→30분) | ✅ 완료 | [Fork C] `web.xml:218` `30`(60→30) + 근거주석. 실사용 webapp/web.xml 확정. 관리자 설정화면은 후속 | +| 26 | 인터셉터 3단 판정 순서 확정 | ✅ 완료 | [Fork C] `egov-com-servlet.xml:66` 리스트 순서 확정 ①authenticInterceptor(비활성)→②secondFactor→③authority + 근거주석. ①재활성 시 위치 유지 핸드오프. xml well-formed | + +#### 6) 인증정보 보호 (SER-003) + +| # | 개발항목 | 상태 | 소스 실측 / 비고 | +|:--:|----------|:----:|------------------| +| 27 | 비밀번호 해시 알고리즘 점검 | ✅ 완료 | [Fork C] 점검노트 `개인정보보호_비밀번호해시_점검노트.md`. 알고리즘 **SHA-256(기준 충족)**, 단 운영경로 **무솔트·무반복** + `BizMberFrtManageController.java:553` 해시값 로그출력 3건 보완 권고 | +| 28 | 신규 인증정보 저장정책 적용 | ✅ 완료 | [Fork C→오케스트레이터] 점검노트 `개인정보보호_인증정보저장정책_점검노트.md`. OTP AES암호화저장(로그노출0)·SMS 미저장설계·CERT DN만 → **3종 준수, 위반 0건**. OTP 프로비저닝 URI 화면(#53) 취급 주의 1건 | +| 29 | 2차 인증 화면 개인정보 마스킹 | ✅ 완료 | [Fork C] `cmm/privacy/util/MaskingUtil.java`. maskPhone(010-****-1234)/maskEmail(ho**@)/maskName(홍*동)/범용 mask. null·형식 방어, javac+self-check 통과. #52 화면에서 사용 | + +### Ⅲ. 접속 및 처리기록 관리 (Ⅲ-4, SFR-004·DBR-001·SER-002) — arko-privacy-auditlog + +#### 7) 접속기록 수집·저장 (SFR-004) + +| # | 개발항목 | 상태 | 소스 실측 / 비고 | +|:--:|----------|:----:|------------------| +| 30 | PERSONAL_DATA_ACCESS_LOG 신규 테이블 | ✅ 완료 | [Fork B] `src/script/mysql/ddl/privacy.pdal_create_mysql.sql`. 기존 PERSONAL_DATA_DML_HISTORY 실측 승격 + HASH_VAL/PREV_HASH_VAL, PK(LOG_ID,CONECT_DT), 월 RANGE COLUMNS 파티셔닝(접속자ID 검색 인덱스 포함). 파티션 자동증설/파기는 #37 연계 | +| 31 | AOP pointcut 한정 수집 설계 | ✅ 완료 | [Fork B] `PersonalDataLogAspect`+Service/Impl+DAO(10파일)+`context-syslogaop.xml`. pointcut **개인정보 서비스 impl 5개 한정**(과수집 방지), `ClientIpResolver`(#34)+`HashChainService.linkAndSeal`(#39) 적재, synchronized 순번직렬화. 본문 미적재. 비동기(#33)·TRGET_ID(#36) 후속 | +| 32 | 업무유형 매핑 테이블 구현 | ✅ 완료 | [Fork B] `privacy.job_mapping_create_mysql.sql`(`PDAL_JOB_MAPPING`+시드 13행) + `JobMappingResolver`(@Component 캐시)+DAO+sqlmap. (클래스,메서드)→업무코드/명, 신규화면은 행 등록만으로 수집(소스 무수정) | +| 33 | 비동기 적재 큐 구현 | ✅ 완료 | [Fork B] `AccessLogQueue`(LinkedBlockingQueue cap1만+단일 데몬컨슈머). Aspect가 enqueue만(업무 응답 무영향), 컨슈머가 persistNow 순차→**단일 라이터로 해시체인 순번 보장**. 백프레셔(offer 5s), @PreDestroy flush 유실방지 | +| 34 | IP 취득 로직 보정 | ✅ 완료 | [Fork B] `cmm/privacy/auditlog/ClientIpResolver.java`. XFF→Proxy/WL-Proxy→HTTP_CLIENT_IP→getRemoteAddr 순 + **신뢰프록시 CIDR 검증(헤더위조 방지)** + IPv6 루프백 정규화. javac+단위검증 통과. AS-IS `IpObtainInterceptor`(getRemoteAddr+인증시만) 격차 확인. 신뢰대역 운영값=#57 후 확정 | + +#### 8) 비정상 접근 탐지 및 처리기록 (SER-002) + +| # | 개발항목 | 상태 | 소스 실측 / 비고 | +|:--:|----------|:----:|------------------| +| 35 | 비정상 접근 탐지 5유형 구현 | ✅ 완료 | [Fork B] `AbnormalAccessType`(enum)+`Policy`+`Detection`Service/Impl/DAO+VO(6파일). 표준컬럼 집계로 5유형(비인가F/대량조회/업무외시간/동일ID다중IP/실패반복≥5). 임계 정책추상화. sqlmap 5종(Mysql/Oracle). **#43 리포트 실집계 교체** | +| 36 | 처리기록 적재 범위 확장 | ✅ 완료 | [Fork B] pointcut 5→16개 개인정보 취급 서비스(회원/취급자/휴면/신청/활동/그룹/마일리지/프로필/쪽지/SMS 등 명시열거, 조직·통계·게시성 제외). **TRGET_ID 식별자만 베스트에포트 추출**(getter/Map키 우선순위, String인자 스킵, 본문 미적재, fail-safe). sqlmap 무변경(컬럼 기보유). javac EXIT=0 | + +#### 9) 보관정책 및 위·변조 방지 (DBR-001) + +| # | 개발항목 | 상태 | 소스 실측 / 비고 | +|:--:|----------|:----:|------------------| +| 37 | 보관정책 730일 + 파기 배치 구현 | ✅ 완료 | [Fork B] `AccessLogPartitionDAO`+`PDAL_PURGE_HISTORY`(DDL). INFORMATION_SCHEMA.PARTITIONS 경계≤오늘-730일 파티션 DROP + 당월~+2M ADD 선행증설. `BatchScheduler.purgeAccessLogPartitionScheduler()`(매월1일 02:00). ⚠파티션명 `$` 치환=Java계산값(안전, 리뷰포인트) | +| 38 | COMTNSYSLOG 7일 삭제정책 재설계 | ✅ 완료 | [Fork C] 노트 `개인정보보호_시스템로그삭제정책_분리노트.md`. 7일 삭제 SQL 위치 `EgovSysLog_SQL_Mysql.xml:159`(COMTNSYSLOG 단일, Quartz 1H이나 **현재 스케줄러 비활성**) 실측 확인. 오적용 위험 낮음(테이블 하드코딩+namespace 분리), 분리 보강 5개 권고 | +| 39 | SHA-256 해시체인 구현 | ✅ 완료 | [Fork B] `cmm/privacy/auditlog/HashChainService.java`(+VO). MessageDigest 자체구현, 결합규칙 CONECT_ID\|CONECT_DT\|CONECT_IP\|JOB_SE_CODE\|TRGET_ID\|DML_GUBUN\|PREV_HASH_VAL→SHA-256. computeHash/linkAndSeal/verifyChain, javac+실행 변조탐지 검증. 순번보장=단일스레드 순차INSERT 전제(#33) | +| 40 | 해시체인 무결성 검증 배치 | ✅ 완료 | [Fork A] `AccessLogIntegrityDAO`+`PrivacyAccessLogIntegrity_SQL`(LOG_ID ASC, CONECT_DT 형식일치) + `verifyAccessLogHashChain()`→verifyChain. `BatchScheduler.verifyAccessLogHashChainScheduler()` @Scheduled(매월1일 03:00). 불일치 시 log.error+notifyAdmin(#49 TODO)·리포트(#43) 반환 | + +#### 10) 조회·다운로드 및 정기점검 + +| # | 개발항목 | 상태 | 소스 실측 / 비고 | +|:--:|----------|:----:|------------------| +| 41 | 접속기록 조회·검색 화면 개선 | ✅ 완료 | [Fork B→오케스트레이터] `PersonalDataAccessLogSearch`DAO/Service/Impl/VO + `selectAccessLogList`/`Count` sqlmap(Mysql/Oracle). 접속자ID/업무유형/대상/IP/처리구분/결과/기간 복합+페이징. Fork B 중단→오케스트레이터가 누락 sqlmap 완결(codebase 관용 맞춰 동적WHERE 인라인, XML well-formed). 화면 #47 후속 | +| 42 | 다운로드 사유·이력 관리 | ✅ 완료 | [Fork B] `PDAL_DOWNLOAD_HISTORY`(DDL)+VO/DAO+`AccessLogDownloadService`. **사유 공백 시 IllegalArgumentException 차단** + 다운로드자·일시·IP·사유·건수 이력(본문 미보관, 10만건 상한). sqlmap 3종. **#43 리포트 실집계 교체** | +| 43 | 정기점검 리포트 생성 배치 | ✅ 완료 | [Fork B] `PDAL_INSPECTION_REPORT`(DDL)+`InspectionReportVO`/`DAO`+`generateMonthlyInspectionReport()`+`BatchScheduler`(매월1일 05:00). 집계: 총/실패건수·#40무결성(재사용)·취급자별 top20 [구현], 이상징후#35·다운로드#42 [자리표시]. sql-map-config 무수정(기존 sqlmap 확장) | +| 44 | 점검결과 입력·보관 화면 | ✅ 완료 | [Fork C] `InspectionResult`Service/Impl/Controller + `INSPECT_DT` 컬럼 추가 + statement 3종(목록/상세/UPDATE). `saveInspectionResult()` 결과 미입력 차단·리포트 존재확인·INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER/INSPECT_DT 갱신(감사증빙, 배치집계값 불변). 화면 #48 후속 | + +### Ⅳ. 신규 화면 (디자인.md 연계) — arko-frontend + +| # | 개발항목 | 구분 | 상태 | +|:--:|----------|:----:|:----:| +| 45 | 권한부여 신청/승인 관리 화면 | mnt | ⬜ 미착수 | +| 46 | 권한변경 이력 조회 화면 | mnt | ⬜ 미착수 | +| 47 | 개인정보 접속기록 조회/다운로드 화면 | mnt | ⬜ 미착수 | +| 48 | 접속기록 정기점검 리포트 대시보드 | mnt | ⬜ 미착수 | +| 49 | 비정상접근 탐지 알림함 화면 | mnt | ⬜ 미착수 | +| 50 | 인증수단 관리 설정 화면 | mnt | ⬜ 미착수 | +| 51 | 계정 잠금/휴면계정 관리 확장 화면 | mnt | ⬜ 미착수 | +| 52 | 2차 인증(추가인증) 입력 화면 | frt | ⬜ 미착수 | +| 53 | 마이페이지 인증수단 등록/관리 화면 | frt | ⬜ 미착수 | +| 54 | 계정 잠금 안내 화면 | frt | ⬜ 미착수 | + +> UI 목업 브리프(스티치 AI 프롬프트 포함)는 `디자인.md`에 문서화 완료 — **설계 산출물은 준비됨**, 코드 구현은 미착수. + +### Ⅴ. 착수 즉시 확인 필요 사항 — arko-privacy-analyst + +| # | 확인항목 | 상태 | 리스크 | +|:--:|----------|:----:|--------| +| 55 | 운영 web.xml 필터 매핑 상태 확인 | ⬜ 미확인 | R1 — 소스/운영 배포본 상이 가능성 | +| 56 | 휴면 배치 운영 로그 확인 | ⬜ 미확인 | MariaDB statement 부재 실운영 재확인 | +| 57 | CONECT_IP 적재값 분포 확인 | ⬜ 미확인 | Apache+L4 이중구성 L4 IP 고정 여부 | +| 58 | 인터셉터 등록부 현재 상태 확인 | ⬜ 미확인 | 등록 순서 재검증 | + +### Ⅵ. 보안·품질 점검 (SER-004) — arko-reviewer + +| # | 개발항목 | 상태 | 비고 | +|:--:|----------|:----:|------| +| 59 | 시큐어코딩 적용 및 점검 | ⬜ 미착수 | 행안부 기준 · 구현 후 | +| 60 | 웹취약점 점검 | ⬜ 미착수 | 국정원 기준 · 개발 완료 후 | +| 61 | 중점 점검 5종 확인 | ⬜ 미착수 | 인증우회/권한상승/접속기록조작/계정열거/인증정보노출 | + +--- + +## 4. 완료된 선행 작업 (구현 착수 기반) + +| 산출물 | 위치 | 상태 | +|--------|------|:----:| +| 요구사항·기술설계 원문 | `E:\지역문화원\제안서\mds\Ⅲ_사업수행계획.md` (Ⅲ-2/3/4) | ✅ | +| 개발항목 정리(61개) | `개인정보보호_개발항목.docx` | ✅ | +| 신규 화면 디자인 브리프(10종) | `디자인.md` | ✅ | +| 권한/인증 구현 에이전트 | `.claude/agents/arko-privacy-authctrl.md` | ✅ | +| 접속기록 구현 에이전트 | `.claude/agents/arko-privacy-auditlog.md` | ✅ | +| 요구사항 분석 에이전트 | `.claude/agents/arko-privacy-analyst.md` | ✅ | +| 구현 패턴 스킬(권한/접속/추적/오케스트레이터) | `.claude/skills/arko-privacy-*` | ✅ | +| 리뷰어 개인정보보호 점검항목 확장 | `.claude/agents/arko-reviewer.md` | ✅ | + +--- + +## 5. 권장 착수 순서 + +1. **Ⅴ. 착수전 확인(55~58)** — 운영 환경 실측으로 리스크 R1 등 조기 해소 (구현 전 필수) +2. **Ⅰ-1) 권한관리(1~6)** — `getAuthorities()` 구현이 전체 권한통제의 뿌리 → 최우선 +3. **Ⅱ. 인증체계(14~29)** — 인터셉터 3단 순서 확정 후 2단계 인증·인증수단 3종 +4. **Ⅲ. 접속기록(30~44)** — 테이블·AOP·해시체인·배치 +5. **Ⅳ. 화면(45~54)** — 백엔드 진척에 맞춰 frt/mnt 병행 +6. **Ⅵ. 보안·품질(59~61)** — 각 기능 완료분 대상 상시 + 최종 웹취약점 점검 + +## 6. 발주기관 확정 대기 항목 (블로커) + +| 항목 | 관련 # | 내용 | +|------|:------:|------| +| 계정 삭제 보존기간 | 12 | 취급자/회원 각 보존기간 정책 | +| 문자발송모듈 연동 규격 | 19 | KT-TCS / MCSAgent3.0 연동 스펙 | + +--- + +_본 문서는 `개인정보보호_개발항목.docx`의 61개 개발항목을 소스 실측(2026-07-21)과 대조해 작성한 진행현황표이다. 코드 구현 진척 시 각 항목의 상태(⬜→🟡→✅)와 소스 실측 열을 갱신한다._ diff --git a/개인정보보호_개발항목.docx b/개인정보보호_개발항목.docx new file mode 100644 index 0000000..941c81b Binary files /dev/null and b/개인정보보호_개발항목.docx differ diff --git a/개인정보보호_비밀번호해시_점검노트.md b/개인정보보호_비밀번호해시_점검노트.md new file mode 100644 index 0000000..5a6ed8e --- /dev/null +++ b/개인정보보호_비밀번호해시_점검노트.md @@ -0,0 +1,63 @@ +# 비밀번호 해시 알고리즘 점검노트 (개발항목 #27) + +> 사업: 문화품앗이 개인정보보호 기능개선 (Ⅲ-3 인증체계 / SER-003 인증정보 보호) +> 점검일: 2026-07-21 | 점검자: Fork C(인증체계) | 대상: `EgovFileScrty.encryptPassword()` +> 성격: **점검(진단)만 수행 — 소스 미수정.** 조치는 후속 개발항목에서 진행. + +--- + +## 1. 결론 (요약) + +**알고리즘은 SHA-256으로 「개인정보의 안전성 확보조치 기준」의 일방향 암호화(SHA-256 이상) 요건을 충족한다. 다만 실제 운영 경로가 무솔트(no-salt) 오버로드를 사용하고, 반복(iteration)이 없으며, 해시값을 stdout으로 출력하는 잔존 코드가 있어 3건의 보완이 권고된다.** + +| 점검 항목 | 판정 | 근거 | +|-----------|:----:|------| +| 해시 알고리즘 SHA-256 이상 | ✅ 충족 | `MessageDigest.getInstance("SHA-256")` | +| 솔트(salt) 적용 | ⚠ 미흡 | 실제 경로가 무솔트 오버로드 사용, 솔트 API는 미사용 | +| 키 스트레칭(반복) | ⚠ 미흡 | 1회 다이제스트, iteration/work-factor 없음 | +| 검증 시 상수시간 비교 | ✅ 양호 | `checkPassword`가 `MessageDigest.isEqual` 사용(단, 미사용) | +| 인증정보 로그 노출 | ⚠ 미흡 | 해시값 `System.out.println` 잔존 | + +--- + +## 2. 실측 상세 + +### 2-1. 알고리즘 (파일: `src/main/java/egovframework/com/utl/sim/service/EgovFileScrty.java`) + +| 메서드 | 위치 | 알고리즘 | 솔트 | 인코딩 | +|--------|------|----------|:----:|--------| +| `encryptPassword(String data)` | L219~237 | SHA-256 | ❌ 없음 | Base64 | +| `encryptPassword(String data, byte[] salt)` | L246~261 | SHA-256 (`md.update(salt)` 후 `digest`) | ✅ 있음 | Base64 | +| `checkPassword(String data, String encoded, byte[] salt)` | L271~281 | SHA-256 | ✅ 있음 | `MessageDigest.isEqual`(상수시간 비교) | + +- 세 메서드 모두 **단일 패스 SHA-256**(반복/키 스트레칭 없음). +- 솔트 오버로드와 `checkPassword`는 구현되어 있으나 **실제 호출 지점이 없다**(주석 처리된 `main()` 테스트 L283~297에서만 참조). + +### 2-2. 실제 사용 경로 — 무솔트 오버로드가 운영 경로 + +`encryptPassword(String)` (무솔트) 호출 지점(발췌): + +| 경로 | 위치 | 성격 | +|------|------|------| +| **로그인 인증** | `EgovLoginServiceImpl.java:83` (`actionLogin` → `enpassword`) | 취급자/회원 로그인 검증 | +| 비밀번호 변경 | `EgovLoginServiceImpl.java:206`, `:237` | | +| 회원/사용자/업체 가입·변경 | `EgovUserManageServiceImpl`, `EgovMberManageServiceImpl`, `EgovEntrprsManageServiceImpl` 등 다수 | 저장 시 해시 | +| 프론트 회원관리 | `kr.or.arko.frt.biz.umt.web.*`, `kr.or.arko.frt.biz.merge.*` 등 | | + +→ 로그인·가입·변경 등 **모든 실제 비밀번호 처리 경로가 무솔트 SHA-256**을 사용한다. 동일 평문 → 동일 해시가 되어 레인보우 테이블·동일비밀번호 식별에 취약. + +### 2-3. 인증정보 로그 노출 (SER-003 / 중점점검 #61 인증정보노출 연계) + +- `BizMberFrtManageController.java:553` + `System.out.println("3패스워드확인::" + EgovFileScrty.encryptPassword(mberManageVO.getPassword()));` + → 비밀번호 해시값을 표준출력(로그)에 기록. 디버그 잔존 코드로 판단되며 제거 대상. + +--- + +## 3. 권고 (조치는 후속 항목에서) + +1. **솔트 전면 적용** — 운영 경로를 솔트 오버로드(`encryptPassword(String, byte[])`) + `checkPassword`로 이설. 사용자별 랜덤 솔트를 생성·저장(가입 시)하고 검증은 `checkPassword`(상수시간 비교)로 통일. 기존 무솔트 해시는 로그인 성공 시점 재해시(rehash-on-login) 방식으로 점진 마이그레이션. +2. **키 스트레칭 검토** — 단일 SHA-256 대신 반복(iteration) 적용 또는 표준 비밀번호 해시(PBKDF2 등)로의 상향을 발주기관과 협의. (기준상 SHA-256은 충족이나, 브루트포스 내성 강화 관점의 개선 권고.) +3. **해시값 로그 출력 제거** — `BizMberFrtManageController.java:553`의 `System.out.println` 삭제. 신규·변경 코드에 인증정보(비밀번호/해시/OTP비밀키) 출력 금지 규칙 적용(보안·품질 #61과 연계 점검). + +> 본 점검은 진단 단계 산출물이며, 위 3건의 실제 코드 수정은 별도 개발항목 승인 후 진행한다. diff --git a/개인정보보호_시스템로그삭제정책_분리노트.md b/개인정보보호_시스템로그삭제정책_분리노트.md new file mode 100644 index 0000000..9a91963 --- /dev/null +++ b/개인정보보호_시스템로그삭제정책_분리노트.md @@ -0,0 +1,45 @@ +# COMTNSYSLOG 7일 삭제정책 분리 분석노트 (#38) + +> 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / DBR-001 / SER-002) +> 개발항목 #38 — 담당: Fork C | 작성일: 2026-07-21 +> 목적: 기존 시스템로그(COMTNSYSLOG) 7일 삭제정책이 신규 개인정보 접속기록 +> (`PERSONAL_DATA_ACCESS_LOG`, 730일 보관)에 **오적용되지 않도록** 분리 상태를 진단·보강한다. + +--- + +## 1. 기존 7일 삭제정책 실측 (파일:라인) + +| 계층 | 위치 | 내용 | +|------|------|------| +| SQL(삭제문) | `src/main/resources/egovframework/sqlmap/com/sym/log/lgm/EgovSysLog_SQL_Mysql.xml:159~165` | `DELETE FROM COMTNSYSLOG WHERE OCCRRNC_DE < DATE_FORMAT(ADDDATE(SYSDATE(), -7), '%Y%m%d')` | +| DAO | `src/main/java/egovframework/com/sym/log/lgm/service/impl/SysLogDAO.java:47~50` | `logInsertSysLogSummary()` 내부에서 **요약 INSERT 직후 삭제 DELETE를 한 메서드로 묶어 호출** | +| Service | `.../service/impl/EgovSysLogServiceImpl.java:60~64` | `logInsertSysLogSummary()` 위임 | +| Scheduling | `.../service/EgovSysLogScheduling.java:36~38` | `sysLogSummary()` → 서비스 호출 | +| 스케줄 설정 | `src/main/resources/egovframework/spring/com/context-scheduling-sym-log-lgm.xml:6~28` | Quartz `MethodInvokingJobDetailFactoryBean`+`SimpleTriggerBean`(1시간 주기). **단, `SchedulerFactoryBean`(L22~28)은 주석 처리 → 현재 비활성** | +| 타 DB 동일본 | `EgovSysLog_SQL_{Oracle,Altibase,Cubrid,Tibero}.xml`의 동일 `SysLogDAO.logDeleteSysLogSummary` | MySQL과 동일 구조 | + +**대상:** `COMTNSYSLOG` 단일 테이블. 삭제 기준 컬럼 `OCCRRNC_DE`(발생일자, `YYYYMMDD` 문자열). 보관 7일. +**주기:** 정의상 매 1시간(요약 배치에 종속) — 요약 INSERT가 실행될 때마다 7일 경과분 삭제. +**현재 활성 여부:** **비활성.** 트리거를 물고 있는 `SchedulerFactoryBean`이 주석 처리되어 스케줄러에 등록되지 않음(`context-scheduling-sym-log-lgm.xml:22~28`). + +## 2. 개인정보 접속기록 오적용 위험 판정 + +**판정: 직접 오적용 위험 낮음(구조적 분리됨). 단, 결합 설계로 인한 잠재적 혼동 리스크 있음.** + +- 삭제문이 테이블명 `COMTNSYSLOG`에 하드코딩되어 있어 `PERSONAL_DATA_ACCESS_LOG`를 물리적으로 건드릴 수 없다. namespace도 `SysLogDAO`로 분리되어 있다. +- 신규 접속기록의 유일한 삭제 경로는 **파티션 DROP 방식 730일 파기 배치(#37, Fork A 담당)**로 별도 설계되어, 개별 DELETE 및 7일 정책과 경로가 완전히 다르다. +- **다만 잠재 리스크 2가지:** + 1. **결합(coupling) 리스크** — 7일 DELETE가 `logInsertSysLogSummary()`(요약 INSERT)에 **묶여 있어**, 향후 이 메서드를 재사용·확장하는 개발자가 삭제 부작용을 인지하지 못할 수 있다. 요약과 파기는 관심사가 다르므로 분리가 바람직. + 2. **오해 리스크** — 신규 접속기록 보관/파기를 구현하며 기존 `SysLogDAO`/`EgovSysLog_SQL_*.xml`에 접속기록용 statement를 추가하면 두 로그 체계가 한 namespace에 섞여 7일 정책이 잘못 상속될 수 있다. + +## 3. 분리 보강 권고 + +1. **namespace 절대 분리(필수):** 개인정보 접속기록 관련 SQL은 **절대** `SysLogDAO`/`EgovSysLog_SQL_*.xml`에 추가하지 않는다. 신규 `PrivacyAccessLog*`/전용 sqlmap namespace로만 관리한다. (Fork A #30/#37 산출물이 이미 별도 DDL/파일로 분리됨 — 이 원칙 유지.) +2. **`PERSONAL_DATA_ACCESS_LOG`에 UPDATE/DELETE statement 미정의(필수):** 애플리케이션 계층에 개별 DELETE를 만들지 않는다(위·변조 방지 + 보관정책 일원화). 유일 삭제 경로 = 파티션 DROP 파기 배치(#37). +3. **방어 주석 추가(권고):** `EgovSysLog_SQL_Mysql.xml`의 `logDeleteSysLogSummary` 및 `SysLogDAO.logInsertSysLogSummary()`에 "이 7일 삭제정책은 COMTNSYSLOG 전용이며 PERSONAL_DATA_ACCESS_LOG(730일)에 적용 금지" 주석을 남겨 혼동 차단. (본 노트는 진단 중심이라 소스 미수정 — 방어 주석은 후속 반영 권고.) +4. **요약/파기 분리(선택·개선):** 7일 삭제를 요약 INSERT 메서드에서 떼어 별도 메서드로 분리하면 결합 리스크 해소. 단 기존 동작 변경이므로 운영 영향 검토 후 진행. +5. **활성화 시 주의(운영):** 현재 시스템로그 요약/삭제 스케줄러가 비활성 상태다. 향후 활성화(`SchedulerFactoryBean` 주석 해제) 시 7일 삭제가 함께 켜지므로, 접속기록 파기 배치(#37)와 **주기·대상이 독립적으로 동작하는지** 재확인한다. + +## 4. 결론 + +기존 7일 삭제정책은 `COMTNSYSLOG` 전용으로 물리·논리적으로 분리되어 있어 신규 접속기록에 직접 오적용될 위험은 낮으며, 현재 스케줄러 자체가 비활성이다. 다만 삭제가 요약 배치에 결합된 구조라 향후 혼동을 막기 위해 **① 접속기록 SQL의 namespace 절대 분리, ② 접속기록 DELETE statement 미정의, ③ 방어 주석**을 유지·반영할 것을 권고한다. 이 원칙은 Fork A의 #30(DDL)/#37(파티션 파기 배치) 산출물이 별도 파일·namespace로 이미 준수하고 있다. diff --git a/개인정보보호_인증정보저장정책_점검노트.md b/개인정보보호_인증정보저장정책_점검노트.md new file mode 100644 index 0000000..b28f2cf --- /dev/null +++ b/개인정보보호_인증정보저장정책_점검노트.md @@ -0,0 +1,43 @@ +# 신규 인증정보 저장정책 점검노트 (#28, SER-003) + +> 문화품앗이 개인정보보호 기능개선 사업 | 작성일: 2026-07-21 +> 대상: 추가 인증수단 3종(OTP/SMS/공동인증서)의 인증정보 저장정책 준수 여부 점검 +> 근거: 개발항목 #28 — "OTP비밀키 암호화저장 / 휴대폰 인증번호 미저장 / 공동인증서 본문 미저장(식별값만 매핑)" + +## 1. 점검 결론 + +| 인증수단 | 저장정책 | 준수 | 근거(파일:라인) | +|----------|----------|:----:|-----------------| +| OTP(#18) | 비밀키 **암호화 저장**, 복호화는 검증 순간 한정, 로그·화면 미노출 | ✅ 준수 | `OtpAuthService.java:85`(encrypt 후 save), `:97~107`(검증 시 복호화) | +| SMS(#19) | 인증번호 **미저장**(만료시각만 세션/캐시), 3분 내 검증 후 폐기 | ✅ 설계 준수 | `SmsAuthService.java:23,31,35`(미저장 명시, 실발송은 발주기관 연동 대기) | +| 공동인증서(#20) | 인증서 **본문 미저장**, 식별값(DN)만 매핑 | ✅ 준수 | `CertAuthService.java:30,69`(DN만 대조, 본문 미취급) | + +**종합: 3종 모두 SER-003 저장정책을 준수한다. 단 OTP 프로비저닝 URI 취급에 1건 주의(아래 3절).** + +## 2. 인증수단별 상세 + +### 2.1 OTP (OtpAuthService) — ✅ 준수 +- **암호화 저장**: `issue/provision()`에서 160bit 비밀키 생성 → `OtpSecretCipher.encrypt()`(AES-128/GCM) 후 `OtpSecretStore.save()`. 평문 비밀키를 저장소에 넣지 않는다. (`OtpAuthService.java:81~85`) +- **복호화 최소화**: `verify()`에서만 복호화(`:107 secretCipher.decrypt`), 검증 직후 지역변수로만 사용. +- **로그/예외 노출 없음**: 비밀키·OTP 코드를 `LOG`/`System.out`/예외 메시지에 출력하는 코드 없음(grep 결과 0건). `isAvailable()`은 존재여부(boolean)만 반환. +- **마스터키 외부화**: 암호화 마스터키는 `arko.otp.master.key` 프로퍼티/환경변수(미설정 시 개발용 폴백 + TODO). → 운영 배포 시 마스터키 주입 필수(핸드오프). + +### 2.2 SMS (SmsAuthService) — ✅ 설계 준수 (실발송 #19 대기) +- 클래스 주석·시그니처에 **인증번호 미저장**(만료시각만 세션/캐시 보관), 3분 내 검증 후 폐기 명시(`:23,31`). +- 실제 발송·검증 본문은 `// TODO(#19)`: KT-TCS/MCSAgent3.0 연동규격 **발주기관 확정 대기**. 구현 시에도 인증번호 원문 저장 금지 원칙 유지할 것(핸드오프). + +### 2.3 공동인증서 (CertAuthService) — ✅ 준수 +- `verify(uniqId, code)`에서 code=인증서 **DN(식별값)**을 기존 GPKI 경로(`EgovLoginServiceImpl.actionCrtfctLogin` → `COMTNEMPLYRINFO.CRTFC_DN_VALUE`)로 대조. **인증서 본문(공개키/서명 원문)은 취급·저장하지 않음**(`:30,69`). +- `issue()`는 서버측 no-op(클라이언트 서명 기반), 실제 서명요청은 `CertProcessFilter`(web.xml:46 주석) 재활성 핸드오프. + +## 3. 주의·보완 권고 + +1. **OTP 프로비저닝 URI(주의)**: `OtpAuthService.buildOtpAuthUri()`(`:147~151`)가 반환하는 `otpauth://...?secret=` URI에는 **평문 비밀키가 포함**된다(QR 등록용 표준 동작). 이 URI는 등록 화면(#53)에서 **HTTPS 전송·1회 표시·로그 미기록** 원칙으로 취급해야 한다. 화면 구현(#53) 시 이 URI를 서버/접근로그·에러로그에 남기지 말 것. +2. **OTP 마스터키 운영 주입**: 개발용 폴백 키가 운영에 유입되지 않도록 배포 시 `arko.otp.master.key` 필수 설정. +3. **SMS 구현 시 원칙 유지**(#19): 인증번호 원문 미저장, 세션/캐시엔 만료시각·해시만. +4. **접속기록 연계**: 2차 인증 성공/실패도 개인정보 접근 맥락에서 접속기록(#31) 대상 여부 검토(현재 pointcut은 개인정보 처리 서비스 5종 한정). + +## 4. 관련 개발항목 +- [#18 OtpAuthService](#) TOTP·AES-GCM / [#19 SmsAuthService](#) 발주기관 연동 대기 / [#20 CertAuthService](#) GPKI 위임 +- [#27 비밀번호 해시 점검노트](개인정보보호_비밀번호해시_점검노트.md) — 기존 비밀번호(SHA-256, 무솔트 보완권고)와 별개 +- 저장정책 위반 소지: 점검 시점 **0건**(로그/화면 평문 노출 없음). OTP 프로비저닝 URI만 화면 구현 시 주의. diff --git a/디자인.md b/디자인.md new file mode 100644 index 0000000..fb15ecd --- /dev/null +++ b/디자인.md @@ -0,0 +1,226 @@ +# 디자인.md — 개인정보보호 기능개선 신규 화면 디자인 브리프 (스티치 AI 연동용) + +> 2026 문화품앗이 개인정보보호 기능개선 사업(한국문화원연합회 발주, SFR-001~004)의 신규 화면을 +> [Google Stitch](https://stitch.withgoogle.com/) 로 목업 생성하기 위한 디자인 브리프. +> 대상 도메인: **frt**(일반회원), **mnt**(관리자). mbl/eng는 이번 범위에서 제외. + +## 0. 사용법 + +1. 새 Stitch 프로젝트를 열 때, **2. 공통 디자인 시스템**의 해당 도메인(frt 또는 mnt) 블록을 + 프롬프트 맨 앞에 항상 포함한다 — 기존 서비스와 톤앤매너가 어긋난 목업이 나오는 것을 방지한다. +2. **4. 신규 화면 명세**의 각 화면 카드 안 "스티치 프롬프트" 코드블록을 그대로 복사해 + Stitch의 화면 생성/편집 입력창에 붙여넣는다. +3. 화면 간 일관성이 필요하면(예: mnt 신규 화면 7개를 한 앱으로) 여러 화면 프롬프트를 이어 붙여 + 한 번에 "여러 화면이 있는 앱" 형태로 요청해도 된다. +4. Stitch 결과물은 목업일 뿐이다. 실제 반영 시 **5. 반영 체크리스트**를 반드시 확인한다. + +## 1. 요구사항 ↔ 화면 매핑 + +| 요구사항 영역 | 대상 패키지 | 신규 화면 | 도메인 | +|---|---|---|---| +| Ⅲ-2 권한관리 | `egovframework.com.sec.ram/rgm` | 권한부여 신청/승인 관리 | mnt | +| Ⅲ-2 권한관리 | 〃 | 권한변경 이력 조회 (AUTHOR_CHANGE_HISTORY) | mnt | +| Ⅲ-3 인증체계 | `egovframework.com.uat.uia` | 2차 인증(추가인증) 입력 화면 | frt | +| Ⅲ-3 인증체계 | 〃 | 마이페이지 – 인증수단 등록/관리 | frt | +| Ⅲ-3 인증체계 | 〃 | 인증수단 관리 설정 (AuthMethodService 3종) | mnt | +| Ⅲ-3 인증체계 | `egovframework.com.uss.umt` | 계정 잠금/휴면계정 관리 확장 | mnt | +| Ⅲ-3 인증체계 | 〃 | 계정 잠금 안내 화면 | frt | +| Ⅲ-4 접속기록 | `egovframework.com.sym.log` | 개인정보 접속기록 조회/다운로드 (PERSONAL_DATA_ACCESS_LOG) | mnt | +| Ⅲ-4 접속기록 | 〃 | 접속기록 정기점검 리포트 대시보드 | mnt | +| Ⅲ-4 접속기록 | 〃 | 비정상접근 탐지 알림함 (5유형) | mnt | + +## 2. 공통 디자인 시스템 + +### 2-1. frt (일반회원 — 리뉴얼 디자인, `webapp/new/`) + +``` +스타일: 공공기관 회원 포털의 2024년 리뉴얼 디자인. 따뜻하고 신뢰감 있는 톤, 둥근 모서리와 부드러운 그림자. +폰트: 제목은 GmarketSans Bold, 본문/라벨은 Pretendard. +주 색상: #f1692a (버튼/포인트), hover 시 #f04c1b. +중립 색상: 본문 텍스트 #212121, 보조 텍스트 #616161, 흐린 텍스트 #9e9e9e, + 구분선 #eeeeee / #e0e0e0, 옅은 배경 #fcfcfc / #f9f9f9 / #f5f5f5, 카드 배경 흰색. +경고/에러 텍스트: #ed4040. +버튼: border-radius 6px, 배경 #f1692a, 글자색 흰색, 폰트 굵게. +입력폼: line-height 45px, padding 16px, 얇은 회색 테두리. +카드/패널: border-radius 10px, box-shadow 0 0 20px rgba(0,0,0,0.2), 내부 padding 56px 48px, 흰 배경. +상태 뱃지(둥근 알약형): 대기/요청 상태는 빨강(#ed4040), 진행/활성 상태는 파랑(#3c79d5), + 종료/마감 상태는 회색(#848690). +레이아웃: 최소 폭 1400px 데스크톱 기준, 본문 영역은 최대 폭 1200px 중앙 정렬. +상단에 페이지 타이틀(중앙 정렬, 32px bold)과 우측 정렬 breadcrumb(홈 아이콘 > 상위메뉴 > 현재메뉴). +``` + +### 2-2. mnt (관리자 — 레거시 백오피스 스킨, `webapp/css/site/mnt/`) + +``` +스타일: 데이터 밀도가 높은 전통적인 관리자 백오피스. 각진 사각형, 그림자 없음, 여백이 좁고 촘촘함. + frt의 둥근 카드/그림자 스타일과 절대 섞지 않는다. +폰트: 나눔고딕(NanumGothic) 본문, 맑은 고딕 보조. +주 색상: #005ea8 (헤더/포인트 블루), 강조 숫자·통계는 #f1592a (오렌지). +중립 색상: 본문 텍스트 #333 / #636363, 얇은 회색 테두리 #ddd / #d1d1d1, 옅은 배경 #f4f4f4. +레이아웃: 상단에 검색 필터 영역(fieldset, 라벨+드롭다운+텍스트입력+검색버튼이 한 줄에 나열), + 그 아래 데이터 표(테두리 있는 table, 헤더 행 진한 배경, 좌측 체크박스 열, 우측 정렬 숫자), + 표 하단 중앙 페이지네이션, 표 아래 우측 정렬 액션 버튼 그룹(각진 사각 버튼). +대시보드형 화면은 상단에 통계 요약 카드(사각형, 얇은 테두리, 큰 숫자 + 작은 라벨) 3~6개를 가로로 배치. +``` + +## 3. 신규 화면 공통 원칙 + +- mnt 신규 화면은 기존 `EgovAccountUnlockManage.jsp` 류의 **검색필터 + 표 + 페이지네이션 + 하단 액션버튼** 구조를 그대로 따른다. 완전히 새로운 레이아웃 문법을 만들지 않는다. +- frt 신규 화면(2차 인증 등)은 `frt/member/login.jsp` 류의 **중앙 정렬 카드(boxR-shadow) + form-control 입력 + btn-login 버튼** 구조를 그대로 따른다. +- 승인/이력/로그 성격의 화면은 상태를 항상 색상 뱃지로 표시한다 (대기=빨강, 진행/승인=파랑, 종료/반려=회색 — 2-1 참조). +- 접속기록·이력류 화면은 반드시 **기간 필터 + 다운로드(엑셀/CSV) 버튼**을 상단에 노출한다 (개인정보 처리방침상 열람 이력 제공 요건). + +## 4. 신규 화면 명세 + +### [mnt] 권한부여 신청/승인 관리 + +- **요구사항:** Ⅲ-2 권한관리 — 승인 워크플로 +- **목적:** 관리자 권한 신청 → 승인권자 승인/반려 → 이력 자동 기록 +- **핵심 요소:** 상단 통계카드(대기/승인/반려 건수), 검색필터(신청자/신청부서/상태/신청기간), 목록표(신청자, 소속, 요청권한, 신청사유, 신청일, 상태뱃지, 승인자), 행 선택 후 일괄 승인/반려, 반려 시 사유 입력 필수 팝업 + +``` +mnt 관리자 백오피스 스타일로 "권한부여 신청/승인 관리" 화면을 그려줘. +상단에 대기/승인/반려 건수를 보여주는 사각형 통계 카드 3개를 가로로 배치. +그 아래 검색 필터(신청자, 신청부서 드롭다운, 상태 드롭다운, 신청기간 날짜범위, 검색 버튼). +그 아래 표: 체크박스, 번호, 신청자, 소속부서, 요청권한, 신청사유, 신청일, 상태(빨강=대기/파랑=승인/회색=반려 알약형 뱃지), 승인자. +표 하단에 페이지네이션, 우측에 "일괄 승인", "반려" 각진 사각 버튼. +행을 클릭하면 상세 승인 팝업이 뜨고, 팝업 안에는 신청 상세 정보와 반려 사유 입력 textarea, 승인/반려 버튼이 있음. +색상은 포인트 블루 #005ea8, 강조 오렌지 #f1592a, 그림자 없는 각진 스타일. +``` + +### [mnt] 권한변경 이력 조회 (AUTHOR_CHANGE_HISTORY) + +- **요구사항:** Ⅲ-2 권한관리 — 변경 이력 추적 +- **목적:** 누가/언제/어떤 권한을/누구에게/왜 변경했는지 열람 +- **핵심 요소:** 검색필터(대상자/처리자/변경유형/기간), 목록표(변경일시, 대상자, 변경 전 권한, 변경 후 권한, 처리자, 처리사유, IP), 엑셀 다운로드 버튼 + +``` +mnt 관리자 백오피스 스타일로 "권한변경 이력 조회" 화면을 그려줘. +상단 검색 필터: 대상자 이름/ID, 처리자, 변경유형 드롭다운(부여/회수/변경), 기간 날짜범위, 검색 버튼, 우측에 "엑셀 다운로드" 버튼. +그 아래 표: 번호, 변경일시, 대상자, 변경 전 권한, 변경 후 권한, 처리자, 처리사유, 접속 IP. +표 하단 페이지네이션. 그림자 없는 각진 표, 헤더 행은 옅은 파란 배경, 포인트 컬러는 #005ea8. +``` + +### [mnt] 개인정보 접속기록 조회/다운로드 (PERSONAL_DATA_ACCESS_LOG) + +- **요구사항:** Ⅲ-4 접속기록 +- **목적:** 개인정보처리시스템 접속기록(조회/변경/삭제/다운로드) 열람 및 법정 보관기간 내 제공 +- **핵심 요소:** 검색필터(처리자/대상 개인정보주체/처리유형: 조회·다운로드·수정·삭제/기간), 목록표(처리일시, 처리자, 처리자 IP, 대상 개인정보주체, 처리유형뱃지, 처리 메뉴/화면명, 처리결과), 상세보기 팝업, 다운로드(CSV) 버튼, 우측 상단에 "3년 보관, 730일 경과 데이터는 자동 파기 대상" 안내 문구 + +``` +mnt 관리자 백오피스 스타일로 "개인정보 접속기록 조회" 화면을 그려줘. +상단에 작은 안내 배너: "본 기록은 관련 법령에 따라 일정 기간 보관되며 이후 자동 파기됩니다." +검색 필터: 처리자 ID, 대상 개인정보주체 ID, 처리유형 드롭다운(조회/다운로드/수정/삭제), 기간 날짜범위, 검색/전체보기 버튼, 우측에 "CSV 다운로드" 버튼. +표: 번호, 처리일시, 처리자, 처리자 IP, 대상자, 처리유형(색상 알약형 뱃지: 조회=파랑, 다운로드=주황, 수정=초록, 삭제=빨강), 처리 화면/메뉴명, 처리결과. +행 클릭 시 상세 팝업(처리 전/후 값, 사유, 승인여부 표시). +하단 페이지네이션. 각진 표 스타일, 포인트 컬러 #005ea8, 강조 오렌지 #f1592a. +``` + +### [mnt] 접속기록 정기점검 리포트 대시보드 + +- **요구사항:** Ⅲ-4 접속기록 — 정기 점검 +- **목적:** 월별/기간별 접속기록 통계를 요약해 정기 점검 보고서 형태로 확인 +- **핵심 요소:** 상단 요약 통계 카드(총 접속건수, 다운로드 건수, 이상탐지 건수, 미확인 알림 수), 기간별 추이 막대/선 그래프, 처리유형별 비중 도넛/막대, 하단 이상탐지 상세 리스트, "점검 리포트 생성" 버튼(PDF/엑셀 산출) + +``` +mnt 관리자 백오피스 스타일로 "접속기록 정기점검 리포트" 대시보드 화면을 그려줘. +상단에 사각형 통계 카드 4개(총 접속건수, 다운로드 건수, 이상탐지 건수, 미확인 알림 수) 가로 배치, 큰 숫자는 오렌지 #f1592a. +그 아래 좌측에 최근 6개월 접속건수 추이 막대 그래프, 우측에 처리유형별 비중 도넛 차트. +하단에 "이상탐지 상세" 표: 탐지일시, 탐지유형, 대상 계정, 위험도(높음=빨강/중간=주황/낮음=회색 뱃지), 확인여부, 조치 버튼. +우측 상단에 "점검 리포트 생성" 각진 버튼(PDF/엑셀 아이콘). +그림자 없는 각진 카드, 포인트 블루 #005ea8, 데이터 밀도 높은 백오피스 톤. +``` + +### [mnt] 비정상접근 탐지 알림함 (5유형) + +- **요구사항:** Ⅲ-4 접속기록 — 이상행위 탐지 +- **목적:** 동시간대 다중 IP 접속, 심야시간 대량 접속, 대량 조회/다운로드, 반복 인증 실패, 권한 외 화면 접근 등 5개 유형의 이상탐지 알림 확인·조치 +- **핵심 요소:** 좌측 유형별 필터 탭(5유형 + 전체), 목록표(탐지일시, 탐지유형뱃지, 대상 계정, 상세 사유, 위험도, 확인여부), 미확인 항목 강조, 행 클릭 시 상세 팝업(관련 접속기록 원본 연결), "확인 처리"/"계정 임시잠금" 액션 버튼 + +``` +mnt 관리자 백오피스 스타일로 "비정상접근 탐지 알림함" 화면을 그려줘. +좌측에 세로 탭 필터: 전체 / 동시간대 다중IP접속 / 심야시간 대량접속 / 대량 조회·다운로드 / 반복 인증실패 / 권한외 화면접근. +우측 메인에 표: 번호, 탐지일시, 탐지유형(색상 알약형 뱃지), 대상 계정, 상세 사유 텍스트, 위험도(높음=빨강/중간=주황/낮음=회색), 확인여부(미확인 행은 옅은 빨간 배경으로 강조). +행 클릭 시 상세 팝업: 관련 접속기록 원본 리스트, "확인 처리", "계정 임시잠금" 각진 버튼. +표 하단 페이지네이션. 그림자 없는 각진 스타일, 포인트 블루 #005ea8, 위험 강조는 #ed4040. +``` + +### [mnt] 인증수단 관리 설정 (AuthMethodService 3종) + +- **요구사항:** Ⅲ-3 인증체계 +- **목적:** 관리자가 서비스 전체에서 허용할 2차 인증수단(예: OTP 앱, SMS, 이메일 등 3종)을 활성/비활성 설정하고 회원구분별 강제 적용 여부 지정 +- **핵심 요소:** 인증수단 3개를 카드 또는 표 형태로 나열, 각 항목에 토글 스위치(사용/미사용), 회원구분별(개인/그룹/활동처/기관관리자) 강제 적용 체크박스 매트릭스, 저장 버튼 + +``` +mnt 관리자 백오피스 스타일로 "인증수단 관리 설정" 화면을 그려줘. +상단에 인증수단 3개(OTP 앱, SMS 인증, 이메일 인증)를 가로로 나열한 카드형 표, 각 카드에 아이콘, 설명, 우측에 사용/미사용 토글 스위치. +그 아래 표: 행은 회원구분(개인/그룹/활동처/기관관리자), 열은 인증수단 3종, 각 셀에 체크박스로 "이 회원구분에 이 인증수단 강제 적용" 설정. +하단 우측에 "저장" 각진 버튼. 포인트 블루 #005ea8, 각진 표 스타일, 그림자 없음. +``` + +### [mnt] 계정 잠금/휴면계정 관리 확장 + +- **요구사항:** Ⅲ-3 인증체계 — 계정 잠금 5회, 휴면계정 정책 +- **목적:** 기존 잠금해제 관리 화면에 "휴면계정" 탭과 잠금 사유(5회 실패/관리자 수동/휴면전환)를 구분해 표시 +- **핵심 요소:** 상단 탭(잠금 계정 / 휴면 계정), 검색필터(회원구분/잠금·휴면 사유/기간), 목록표(계정, 이름, 최종접속일, 잠금·휴면 사유뱃지, 처리버튼), 일괄 해제 + +``` +mnt 관리자 백오피스 스타일로 "계정 잠금/휴면계정 관리" 화면을 그려줘. +상단에 탭 2개: "잠금 계정" / "휴면 계정". +탭 아래 검색필터: 회원구분 드롭다운, 잠금·휴면 사유 드롭다운(5회 실패/관리자 수동/장기 미접속 휴면전환), 기간, 검색버튼. +표: 체크박스, 번호, 회원구분, 이름, 계정ID, 최종접속일, 사유(알약형 뱃지: 5회실패=빨강, 관리자수동=회색, 휴면전환=주황), 처리상태. +하단에 "선택 해제", "전체 해제" 각진 버튼. 포인트 블루 #005ea8, 각진 표 스타일. +``` + +### [frt] 2차 인증(추가인증) 입력 화면 + +- **요구사항:** Ⅲ-3 인증체계 +- **목적:** 아이디/비밀번호 로그인 성공 후 추가 인증수단(OTP/SMS/이메일) 입력을 요구 +- **핵심 요소:** 중앙 정렬 카드, 안내 문구, 인증수단 선택 탭 또는 라디오, 인증번호 입력창 + 타이머(예: 03:00), 재발송 버튼, 인증 확인 버튼 + +``` +frt 회원 포털의 리뉴얼 디자인 스타일로 "2차 인증(추가인증)" 화면을 그려줘. +중앙에 둥근 모서리(radius 10px)와 부드러운 그림자가 있는 흰색 카드, 폭은 화면 중앙 800px. +카드 상단에 자물쇠 아이콘과 "본인확인을 위해 추가 인증이 필요합니다" 안내 문구(GmarketSans 제목 폰트). +그 아래 인증수단 선택 라디오 3개(OTP 앱 / SMS 인증 / 이메일 인증). +그 아래 인증번호 입력창(둥근 테두리, padding 넉넉)과 우측에 "03:00" 남은시간 타이머, 아래에 작은 "인증번호 재발송" 링크. +하단에 폭 100%, radius 6px, 배경 오렌지(#f1692a) "인증 확인" 버튼. +전체적으로 따뜻하고 신뢰감 있는 공공서비스 톤, 여백 넉넉. +``` + +### [frt] 마이페이지 – 인증수단 등록/관리 + +- **요구사항:** Ⅲ-3 인증체계 +- **목적:** 회원이 마이페이지에서 본인 인증수단을 등록/변경/해제 +- **핵심 요소:** 등록된 인증수단 리스트(카드형), 각 항목에 상태뱃지(등록됨/미등록), 등록·해제 버튼, OTP 등록 시 QR코드 표시 팝업 + +``` +frt 회원 포털의 리뉴얼 디자인 스타일로 마이페이지 내 "인증수단 관리" 화면을 그려줘. +상단 타이틀 "인증수단 관리"(중앙정렬, GmarketSans bold, 32px)와 우측 브레드크럼(홈 > 마이페이지 > 인증수단 관리). +본문 최대폭 1200px 중앙정렬. 그 아래 카드 3개를 세로로 나열(OTP 앱, SMS 인증, 이메일 인증), 각 카드는 둥근 모서리 흰 배경에 그림자. +각 카드 좌측에 아이콘과 인증수단명, 우측에 상태 뱃지(등록됨=파랑 알약형, 미등록=회색)와 "등록" 또는 "해제" 버튼(radius 6px, 오렌지 배경). +OTP 카드의 "등록" 버튼을 누르면 열리는 팝업: QR코드 이미지, 등록 확인용 6자리 코드 입력창, "등록 완료" 버튼. +``` + +### [frt] 계정 잠금 안내 화면 + +- **요구사항:** Ⅲ-3 인증체계 — 로그인 5회 실패 시 잠금 +- **목적:** 로그인 5회 실패로 잠긴 계정에게 사유와 해제 절차 안내 +- **핵심 요소:** 경고 아이콘, 잠금 사유/잠금 해제 가능 시각 안내, 본인인증을 통한 잠금 해제 버튼, 고객센터 안내 + +``` +frt 회원 포털의 리뉴얼 디자인 스타일로 "계정 잠금 안내" 화면을 그려줘. +중앙 정렬 카드(둥근 모서리, 그림자), 상단에 경고 아이콘과 빨간색(#ed4040) "계정이 잠겼습니다" 타이틀. +그 아래 안내 문구: "비밀번호 5회 오류로 계정이 잠금 처리되었습니다." 및 잠금 해제 가능 조건 설명. +중앙에 "본인인증하고 잠금 해제하기" radius 6px 오렌지 버튼. +하단에 옅은 배경 박스로 고객센터 문의 안내(전화번호, 운영시간). +``` + +## 5. 반영 체크리스트 (Stitch 목업 → 실제 구현 시) + +- [ ] mnt 화면은 실제 구현 시 `ui:pagination` 태그, `EgovComAbstractDAO` 기반 iBatis 2 쿼리와 맞물리므로, Stitch가 제안한 표 컬럼 수/정렬이 실제 VO 필드와 일치하는지 확인 +- [ ] 상태 뱃지 색상은 2-1/2-2에 정의된 팔레트를 그대로 사용 (임의 색상 추가 금지) +- [ ] frt 신규 화면은 `new/css/sub/member.css` 등 기존 클래스(`boxR-shadow`, `box-sm`, `form-control`, `btn-login`)를 재사용하고 새 클래스를 남발하지 않는다 +- [ ] 다운로드(CSV/엑셀) 버튼이 포함된 화면은 실제 다운로드 시 개인정보 마스킹/권한 체크 로직이 필요함을 백엔드 설계에 반영 +- [ ] 접근성: 상태를 색상만으로 구분하지 않고 텍스트 라벨을 항상 병기 (색약 사용자 고려) +- [ ] Stitch가 생성한 폰트가 Pretendard/GmarketSans/NanumGothic이 아니면 실제 반영 시 교체