개인정보보호 기능개선 사업 구현 (SFR-001~004, 권한관리·인증체계·접속기록)

권한관리(SFR-001):
- getAuthorities() 구현(EgovUserAuthorityDAO), AuthorityInterceptor 역할별 URL 접근제어
  (COMTNMENUCREATDTLS×COMTNMENUINFO×COMTNPROGRMLIST 메뉴권한 기반, 캐시/TTL, ROLE_ADMIN 바이패스)
- 권한 신청·승인 워크플로 + AUTHOR_CHANGE_HISTORY 이력(3년 보관), 조회·다운로드
계정관리(SFR-002): 계정관리정책, 장기미사용(휴면) 확장(취급자 포함)·사전알림, 계정잠금 5회
인증체계(SFR-003): 로그인 통합 2단계 인증(SecondFactorInterceptor+SecondFactorController),
  OTP QR 등록(ZXing)·비밀키 DB 영속(OTP_DEVICE, AES-GCM), 세션재발급(고정공격 방지), 자동로그아웃 30분
접속기록(SFR-004): PERSONAL_DATA_ACCESS_LOG(월파티션), PersonalDataLogAspect 한정수집,
  SHA-256 해시체인 무결성, 업무유형매핑, IP 취득보정, 비정상접근 탐지, 정기점검 리포트
DB: privacy DDL 11종 + 설치용 통합 SQL(privacy.install_mysql.sql, DDL+시드 DML)
빌드복구: pom.xml 시스템 jar/JAXB 선언, iBatis2 SQL주석 '#'→'No.' 정정

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
infraurp 2026-07-22 18:03:02 +09:00
parent fde7598b48
commit 2a2ab99d10
136 changed files with 10789 additions and 77 deletions

42
CLAUDE.md Normal file
View File

@ -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-* 하네스 구축 이어서 완료 |

76
pom.xml
View File

@ -15,7 +15,7 @@
<repositories> <repositories>
<repository> <repository>
<id>mvn2</id> <id>mvn2</id>
<url>http://repo1.maven.org/maven2/</url> <url>https://repo1.maven.org/maven2/</url>
<releases> <releases>
<enabled>true</enabled> <enabled>true</enabled>
</releases> </releases>
@ -665,6 +665,80 @@
<artifactId>backport-util-concurrent</artifactId> <artifactId>backport-util-concurrent</artifactId>
<version>3.1</version> <version>3.1</version>
</dependency> </dependency>
<!-- 2026 빌드 복구: WEB-INF/lib에는 있으나 pom.xml에 선언 누락되어 컴파일 실패하던 라이브러리 추가 -->
<dependency>
<groupId>com.dreamsecurity</groupId>
<artifactId>dreamCrypto</artifactId>
<version>1.4</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/webapp/WEB-INF/lib/dreamCryptoV1.4.jar</systemPath>
</dependency>
<dependency>
<groupId>eightbyte.safestamp</groupId>
<artifactId>x8byte-safestamp</artifactId>
<version>4.0.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/webapp/WEB-INF/lib/x8byte-safestamp-4.0.0.jar</systemPath>
</dependency>
<dependency>
<groupId>eightbyte.common</groupId>
<artifactId>x8byte-common</artifactId>
<version>4.0.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/webapp/WEB-INF/lib/x8byte-common-4.0.0.jar</systemPath>
</dependency>
<dependency>
<groupId>org.apache.xmlrpc</groupId>
<artifactId>xmlrpc</artifactId>
<version>3.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/webapp/WEB-INF/lib/xmlrpc.jar</systemPath>
</dependency>
<dependency>
<groupId>eltz.apps</groupId>
<artifactId>eltz-keypad</artifactId>
<version>1.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/webapp/WEB-INF/lib/eltz-keypad.jar</systemPath>
</dependency>
<dependency>
<groupId>net.sf.supercsv</groupId>
<artifactId>super-csv</artifactId>
<version>2.4.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/webapp/WEB-INF/lib/super-csv-2.4.0.jar</systemPath>
</dependency>
<dependency>
<groupId>helma</groupId>
<artifactId>helma</artifactId>
<version>1.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/webapp/WEB-INF/lib/helma.jar</systemPath>
</dependency>
<!-- 2026 빌드 복구: JDK11 런타임에는 javax.xml.bind(JAXB)가 제거되어 있어 WAR에 직접 번들링 -->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@ -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).
*
* <p>#13 계정 관리 정책 조회/수정, #11 장기 미사용 계정 현황 조회 백엔드 엔드포인트.
* 화면(JSP) 후속(#11/#13 프론트) 여기서는 jsonView 반환.</p>
*/
@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<DormantAccountStatusVO> list = dormantAccountStatusService.selectDormantStatusList(searchVO);
model.addAttribute("resultList", list);
model.addAttribute("totalCount", Integer.valueOf(searchVO.getTotalRecordCount()));
// TODO 화면 #11 프론트 후속(JSP)
return "jsonView";
}
}

View File

@ -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).
*
* <p>#13/#9 정책 조회·수정, #11 장기 미사용 계정 현황 조회, #10 휴면 사전알림 대상 조회.
* statement는 {@code PrivacyAccountMng_SQL_Mysql.xml}(namespace {@code AccountMngDAO}),
* sql-map-config 등록.</p>
*/
@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<DormantAccountStatusVO> 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<DormantAccountStatusVO> selectDormantAlertTargets(int alertBefore) {
HashMap<String, Object> param = new HashMap<String, Object>();
param.put("alertBefore", Integer.valueOf(alertBefore));
return list("AccountMngDAO.selectDormantAlertTargets", param);
}
}

View File

@ -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;
}

View File

@ -0,0 +1,31 @@
package egovframework.com.cmm.privacy.account;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
/**
* 계정 관리 정책 서비스 구현 (#13/#9).
*
* <p>#9의 휴면 기준일수는 sqlmap 서브쿼리(ACCOUNT_MNG_POLICY.DORMANT_DAY, 미설정 365)
* 직접 적용되며, 서비스는 관리자 설정 화면(#13) 조회/수정을 담당한다.
* 정책 미설정(테이블 empty) 기본값 VO를 반환해 화면·배치가 안전하게 동작한다.</p>
*/
@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);
}
}

View File

@ -0,0 +1,62 @@
package egovframework.com.cmm.privacy.account;
import java.io.Serializable;
/**
* 계정 관리 정책 VO ACCOUNT_MNG_POLICY(단일행) 매핑.
*
* <p>개인정보보호 기능개선 사업(SFR-002, 장기 미사용 계정 관리) 개발항목
* #13(정책 설정) + #9(휴면 기준일수 정책값화).</p>
*
* <pre>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.07.21 URP 최초 생성 (#9/#13)
* </pre>
*/
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; }
}

View File

@ -0,0 +1,15 @@
package egovframework.com.cmm.privacy.account;
import java.util.List;
/**
* 장기 미사용 계정 현황 조회 서비스 (#11).
*/
public interface DormantAccountStatusService {
/** 현황 목록 조회(페이징, totalRecordCount 세팅). */
List<DormantAccountStatusVO> selectDormantStatusList(DormantAccountStatusVO searchVO) throws Exception;
/** 현황 전체 건수. */
int selectDormantStatusTotCnt(DormantAccountStatusVO searchVO) throws Exception;
}

View File

@ -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<DormantAccountStatusVO> 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);
}
}

View File

@ -0,0 +1,66 @@
package egovframework.com.cmm.privacy.account;
import java.io.Serializable;
/**
* 장기 미사용 계정 현황 VO 조회 결과 + 검색조건(#11) + 사전알림 대상(#10).
*
* <p>개인정보보호 기능개선 사업(SFR-002) 개발항목 #11(현황 조회) / #10(사전알림).
* 계정ID·성명·소속·최종로그인일·미접속일수·상태·휴면예정일.</p>
*/
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; }
}

View File

@ -0,0 +1,19 @@
package egovframework.com.cmm.privacy.account;
/**
* 휴면 사전 알림 서비스 (#10).
*
* <p>기존 본인 메일 2단계(유예/확정) 더해, 휴면 예정 <b>D-30 / D-7 / D-Day</b> 3단계
* 사전 알림을 본인 + 관리자에게 발송한다. 알림 시점(D-일수)·관리자 수신주소는
* ACCOUNT_MNG_POLICY(#13)에서 조회한다.</p>
*/
public interface DormantAlertService {
/**
* 정책(ALERT_D1/D2/D3) 해당하는 휴면 사전알림 대상을 조회해 본인·관리자에게 발송한다.
* (배치에서 호출; 개별 발송 실패는 로깅 계속 fail-safe)
*
* @return 발송 건수
*/
int sendDormantPreAlerts() throws Exception;
}

View File

@ -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 + 관리자 알림).
*
* <p>정책(ACCOUNT_MNG_POLICY) ALERT_D1/D2/D3(기본 30/7/0) 대상을 조회해
* 본인과 관리자(ADMIN_EMAIL)에게 사전 알림 메일을 발송한다. 기존 휴면 배치의
* 유예/확정 본인 메일(EgovLoginServiceImpl.processDiapauseList) 독립적으로 동작하며,
* 발송 실패가 배치 본류를 막지 않도록 구간 fail-safe.</p>
*/
@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<Integer> stages = new ArrayList<Integer>();
addStage(stages, policy.getAlertD1());
addStage(stages, policy.getAlertD2());
addStage(stages, policy.getAlertD3());
String adminEmail = policy.getAdminEmail();
int sent = 0;
for (Integer before : stages) {
List<DormantAccountStatusVO> 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<Integer> 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;
}
}
}

View File

@ -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).
*
* <p>PERSONAL_DATA_ACCESS_LOG 표준 컬럼만 SELECT(별도 탐지 테이블 없음, ·변조 방지).
* statement는 이미 등록된 {@code PrivacyAccessLog_SQL_Mysql.xml}
* (namespace {@code PersonalDataAccessLogDAO}) 정의 신규 sqlmap 등록 불요.</p>
*
* @author arko-privacy-auditlog (Fork B - 6차)
* @since 2026-07-21
*/
@Repository("abnormalAccessDetectionDAO")
public class AbnormalAccessDetectionDAO extends EgovComAbstractDAO {
/** ① 비인가접근시도(처리결과 F) 총건수. */
public int countUnauthorized(Map<String, Object> param) {
Object o = selectByPk("PersonalDataAccessLogDAO.detectUnauthorized", param);
return o == null ? 0 : ((Integer) o).intValue();
}
/** ② 대량조회 의심 접속자 목록({conectId, cnt}). */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> selectMassQuery(Map<String, Object> param) {
return list("PersonalDataAccessLogDAO.detectMassQuery", param);
}
/** ③ 비정상시간대접속 총건수. */
public int countOffHours(Map<String, Object> param) {
Object o = selectByPk("PersonalDataAccessLogDAO.detectOffHours", param);
return o == null ? 0 : ((Integer) o).intValue();
}
/** ④ 계정공유의심 접속자 목록({conectId, ipCnt}). */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> selectAccountSharing(Map<String, Object> param) {
return list("PersonalDataAccessLogDAO.detectAccountSharing", param);
}
/** ⑤ 인증실패반복 접속자 목록({conectId, cnt}). */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> selectRepeatedAuthFailure(Map<String, Object> param) {
return list("PersonalDataAccessLogDAO.detectRepeatedAuthFailure", param);
}
}

View File

@ -0,0 +1,35 @@
package egovframework.com.cmm.privacy.auditlog;
import java.util.List;
/**
* 비정상 접근 탐지 서비스 (#35, SER-002).
*
* <p>기간 접속기록을 5유형(비인가/대량조회/비정상시간대/계정공유/인증실패반복)으로 탐지한다.
* 정기점검 리포트(#43) 이상징후 집계와 비정상접근 알림함(#49)에서 사용한다.</p>
*
* @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<AbnormalAccessResultVO> 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;
}

View File

@ -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).
*
* <p>임계치는 {@link AbnormalAccessPolicy}(기본값 + 정책 주입점)에서 취득한다.
* 탐지는 PERSONAL_DATA_ACCESS_LOG 표준 컬럼 집계로만 수행하며 개인정보 본문을 읽지 않는다.</p>
*
* @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<AbnormalAccessResultVO> detect(String fromDt, String toDt) throws Exception {
Map<String, Object> p = new HashMap<String, Object>();
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<AbnormalAccessResultVO> results = new ArrayList<AbnormalAccessResultVO>();
// 비인가접근시도
int unauth = abnormalAccessDetectionDAO.countUnauthorized(p);
results.add(new AbnormalAccessResultVO(AbnormalAccessType.UNAUTHORIZED_ACCESS, unauth,
unauth + ""));
// 대량조회(의심 접속자 목록)
List<Map<String, Object>> 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<Map<String, Object>> sharing = abnormalAccessDetectionDAO.selectAccountSharing(p);
results.add(new AbnormalAccessResultVO(AbnormalAccessType.ACCOUNT_SHARING, size(sharing),
offenders(sharing, "ipCnt", "IP")));
// 인증실패반복(의심 접속자 목록)
List<Map<String, Object>> 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<AbnormalAccessResultVO> 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<Map<String, Object>> list) {
return list == null ? 0 : list.size();
}
/** 상위 몇 명의 접속자ID + 값을 요약(개인정보 본문 미포함, 최대 5명). */
private static String offenders(List<Map<String, Object>> list, String valKey, String unit) {
if (list == null || list.isEmpty()) {
return "0명";
}
StringBuilder sb = new StringBuilder();
int shown = 0;
for (Map<String, Object> 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();
}
}

View File

@ -0,0 +1,44 @@
package egovframework.com.cmm.privacy.auditlog;
/**
* 비정상 접근 탐지 임계치 정책 (#35).
*
* <p>대량조회 임계·업무시간 범위·계정공유 IP수·인증실패 반복 임계를 담는다.
* 기본값을 제공하되, 관리자 설정 화면/정책 테이블 연동 값을 주입·교체한다.
* (임계치 하드코딩 금지 원칙 현재는 기본값 + setter 주입점.)</p>
*
* @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; }
}

View File

@ -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; }
}

View File

@ -0,0 +1,39 @@
package egovframework.com.cmm.privacy.auditlog;
/**
* 비정상 접근 탐지 5유형 (#35, SER-002).
*
* <p>개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 표준 컬럼 집계로 탐지한다.
* 유형의 임계치는 하드코딩하지 않고 정책값({@link AbnormalAccessPolicy})으로 주입한다.</p>
*
* @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;
}
}

View File

@ -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).
*
* <p>INSERT/SELECT 전용(·변조 방지). statement는 이미 sql-map-config에 등록된
* {@code PrivacyAccessLog_SQL_Mysql.xml}(namespace {@code PersonalDataAccessLogDAO})
* 함께 정의되어 있다 신규 sqlmap 등록 불요.</p>
*
* @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<String, Object> param) {
Object cnt = selectByPk("PersonalDataAccessLogDAO.countDownloadByPeriod", param);
return cnt == null ? 0 : ((Integer) cnt).intValue();
}
/** 기간 내 다운로드 이력 목록. */
@SuppressWarnings("unchecked")
public List<AccessLogDownloadHistoryVO> selectByPeriod(Map<String, Object> param) {
return list("PersonalDataAccessLogDAO.selectDownloadHistoryByPeriod", param);
}
}

View File

@ -0,0 +1,59 @@
package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable;
/**
* 개인정보 접속기록 다운로드 이력 VO (#42, SER-002).
*
* <p>다운로드 사유(필수)·다운로드자·일시·IP·건수 이력. 개인정보 본문 미보관
* (검색조건 요약만). PDAL_DOWNLOAD_HISTORY 매핑.</p>
*
* @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; }
}

View File

@ -0,0 +1,28 @@
package egovframework.com.cmm.privacy.auditlog;
import java.util.List;
/**
* 개인정보 접속기록 다운로드 서비스 (#42, SER-002).
*
* <p>다운로드 <b>사유 입력이 필수</b>이며(미입력 차단), 다운로드자·일시·IP·사유·건수를
* 이력(PDAL_DOWNLOAD_HISTORY) 적재한다. 반환된 목록은 화면/CSV 생성에 사용한다.</p>
*
* @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<PersonalDataAccessLogVO> downloadAccessLog(String downloaderId, String downloaderIp,
String reason, PersonalDataAccessLogSearchVO searchVO) throws Exception;
}

View File

@ -0,0 +1,83 @@
package egovframework.com.cmm.privacy.auditlog;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
/**
* 개인정보 접속기록 다운로드 서비스 구현 (#42).
*
* <p>사유 필수 검증 조회 다운로드 이력 적재 순으로 처리한다.
* 사유 미입력 예외로 다운로드 자체를 차단한다.</p>
*
* @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<PersonalDataAccessLogVO> 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<PersonalDataAccessLogVO> 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());
}
}
}

View File

@ -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).
*
* <p><b>목적:</b> 접속기록 적재(직전 해시 조회 봉인 INSERT) 업무 트랜잭션과 분리해
* 개인정보 처리 서비스의 응답시간에 영향을 주지 않게 한다({@link PersonalDataLogAspect}
* {@link #enqueue} 호출하고 즉시 반환).</p>
*
* <p><b>순번·해시체인 무결성(#39):</b> 컨슈머는 <b>단일 스레드</b>. 큐에서 하나씩 꺼내
* {@link PersonalDataLogService#persistNow} 순차 호출하므로 {@code PREV_HASH_VAL} 항상
* 직전 레코드를 정확히 가리킨다. 병렬 컨슈머를 두지 않는다(전체 순번 보장).</p>
*
* <p><b>드롭 금지·백프레셔:</b> 큐가 가득 차면 레코드를 버리지 않고 {@code offer(timeout)}
* 대기(백프레셔)한다. enqueue는 업무 트랜잭션 커밋 이후(Aspect의 finally) 호출되므로 짧은
* 대기가 업무 데이터 정합성을 해치지 않는다.</p>
*
* <p><b>종료 flush:</b> 컨테이너 종료 잔여 큐를 모두 적재한 스레드를 정리한다(유실 방지).</p>
*
* @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<PersonalDataAccessLogVO> queue =
new LinkedBlockingQueue<PersonalDataAccessLogVO>(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<PersonalDataAccessLogVO> remaining = new ArrayList<PersonalDataAccessLogVO>();
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 완료.");
}
}
}

View File

@ -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 취득 보정 유틸리티.
*
* <p>문화품앗이 개인정보보호 기능개선 사업(SFR-004 / SER-002) 개발항목 #34.</p>
*
* <p><b>AS-IS 격차:</b> 기존 {@code IpObtainInterceptor} {@code request.getRemoteAddr()}
* 사용하고, 그나마 <b>인증된 사용자(loginVO != null) 때만</b> IP를 세팅한다.
* Apache+L4 이중구성에서는 레코드가 L4 IP로 고정되고, 비인가 접근 시도의 IP는 남지 않는다.</p>
*
* <p><b>보정 규칙:</b> 프록시 계층이 전달하는 헤더를 우선순위대로 확인하되,
* <b>직접 접속지({@code getRemoteAddr()}) 신뢰 프록시 대역일 때만</b> 헤더를 신뢰한다
* (헤더 위조 방지). 신뢰 프록시가 아니면 헤더를 무시하고 원격 주소를 사용한다.
* 인증 여부와 무관하게 취득한다.</p>
*
* <pre>
* 확인 순서: X-Forwarded-For Proxy-Client-IP WL-Proxy-Client-IP
* HTTP_CLIENT_IP HTTP_X_FORWARDED_FOR getRemoteAddr()
* </pre>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.07.21 URP 최초 생성 (개인정보보호 기능개선 사업 #34)
* </pre>
*/
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<String> trustedProxyCidrs = new ArrayList<String>(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<String> cidrs) {
if (cidrs != null && !cidrs.isEmpty()) {
trustedProxyCidrs = new ArrayList<String>(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
}
}

View File

@ -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 해시체인 서비스.
*
* <p>문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #39 (DBR-001).
* 레코드 적재 직전 레코드의 해시({@code PREV_HASH_VAL}) 결합해 현재 레코드
* 해시({@code HASH_VAL}) 생성함으로써, 임의의 레코드 ·변조/삭제 이후 체인이
* 전부 불일치하도록 만든다.</p>
*
* <h3>해시 결합 규칙 (DDL 컬럼과 정확히 일치)</h3>
* <pre>
* payload = CONECT_ID | CONECT_DT | CONECT_IP | JOB_SE_CODE | TRGET_ID | DML_GUBUN | PREV_HASH_VAL
* HASH_VAL = SHA-256(payload) 소문자 Hex
* </pre>
* <ul>
* <li>구분자는 파이프({@code |}) 고정. 필드 null 문자열로 치환.</li>
* <li>필수 5항목(ID/일시/IP/업무유형/처리대상) + 처리구분 + 직전 해시를 결합한다.</li>
* <li>{@code CONECT_DT} DB 적재 문자열과 동일 형식이어야 재검증 해시가 일치한다.</li>
* </ul>
*
* <h3>순번 보장 전제</h3>
* 비동기 컨슈머가 <b>단일 스레드로 순차 INSERT</b>해야 {@code PREV_HASH_VAL} 정확히
* 직전 레코드를 가리킨다. 병렬 적재가 필요하면 {@code CONECT_ID} 파티션 단위 체인으로
* 설계를 조정한다.
*
* <p> 클래스는 외부 라이브러리 의존 없이 {@link java.security.MessageDigest} 사용한다.
* 무상태(stateless)이며 스레드-세이프하다.</p>
*
* @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<PersonalDataAccessLogVO> 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;
}
/**
* 단위 검증용 데모. 정상 체인 검증 중간 레코드 변조 재검증 해당 인덱스 탐지 확인.
* <pre>
* javac HashChainService.java PersonalDataAccessLogVO.java
* java egovframework.com.cmm.privacy.auditlog.HashChainService
* </pre>
*/
public static void main(String[] args) {
HashChainService svc = new HashChainService();
java.util.List<PersonalDataAccessLogVO> chain = new java.util.ArrayList<PersonalDataAccessLogVO>();
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));
}
}

View File

@ -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.
*
* <p>문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #32.</p>
*
* @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<JobMappingVO> selectJobMappingList() {
return list("JobMappingDAO.selectJobMappingList", null);
}
}

View File

@ -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).
*
* <p>PDAL_JOB_MAPPING 테이블을 메모리에 캐시하고, 접속기록 Aspect(#31) 가로챈
* (대상클래스, 메서드명) 조합을 업무코드/업무명으로 해석한다.</p>
*
* <p>매칭 규칙: USE_AT='Y' 매핑을 SORT_ORDR 오름차순으로 순회하여, 대상 클래스가
* 일치(단순명 또는 FQN)하고 메서드명이 METHOD_PTTRN으로 시작(또는 '*')하는 매핑을 반환.
* 구체 규칙(낮은 SORT_ORDR) 폴백('*', 높은 SORT_ORDR)보다 우선한다.</p>
*
* @author arko-privacy-auditlog (Fork B)
* @since 2026-07-21
*/
@Component("jobMappingResolver")
public class JobMappingResolver {
@Resource(name = "jobMappingDAO")
private JobMappingDAO jobMappingDAO;
/** 캐시(불변 스냅샷 참조 교체 방식). */
private volatile List<JobMappingVO> cache;
/**
* (대상클래스 단순명, 메서드명) 매칭되는 업무유형 매핑을 반환한다.
* 매칭이 없으면 null (호출측이 메서드명 기반 기본값으로 폴백).
*/
public JobMappingVO resolve(String targetSimpleClassName, String methodName) {
if (targetSimpleClassName == null || methodName == null) {
return null;
}
List<JobMappingVO> 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<JobMappingVO> getCache() {
List<JobMappingVO> local = this.cache;
if (local == null) {
synchronized (this) {
if (this.cache == null) {
List<JobMappingVO> loaded = null;
try {
loaded = jobMappingDAO.selectJobMappingList();
} catch (Exception e) {
// 매핑 로드 실패는 접속기록 수집 자체를 막지 않는다(메서드명 폴백으로 동작).
loaded = null;
}
this.cache = (loaded == null) ? new ArrayList<JobMappingVO>() : 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/saveI, update/modify/changU, delete/removD, 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";
}
}

View File

@ -0,0 +1,62 @@
package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable;
/**
* 개인정보 처리 업무유형 매핑(PDAL_JOB_MAPPING) VO.
*
* <p>문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #32 (SFR-004 / SER-002).
* 접속기록 수집({@link PersonalDataLogAspect}) 가로챈 서비스 메서드를
* 업무코드/업무명으로 해석하는 매핑 .</p>
*
* @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; }
}

View File

@ -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.
*
* <p>문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #31 (SFR-004 / DBR-001 / SER-002).
* <b>애플리케이션 계층에는 INSERT/SELECT만 정의한다 UPDATE/DELETE statement는 두지 않는다</b>
* (유일한 삭제 경로는 #37 파티션 DROP 배치). ·변조 방지 원칙(DBR-001).</p>
*
* @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(제네시스).
*
* <p>순번 보장 전제: 단일 스레드 순차 적재(서비스 recordAccess 동기화). #33 비동기 단일컨슈머 대체 예정.</p>
*/
public String selectLastHashVal() {
return (String) selectByPk("PersonalDataAccessLogDAO.selectLastHashVal", null);
}
}

View File

@ -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).
*
* <p>SELECT 전용(·변조 방지 원칙: 테이블에 UPDATE/DELETE statement 미정의).
* statement는 이미 sql-map-config에 등록된 {@code PrivacyAccessLog_SQL_Mysql.xml}
* (namespace {@code PersonalDataAccessLogDAO}) 추가되어 있다 신규 sqlmap 등록 불요.</p>
*
* @author arko-privacy-auditlog (Fork B - 5차)
* @since 2026-07-21
*/
@Repository("personalDataAccessLogSearchDAO")
public class PersonalDataAccessLogSearchDAO extends EgovComAbstractDAO {
/** 복합 검색조건 + 페이징 목록 조회. */
@SuppressWarnings("unchecked")
public List<PersonalDataAccessLogVO> 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();
}
}

View File

@ -0,0 +1,34 @@
package egovframework.com.cmm.privacy.auditlog;
import java.util.List;
/**
* 개인정보 접속기록 조회·검색 서비스 (#41).
*
* <p>문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002).
* 접속자 ID·업무유형·처리대상·IP·처리구분·처리결과·기간 복합 검색 + 페이징 조회를 제공한다.</p>
*
* @author arko-privacy-auditlog (Fork B - 5차)
* @since 2026-07-21
*/
public interface PersonalDataAccessLogSearchService {
/**
* 검색조건에 맞는 접속기록 목록을 페이징 조회한다.
* (검색 결과 건수는 {@link PersonalDataAccessLogSearchVO#getTotalRecordCount()} 세팅)
*
* @param searchVO 검색조건·페이징
* @return 접속기록 목록
* @throws Exception 조회 오류
*/
List<PersonalDataAccessLogVO> selectAccessLogList(PersonalDataAccessLogSearchVO searchVO) throws Exception;
/**
* 검색조건에 맞는 접속기록 전체 건수.
*
* @param searchVO 검색조건
* @return 건수
* @throws Exception 조회 오류
*/
int selectAccessLogListTotCnt(PersonalDataAccessLogSearchVO searchVO) throws Exception;
}

View File

@ -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<PersonalDataAccessLogVO> 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);
}
}

View File

@ -0,0 +1,84 @@
package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable;
/**
* 개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 조회·검색 조건 VO (#41).
*
* <p>문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002) 조회·검색 개선.
* 현행 접속기록 조회는 검색조건이 빈약(수단 LIKE + 날짜뿐)하여 <b>접속자 ID 검색이 불가</b>했다.
* VO는 접속자 ID·업무유형·처리대상·IP·처리구분·처리결과·기간을 복합 검색조건으로 제공하며,
* {@code IDX_PDAL_01(CONECT_ID, CONECT_DT)} 인덱스로 접속자 ID 검색 결함을 해소한다.</p>
*
* <p>화면(JSP) 작업 범위 밖이다 백엔드까지 구현. (TODO: 화면 #47 연계)</p>
*
* @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; }
}

View File

@ -0,0 +1,86 @@
package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable;
/**
* 개인정보 접속기록 표준 테이블(PERSONAL_DATA_ACCESS_LOG) VO.
*
* <p>문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #30/#39 (SFR-004 / DBR-001 / SER-002).
* 해시체인({@link HashChainService}) 해시 대상 필드를 정의한다.</p>
*
* <p>주의: {@code conectDt} DB에 적재되는 문자열 표현과 동일한 형식
* (기본 {@code yyyy-MM-dd HH:mm:ss})으로 세팅해야 무결성 재검증 해시가 일치한다.</p>
*
* @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; }
}

View File

@ -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).
*
* <p><b>한정 수집:</b> {@code COMTNSYSLOG} select* 서비스 대상 방식이 아니라,
* 개인정보 처리 서비스로 한정한 pointcut(스키마 기반 {@code <aop:config>}, context-syslogaop.xml)에만
* 적용된다. 가로챈 메서드는 업무유형 매핑 테이블({@link JobMappingResolver}, #32) 업무코드를 얻고,
* 접속 IP({@link ClientIpResolver}, #34) 해시체인({@link HashChainService}, #39) 결합해
* {@code PERSONAL_DATA_ACCESS_LOG} 적재한다.</p>
*
* <p><b>본문 미적재 원칙:</b> 처리 파라미터(개인정보 본문) 로그에 담지 않는다. 식별자(TRGET_ID)
* 베스트에포트로 취득한다(#36).</p>
*
* <p><b>수집 범위(#36):</b> 회원·업체 2개 지점 한정에서 개인정보 처리 서비스로 확장.
* pointcut 대상은 {@code context-syslogaop.xml} 개인정보 취급 서비스 impl만 열거(과수집 방지).
* 신규 화면 추가 시에는 pointcut(또는 대상 서비스에 편입) 업무유형 매핑({@link JobMappingResolver})
* 갱신하면 되고 컨트롤러 수동 호출은 불요하다.</p>
*
* <p><b>TRGET_ID 취득 규약(#36):</b> 가로챈 메서드 인자에서 <u>식별자만</u> 베스트에포트로 추출한다.
* 인자가 VO/도메인 빈이면 우선순위 getter({@code getEsntlIdgetUniqIdgetMberIdgetEmplyrId
* getEntrprsIdgetGroupIdgetUserIdgetIdgetSn}) non-blank . 인자가 {@code Map}이면
* 동일 우선순위 . 원시 {@code String}/{@code Number} 인자는 식별자·검색어 구분이 불가하므로
* <b>스킵</b>(검색어·개인정보 본문 오적재 방지). 어느 것도 얻으면 {@code null}(fail-safe).
* 처리대상 건수(TRGET_CN) 반환값이 {@code Collection}이면 "N건"으로 기록한다.</p>
*
* <p>스키마 기반 AOP(@AspectJ 미사용) {@code aspectj-autoproxy} 전역 활성화하지 않는다
* 기존 프로젝트 프록시 동작에 영향을 주지 않기 위함.</p>
*
* @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 <aop:around>} 대상 메서드.
* 업무는 정상 수행하고, 성공/예외 여부와 무관하게 접속기록을 남긴다(기록 실패는 업무에 영향 없음).
*/
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;
}
}

View File

@ -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}이다.
* 동기 메서드는 큐를 우회하므로 <b>비동기 컨슈머와 동시에 호출하면
* 해시체인 순번(단일 라이터 전제) 깨질 있다.</b> 호환용으로만 남긴다.
* @param vo 적재 대상(해시 제외 필드 세팅되어 있어야 )
*/
@Deprecated
void recordAccess(PersonalDataAccessLogVO vo);
/**
* 접속기록 1건을 해시체인으로 봉인해 실제로 DB에 적재한다(직전 해시 조회봉인INSERT).
*
* <p><b>단일 라이터 전제:</b> 메서드는 반드시 접속기록 비동기 큐의
* <b>단일 컨슈머 스레드에서만</b> 호출되어야 {@code PREV_HASH_VAL} 정확히 직전
* 레코드를 가리킨다(#33/#39). 종료 잔여 flush도 컨슈머 정지 단일 스레드로 수행된다.</p>
*
* @param vo 적재 대상(해시 제외 필드 세팅되어 있어야 )
*/
void persistNow(PersonalDataAccessLogVO vo);
}

View File

@ -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).
*
* <p><b>순번 보장(#33):</b> 실제 적재({@link #persistNow}) 접속기록 비동기
* ({@link AccessLogQueue}) <b>단일 컨슈머 스레드</b>에서만 호출된다. 단일 라이터이므로
* "직전 해시 조회 → 봉인 → INSERT" 순서를 지켜 해시체인({@link HashChainService})
* {@code PREV_HASH_VAL} 정확히 직전 레코드를 가리킨다. 방어적으로 {@code synchronized}
* 유지해 종료 flush 예외적 동시 호출에도 순번이 깨지지 않게 한다.</p>
*
* <p>업무 트랜잭션과 분리(#33): Aspect는 큐에 enqueue만 하고 즉시 반환하므로 개인정보 처리
* 서비스의 응답시간에 적재 I/O가 영향을 주지 않는다.</p>
*
* @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);
}
}
}

View File

@ -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 ) 정합.
*
* <pre>
* 개정이력
* 2026.07.21 URP(Fork A 6차) 최초 생성(#3 승인 이력 적재 / #6 조회·다운로드)
* </pre>
*/
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; }
}

View File

@ -0,0 +1,98 @@
package egovframework.com.cmm.privacy.author;
import java.io.Serializable;
/**
* 권한 변경 신청·승인 워크플로 VO (개인정보보호 SFR-001 / #3).
*
* <pre>
* 개정이력
* 2026.07.21 URP(Fork A 6차) 최초 생성
* </pre>
*/
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; }
}

View File

@ -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; }
}

View File

@ -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).
*
* <p>AUTHOR_CHANGE_HISTORY SELECT 전용(UPDATE/DELETE 미정의, ·변조 억제
* 유일 삭제는 #5 보관기간 경과 파기 배치). 다운로드 증빙만 INSERT.</p>
*/
@Repository("authorHistoryDAO")
public class AuthorHistoryDAO extends EgovComAbstractDAO {
/** 권한 변경 이력 목록(대상자·변경자 양방향 검색 + 기간/구분 + 페이징) */
@SuppressWarnings("unchecked")
public List<AuthorChangeHistoryVO> 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);
}
}

View File

@ -0,0 +1,24 @@
package egovframework.com.cmm.privacy.author;
import java.util.List;
/**
* 권한 변경 이력 조회·다운로드 서비스 (개인정보보호 SFR-001 / #6).
*/
public interface AuthorHistoryService {
/** 이력 목록 조회(대상자·변경자 양방향 검색 + 페이징) */
List<AuthorChangeHistoryVO> selectHistoryList(AuthorChangeHistoryVO vo) throws Exception;
/** 이력 목록 건수 */
int selectHistoryListCount(AuthorChangeHistoryVO vo) throws Exception;
/**
* 이력 다운로드용 데이터 추출 + 다운로드 이력 적재.
* <p>다운로드 사유(dwldResn) 필수 미입력 예외로 차단한다.</p>
* @param searchVo 검색조건(다운로드 대상 범위)
* @param download 다운로드 증빙(다운로드자·IP·사유)
* @return 추출된 이력 목록(파일 생성은 화면/컨트롤러 계층에서)
*/
List<AuthorChangeHistoryVO> downloadHistory(AuthorChangeHistoryVO searchVo, AuthorHistDownloadVO download) throws Exception;
}

View File

@ -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<AuthorChangeHistoryVO> 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<AuthorChangeHistoryVO> 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<AuthorChangeHistoryVO> list = authorHistoryDAO.selectHistoryList(searchVo);
// 다운로드 증빙 이력 적재(다운로드자·일시·IP·사유·건수)
download.setDwldCnt(list == null ? 0 : list.size());
authorHistoryDAO.insertDownloadHistory(download);
return list;
}
}

View File

@ -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).
*
* <p>취급자 영역(/mnt) 백엔드 엔드포인트. 응답은 jsonView(화면 JSP는 #46 후속).
* 처리자/신청자 식별자와 IP는 세션 loginVO·ClientIpResolver(#34)에서 취득한다.</p>
*/
@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<AuthorChangeRequestVO> 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<AuthorChangeHistoryVO> 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<AuthorChangeHistoryVO> 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;
}
}

View File

@ -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).
*
* <p>iBatis 2. 권한 반영(COMTNEMPLYRSCRTYESTBS) 이력 적재(AUTHOR_CHANGE_HISTORY)
* 서비스 단일 트랜잭션(egovframework.com..*Impl AOP tx)에서 함께 호출된다.</p>
*
* <pre>
* 개정이력
* 2026.07.21 URP(Fork A 6차) 최초 생성
* </pre>
*/
@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<AuthorChangeRequestVO> 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);
}
}

View File

@ -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<AuthorChangeRequestVO> 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;
}

View File

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

View File

@ -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).
*
* <p>해시체인 재계산을 위해 적재 순서(LOG_ID 오름차순)대로 레코드를 조회한다.
* 테이블에는 UPDATE/DELETE statement를 정의하지 않는다(·변조 방지).</p>
*
* @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<PersonalDataAccessLogVO> selectOrderedChain() throws Exception {
return list("AccessLogIntegrityDAO.selectOrderedChain", null);
}
}

View File

@ -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).
*
* <p>보관정책(730일) 파기 배치가 사용하는 <b>스키마 관리(DDL) 전용</b> DAO다.
* 로그 행에 대한 UPDATE/DELETE 정의하지 않는다(·변조 방지). 파기는 개별 DELETE 아닌
* 파티션 DROP 으로만 수행하며, 미래 파티션은 선행 ADD 확보한다.</p>
*
* <p>파티션명/경계값은 배치가 Java 에서 안전하게 계산(사용자 입력 아님) 전달하므로
* 식별자 치환(iBatis {@code $...$}) 사용한다.</p>
*
* @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<Map<String, Object>> 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<String, String> param) throws Exception {
return update("PersonalDataAccessLogDAO.addPartition", param);
}
/**
* 파기·증설 이력을 기록한다.
* @param param partitionName, boundaryDt, purgeType(DROP/ADD), note
*/
public void insertPurgeHistory(Map<String, String> param) throws Exception {
insert("PersonalDataAccessLogDAO.insertPurgeHistory", param);
}
}

View File

@ -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).
*
* <p>보관기간 경과 이력을 파기하는 단일 DELETE만 제공한다.
* 개별 이력 삭제 statement는 정의하지 않는다(파기 배치가 유일한 삭제 경로).</p>
*
* @author arko-privacy (Fork A - 2차)
* @since 2026-07-21
*/
@Repository("authorHistoryPurgeDAO")
public class AuthorHistoryPurgeDAO extends EgovComAbstractDAO {
/**
* 기준일시(cutoff) 이전(CHG_DT &lt; cutoff) 권한 변경 이력을 파기한다.
*
* @param cutoffDt 파기 기준일시 문자열(yyyy-MM-dd HH:mm:ss). 시각 이전 이력이 대상.
* @return 파기된 건수
* @throws Exception 파기 오류
*/
public int deleteExpired(String cutoffDt) throws Exception {
return delete("AuthorHistoryPurgeDAO.deleteExpired", cutoffDt);
}
}

View File

@ -0,0 +1,42 @@
package egovframework.com.cmm.privacy.batch;
import java.io.Serializable;
/**
* 접속기록 해시체인 무결성 검증(#40) 결과.
*
* <p>정기점검 리포트(#43) 관리자 통보에 사용한다.</p>
*
* @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; }
}

View File

@ -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).
*
* <p>전월분 접속기록을 집계(총건수/실패건수/취급자별 처리현황)하고 리포트 1행을 적재한다.
* 집계 대상 statement는 이미 sql-map-config에 등록된 {@code PrivacyAccessLog_SQL_Mysql.xml}
* (namespace {@code PersonalDataAccessLogDAO}) 함께 정의되어 있다 신규 sqlmap 등록 불요.
* PERSONAL_DATA_ACCESS_LOG 에는 SELECT 수행(·변조 방지), 적재는 PDAL_INSPECTION_REPORT 대상.</p>
*
* @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<String, Object> summarizeAccessLogByPeriod(Map<String, Object> param) {
return (Map<String, Object>) selectByPk("PersonalDataAccessLogDAO.summarizeAccessLogByPeriod", param);
}
/**
* 기간 취급자별(CONECT_ID·JOB_SE_CODE) 처리현황을 집계한다.
*
* @param param fromDt/toDt 담은 Map
* @return {conectId, jobSeCode, procCnt} 목록
*/
@SuppressWarnings("unchecked")
public List<Map<String, Object>> summarizeByHandler(Map<String, Object> 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<InspectionReportVO> selectInspectionReportList(Map<String, Object> 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);
}
}

View File

@ -0,0 +1,103 @@
package egovframework.com.cmm.privacy.batch;
import java.io.Serializable;
/**
* 개인정보 접속기록 정기점검 리포트 VO (#43).
*
* <p> 1회 정기점검 배치가 전월분을 집계해 {@code PDAL_INSPECTION_REPORT} 1행으로 적재한다.
* INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER 배치가 아닌 점검자가 화면(#48)에서 입력한다.</p>
*
* @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; }
}

View File

@ -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).
*
* <p>취급자 영역(/mnt) 백엔드 엔드포인트. 응답은 jsonView(화면 JSP는 #48 대시보드 후속).
* 점검자 식별자는 세션 loginVO 에서 취득한다.</p>
*
* @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<String, Object> param = new HashMap<String, Object>();
param.put("rptYmPrefix", rptYmPrefix);
param.put("inspectedYn", inspectedYn);
List<InspectionReportVO> 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;
}
}

View File

@ -0,0 +1,34 @@
package egovframework.com.cmm.privacy.batch;
import java.util.List;
import java.util.Map;
/**
* 정기점검 결과 입력·보관 서비스 (#44, SFR-004/SER-002).
*
* <p> 1회 배치(#43) 생성한 {@code PDAL_INSPECTION_REPORT} 리포트에 대해
* 점검자가 점검결과·조치사항을 입력·보관해 "월 1회 이상 점검 내역" 증빙한다.
* 리포트 집계값(배치 적재분) 수정하지 않고, 점검자 입력 필드
* (INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER/INSPECT_DT) 갱신한다.</p>
*
* @author arko-privacy-auditlog (Fork C - 7차)
* @since 2026-07-21
*/
public interface InspectionResultService {
/** 정기점검 리포트 월별 목록 조회(연월 역순). param: rptYmPrefix/inspectedYn */
List<InspectionReportVO> selectReportList(Map<String, Object> 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;
}

View File

@ -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).
*
* <p>{@code *Impl} + {@code throws Exception} 이므로 context-transaction.xml
* AOP advisor(REQUIRED·rollback-for=Exception) 트랜잭션을 적용한다.</p>
*
* @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<InspectionReportVO> selectReportList(Map<String, Object> 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);
}
}

View File

@ -0,0 +1,66 @@
package egovframework.com.cmm.privacy.batch;
/**
* 개인정보보호 정기 배치 서비스.
*
* <p>문화품앗이 개인정보보호 기능개선 사업 1회 정기 배치 진입점.</p>
* <ul>
* <li>#5 권한 변경 이력(AUTHOR_CHANGE_HISTORY) 보관기간 경과분 파기 (SFR-001)</li>
* <li>#40 개인정보 접속기록(PERSONAL_DATA_ACCESS_LOG) 해시체인 무결성 검증 (DBR-001)</li>
* <li>#37 개인정보 접속기록 보관정책(730일) 파티션 파기 + 미래 파티션 선행 증설 (SFR-004/DBR-001)</li>
* </ul>
*
* <p>실제 스케줄 트리거는 {@code kr.or.arko.cmm.batch.BatchScheduler} {@code @Scheduled}
* 메서드가 담당하며, 서비스는 트랜잭션 경계·로깅·관리자 통보를 책임진다.</p>
*
* @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).
*
* <p>보관기간(기본 730일) 경과 파티션을 {@code ALTER TABLE ... DROP PARTITION} 으로 파기하고
* (개별 DELETE 미사용), 적재 누락 방지를 위해 미래 파티션을 선행 증설한다. 수행 내역은
* {@code PDAL_PURGE_HISTORY} 파기 증빙으로 기록한다. {@code COMTNSYSLOG} 7일 삭제정책은
* 테이블에 절대 적용하지 않는다(분리 원칙 #38).</p>
*
* @return 파기(DROP) 파티션
* @throws Exception 파기/증설 오류
*/
int purgeExpiredAccessLogPartitions() throws Exception;
/**
* 정기점검 리포트 생성 배치 (#43).
*
* <p>전월분 접속기록을 집계해 {@code PDAL_INSPECTION_REPORT} 1행으로 적재한다.
* 총건수·실패(F)건수, 해시체인 무결성 검증결과(#40 재사용), 취급자별 처리현황을 담는다.
* 이상징후 5유형(#35)·다운로드 이력(#42) 미구현이라 자리표시(요약/0) 적재하며,
* 점검자·점검결과·조치사항은 점검자가 화면(#48)에서 별도 입력한다.</p>
*
* @return 생성된 리포트 VO(적재값 스냅샷)
* @throws Exception 집계/적재 오류
*/
InspectionReportVO generateMonthlyInspectionReport() throws Exception;
}

View File

@ -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<PersonalDataAccessLogVO> 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<String, Object> param = new HashMap<String, Object>();
param.put("fromDt", formatDate(from));
param.put("toDt", formatDate(to));
// 1) 총건수·실패(F)건수
Map<String, Object> 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<Map<String, Object>> handlers = inspectionReportDAO.summarizeByHandler(param);
StringBuilder sb = new StringBuilder();
int shown = 0;
if (handlers != null) {
for (Map<String, Object> 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<Map<String, Object>> partitions = accessLogPartitionDAO.selectPartitions(PDAL_TABLE);
int dropped = 0;
if (partitions != null) {
for (int i = 0; i < partitions.size(); i++) {
Map<String, Object> 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<Map<String, Object>> partitions = accessLogPartitionDAO.selectPartitions(PDAL_TABLE);
Set<String> names = new HashSet<String>();
Date maxBoundary = null;
if (partitions != null) {
for (int i = 0; i < partitions.size(); i++) {
Map<String, Object> 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<String, String> param = new HashMap<String, String>();
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<String, String> m = new HashMap<String, String>();
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);
}
}

View File

@ -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).
*
* <p>목적: "메뉴 노출 = URL 접근 허용" 일치시킨다. 관리자 메뉴가 롤별로 다르게 보이는데
* (admin은 많이, 시군구는 적게), <b>메뉴에 보이는 화면을 URL 직접입력으로 들어가는 것을 차단</b>한다.</p>
*
* <p><b>판정 데이터(기존 arko 메뉴권한 DB):</b> {@link PrivacyMenuAuthDAO} 기존 3테이블
* (COMTNMENUCREATDTLS·COMTNMENUINFO·COMTNPROGRMLIST) 조인으로 "롤 → 허용 /mnt/ URL" 전량 로드하고,
* 이를 <b>최초 요청 1회 캐싱</b>(TTL 경과 갱신)한다(요청마다 조인 금지).</p>
*
* <p><b>preHandle 판정 순서:</b>
* <ol>
* <li>{@code /mnt/} · 제외패턴 · 미인증(loginVO==null) 통과(기존 유지)</li>
* <li>명시 매핑({@code urlRoleMapping}) 우선 매칭 해당 필요(신규 privacy 화면 보호 유지)</li>
* <li>바이패스 ({@code bypassRoles}, 기본 ROLE_ADMIN/ROLE_ADMINS) 보유자 통과(관리자 잠금 방지)</li>
* <li>메뉴권한 판정 요청 path(쿼리 제외):
* <ul>
* <li>사용자 롤들의 허용집합에 있으면 통과</li>
* <li>없지만 전체 메뉴경로 합집합(다른 롤엔 있는 화면) 있으면 <b>403 차단</b></li>
* <li>전체 메뉴경로에도 없으면(메뉴 아닌 하위액션/ajax) <b>통과</b>(하위액션 우선 허용 정책)</li>
* </ul>
* </li>
* </ol>
*
* <p><b>운영 안전:</b> 바이패스 (ROLE_ADMIN) 관리자는 항상 통과. DAO/캐시 로드 실패
* 예외를 삼키고 통과(fail-open) + ERROR 로깅 사이트 잠금 방지.</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.07.21 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-001 / #2)
* 2026.07.22 URP 기존 메뉴권한 DB 기반 URL 접근제어로 개편(요건4) 캐시/TTL/바이패스/fail-open
* </pre>
*/
public class AuthorityInterceptor extends HandlerInterceptorAdapter {
private static final Logger LOG = LoggerFactory.getLogger(AuthorityInterceptor.class);
/** 적용 경로 접두(개인정보취급자 영역). 기본 /mnt/ */
private String applyPathPrefix = "/mnt/";
/** 적용 제외 정규식 패턴(로그인/프레임/공통 등). contextPath 제외 URI 대상 */
private List<String> excludePatterns;
/**
* URL(정규식) 필요 권한코드 명시 매핑. 메뉴권한 판정보다 우선한다.
* 신규 privacy 화면(/mnt/privacy/·/mnt/sec/ ROLE_ADMIN) 보호용.
*/
private Map<String, String> urlRoleMapping;
/** 메뉴권한 판정을 건너뛰고 항상 통과시킬 롤(관리자 잠금 방지). 기본 ROLE_ADMIN/ROLE_ADMINS */
private Set<String> bypassRoles;
/** 캐시 유효시간(초). 기본 600초. */
private int cacheTtlSeconds = 600;
/** 메뉴권한 조회 DAO(루트 컨텍스트 @Repository, 자식→부모 가시성으로 주입) */
private PrivacyMenuAuthDAO menuAuthDAO;
// --- 캐시(최초 요청 로드, TTL 경과 갱신) ---
/** 롤 → 허용 path 집합('?' 앞 경로) */
private volatile Map<String, Set<String>> allowedByRole;
/** 모든 롤의 메뉴 path 합집합 */
private volatile Set<String> allMenuPaths;
/** 캐시 적재 시각(ms) */
private volatile long cacheLoadedAt = 0L;
public void setApplyPathPrefix(String applyPathPrefix) { this.applyPathPrefix = applyPathPrefix; }
public void setExcludePatterns(List<String> excludePatterns) { this.excludePatterns = excludePatterns; }
public void setUrlRoleMapping(Map<String, String> urlRoleMapping) { this.urlRoleMapping = urlRoleMapping; }
public void setBypassRoles(Set<String> 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<String> 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<String, Set<String>> byRole;
Set<String> 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<String> 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<String, String> e : urlRoleMapping.entrySet()) {
if (e.getKey() != null && Pattern.matches(e.getKey(), path)) {
return e.getValue();
}
}
return null;
}
/** 바이패스 롤 보유 여부. */
private boolean hasBypassRole(List<String> 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<MenuAuthVO> rows = menuAuthDAO.selectMenuUrlRoles();
Map<String, Set<String>> byRole = new HashMap<String, Set<String>>();
Set<String> allPaths = new HashSet<String>();
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<String> set = byRole.get(role);
if (set == null) {
set = new HashSet<String>();
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);
}
}
}

View File

@ -0,0 +1,50 @@
package egovframework.com.cmm.privacy.interceptor;
import java.io.Serializable;
/**
* 메뉴권한 허용 URL 매핑 (row) VO.
*
* <p>개인정보보호 기능개선 사업(SFR-001 요건4, 역할별 차등 접근 / #2) {@link AuthorityInterceptor}
* 기존 arko 메뉴권한 데이터(COMTNMENUCREATDTLS + COMTNMENUINFO + COMTNPROGRMLIST 조인)
* "롤 → 허용 /mnt/ URL" 캐싱하기 위한 단일 표현.</p>
*
* <ul>
* <li>authorCode : 권한코드(AUTHOR_CODE, : ROLE_ADMIN, ROLE_GUGUN_ADMIN)</li>
* <li>url : 프로그램 URL(COMTNPROGRMLIST.URL). 쿼리스트링 포함 가능
* 인터셉터가 '?' 경로만 취해 캐시에 적재</li>
* </ul>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.07.22 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-001 요건4 / #2)
* </pre>
*/
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;
}
}

View File

@ -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.
*
* <p>개인정보보호 기능개선 사업(SFR-001 요건4, 역할별 차등 접근 / #2) {@link AuthorityInterceptor}
* 최초 요청 1회 전량 로드해 캐싱한다(요청마다 조인 금지). 신규 테이블 없음
* 기존 arko 메뉴권한 3테이블(COMTNMENUCREATDTLS·COMTNMENUINFO·COMTNPROGRMLIST) 조인.</p>
*
* <p>루트 컨텍스트 component-scan(egovframework, {@code @Repository} 포함) 등록되며,
* 자식(servlet) 컨텍스트의 {@code authorityInterceptor} 빈이 자식부모 가시성으로 참조한다.</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.07.22 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-001 요건4 / #2)
* </pre>
*/
@Repository("privacyMenuAuthDAO")
public class PrivacyMenuAuthDAO extends EgovComAbstractDAO {
/**
* 메뉴에 배정된 (권한코드, 프로그램 URL) 전체 목록을 조회한다(파라미터 없음).
* URL은 '/mnt/%' 한정한다. URL에는 쿼리스트링이 포함될 있다.
*
* @return (authorCode, url) 목록. 없으면 목록.
* @throws Exception 조회 오류
*/
@SuppressWarnings("unchecked")
public List<MenuAuthVO> selectMenuUrlRoles() throws Exception {
return list("PrivacyMenuAuthDAO.selectMenuUrlRoles", null);
}
}

View File

@ -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).
*
* <p>개인정보취급자 영역(기본 {@code /mnt/}) 한해, 세션의 2차 인증 완료 표식
* ({@code SECOND_FACTOR_DONE}) 없으면 2차 인증 화면으로 유도한다.
* 인터셉터 등록 순서상 {@link egovframework.com.cmm.privacy.interceptor.AuthorityInterceptor}
* <b>보다 먼저</b> 실행되어야 한다(3단 순서: 인증판정 SecondFactor Authority, #26).</p>
*
* <p><b>운영 안전(사이트 잠금 방지):</b> 프로젝트는 전용 {@code <mvc:interceptors>} 경로매핑이
* 아니라 {@code selectAnnotaionMapper} {@code /**}/*.do 전체에 인터셉터를 적용하므로,
* 적용 범위 판정을 클래스 내부에서 수행한다( 경로는 통과). 또한 2차 인증 화면(#52)
* 인증수단 구현(#18~20) 완료되기 전에는 리다이렉트 대상이 없어 취급자가 잠길 있으므로,
* {@code enforce} 스위치를 기본 {@code false} 두어 <b>등록만 하고 강제하지 않는다</b>.
* 화면·인증수단 완료 {@code enforce=true} + {@code authMethodResolver} 주입으로 활성화한다.</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.07.21 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-003 / #14)
* </pre>
*/
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<String> excludePatterns;
/** 취급자별 인증수단 조회·분기(#21). enforce=true 시에만 사용, 빈 미주입이면 null 허용 */
private AuthMethodResolver authMethodResolver;
// 적용대상·경로 판정(#15)
/**
* 내부접속 2차 인증 적용 여부(정책 설정, 기본 적용=true).
* <b>외부접속은 값과 무관하게 항상 필수</b>(설정변경 불가) 판정 로직에서 강제.
*/
private boolean applyToInternal = true;
/**
* 내부(사내)접속 판정용 IP 대역(CIDR). 요청 클라이언트 IP가 대역이면 내부접속.
* TODO: 운영/발주기관 확인 실제 사내/전용선 대역으로 확정하고 정책 테이블 연동(#34 신뢰대역과 별개일 있음).
*/
private List<String> internalIpCidrs = new ArrayList<String>(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<String> handlerUserSeCodes = new ArrayList<String>();
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<String> excludePatterns) { this.excludePatterns = excludePatterns; }
public void setAuthMethodResolver(AuthMethodResolver authMethodResolver) { this.authMethodResolver = authMethodResolver; }
public void setApplyToInternal(boolean applyToInternal) { this.applyToInternal = applyToInternal; }
public void setInternalIpCidrs(List<String> internalIpCidrs) {
if (internalIpCidrs != null && !internalIpCidrs.isEmpty()) {
this.internalIpCidrs = new ArrayList<String>(internalIpCidrs);
}
}
public void setHandlerUserSeCodes(List<String> handlerUserSeCodes) {
if (handlerUserSeCodes != null) {
this.handlerUserSeCodes = new ArrayList<String>(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} 대역에 속하면 내부접속. 판정 불가/예외 <b>외부접속으로 간주</b>
* (안전측: 외부는 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;
}
}

View File

@ -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).
*
* <p>iBatis 2 기반. 잠금 해제 이력 적재(insert) 조회(select) 제공한다.
* UPDATE/DELETE statement는 정의하지 않는다(이력 ·변조 억제).</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.07.21 URP 최초 생성 (개인정보보호 SER-001 #24)
* </pre>
*/
@Repository("accountUnlockHistoryDAO")
public class AccountUnlockHistoryDAO extends EgovComAbstractDAO {
/** 잠금 해제 이력 1건 적재 */
public void insertUnlockHistory(AccountUnlockHistoryVO vo) {
insert("AccountUnlockHistoryDAO.insertUnlockHistory", vo);
}
/** 잠금 해제 이력 조회(대상/해제자/기간 검색 — 화면 후속) */
@SuppressWarnings("unchecked")
public List<AccountUnlockHistoryVO> selectUnlockHistoryList(AccountUnlockHistoryVO vo) {
return list("AccountUnlockHistoryDAO.selectUnlockHistoryList", vo);
}
}

View File

@ -0,0 +1,57 @@
package egovframework.com.cmm.privacy.lock;
import java.io.Serializable;
/**
* 계정 잠금 해제 이력 VO 개인정보보호 기능개선 사업(SER-001 / #24).
*
* <p>관리자가 잠긴 계정을 해제할 때마다 해제자·해제일시·대상·사유를 적재한다
* (DB 직접조작 대신 관리자 화면 해제 + 이력화).</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.07.21 URP 최초 생성 (개인정보보호 SER-001 #24)
* </pre>
*/
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; }
}

View File

@ -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;
/**
* 세션 재발급(세션 고정 방지) 유틸리티.
*
* <p>문화품앗이 개인정보보호 기능개선 사업(SFR-003 / SER-001) 개발항목 #16.</p>
*
* <p>2차 인증 성공 <b>권한 상승 시점</b> 기존 세션 속성을 보존한
* 세션을 무효화({@code invalidate})하고 세션을 재발급한다. 이로써 로그인 이전에
* 발급된 세션 ID를 그대로 재사용하는 <b>세션 고정(Session Fixation)</b> 공격을 차단한다.</p>
*
* <p><b>사용 지점(후속):</b> {@code SecondFactorInterceptor}(#14) 2차 인증 성공 콜백에서 호출.
* 인터셉터가 아직 없는 현재는 재사용 가능한 static 유틸로만 제공한다.</p>
*
* <pre>
* 흐름: 보존 속성 복사 oldSession.invalidate() newSession = getSession(true) 속성 재적재
* </pre>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.07.21 URP 최초 생성 (개인정보보호 기능개선 사업 #16)
* </pre>
*/
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";
/**
* 기존 세션의 <b>모든 속성</b> 보존한 세션을 재발급한다.
*
* @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<String, Object> preserved = new HashMap<String, Object>();
Enumeration<String> 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<String, Object> 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;
}
}

View File

@ -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;
/**
* 개인정보 <b>표시(display) 전용</b> 마스킹 유틸리티.
*
* <p>2차 인증 화면(#52)·마이페이지·접속기록 조회 등에서 화면에 개인정보를 노출할
* 사용한다. JSP EL/스크립틀릿에서 바로 호출할 있도록 모든 메서드를 {@code static}으로
* 제공한다(: {@code ${egovframework.com.cmm.privacy.util.MaskingUtil.maskPhone(vo.moblphonNo)}}).</p>
*
* <p><b>주의:</b> 유틸은 표시값만 변환한다 원본은 저장·전송·해시체인 적재에 그대로 사용하고,
* 마스킹된 문자열을 다시 저장하거나 로그로 남기지 않는다(마스킹 결과의 역산은 불가).</p>
*
* <p>null/빈값/형식 이상(하이픈 유무, 길이 부족) 예외 없이 방어적으로 처리한다.</p>
*/
public final class MaskingUtil {
private MaskingUtil() {
}
private static final String EMPTY = "";
/**
* 휴대폰/전화번호의 가운데 국번을 마스킹한다. (개인정보 보호 관행: 가운데 4자리)
*
* <pre>
* "01012341234" "010-****-1234"
* "010-1234-1234" "010-****-1234"
* "0212345678" "02-****-5678"
* null/짧은값 원본 그대로(빈값은 빈문자열)
* </pre>
*
* @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자만 노출)
*
* <pre>
* "hong@arko.or.kr" "ho**@arko.or.kr"
* "a@b.com" "a*@b.com"
* 도메인/@ 없는 일부만 노출
* </pre>
*
* @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();
}
/**
* 성명을 마스킹한다. (가운데 글자를 가림)
*
* <pre>
* "홍길동" "홍*동"
* "김철수영" "김**영"
* "이순" "이*"
* 1글자 "*"
* </pre>
*
* @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 + "]");
}
}
}

View File

@ -0,0 +1,35 @@
package egovframework.com.cmm.service.impl;
import java.util.List;
import org.springframework.stereotype.Repository;
/**
* 인증된 사용자의 권한() 목록 조회 DAO.
*
* <p>개인정보보호 기능개선 사업(SFR-001, 역할별 차등 접근) {@code getAuthorities()} 구현용.
* 신규 권한 테이블을 만들지 않고 기존 표준 권한 매핑 테이블(COMTNEMPLYRSCRTYESTBS)
* 사용자 고유식별자(uniqId = ESNTL_ID = SCRTY_DTRMN_TRGET_ID) 조회한다.</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.07.21 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-001)
* </pre>
*/
@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<String> selectAuthoritiesByUniqId(String uniqId) throws Exception {
return list("EgovUserAuthorityDAO.selectAuthoritiesByUniqId", uniqId);
}
}

View File

@ -6,6 +6,7 @@ import java.util.List;
import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.RequestContextHolder;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.service.EgovUserDetailsService; import egovframework.com.cmm.service.EgovUserDetailsService;
import egovframework.rte.fdl.cmmn.AbstractServiceImpl; import egovframework.rte.fdl.cmmn.AbstractServiceImpl;
@ -29,6 +30,13 @@ import egovframework.rte.fdl.cmmn.AbstractServiceImpl;
public class EgovUserDetailsSessionServiceImpl extends AbstractServiceImpl implements public class EgovUserDetailsSessionServiceImpl extends AbstractServiceImpl implements
EgovUserDetailsService { EgovUserDetailsService {
/** 사용자 권한(롤) 조회 DAO — 개인정보보호 기능개선 사업(SFR-001) */
private EgovUserAuthorityDAO userAuthorityDAO;
public void setUserAuthorityDAO(EgovUserAuthorityDAO userAuthorityDAO) {
this.userAuthorityDAO = userAuthorityDAO;
}
public Object getAuthenticatedUser() { public Object getAuthenticatedUser() {
@ -37,11 +45,44 @@ public class EgovUserDetailsSessionServiceImpl extends AbstractServiceImpl imple
} }
/**
* 인증된 사용자의 권한() 목록을 반환한다.
*
* <p>개인정보보호 기능개선 사업(SFR-001, 역할별 차등 접근) 구현.
* 세션의 로그인 정보(LoginVO)에서 고유식별자(uniqId) 얻어 기존 표준 권한 매핑
* 테이블(COMTNEMPLYRSCRTYESTBS) 조회한다 신규 권한 테이블 없음.
* 미인증/미조회 목록을 반환한다(fail-safe: 권한 없음).</p>
*/
public List<String> getAuthorities() { public List<String> getAuthorities() {
// 권한 설정을 리턴한다. // 권한 설정을 리턴한다.
List<String> listAuth = new ArrayList<String>(); List<String> listAuth = new ArrayList<String>();
// 요청 컨텍스트/세션이 없으면 권한 없음
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<String> roleList = userAuthorityDAO.selectAuthoritiesByUniqId(uniqId);
if (roleList != null) {
listAuth.addAll(roleList);
}
} catch (Exception e) {
// 조회 실패 안전측(권한 없음)으로 처리한다.
// TODO: 공통 로깅 체계(EgovLogger ) 연동
}
return listAuth; return listAuth;
} }

View File

@ -270,6 +270,9 @@ private FilterConfig config;
// 2025.07.21 나혁제 urp IP 등록처리 // 2025.07.21 나혁제 urp IP 등록처리
allowList.add("220.118.247.143"); allowList.add("220.118.247.143");
allowList.add("175.198.62.216"); 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) for (String ip : allowList)
{ {

View File

@ -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;
/**
* 개인정보취급자별로 지정된 추가 인증수단 코드를 조회하는 정책 제공자.
*
* <p>{@link AuthMethodResolver} 인터페이스를 통해 취급자별 인증수단을 얻는다.
* 정책 테이블(: {@code AUTH_METHOD_POLICY}) 연동 구현은 후속(#21 후속)이며,
* 그때 인터페이스의 구현체 1개만 교체하면 되도록 리졸버와 분리한다.</p>
*/
public interface AuthMethodPolicyProvider {
/**
* 취급자 고유식별자로 지정 인증수단 코드를 조회한다.
*
* @param uniqId 개인정보취급자 고유식별자(ESNTL_ID)
* @return 인증수단 코드(OTP/SMS/CERT). 미지정 null 가능(리졸버가 기본수단으로 폴백).
* @throws Exception 조회 오류
*/
String resolveAuthMethodCode(String uniqId) throws Exception;
}

View File

@ -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} 구현체로 분기한다.
*
* <p>분기 방식: {@link AuthMethodPolicyProvider} 반환한 인증수단 코드(OTP/SMS/CERT)
* 주입된 {@code serviceMap}(코드 구현체)으로 매핑한다. 신규 인증수단 추가
* 구현체 1개 + 항목 1개만 추가하면 되며 리졸버 소스는 수정하지 않는다.</p>
*
* <p>폴백: 정책상 지정 수단이 해당 취급자에게 사용 불가({@code isAvailable == false})하면
* 우선순위(OTPSMSCERT) 순으로 사용 가능한 수단을 선택한다.</p>
*
* <p><b>주의:</b> 인증수단 구현체(#18~20) 스텁 상태인 동안 {@code isAvailable}
* {@code UnsupportedOperationException} 던진다 리졸버 로직 자체는 완성이며,
* 구현체 완료 정상 동작한다.</p>
*/
public class AuthMethodResolver {
/** 인증수단 코드("OTP"/"SMS"/"CERT") → 구현체. Spring 설정에서 주입. */
private Map<String, AuthMethodService> serviceMap;
/** 취급자별 인증수단 정책 조회기. */
private AuthMethodPolicyProvider policyProvider;
public void setServiceMap(Map<String, AuthMethodService> 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) 폴백 우선순위(OTPSMSCERT) 사용 가능한 수단
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());
}
}

View File

@ -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단계 인증) 공통 서비스 인터페이스.
*
* <p>신규 인증수단 도입 인터페이스의 구현체 1개만 추가하면 되도록 설계한다
* (소스 수정 범위를 신규 클래스 1개로 한정). 취급자별 활성 인증수단은 정책 테이블에서
* 조회하여 {@code AuthMethodResolver} 적절한 구현체로 분기한다.</p>
*
* <p>구현체(적용 순서): {@code OtpAuthService}(1차, TOTP 자체구현)
* {@code SmsAuthService}(2차, KT-TCS/MCSAgent3.0 연계)
* {@code CertAuthService}(3차, GPKI 기존 자산 재활용).</p>
*
* <p><b>인증정보 보호 원칙(SER-003):</b> OTP 비밀키는 암호화 저장하고 화면·로그에 노출하지 않는다.
* SMS 인증번호는 저장하지 않고 유효시간(3분) 검증 폐기한다.
* 공동인증서 본문은 저장하지 않고 식별값만 매핑한다.</p>
*/
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;
}

View File

@ -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 매핑된다.
*
* <p>적용 순서(폴백 우선순위): OTP(1차) SMS(2차) CERT(3차).</p>
*/
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; // 기본수단
}
/**
* 폴백 탐색 우선순위(OTPSMSCERT).
*/
public static AuthMethodType[] priorityOrder() {
return new AuthMethodType[] { OTP, SMS, CERT };
}
}

View File

@ -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차 적용).
*
* <p>기존 자산을 <b>재활용·위임</b>한다:
* {@code EgovLoginServiceImpl.actionCrtfctLogin(LoginVO)}
* {@code LoginDAO.actionCrtfctLogin}
* {@code loginDAO.actionCrtfctLogin} 쿼리(EgovLoginUsr_SQL_Mysql.xml:434,
* {@code COMTNEMPLYRINFO WHERE CRTFC_DN_VALUE = #dn#}).
* 인증서 <b>본문은 저장하지 않고 식별값(DN)</b> COMTNEMPLYRINFO.CRTFC_DN_VALUE에 매핑한다.</p>
*
* <p><b>핸드오프(운영 config, fork 범위 ):</b> 인증서 로그인 실제 활성화는
* {@code CertProcessFilter}(web.xml:46~59 주석 처리) 재활성이 필요하다. 공용 config 충돌·운영
* 영향 때문에 여기서 web.xml을 수정하지 않는다 서비스 계층 위임 코드만 제공하고 필터 재활성은
* 인터셉터 배선(#14·#26)·운영 협의 함께 처리한다.</p>
*/
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) 있는지 판정한다.
* <p>uniqId 기준 매핑 조회는 신규 statement가 필요하므로(접속기록 파이프라인과 config 분리)
* {@link CertMappingChecker} 추상화한다 운영 연동 구현체 1개 교체.</p>
*/
@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;
}
}
}

View File

@ -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 비밀키 <b>암호문</b> OTP_DEVICE 테이블에 저장·조회한다.
*
* <p>{@link InMemoryOtpSecretStore}(메모리, 재기동 유실) 대체한다. 저장소는 암호문만 다루며
* 평문 비밀키는 계층에 존재하지 않는다(SER-003).</p>
*
* <p><b>예외 처리:</b> 기존 계약대로 예외를 삼키지 않고 그대로 던진다 저장/조회 실패 판단은
* 상위({@link OtpAuthService}) 수행한다. (등록 저장 실패가 로그인을 막지 않도록 하는 정책은
* 상위/인터셉터 계층에 이미 있다.)</p>
*/
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;
}
}

View File

@ -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;
/**
* 정책 테이블 연동 이전 단계의 기본 정책 제공자.
*
* <p>모든 취급자에 대해 기본수단(OTP) 반환한다. 실제 취급자별 인증수단은
* 정책 테이블({@code AUTH_METHOD_POLICY}) 연동 (#21 후속) 구현체를 교체한다.</p>
*/
public class DefaultAuthMethodPolicyProvider implements AuthMethodPolicyProvider {
@Override
public String resolveAuthMethodCode(String uniqId) throws Exception {
// TODO: 정책 테이블 연동(#21 후속) 취급자별 인증수단(AUTH_METHOD_POLICY) 조회.
// 정책 미지정 취급자는 기본수단(OTP)으로 처리한다.
return AuthMethodType.OTP.code();
}
}

View File

@ -289,32 +289,40 @@ public class EgovLoginServiceImpl extends AbstractServiceImpl implements
// 휴먼고객 확인후 테이블 입력 // 휴먼고객 확인후 테이블 입력
@Override @Override
public void insertDiapause(LoginVO loginVO) throws Exception { public void insertDiapause(LoginVO loginVO) throws Exception {
loginDAO.insertDiapause(loginVO);
EgovWebUtil egovUtil = new EgovWebUtil(); EgovWebUtil egovUtil = new EgovWebUtil();
List<LoginVO> list = loginDAO.selectDiapauseList(loginVO); // 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<LoginVO> list, EgovWebUtil egovUtil) throws Exception {
if (list == null) {
return;
}
for (int i = 0; i < list.size(); i++) { for (int i = 0; i < list.size(); i++) {
LoginVO vo = list.get(i); LoginVO vo = list.get(i);
HashMap<String, String> map = new HashMap<>(); HashMap<String, String> map = new HashMap<>();
if (vo.getDiapause_stat() == null) { if (vo.getDiapause_stat() == null) {
egovUtil.sendIdDiapauseMail(vo.getEmail(), vo.getCreatDt(), vo.getName(), vo.getId()); egovUtil.sendIdDiapauseMail(vo.getEmail(), vo.getCreatDt(), vo.getName(), vo.getId());
//System.out.println("--------111.휴면 유예 메일을 보냈다.----------");
map.put("id", vo.getId()); map.put("id", vo.getId());
map.put("diapause_stat", "S"); map.put("diapause_stat", "S");
} else if (vo.getDiapause_stat().equals("S")) { } else if (vo.getDiapause_stat().equals("S")) {
egovUtil.sendIdDiapauseStandbyMail(vo.getEmail(), vo.getCreatDt(), vo.getName(), vo.getId()); egovUtil.sendIdDiapauseStandbyMail(vo.getEmail(), vo.getCreatDt(), vo.getName(), vo.getId());
//System.out.println("--------222.휴면 확정 메일을 보냈다.----------");
map.put("id", vo.getId()); map.put("id", vo.getId());
map.put("diapause_stat", "P"); map.put("diapause_stat", "P");
} }
loginDAO.updateDiapauseMber(map); loginDAO.updateDiapauseMber(map);
} }
} }
// 휴먼고객 활성화 // 휴먼고객 활성화

View File

@ -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 비밀키 <b>암호문</b> 메모리에 보관한다.
*
* <p><b>주의:</b> 개발/단위검증용이다. 애플리케이션 재기동 소실되므로 운영에서는
* 신규 비밀키 테이블(EMPLYR_OTP_SECRET) + iBatis2 sqlmap 연동 구현체로 교체한다.
* (테이블/sqlmap 신설·sql-map-config 등록은 접속기록 파이프라인과의 config 충돌 회피를 위해
* 후속 작업으로 분리 TODO: 비밀키 테이블 연동)</p>
*/
public class InMemoryOtpSecretStore implements OtpSecretStore {
private final ConcurrentHashMap<String, String> store = new ConcurrentHashMap<String, String>();
@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);
}
}

View File

@ -164,6 +164,16 @@ public class LoginDAO extends EgovComAbstractDAO {
insert("loginDAO.insertDiapause", loginVO); insert("loginDAO.insertDiapause", loginVO);
} }
// [개인정보보호 #7] 개인정보취급자(COMTNEMPLYRINFO) 휴면 대상 적재
public void insertDiapauseEmplyr(LoginVO loginVO) {
insert("loginDAO.insertDiapauseEmplyr", loginVO);
}
// [개인정보보호 #7] 개인정보취급자 휴면 확인 대상 조회
public List<LoginVO> selectDiapauseListEmplyr(LoginVO loginVO) {
return list("loginDAO.selectDiapauseListEmplyr", loginVO);
}
// 휴먼고객 활성화 // 휴먼고객 활성화
public void deleteDiapause(LoginVO vo) { public void deleteDiapause(LoginVO vo) {
delete("loginDAO.deleteDiapause", vo); delete("loginDAO.deleteDiapause", vo);

View File

@ -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) 표준 알고리즘 <b>자체 구현</b> 인증수단(1차 적용).
*
* <p>알고리즘: HMAC-SHA1, 타임스텝 30초, 6자리. 검증은 시계 오차를 고려해 ±1 스텝 윈도우를 허용한다.
* 외부 라이브러리(Google Authenticator ) 없이 {@code javax.crypto.Mac} 사용한다
* 국정원 검증필 암호모듈 대상 상용 솔루션이 아니므로 도입절차 불요.</p>
*
* <p><b>비밀키 보호(SER-003):</b> 비밀키는 {@link OtpSecretCipher} 암호화하여
* {@link OtpSecretStore} 저장하고, 복호화는 검증 시점으로 한정한다.
* 평문 비밀키·OTP 코드는 화면·로그·예외 메시지에 절대 노출하지 않는다.</p>
*/
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를 반환한다.
* <p>반환된 URI에는 평문 비밀키(Base32) 포함되므로 <b>등록 화면에 1회 표기/QR 렌더링</b> 용도로만
* 사용하고 저장·로깅하지 않는다.</p>
*
* @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") 자체 검증한다.
* 로그·화면 미노출 대상이 아니므로 임시 실행용.
* <pre> T=59s step 1 94287082 8자리, 6자리 절단 287082 </pre>
*/
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)));
}
}

View File

@ -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} 상속한다.
*
* <p>루트 컨텍스트 component-scan(base-package {@code egovframework})으로 {@code @Repository}
* 등록되어 루트의 {@code sqlMapClient}(context-sqlMap.xml)에서 statement를 로드한다.
* DispatcherServlet(자식) 컨텍스트의 {@code otpSecretStore} 빈이 자식부모 가시성으로 DAO를 주입받는다.</p>
*
* <p>iBatis2 MyBatis/{@code #&#123;&#125;} 문법 아님. statement PrivacyOtpDevice_SQL_Mysql.xml(namespace) 참조.</p>
*/
@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();
}
}

View File

@ -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;
}
}

View File

@ -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 비밀키를 <b>가역 암호화(AES-256/GCM)</b>하여 저장하고, 검증 시점에만 복호화한다.
*
* <p>비밀번호 해시(SHA-256, 단방향) 달리 TOTP 비밀키는 검증마다 원본이 필요하므로
* 가역 대칭암호를 사용한다. 평문 비밀키는 발급/검증 순간 메모리에만 존재하며 저장소·화면·로그에
* 절대 노출하지 않는다(SER-003).</p>
*
* <p><b> 관리 TODO:</b> 마스터 키는 현재 환경변수/시스템프로퍼티({@code arko.otp.master.key},
* Base64 16바이트)에서 로드한다. 운영에서는 KMS/보안 저장소 연동으로 교체한다.
* 미설정 개발용 임시 키로 폴백하되 경고를 남긴다(운영 배포 반드시 설정).</p>
*
* <p> 길이는 AES-128(16바이트) 고정한다 구형 JDK의 JCE 제한정책 파일 없이도
* 동작하도록 하기 위함(AES-256은 unlimited policy 필요). 강화 운영 JDK 정책 확인 상향.</p>
*/
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)} 결과를 복호화하여 평문 비밀키를 반환한다.
* <b>검증 시점에만 호출</b>한다.
*/
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");
}
}

View File

@ -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 비밀키(암호문) 보관·조회하는 저장소 계약.
*
* <p>OTP 비밀키는 <b>암호문(AES) 상태로만</b> 저장소를 오간다 평문 비밀키는
* {@link OtpAuthService} 내부에서 발급/검증 순간에만 존재하고 저장소·화면·로그에
* 노출되지 않는다(SER-003).</p>
*
* <p>정책상 저장 위치는 신규 비밀키 테이블(: EMPLYR_OTP_SECRET)이나, 해당 테이블/sqlmap
* 등록은 접속기록 수집 파이프라인과 config 충돌을 피하기 위해 후속으로 분리한다.
* 기본 구현({@link InMemoryOtpSecretStore}) 개발/검증용이며, 운영 반영
* DB 연동 구현체 1개로 교체한다.</p>
*/
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;
}

View File

@ -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 문자발송모듈 연계.
*
* <p><b>연동 규격은 착수 발주기관과 확정</b> 필요(블로커, 개발항목 #19).
* 인증번호는 저장하지 않고 유효시간(3분) 검증 폐기한다.</p>
*
* <p><b>주의:</b> 클래스는 인터페이스 계약 확정을 위한 골격(stub)이다.
* 실제 발송/검증 로직은 개발항목 #19에서 구현한다.</p>
*/
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");
}
}

View File

@ -71,6 +71,9 @@ public class EgovLoginController {
protected static final String JSON = "jsonView"; protected static final String JSON = "jsonView";
/** 개인정보보호(SER-001/#24) 계정 잠금 기본 임계값 — LOGIN_LOCK_COUNT 미설정 시 fallback */
private static final int DEFAULT_LOCK_THRESHOLD = 5;
/** EgovLoginService */ /** EgovLoginService */
@Resource(name = "loginService") @Resource(name = "loginService")
private EgovLoginService loginService; private EgovLoginService loginService;
@ -149,8 +152,6 @@ public class EgovLoginController {
public String actionLogin(@ModelAttribute("loginVO") LoginVO loginVO, public String actionLogin(@ModelAttribute("loginVO") LoginVO loginVO,
HttpServletRequest request, HttpServletRequest request,
ModelMap model)throws Exception { ModelMap model)throws Exception {
System.out.println("로그인");
// Start~ KTH, 2019.12.16 가상키패드 보안 솔루션 적용건 // Start~ KTH, 2019.12.16 가상키패드 보안 솔루션 적용건
String password = request.getParameter("civpad_cipher_value_password"); String password = request.getParameter("civpad_cipher_value_password");
@ -164,17 +165,81 @@ public class EgovLoginController {
loginVO.setPassword(password); loginVO.setPassword(password);
// End~ KTH, 2019.12.16 가상키패드 보안 솔루션 적용건 // End~ KTH, 2019.12.16 가상키패드 보안 솔루션 적용건
// 1. 일반 로그인 처리 // ============================================================
// 개인정보보호(SER-001 / #22·#24) 인증 실패 이력·계정 잠금 활성 경로 이설
// · 기존 로직은 EgovSpringSecurityLoginFilter(운영 조건부)에만 존재해,
// 필터 미사용 경로(actionLogin)에서는 실패이력·잠금이 동작하지 않았다.
// LOGIN_HISTORY / LOGIN_LOCK_COUNT 기존 테이블·statement를 그대로 재사용하고
// 호출 지점만 활성 경로로 이설한다(신규 테이블 없음).
// · 실패이력 처리 오류가 로그인 자체를 막지 않도록 구간 fail-safe(try-catch).
// · 잠금 임계값은 LOGIN_LOCK_COUNT(관리자 설정값), 부재 기본 5회(#24, 105).
// ============================================================
// 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); LoginVO resultVO = loginService.actionLogin(loginVO);
if (resultVO != null && resultVO.getId() != null && !resultVO.getId().equals("")) { 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); 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 { } else {
System.out.println("로그인시도2"); // 2-2. 실패 계정이 존재하는 경우에만 실패 이력 적재 + 임계 초과 잠금
int currentFail = 0;
int threshold = DEFAULT_LOCK_THRESHOLD;
try {
HashMap<String, String> map = new HashMap<String, String>();
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<String, String>());
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")); model.addAttribute("message", egovMessageSource.getMessage("fail.common.login"));
return "egovframework/com/uat/uia/EgovLoginUsr"; return "egovframework/com/uat/uia/EgovLoginUsr";
} }

View File

@ -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).
*
* <p>모든 엔드포인트는 {@code /uat/uia/} 하위에 둔다 {@link SecondFactorInterceptor}
* excludePatterns({@code \A/uat/uia/.*\Z}) 걸려 2차 인증 판정에서 <b>제외</b>되므로
* 리다이렉트 루프가 발생하지 않는다.</p>
*
* <p>흐름:
* <ol>
* <li>{@code /mnt/} 접근 인터셉터가 2차 인증 미완료 취급자를 {@code /uat/uia/secondFactor.do} 유도.</li>
* <li>{@link #secondFactorView}(GET) 배정 수단 안내 + 코드 입력 .</li>
* <li>{@link #secondFactorConfirm}(POST) {@link AuthMethodService#verify} 성공
* {@link SessionRegenerator#onSecondFactorSuccess} 세션 재발급(고정공격 차단, #16) +
* {@code SECOND_FACTOR_DONE=true} 세팅 목적지(또는 {@code /mnt/main.do}) 이동.</li>
* <li>{@link #enrollView}/{@link #enrollConfirm} OTP 비밀키 발급/최초검증(등록 경로).
* 등록이 없으면 아무도 {@code isAvailable=true} 아니어서 2FA가 무력하므로 최소 포함.</li>
* </ol>
* </p>
*
* <p><b>운영 안전(관리자 잠금 방지):</b> 컨트롤러는 취급자를 <b>잠그지 않는다</b>.
* 잠금 회피 판정은 {@link SecondFactorInterceptor} 수행한다(수단 미설정 사용자는 통과).
* 컨트롤러는 인증수단이 준비된 사용자에게 검증 UI만 제공한다.</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.07.22 URP 최초 생성 (개인정보보호 SFR-003 / #52 2차 인증 화면·검증·OTP 등록)
* </pre>
*/
@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) 표시한다.
*
* <p><b>주의:</b> 진입 비밀키가 발급되므로, 표시된 secret으로 인증앱을 설정한
* 반드시 하단 최초검증까지 완료해야 한다. (기본 저장소 {@code InMemoryOtpSecretStore}
* 애플리케이션 재기동 유실되므로 운영에서는 DB 저장소 구현체로 교체한다.)</p>
*/
@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 코드로 인코딩해 <b>자립형 data URI(PNG, Base64)</b> 반환한다.
* <p>외부 리소스/네트워크 없이 서버측(ZXing, 기존 의존성)에서 생성한다 폐쇄망 전제.
* 반환값은 {@code data:image/png;base64,...} 형식으로 {@code <img src>} 그대로 바인딩된다.</p>
*/
private String buildQrDataUri(String content, int size) throws Exception {
QRCodeWriter writer = new QRCodeWriter();
java.util.Map<EncodeHintType, Object> hints = new java.util.HashMap<EncodeHintType, Object>();
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);
}
}

View File

@ -3,10 +3,17 @@ package egovframework.com.uss.umt.service.impl;
import java.util.List; import java.util.List;
import javax.annotation.Resource; import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Service; 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.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.uat.uia.service.impl.LoginDAO;
import egovframework.com.uss.umt.service.DiapauseManageVO; import egovframework.com.uss.umt.service.DiapauseManageVO;
import egovframework.com.uss.umt.service.EgovAccountUnlockManageService; import egovframework.com.uss.umt.service.EgovAccountUnlockManageService;
@ -26,6 +33,10 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp
@Resource(name="loginDAO") @Resource(name="loginDAO")
private LoginDAO loginDAO; private LoginDAO loginDAO;
/** 개인정보보호(SER-001 / #24) 계정 잠금 해제 이력 DAO */
@Resource(name="accountUnlockHistoryDAO")
private AccountUnlockHistoryDAO accountUnlockHistoryDAO;
@Override @Override
public List selectUserLockCheckList(UserDefaultVO userSearchVO) { public List selectUserLockCheckList(UserDefaultVO userSearchVO) {
return diapauseManageDAO.selectUserLockCheckList(userSearchVO); return diapauseManageDAO.selectUserLockCheckList(userSearchVO);
@ -51,6 +62,9 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp
vo.setUser_id(sUser_id[i]); vo.setUser_id(sUser_id[i]);
vo.setUser_gubun(sUser_gubun[i]); vo.setUser_gubun(sUser_gubun[i]);
diapauseManageDAO.updateUnlock(vo); diapauseManageDAO.updateUnlock(vo);
// 개인정보보호(SER-001 / #24) 잠금 해제 이력 적재
recordUnlockHistory(sUser_id[i], sUser_gubun[i], "I");
} }
} }
@ -75,6 +89,50 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp
vo.setUser_gubun("USR"); vo.setUser_gubun("USR");
diapauseManageDAO.updateUnlockAll(vo); diapauseManageDAO.updateUnlockAll(vo);
// 개인정보보호(SER-001 / #24) 전체 잠금 해제 이력 적재(대상 ID는 전체이므로 NULL)
recordUnlockHistory(null, "ALL", "A");
}
/**
* 개인정보보호(SER-001 / #24) 계정 잠금 해제 이력 1건 적재.
* 해제자(관리자) 인증 컨텍스트에서, IP는 ClientIpResolver(#34)에서 취득한다.
* 이력 적재 실패가 해제 처리 자체를 막지 않도록 fail-safe(try-catch).
*
* @param trgetId 해제 대상 계정 ID(전체해제 null)
* @param trgetSe 대상 회원구분(GNR/GRO/ENT/USR/ALL)
* @param unlockTy 해제 유형 I:개별 A:전체
*/
private void recordUnlockHistory(String trgetId, String trgetSe, String unlockTy) {
try {
AccountUnlockHistoryVO hist = new AccountUnlockHistoryVO();
hist.setTrgetId(trgetId);
hist.setTrgetSe(trgetSe);
hist.setUnlockTy(unlockTy);
// 해제 처리자(관리자) ID
Object user = EgovUserDetailsHelper.getAuthenticatedUser();
if (user instanceof LoginVO) {
hist.setUnlockUserId(((LoginVO) user).getId());
}
// 해제자 접속 IP (#34 보정 로직 재사용)
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
HttpServletRequest req = attrs.getRequest();
if (req != null) {
hist.setUnlockIp(ClientIpResolver.getClientIp(req));
}
}
// 사유: 관리자 화면에서 사유 입력 필드 연동은 프론트 후속. 현재는 기본값.
// TODO: 관리자 해제 화면에서 사유(필수) 입력받아 전달(#24 화면 후속)
hist.setUnlockResn("관리자 화면 잠금 해제");
accountUnlockHistoryDAO.insertUnlockHistory(hist);
} catch (Exception e) {
log.error("[개인정보/#24] 잠금 해제 이력 적재 오류(해제는 정상 처리): " + e.getMessage());
}
} }
} }

View File

@ -15,6 +15,9 @@ import org.springframework.stereotype.Component;
import egovframework.com.cmm.EgovWebUtil; import egovframework.com.cmm.EgovWebUtil;
import egovframework.com.cmm.LoginVO; import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.privacy.batch.HashChainVerifyResultVO;
import egovframework.com.cmm.privacy.batch.InspectionReportVO;
import egovframework.com.cmm.privacy.batch.PrivacyBatchService;
import egovframework.com.cntc.service.CntcService; import egovframework.com.cntc.service.CntcService;
import egovframework.com.uat.uia.service.EgovLoginService; import egovframework.com.uat.uia.service.EgovLoginService;
import egovframework.com.uss.umt.service.EgovMberManageService; import egovframework.com.uss.umt.service.EgovMberManageService;
@ -54,6 +57,14 @@ public class BatchScheduler {
@Resource(name = "mberManageService") @Resource(name = "mberManageService")
private EgovMberManageService mberManageService; private EgovMberManageService mberManageService;
/** 개인정보보호 정기 배치 서비스 (#5 권한이력 파기 / #40 접속기록 무결성 검증) */
@Resource(name = "privacyBatchService")
private PrivacyBatchService privacyBatchService;
/** 개인정보보호 휴면 사전알림 서비스 (#10 D-30/D-7/D-Day + 관리자 알림) */
@Resource(name = "dormantAlertService")
private egovframework.com.cmm.privacy.account.DormantAlertService dormantAlertService;
/** /**
* 테스트 배치 예제 * 테스트 배치 예제
*//* *//*
@ -165,4 +176,82 @@ public class BatchScheduler {
loginService.insertDiapause(loginVO); loginService.insertDiapause(loginVO);
} }
/**
* [개인정보보호 #5] 권한 변경 이력(AUTHOR_CHANGE_HISTORY) 파기 배치.
* 매월 1일 04:00, 보관기간(1,095일=3년) 경과 이력만 선별 파기. 개별 삭제 API 미제공.
*
* <p> 스케줄 활성화 주의: 프로젝트는 {@code <task:annotation-driven>}
* context-common.xml/egov-com-servlet.xml 양쪽 모두 주석 처리되어 있어 현재
* {@code @Scheduled} 동작하지 않는다(배치는 OS crontab 운영). 활성화 기존 휴면/메일
* 휴면 배치가 함께 기동되므로, 활성화 여부·경합은 운영과 협의해 결정한다.</p>
*
* @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 적재.
*
* <p> 스케줄 활성화 주의: 전역 {@code <task:annotation-driven>} 비활성 상태라
* 현재 {@code @Scheduled} 동작하지 않는다(OS crontab 운영). 활성화·경합은 운영과 협의.</p>
*
* @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(로깅 계속).
*
* <p> 전역 {@code <task:annotation-driven>} 비활성 상태라 현재 @Scheduled 동작하지
* 않는다(OS crontab 운영). 활성화·경합은 운영과 협의(핸드오프).</p>
*
* @throws Exception 알림 처리 오류
*/
@Scheduled(cron = "0 0 6 * * ?")
public void dormantPreAlertScheduler() throws Exception {
int sent = dormantAlertService.sendDormantPreAlerts();
log.info("[개인정보/#10] 휴면 사전알림 배치 완료 — " + sent + "건 발송");
}
} }

View File

@ -32,7 +32,10 @@
<!-- 1. 세션을 이용한 인증을 사용하는 빈 --> <!-- 1. 세션을 이용한 인증을 사용하는 빈 -->
<bean id="egovUserDetailsSessionService" class="egovframework.com.cmm.service.impl.EgovUserDetailsSessionServiceImpl"/> <bean id="egovUserDetailsSessionService" class="egovframework.com.cmm.service.impl.EgovUserDetailsSessionServiceImpl">
<!-- 개인정보보호(SFR-001) 역할별 차등 접근 : 권한 조회 DAO 주입 (component-scan @Repository) -->
<property name="userAuthorityDAO" ref="egovUserAuthorityDAO"/>
</bean>
<!-- 2. 스프링 시큐리티를 이용한 인증을 사용할 빈 --> <!-- 2. 스프링 시큐리티를 이용한 인증을 사용할 빈 -->

View File

@ -24,4 +24,39 @@
</aop:aspect> </aop:aspect>
</aop:config> --> </aop:config> -->
<!-- =========================================================================
개인정보보호 기능개선 사업 (SFR-004 / DBR-001 / SER-002) — 접속기록 한정 수집(#31)
· 위 COMTNSYSLOG용 syslog AOP(전 서비스 select* 대상)는 주석 그대로 유지(활성화하지 않음).
· 아래 personalDataLogAspect 는 '개인정보 처리 서비스'로 한정한 별도 체계다.
· personalDataLogAspect / jobMappingResolver / personalDataLogService 는
component-scan(context-common.xml, base-package egovframework/kr.or.arko)으로 등록되므로
여기서는 ref 로만 참조한다(대상 서비스 빈도 동일 root 컨텍스트).
· 스키마 기반 <aop:config> 라 aspectj-autoproxy 를 전역 활성화하지 않는다.
· 대상 확장 시: 이 pointcut 에 개인정보 처리 서비스 impl 만 추가(과수집 R2 방지). 게시판/공지 등 무관 조회 제외.
· #36(2026-07-21): 회원·업체 2개 지점 → 전 개인정보 처리 서비스(16종)로 확장. TRGET_ID 취득 규약은 PersonalDataLogAspect Javadoc 참조.
[제외] main/search/data/event/dept/orgnztchart/team/stat/provol/rspofc/prbro/prmvp/midact/srv/sample/test/excel 등 개인정보 미취급 서비스.
========================================================================= -->
<aop:config>
<aop:aspect id="personalDataLogAspect" ref="personalDataLogAspect">
<aop:pointcut id="personalDataProcessPointcut"
expression="execution(* egovframework.com.uss.umt.service.impl.EgovMberManageServiceImpl.*(..))
or execution(* egovframework.com.uss.umt.service.impl.EgovEntrprsManageServiceImpl.*(..))
or execution(* kr.or.arko.frt.biz.ssn.service.impl.MberSsnServiceImpl.*(..))
or execution(* kr.or.arko.frt.biz.merge.service.impl.BizMberMergeServiceImpl.*(..))
or execution(* egovframework.com.uss.umt.service.impl.EgovUserManageServiceImpl.*(..))
or execution(* egovframework.com.uss.umt.service.impl.EgovDiapauseManageServiceImpl.*(..))
or execution(* egovframework.com.uss.umt.service.impl.EgovAccountUnlockManageServiceImpl.*(..))
or execution(* kr.or.arko.frt.biz.note.service.impl.BizNoteServiceImpl.*(..))
or execution(* kr.or.arko.frt.biz.sms.service.impl.BizSmsServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.talent.service.impl.BizTalentMberManageServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.proapp.service.impl.BizProappServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.appconfirm.service.impl.BizAppconfirmServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.proresult.service.impl.BizProresultServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.group.service.impl.BizGroupManageServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.mileage.service.impl.BizMileageServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.profile.service.impl.BizProfileServiceImpl.*(..))" />
<aop:around pointcut-ref="personalDataProcessPointcut" method="around" />
</aop:aspect>
</aop:config>
</beans> </beans>

View File

@ -185,6 +185,119 @@
]]> ]]>
</insert> </insert>
<!-- ================================================================
개인정보보호 기능개선 사업 (SFR-002, 장기 미사용 계정 관리) 2026.07.21 Fork A
· #8: 휴면 배치 statement가 Oracle sqlmap에만 존재하고 MySQL(MariaDB)에는
전무하여 MariaDB 운영 시 statement-not-found 로 휴면 배치가 동작하지
않았다. Oracle(EgovLoginUsr_SQL_Oracle.xml:561~646)을 MariaDB 문법으로
정비 이설한다(NVL→IFNULL, SYSDATE→NOW(), 날짜연산→DATE_ADD/DATEDIFF).
· #7: 개인정보취급자(COMTNEMPLYRINFO)까지 휴면 대상 확장(insertDiapauseEmplyr,
selectDiapauseListEmplyr). COMTNDIAPAUSEMBER.MBER_TY_CODE 로 유형 분리.
================================================================ -->
<resultMap id="logList" class="egovframework.com.cmm.LoginVO">
<result property="id" column="id" columnIndex="1"/>
<result property="uniqId" column="uniqId" columnIndex="2"/>
<result property="email" column="email" columnIndex="3"/>
<result property="name" column="name" columnIndex="4"/>
<result property="creatDt" column="creatDt" columnIndex="5"/>
<result property="diapause_dt" column="diapause_dt" columnIndex="6"/>
<result property="diapause_stat" column="diapause_stat" columnIndex="7"/>
</resultMap>
<!-- [#8] 휴면 대상(일반회원) 적재 — 최종접속 365일 초과 & 정상('P') & 미적재 -->
<insert id="loginDAO.insertDiapause" parameterClass="loginVO">
<![CDATA[
INSERT INTO COMTNDIAPAUSEMBER (MBER_ID, ESNTL_ID, LAST_LOG, DIAPAUSE_STAT, DIAPAUSE_DT, MBER_TY_CODE)
SELECT
a.MBER_ID
, IFNULL(b.CONECT_ID, a.ESNTL_ID)
, b.CREAT_DT
, ''
, DATE_ADD(NOW(), INTERVAL 30 DAY)
, 'GNR'
FROM COMTNGNRLMBER a
LEFT OUTER JOIN (
SELECT CONECT_ID, MAX(CREAT_DT) AS CREAT_DT FROM COMTNLOGINLOG GROUP BY CONECT_ID
) b ON b.CONECT_ID = a.ESNTL_ID
WHERE DATEDIFF(NOW(), IFNULL(b.CREAT_DT, a.SBSCRB_DE)) > (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'
]]>
</insert>
<!-- [#7] 휴면 대상(개인정보취급자) 적재 — COMTNEMPLYRINFO.LAST_LOGIN_DT 기준 -->
<insert id="loginDAO.insertDiapauseEmplyr" parameterClass="loginVO">
<![CDATA[
INSERT INTO COMTNDIAPAUSEMBER (MBER_ID, ESNTL_ID, LAST_LOG, DIAPAUSE_STAT, DIAPAUSE_DT, MBER_TY_CODE)
SELECT
e.EMPLYR_ID
, e.ESNTL_ID
, IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)
, ''
, DATE_ADD(NOW(), INTERVAL 30 DAY)
, 'EMP'
FROM COMTNEMPLYRINFO e
WHERE DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) > (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'
]]>
</insert>
<!-- [#8] 휴면 확인 대상(일반회원) — 유예('S')/확정 프로세스용 -->
<select id="loginDAO.selectDiapauseList" resultMap="logList">
SELECT A.MBER_ID AS id
, B.ESNTL_ID AS uniqId
, B.MBER_EMAIL_ADRES AS email
, B.MBER_NM AS name
, IFNULL(A.LAST_LOG, B.SBSCRB_DE) AS creatDt
, A.DIAPAUSE_DT AS diapause_dt
, A.DIAPAUSE_STAT AS diapause_stat
FROM COMTNDIAPAUSEMBER A
LEFT JOIN COMTNGNRLMBER B ON A.MBER_ID = B.MBER_ID
WHERE A.MBER_TY_CODE = 'GNR'
AND (A.DIAPAUSE_STAT IS NULL OR (A.DIAPAUSE_STAT = 'S' AND NOW() &gt;= A.DIAPAUSE_DT))
</select>
<!-- [#7] 휴면 확인 대상(개인정보취급자) -->
<select id="loginDAO.selectDiapauseListEmplyr" resultMap="logList">
SELECT A.MBER_ID AS id
, B.ESNTL_ID AS uniqId
, B.EMAIL_ADRES AS email
, B.USER_NM AS name
, IFNULL(A.LAST_LOG, B.SBSCRB_DE) AS creatDt
, A.DIAPAUSE_DT AS diapause_dt
, A.DIAPAUSE_STAT AS diapause_stat
FROM COMTNDIAPAUSEMBER A
LEFT JOIN COMTNEMPLYRINFO B ON A.MBER_ID = B.EMPLYR_ID
WHERE A.MBER_TY_CODE = 'EMP'
AND (A.DIAPAUSE_STAT IS NULL OR (A.DIAPAUSE_STAT = 'S' AND NOW() &gt;= A.DIAPAUSE_DT))
</select>
<!-- [#8] 휴면 상태 갱신(공통) -->
<update id="loginDAO.updateDiapauseMber" parameterClass="java.util.HashMap">
UPDATE COMTNDIAPAUSEMBER SET DIAPAUSE_STAT = #diapause_stat# WHERE MBER_ID = #id#
</update>
<!-- [#8] 휴면 해제 토큰 검증 -->
<select id="loginDAO.selectToken" parameterClass="java.util.HashMap" resultClass="java.lang.Integer">
<![CDATA[
SELECT COUNT(*)
FROM COMTNDIAPAUSEMBER
WHERE MBER_ID = #mber_id#
AND TOKEN = #token#
AND TOKEN_EXPDT &gt; NOW()
]]>
</select>
<!-- [#8] 휴면 해제 토큰 발급(유효 10분) -->
<update id="loginDAO.inputToken" parameterClass="java.util.HashMap">
UPDATE COMTNDIAPAUSEMBER
SET TOKEN = #token#
, TOKEN_EXPDT = DATE_ADD(NOW(), INTERVAL 10 MINUTE)
WHERE MBER_ID = #mber_id#
AND ESNTL_ID = #esntl_id#
</update>
<delete id="loginDAO.deleteDiapause" parameterClass="loginVO"> <delete id="loginDAO.deleteDiapause" parameterClass="loginVO">
<![CDATA[ <![CDATA[
DELETE FROM COMTNDIAPAUSEMBER DELETE FROM COMTNDIAPAUSEMBER

View File

@ -581,12 +581,47 @@
FROM COMTNGNRLMBER a FROM COMTNGNRLMBER a
LEFT OUTER JOIN (SELECT CONECT_ID, MAX(CREAT_DT) AS CREAT_DT FROM COMTNLOGINLOG GROUP BY CONECT_ID) b LEFT OUTER JOIN (SELECT CONECT_ID, MAX(CREAT_DT) AS CREAT_DT FROM COMTNLOGINLOG GROUP BY CONECT_ID) b
ON b.CONECT_ID = a.ESNTL_ID ON b.CONECT_ID = a.ESNTL_ID
WHERE NVL(SYSDATE - b.CREAT_DT,SYSDATE - a.SBSCRB_DE) > 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_ID NOT IN (SELECT MBER_ID FROM COMTNDIAPAUSEMBER)
AND a.MBER_STTUS = 'P' AND a.MBER_STTUS = 'P'
]]> ]]>
</insert> </insert>
<!-- [개인정보보호 #7] 휴면 대상(개인정보취급자) 적재 — COMTNEMPLYRINFO 기준. 2026.07.21 Fork A
COMTNDIAPAUSEMBER.MBER_TY_CODE 로 일반회원('GNR')과 분리('EMP'). -->
<insert id="loginDAO.insertDiapauseEmplyr" parameterClass="loginVO" >
<![CDATA[
INSERT INTO COMTNDIAPAUSEMBER (MBER_ID,ESNTL_ID,LAST_LOG,DIAPAUSE_STAT,DIAPAUSE_DT,MBER_TY_CODE)
SELECT
e.EMPLYR_ID
,e.ESNTL_ID
,NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE)
,''
,SYSDATE +30
,'EMP'
FROM COMTNEMPLYRINFO e
WHERE NVL(SYSDATE - e.LAST_LOGIN_DT, SYSDATE - e.SBSCRB_DE) > (SELECT NVL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY) /* No.9 정책값화 */
AND e.EMPLYR_ID NOT IN (SELECT MBER_ID FROM COMTNDIAPAUSEMBER)
AND e.EMPLYR_STTUS_CODE = 'P'
]]>
</insert>
<!-- [개인정보보호 #7] 휴면 확인 대상(개인정보취급자) -->
<select id="loginDAO.selectDiapauseListEmplyr" resultMap="logList">
SELECT A.MBER_ID AS id
,B.ESNTL_ID AS uniqId
,B.EMAIL_ADRES AS email
,B.USER_NM AS name
,NVL(A.LAST_LOG,B.SBSCRB_DE) AS creatDt
,A.DIAPAUSE_DT
,A.DIAPAUSE_STAT
FROM COMTNDIAPAUSEMBER A
LEFT JOIN COMTNEMPLYRINFO B
ON A.MBER_ID = B.EMPLYR_ID
WHERE A.MBER_TY_CODE = 'EMP'
AND (A.DIAPAUSE_STAT IS NULL OR (A.DIAPAUSE_STAT = 'S' AND SYSDATE >= A.DIAPAUSE_DT))
</select>
<delete id="loginDAO.deleteDiapause" parameterClass="loginVO"> <delete id="loginDAO.deleteDiapause" parameterClass="loginVO">
<![CDATA[ <![CDATA[
DELETE FROM COMTNDIAPAUSEMBER DELETE FROM COMTNDIAPAUSEMBER
@ -624,7 +659,8 @@
FROM COMTNDIAPAUSEMBER A FROM COMTNDIAPAUSEMBER A
LEFT JOIN COMTNGNRLMBER B LEFT JOIN COMTNGNRLMBER B
ON A.MBER_ID = B.MBER_ID ON A.MBER_ID = B.MBER_ID
WHERE DIAPAUSE_STAT IS NULL OR (DIAPAUSE_STAT = 'S' AND SYSDATE >= DIAPAUSE_DT) WHERE A.MBER_TY_CODE = 'GNR'
AND (DIAPAUSE_STAT IS NULL OR (DIAPAUSE_STAT = 'S' AND SYSDATE >= DIAPAUSE_DT))
</select> </select>
<update id="loginDAO.updateDiapauseMber" parameterClass="java.util.HashMap"> <update id="loginDAO.updateDiapauseMber" parameterClass="java.util.HashMap">

View File

@ -99,4 +99,34 @@
<!-- 마일리지신청 --> <!-- 마일리지신청 -->
<sqlMap resource="egovframework/sqlmap/site/frt/biz/mileage/BizApplyMIleage_SQL_Mysql.xml"/> <sqlMap resource="egovframework/sqlmap/site/frt/biz/mileage/BizApplyMIleage_SQL_Mysql.xml"/>
<!-- 개인정보보호(SFR-001) - 사용자 권한(롤) 조회 : getAuthorities() 구현용 -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthority_SQL_Mysql.xml"/>
<!-- 개인정보보호(SFR-001) - 권한 변경 이력 파기 배치(#5) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistoryPurge_SQL_Mysql.xml"/>
<!-- 개인정보보호(DBR-001) - 접속기록 해시체인 무결성 검증 배치(#40) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLogIntegrity_SQL_Mysql.xml"/>
<!-- 개인정보보호(SFR-004) - 접속기록 적재(#31, AOP 수집) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml"/>
<!-- 개인정보보호(SFR-004) - 업무유형 매핑 조회(#32) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Mysql.xml"/>
<!-- 개인정보보호(SER-001) - 계정 잠금 해제 이력(#24) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountUnlockHistory_SQL_Mysql.xml"/>
<!-- 개인정보보호(SFR-001) - 권한 신청·승인 워크플로(#3) + 권한이력 조회·다운로드(#6) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorWorkflow_SQL_Mysql.xml"/>
<!-- 개인정보보호(SFR-002) - 계정 관리 정책(#13)/휴면 기준일수 정책값화(#9)/현황조회(#11)/사전알림(#10) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Mysql.xml"/>
<!-- 개인정보보호(SFR-003) - OTP 비밀키 영구 저장(No.18 후속, DaoOtpSecretStore) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyOtpDevice_SQL_Mysql.xml"/>
<!-- 개인정보보호(SFR-001 요건4) - 메뉴권한 기반 URL 접근제어(#2, 롤→허용 /mnt/ URL) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMenuAuth_SQL_Mysql.xml"/>
</sqlMapConfig> </sqlMapConfig>

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
<!--
개인정보보호 기능개선 사업 (DBR-001) — 접속기록 해시체인 무결성 검증 배치(#40).
PERSONAL_DATA_ACCESS_LOG 를 적재 순서(LOG_ID 오름차순)대로 조회해 HashChainService 로 재계산·검증한다.
※ CONECT_DT 는 해시 계산 시점과 동일한 문자열 형식(yyyy-MM-dd HH:mm:ss)으로 반환해야 재검증 시 해시가 일치한다.
※ 이 테이블에는 UPDATE/DELETE statement 를 정의하지 않는다(위·변조 방지, 삭제는 파티션 DROP 배치 #37 한정).
TODO(성능): 전량 검증은 대용량 시 부하가 크다. #37 파티션·기간 단위 증분 검증으로 확장 검토.
-->
<sqlMap namespace="AccessLogIntegrityDAO">
<select id="AccessLogIntegrityDAO.selectOrderedChain"
resultClass="egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogVO">
<![CDATA[
SELECT LOG_ID AS logId,
CONECT_ID AS conectId,
DATE_FORMAT(CONECT_DT, '%Y-%m-%d %H:%i:%s') AS conectDt,
CONECT_IP AS conectIp,
JOB_SE_CODE AS jobSeCode,
JOB_SE_NM AS jobSeNm,
TRGET_ID AS trgetId,
TRGET_CN AS trgetCn,
DML_GUBUN AS dmlGubun,
PROCESS_RESULT AS processResult,
USER_SE AS userSe,
HASH_VAL AS hashVal,
PREV_HASH_VAL AS prevHashVal
FROM PERSONAL_DATA_ACCESS_LOG
ORDER BY LOG_ID ASC
]]>
</select>
</sqlMap>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
<!--
개인정보보호 기능개선 사업 (DBR-001) — 접속기록 해시체인 무결성 검증 배치(#40) (Oracle 쌍).
※ 이중 관리 규약(Mysql/Oracle 쌍)에 따른 병행 파일. 운영 config(config/mysql/*)는 Mysql만 로드.
※ CONECT_DT 는 해시 계산 시점과 동일 문자열 형식(yyyy-MM-dd HH:mm:ss)으로 반환.
-->
<sqlMap namespace="AccessLogIntegrityDAO">
<select id="AccessLogIntegrityDAO.selectOrderedChain"
resultClass="egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogVO">
<![CDATA[
SELECT LOG_ID AS logId,
CONECT_ID AS conectId,
TO_CHAR(CONECT_DT, 'YYYY-MM-DD HH24:MI:SS') AS conectDt,
CONECT_IP AS conectIp,
JOB_SE_CODE AS jobSeCode,
JOB_SE_NM AS jobSeNm,
TRGET_ID AS trgetId,
TRGET_CN AS trgetCn,
DML_GUBUN AS dmlGubun,
PROCESS_RESULT AS processResult,
USER_SE AS userSe,
HASH_VAL AS hashVal,
PREV_HASH_VAL AS prevHashVal
FROM PERSONAL_DATA_ACCESS_LOG
ORDER BY LOG_ID ASC
]]>
</select>
</sqlMap>

View File

@ -0,0 +1,358 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
<!--
개인정보보호 기능개선 사업 (SFR-004 / DBR-001 / SER-002) — 접속기록 적재(#31).
PERSONAL_DATA_ACCESS_LOG 에는 INSERT/SELECT 만 정의한다(UPDATE/DELETE 미정의, 위·변조 방지).
유일한 삭제 경로는 #37 파티션 DROP 배치.
- CONECT_DT 는 VO(문자열 yyyy-MM-dd HH:mm:ss)를 그대로 전달 → DATETIME 캐스팅(해시 입력 형식과 동일).
-->
<sqlMap namespace="PersonalDataAccessLogDAO">
<insert id="PersonalDataAccessLogDAO.insertAccessLog"
parameterClass="egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogVO">
<![CDATA[
INSERT INTO PERSONAL_DATA_ACCESS_LOG (
CONECT_ID, CONECT_DT, CONECT_IP,
JOB_SE_CODE, JOB_SE_NM,
TRGET_ID, TRGET_CN,
DML_GUBUN, PROCESS_RESULT, USER_SE,
HASH_VAL, PREV_HASH_VAL
) VALUES (
#conectId#, #conectDt#, #conectIp#,
#jobSeCode#, #jobSeNm#,
#trgetId#, #trgetCn#,
#dmlGubun#, #processResult#, #userSe#,
#hashVal#, #prevHashVal#
)
]]>
</insert>
<!-- 해시체인 연결용 직전(최신) HASH_VAL. 레코드 없으면 null(제네시스). -->
<select id="PersonalDataAccessLogDAO.selectLastHashVal" resultClass="java.lang.String">
<![CDATA[
SELECT HASH_VAL
FROM PERSONAL_DATA_ACCESS_LOG
ORDER BY LOG_ID DESC
LIMIT 1
]]>
</select>
<!-- ================================================================
#37 파티션 유지보수(보관 730일 파기 + 미래 파티션 선행 증설).
이는 스키마 관리(DDL)이며 로그 행 UPDATE/DELETE 가 아니다 — 위·변조 방지 원칙 유지.
파티션명/경계값은 배치가 Java 에서 계산(사용자 입력 아님)하므로 식별자 치환($...$) 사용.
================================================================ -->
<!-- 현재 파티션 목록(경계 리터럴 포함) 조회. -->
<select id="PersonalDataAccessLogDAO.selectPartitions"
parameterClass="java.lang.String" resultClass="java.util.HashMap">
<![CDATA[
SELECT PARTITION_NAME AS partitionName,
PARTITION_DESCRIPTION AS partitionDesc,
PARTITION_ORDINAL_POSITION AS ordinalPosition
FROM INFORMATION_SCHEMA.PARTITIONS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = #value#
AND PARTITION_NAME IS NOT NULL
ORDER BY PARTITION_ORDINAL_POSITION
]]>
</select>
<!-- 보관기간 경과 파티션 파기(개별 DELETE 미사용). -->
<update id="PersonalDataAccessLogDAO.dropPartition" parameterClass="java.lang.String">
ALTER TABLE PERSONAL_DATA_ACCESS_LOG DROP PARTITION $value$
</update>
<!-- 미래 월 파티션 선행 증설. -->
<update id="PersonalDataAccessLogDAO.addPartition" parameterClass="java.util.HashMap">
ALTER TABLE PERSONAL_DATA_ACCESS_LOG
ADD PARTITION (PARTITION $partitionName$ VALUES LESS THAN ('$boundaryDt$'))
</update>
<!-- 파기·증설 이력 기록(파기 증빙). -->
<insert id="PersonalDataAccessLogDAO.insertPurgeHistory" parameterClass="java.util.HashMap">
<![CDATA[
INSERT INTO PDAL_PURGE_HISTORY (
PARTITION_NAME, BOUNDARY_DT, PURGE_TYPE, PURGE_DT, NOTE
) VALUES (
#partitionName#, #boundaryDt#, #purgeType#, NOW(), #note#
)
]]>
</insert>
<!-- ================================================================
#41 접속기록 조회·검색(백엔드). 접속자ID/업무유형/처리대상/IP/처리구분/처리결과/기간 복합 + 페이징.
SELECT 전용(위·변조 방지: UPDATE/DELETE 미정의). 파라미터는 PersonalDataAccessLogSearchVO.
(이 코드베이스는 <sql>/<include> 미사용 관용 → 동적 WHERE 인라인)
================================================================ -->
<select id="PersonalDataAccessLogDAO.selectAccessLogList"
parameterClass="egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogSearchVO"
resultClass="egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogVO">
SELECT LOG_ID AS logId,
CONECT_ID AS conectId,
CONECT_DT AS conectDt,
CONECT_IP AS conectIp,
JOB_SE_CODE AS jobSeCode,
JOB_SE_NM AS jobSeNm,
TRGET_ID AS trgetId,
TRGET_CN AS trgetCn,
DML_GUBUN AS dmlGubun,
PROCESS_RESULT AS processResult,
USER_SE AS userSe,
HASH_VAL AS hashVal,
PREV_HASH_VAL AS prevHashVal
FROM PERSONAL_DATA_ACCESS_LOG
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="conectId"> CONECT_ID = #conectId# </isNotEmpty>
<isNotEmpty prepend="AND" property="jobSeCode"> JOB_SE_CODE = #jobSeCode# </isNotEmpty>
<isNotEmpty prepend="AND" property="trgetId"> TRGET_ID = #trgetId# </isNotEmpty>
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP = #conectIp# </isNotEmpty>
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDt# </isNotEmpty>
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDt# </isNotEmpty>
</dynamic>
ORDER BY LOG_ID DESC
LIMIT #firstIndex#, #recordCountPerPage#
</select>
<select id="PersonalDataAccessLogDAO.selectAccessLogListCount"
parameterClass="egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogSearchVO"
resultClass="java.lang.Integer">
SELECT COUNT(1)
FROM PERSONAL_DATA_ACCESS_LOG
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="conectId"> CONECT_ID = #conectId# </isNotEmpty>
<isNotEmpty prepend="AND" property="jobSeCode"> JOB_SE_CODE = #jobSeCode# </isNotEmpty>
<isNotEmpty prepend="AND" property="trgetId"> TRGET_ID = #trgetId# </isNotEmpty>
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP = #conectIp# </isNotEmpty>
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDt# </isNotEmpty>
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDt# </isNotEmpty>
</dynamic>
</select>
<!-- ================================================================
#43 정기점검 리포트 집계·적재. 전월분 PERSONAL_DATA_ACCESS_LOG 집계(SELECT) +
PDAL_INSPECTION_REPORT 적재(INSERT). 파라미터는 fromDt/toDt(Map) 또는 InspectionReportVO.
================================================================ -->
<!-- 기간 내 총건수·실패(F)건수 집계 -->
<select id="PersonalDataAccessLogDAO.summarizeAccessLogByPeriod"
parameterClass="java.util.HashMap" resultClass="java.util.HashMap">
<![CDATA[
SELECT COUNT(1) AS totalCnt,
SUM(CASE WHEN PROCESS_RESULT = 'F' THEN 1 ELSE 0 END) AS failCnt
FROM PERSONAL_DATA_ACCESS_LOG
WHERE CONECT_DT >= #fromDt#
AND CONECT_DT <= #toDt#
]]>
</select>
<!-- 기간 내 취급자별(접속자ID·업무유형) 처리현황 집계 -->
<select id="PersonalDataAccessLogDAO.summarizeByHandler"
parameterClass="java.util.HashMap" resultClass="java.util.HashMap">
<![CDATA[
SELECT CONECT_ID AS conectId,
JOB_SE_CODE AS jobSeCode,
COUNT(1) AS procCnt
FROM PERSONAL_DATA_ACCESS_LOG
WHERE CONECT_DT >= #fromDt#
AND CONECT_DT <= #toDt#
GROUP BY CONECT_ID, JOB_SE_CODE
ORDER BY procCnt DESC
]]>
</select>
<!-- 정기점검 리포트 1행 적재(RPT_YM UNIQUE — 재실행 시 중복은 앱 계층에서 선삭제/무시 처리) -->
<insert id="PersonalDataAccessLogDAO.insertInspectionReport"
parameterClass="egovframework.com.cmm.privacy.batch.InspectionReportVO">
<![CDATA[
INSERT INTO PDAL_INSPECTION_REPORT (
RPT_YM, PERIOD_FROM, PERIOD_TO,
TOTAL_LOG_CNT, FAIL_CNT, ABNORMAL_SUMMARY,
INTEGRITY_RESULT, INTEGRITY_MISMATCH_ID, INTEGRITY_TOTAL_CNT,
HANDLER_SUMMARY, DOWNLOAD_CNT, REG_DT
) VALUES (
#rptYm#, #periodFrom#, #periodTo#,
#totalLogCnt#, #failCnt#, #abnormalSummary#,
#integrityResult#, #integrityMismatchId#, #integrityTotalCnt#,
#handlerSummary#, #downloadCnt#, NOW()
)
]]>
</insert>
<!-- ================================================================
#44 점검결과 입력·보관. 배치 생성 리포트(PDAL_INSPECTION_REPORT)에 대해
점검자·점검일시·점검결과·조치사항을 입력·보관(감사증빙). 리포트 테이블은 UPDATE 허용
(위·변조 방지 원칙은 PERSONAL_DATA_ACCESS_LOG 한정, 리포트는 점검자 입력 대상).
================================================================ -->
<!-- 정기점검 리포트 월별 목록(연월 역순). 검색: 연도(rptYmPrefix)·점검여부(inspectedYn) -->
<select id="PersonalDataAccessLogDAO.selectInspectionReportList"
parameterClass="java.util.HashMap"
resultClass="egovframework.com.cmm.privacy.batch.InspectionReportVO">
SELECT RPT_ID AS rptId, RPT_YM AS rptYm, PERIOD_FROM AS periodFrom, PERIOD_TO AS periodTo,
TOTAL_LOG_CNT AS totalLogCnt, FAIL_CNT AS failCnt, ABNORMAL_SUMMARY AS abnormalSummary,
INTEGRITY_RESULT AS integrityResult, INTEGRITY_MISMATCH_ID AS integrityMismatchId,
INTEGRITY_TOTAL_CNT AS integrityTotalCnt, HANDLER_SUMMARY AS handlerSummary,
DOWNLOAD_CNT AS downloadCnt, INSPECTOR_ID AS inspectorId, INSPECT_RESULT AS inspectResult,
ACTN_MATTER AS actnMatter, INSPECT_DT AS inspectDt, REG_DT AS regDt
FROM PDAL_INSPECTION_REPORT
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="rptYmPrefix"> RPT_YM LIKE #rptYmPrefix# || '%' </isNotEmpty>
<isEqual prepend="AND" property="inspectedYn" compareValue="Y"> INSPECTOR_ID IS NOT NULL </isEqual>
<isEqual prepend="AND" property="inspectedYn" compareValue="N"> INSPECTOR_ID IS NULL </isEqual>
</dynamic>
ORDER BY RPT_YM DESC
</select>
<!-- 리포트 상세(RPT_ID 단건) -->
<select id="PersonalDataAccessLogDAO.selectInspectionReport"
parameterClass="java.lang.Long"
resultClass="egovframework.com.cmm.privacy.batch.InspectionReportVO">
SELECT RPT_ID AS rptId, RPT_YM AS rptYm, PERIOD_FROM AS periodFrom, PERIOD_TO AS periodTo,
TOTAL_LOG_CNT AS totalLogCnt, FAIL_CNT AS failCnt, ABNORMAL_SUMMARY AS abnormalSummary,
INTEGRITY_RESULT AS integrityResult, INTEGRITY_MISMATCH_ID AS integrityMismatchId,
INTEGRITY_TOTAL_CNT AS integrityTotalCnt, HANDLER_SUMMARY AS handlerSummary,
DOWNLOAD_CNT AS downloadCnt, INSPECTOR_ID AS inspectorId, INSPECT_RESULT AS inspectResult,
ACTN_MATTER AS actnMatter, INSPECT_DT AS inspectDt, REG_DT AS regDt
FROM PDAL_INSPECTION_REPORT
WHERE RPT_ID = #value#
</select>
<!-- 점검결과 입력·보관(UPDATE). 점검자·결과·조치사항 + 점검일시(NOW) 갱신. -->
<update id="PersonalDataAccessLogDAO.updateInspectionResult"
parameterClass="egovframework.com.cmm.privacy.batch.InspectionReportVO">
<![CDATA[
UPDATE PDAL_INSPECTION_REPORT
SET INSPECTOR_ID = #inspectorId#,
INSPECT_RESULT = #inspectResult#,
ACTN_MATTER = #actnMatter#,
INSPECT_DT = NOW()
WHERE RPT_ID = #rptId#
]]>
</update>
<!-- ================================================================
#35 비정상 접근 탐지 5유형(집계형). PERSONAL_DATA_ACCESS_LOG 표준 컬럼만 SELECT.
임계치(mass/authFail/offHours)는 파라미터 Map(정책값)으로 주입. 개인정보 본문 미조회.
================================================================ -->
<!-- ① 비인가접근시도: 처리결과 F(실패·차단) 총건수 -->
<select id="PersonalDataAccessLogDAO.detectUnauthorized"
parameterClass="java.util.HashMap" resultClass="java.lang.Integer">
<![CDATA[
SELECT COUNT(1)
FROM PERSONAL_DATA_ACCESS_LOG
WHERE PROCESS_RESULT = 'F'
AND CONECT_DT >= #fromDt#
AND CONECT_DT <= #toDt#
]]>
</select>
<!-- ② 대량조회: 단위기간 조회(S) 건수가 임계 초과인 접속자 -->
<select id="PersonalDataAccessLogDAO.detectMassQuery"
parameterClass="java.util.HashMap" resultClass="java.util.HashMap">
<![CDATA[
SELECT CONECT_ID AS conectId, COUNT(1) AS cnt
FROM PERSONAL_DATA_ACCESS_LOG
WHERE DML_GUBUN = 'S'
AND CONECT_DT >= #fromDt#
AND CONECT_DT <= #toDt#
GROUP BY CONECT_ID
HAVING COUNT(1) > #massThreshold#
ORDER BY cnt DESC
]]>
</select>
<!-- ③ 비정상시간대접속: 업무시간(startHour~endHour) 밖 처리 건수 -->
<select id="PersonalDataAccessLogDAO.detectOffHours"
parameterClass="java.util.HashMap" resultClass="java.lang.Integer">
<![CDATA[
SELECT COUNT(1)
FROM PERSONAL_DATA_ACCESS_LOG
WHERE CONECT_DT >= #fromDt#
AND CONECT_DT <= #toDt#
AND ( HOUR(CONECT_DT) < #offHoursStart# OR HOUR(CONECT_DT) >= #offHoursEnd# )
]]>
</select>
<!-- ④ 계정공유의심: 동일 접속자ID가 상이 IP 다수(임계 초과) -->
<select id="PersonalDataAccessLogDAO.detectAccountSharing"
parameterClass="java.util.HashMap" resultClass="java.util.HashMap">
<![CDATA[
SELECT CONECT_ID AS conectId, COUNT(DISTINCT CONECT_IP) AS ipCnt
FROM PERSONAL_DATA_ACCESS_LOG
WHERE CONECT_DT >= #fromDt#
AND CONECT_DT <= #toDt#
GROUP BY CONECT_ID
HAVING COUNT(DISTINCT CONECT_IP) > #sharingThreshold#
ORDER BY ipCnt DESC
]]>
</select>
<!-- ⑤ 인증실패반복: 접속자별 실패(F) 반복 임계 이상 -->
<select id="PersonalDataAccessLogDAO.detectRepeatedAuthFailure"
parameterClass="java.util.HashMap" resultClass="java.util.HashMap">
<![CDATA[
SELECT CONECT_ID AS conectId, COUNT(1) AS cnt
FROM PERSONAL_DATA_ACCESS_LOG
WHERE PROCESS_RESULT = 'F'
AND CONECT_DT >= #fromDt#
AND CONECT_DT <= #toDt#
GROUP BY CONECT_ID
HAVING COUNT(1) >= #authFailThreshold#
ORDER BY cnt DESC
]]>
</select>
<!-- ================================================================
#42 접속기록 다운로드 사유·이력. 다운로드이력 별도 테이블(PDAL_DOWNLOAD_HISTORY).
사유 필수 검증은 서비스 계층(미입력 차단). 여기는 이력 적재/조회/집계만.
================================================================ -->
<insert id="PersonalDataAccessLogDAO.insertDownloadHistory"
parameterClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryVO">
<![CDATA[
INSERT INTO PDAL_DOWNLOAD_HISTORY (
DOWNLOADER_ID, DOWNLOAD_DT, DOWNLOADER_IP,
DL_REASON, SEARCH_COND, RESULT_CNT, REG_DT
) VALUES (
#downloaderId#, NOW(), #downloaderIp#,
#dlReason#, #searchCond#, #resultCnt#, NOW()
)
]]>
</insert>
<select id="PersonalDataAccessLogDAO.countDownloadByPeriod"
parameterClass="java.util.HashMap" resultClass="java.lang.Integer">
<![CDATA[
SELECT COUNT(1)
FROM PDAL_DOWNLOAD_HISTORY
WHERE DOWNLOAD_DT >= #fromDt#
AND DOWNLOAD_DT <= #toDt#
]]>
</select>
<select id="PersonalDataAccessLogDAO.selectDownloadHistoryByPeriod"
parameterClass="java.util.HashMap"
resultClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryVO">
<![CDATA[
SELECT DL_ID AS dlId,
DOWNLOADER_ID AS downloaderId,
DOWNLOAD_DT AS downloadDt,
DOWNLOADER_IP AS downloaderIp,
DL_REASON AS dlReason,
SEARCH_COND AS searchCond,
RESULT_CNT AS resultCnt
FROM PDAL_DOWNLOAD_HISTORY
WHERE DOWNLOAD_DT >= #fromDt#
AND DOWNLOAD_DT <= #toDt#
ORDER BY DL_ID DESC
]]>
</select>
</sqlMap>

View File

@ -0,0 +1,299 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
<!--
개인정보보호 기능개선 사업 — 접속기록 적재(#31) Oracle 쌍(이중관리 규약).
※ arko 런타임은 MySQL sqlmap 만 로드한다. 본 파일은 이중관리 관례상 병행 유지.
※ LOG_ID 는 시퀀스(PERSONAL_DATA_ACCESS_LOG_SEQ) 전제. CONECT_DT 는 문자열→TO_DATE 변환.
-->
<sqlMap namespace="PersonalDataAccessLogDAO">
<insert id="PersonalDataAccessLogDAO.insertAccessLog"
parameterClass="egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogVO">
<![CDATA[
INSERT INTO PERSONAL_DATA_ACCESS_LOG (
LOG_ID, CONECT_ID, CONECT_DT, CONECT_IP,
JOB_SE_CODE, JOB_SE_NM,
TRGET_ID, TRGET_CN,
DML_GUBUN, PROCESS_RESULT, USER_SE,
HASH_VAL, PREV_HASH_VAL
) VALUES (
PERSONAL_DATA_ACCESS_LOG_SEQ.NEXTVAL,
#conectId#, TO_DATE(#conectDt#, 'YYYY-MM-DD HH24:MI:SS'), #conectIp#,
#jobSeCode#, #jobSeNm#,
#trgetId#, #trgetCn#,
#dmlGubun#, #processResult#, #userSe#,
#hashVal#, #prevHashVal#
)
]]>
</insert>
<select id="PersonalDataAccessLogDAO.selectLastHashVal" resultClass="java.lang.String">
<![CDATA[
SELECT HASH_VAL FROM (
SELECT HASH_VAL
FROM PERSONAL_DATA_ACCESS_LOG
ORDER BY LOG_ID DESC
) WHERE ROWNUM = 1
]]>
</select>
<!-- ================================================================
#37 파티션 유지보수(Oracle 쌍, 이중관리 규약 · 런타임 미로드).
Oracle 은 USER_TAB_PARTITIONS 로 파티션을 조회하고 HIGH_VALUE 에 경계가 담긴다.
Range 파티션 DROP/ADD 문법은 아래와 같다. 스키마 관리(DDL)이며 로그 행 삭제 아님.
================================================================ -->
<select id="PersonalDataAccessLogDAO.selectPartitions"
parameterClass="java.lang.String" resultClass="java.util.HashMap">
<![CDATA[
SELECT PARTITION_NAME AS partitionName,
HIGH_VALUE AS partitionDesc,
PARTITION_POSITION AS ordinalPosition
FROM USER_TAB_PARTITIONS
WHERE TABLE_NAME = UPPER(#value#)
ORDER BY PARTITION_POSITION
]]>
</select>
<update id="PersonalDataAccessLogDAO.dropPartition" parameterClass="java.lang.String">
ALTER TABLE PERSONAL_DATA_ACCESS_LOG DROP PARTITION $value$
</update>
<update id="PersonalDataAccessLogDAO.addPartition" parameterClass="java.util.HashMap">
ALTER TABLE PERSONAL_DATA_ACCESS_LOG
ADD PARTITION $partitionName$ VALUES LESS THAN (TO_DATE('$boundaryDt$', 'YYYY-MM-DD'))
</update>
<insert id="PersonalDataAccessLogDAO.insertPurgeHistory" parameterClass="java.util.HashMap">
<![CDATA[
INSERT INTO PDAL_PURGE_HISTORY (
PURGE_ID, PARTITION_NAME, BOUNDARY_DT, PURGE_TYPE, PURGE_DT, NOTE
) VALUES (
PDAL_PURGE_HISTORY_SEQ.NEXTVAL,
#partitionName#, #boundaryDt#, #purgeType#, SYSDATE, #note#
)
]]>
</insert>
<!-- #41 접속기록 조회·검색(백엔드) — Oracle 쌍(ROWNUM 페이징). Mysql 쌍과 동일 검색조건. -->
<select id="PersonalDataAccessLogDAO.selectAccessLogList"
parameterClass="egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogSearchVO"
resultClass="egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogVO">
SELECT * FROM (
SELECT ROWNUM AS RN, T.* FROM (
SELECT LOG_ID AS logId, CONECT_ID AS conectId, CONECT_DT AS conectDt,
CONECT_IP AS conectIp, JOB_SE_CODE AS jobSeCode, JOB_SE_NM AS jobSeNm,
TRGET_ID AS trgetId, TRGET_CN AS trgetCn, DML_GUBUN AS dmlGubun,
PROCESS_RESULT AS processResult, USER_SE AS userSe,
HASH_VAL AS hashVal, PREV_HASH_VAL AS prevHashVal
FROM PERSONAL_DATA_ACCESS_LOG
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="conectId"> CONECT_ID = #conectId# </isNotEmpty>
<isNotEmpty prepend="AND" property="jobSeCode"> JOB_SE_CODE = #jobSeCode# </isNotEmpty>
<isNotEmpty prepend="AND" property="trgetId"> TRGET_ID = #trgetId# </isNotEmpty>
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP = #conectIp# </isNotEmpty>
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDt# </isNotEmpty>
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDt# </isNotEmpty>
</dynamic>
ORDER BY LOG_ID DESC
) T WHERE ROWNUM &lt;= #firstIndex# + #recordCountPerPage#
) WHERE RN &gt; #firstIndex#
</select>
<select id="PersonalDataAccessLogDAO.selectAccessLogListCount"
parameterClass="egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogSearchVO"
resultClass="java.lang.Integer">
SELECT COUNT(1)
FROM PERSONAL_DATA_ACCESS_LOG
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="conectId"> CONECT_ID = #conectId# </isNotEmpty>
<isNotEmpty prepend="AND" property="jobSeCode"> JOB_SE_CODE = #jobSeCode# </isNotEmpty>
<isNotEmpty prepend="AND" property="trgetId"> TRGET_ID = #trgetId# </isNotEmpty>
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP = #conectIp# </isNotEmpty>
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDt# </isNotEmpty>
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDt# </isNotEmpty>
</dynamic>
</select>
<!-- #43 정기점검 리포트 집계·적재 — Oracle 쌍(시퀀스 PDAL_INSPECTION_REPORT_SEQ 필요). -->
<select id="PersonalDataAccessLogDAO.summarizeAccessLogByPeriod"
parameterClass="java.util.HashMap" resultClass="java.util.HashMap">
<![CDATA[
SELECT COUNT(1) AS totalCnt,
SUM(CASE WHEN PROCESS_RESULT = 'F' THEN 1 ELSE 0 END) AS failCnt
FROM PERSONAL_DATA_ACCESS_LOG
WHERE CONECT_DT >= #fromDt#
AND CONECT_DT <= #toDt#
]]>
</select>
<select id="PersonalDataAccessLogDAO.summarizeByHandler"
parameterClass="java.util.HashMap" resultClass="java.util.HashMap">
<![CDATA[
SELECT CONECT_ID AS conectId,
JOB_SE_CODE AS jobSeCode,
COUNT(1) AS procCnt
FROM PERSONAL_DATA_ACCESS_LOG
WHERE CONECT_DT >= #fromDt#
AND CONECT_DT <= #toDt#
GROUP BY CONECT_ID, JOB_SE_CODE
ORDER BY procCnt DESC
]]>
</select>
<insert id="PersonalDataAccessLogDAO.insertInspectionReport"
parameterClass="egovframework.com.cmm.privacy.batch.InspectionReportVO">
<![CDATA[
INSERT INTO PDAL_INSPECTION_REPORT (
RPT_ID, RPT_YM, PERIOD_FROM, PERIOD_TO,
TOTAL_LOG_CNT, FAIL_CNT, ABNORMAL_SUMMARY,
INTEGRITY_RESULT, INTEGRITY_MISMATCH_ID, INTEGRITY_TOTAL_CNT,
HANDLER_SUMMARY, DOWNLOAD_CNT, REG_DT
) VALUES (
PDAL_INSPECTION_REPORT_SEQ.NEXTVAL,
#rptYm#, TO_DATE(#periodFrom#, 'YYYY-MM-DD HH24:MI:SS'), TO_DATE(#periodTo#, 'YYYY-MM-DD HH24:MI:SS'),
#totalLogCnt#, #failCnt#, #abnormalSummary#,
#integrityResult#, #integrityMismatchId#, #integrityTotalCnt#,
#handlerSummary#, #downloadCnt#, SYSDATE
)
]]>
</insert>
<!-- #44 점검결과 입력·보관(Oracle 쌍). 리포트 UPDATE 허용(위·변조 방지는 접속기록 한정). -->
<select id="PersonalDataAccessLogDAO.selectInspectionReportList"
parameterClass="java.util.HashMap"
resultClass="egovframework.com.cmm.privacy.batch.InspectionReportVO">
SELECT RPT_ID AS rptId, RPT_YM AS rptYm, PERIOD_FROM AS periodFrom, PERIOD_TO AS periodTo,
TOTAL_LOG_CNT AS totalLogCnt, FAIL_CNT AS failCnt, ABNORMAL_SUMMARY AS abnormalSummary,
INTEGRITY_RESULT AS integrityResult, INTEGRITY_MISMATCH_ID AS integrityMismatchId,
INTEGRITY_TOTAL_CNT AS integrityTotalCnt, HANDLER_SUMMARY AS handlerSummary,
DOWNLOAD_CNT AS downloadCnt, INSPECTOR_ID AS inspectorId, INSPECT_RESULT AS inspectResult,
ACTN_MATTER AS actnMatter, INSPECT_DT AS inspectDt, REG_DT AS regDt
FROM PDAL_INSPECTION_REPORT
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="rptYmPrefix"> RPT_YM LIKE #rptYmPrefix# || '%' </isNotEmpty>
<isEqual prepend="AND" property="inspectedYn" compareValue="Y"> INSPECTOR_ID IS NOT NULL </isEqual>
<isEqual prepend="AND" property="inspectedYn" compareValue="N"> INSPECTOR_ID IS NULL </isEqual>
</dynamic>
ORDER BY RPT_YM DESC
</select>
<select id="PersonalDataAccessLogDAO.selectInspectionReport"
parameterClass="java.lang.Long"
resultClass="egovframework.com.cmm.privacy.batch.InspectionReportVO">
SELECT RPT_ID AS rptId, RPT_YM AS rptYm, PERIOD_FROM AS periodFrom, PERIOD_TO AS periodTo,
TOTAL_LOG_CNT AS totalLogCnt, FAIL_CNT AS failCnt, ABNORMAL_SUMMARY AS abnormalSummary,
INTEGRITY_RESULT AS integrityResult, INTEGRITY_MISMATCH_ID AS integrityMismatchId,
INTEGRITY_TOTAL_CNT AS integrityTotalCnt, HANDLER_SUMMARY AS handlerSummary,
DOWNLOAD_CNT AS downloadCnt, INSPECTOR_ID AS inspectorId, INSPECT_RESULT AS inspectResult,
ACTN_MATTER AS actnMatter, INSPECT_DT AS inspectDt, REG_DT AS regDt
FROM PDAL_INSPECTION_REPORT
WHERE RPT_ID = #value#
</select>
<update id="PersonalDataAccessLogDAO.updateInspectionResult"
parameterClass="egovframework.com.cmm.privacy.batch.InspectionReportVO">
<![CDATA[
UPDATE PDAL_INSPECTION_REPORT
SET INSPECTOR_ID = #inspectorId#,
INSPECT_RESULT = #inspectResult#,
ACTN_MATTER = #actnMatter#,
INSPECT_DT = SYSDATE
WHERE RPT_ID = #rptId#
]]>
</update>
<!-- #35 비정상 접근 탐지 5유형(Oracle 쌍). HOUR→TO_CHAR(HH24). -->
<select id="PersonalDataAccessLogDAO.detectUnauthorized"
parameterClass="java.util.HashMap" resultClass="java.lang.Integer">
<![CDATA[
SELECT COUNT(1) FROM PERSONAL_DATA_ACCESS_LOG
WHERE PROCESS_RESULT = 'F' AND CONECT_DT >= #fromDt# AND CONECT_DT <= #toDt#
]]>
</select>
<select id="PersonalDataAccessLogDAO.detectMassQuery"
parameterClass="java.util.HashMap" resultClass="java.util.HashMap">
<![CDATA[
SELECT CONECT_ID AS conectId, COUNT(1) AS cnt
FROM PERSONAL_DATA_ACCESS_LOG
WHERE DML_GUBUN = 'S' AND CONECT_DT >= #fromDt# AND CONECT_DT <= #toDt#
GROUP BY CONECT_ID HAVING COUNT(1) > #massThreshold#
ORDER BY cnt DESC
]]>
</select>
<select id="PersonalDataAccessLogDAO.detectOffHours"
parameterClass="java.util.HashMap" resultClass="java.lang.Integer">
<![CDATA[
SELECT COUNT(1) FROM PERSONAL_DATA_ACCESS_LOG
WHERE CONECT_DT >= #fromDt# AND CONECT_DT <= #toDt#
AND ( TO_NUMBER(TO_CHAR(CONECT_DT,'HH24')) < #offHoursStart#
OR TO_NUMBER(TO_CHAR(CONECT_DT,'HH24')) >= #offHoursEnd# )
]]>
</select>
<select id="PersonalDataAccessLogDAO.detectAccountSharing"
parameterClass="java.util.HashMap" resultClass="java.util.HashMap">
<![CDATA[
SELECT CONECT_ID AS conectId, COUNT(DISTINCT CONECT_IP) AS ipCnt
FROM PERSONAL_DATA_ACCESS_LOG
WHERE CONECT_DT >= #fromDt# AND CONECT_DT <= #toDt#
GROUP BY CONECT_ID HAVING COUNT(DISTINCT CONECT_IP) > #sharingThreshold#
ORDER BY ipCnt DESC
]]>
</select>
<select id="PersonalDataAccessLogDAO.detectRepeatedAuthFailure"
parameterClass="java.util.HashMap" resultClass="java.util.HashMap">
<![CDATA[
SELECT CONECT_ID AS conectId, COUNT(1) AS cnt
FROM PERSONAL_DATA_ACCESS_LOG
WHERE PROCESS_RESULT = 'F' AND CONECT_DT >= #fromDt# AND CONECT_DT <= #toDt#
GROUP BY CONECT_ID HAVING COUNT(1) >= #authFailThreshold#
ORDER BY cnt DESC
]]>
</select>
<!-- #42 다운로드 사유·이력(Oracle 쌍, 시퀀스 PDAL_DOWNLOAD_HISTORY_SEQ 필요). -->
<insert id="PersonalDataAccessLogDAO.insertDownloadHistory"
parameterClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryVO">
<![CDATA[
INSERT INTO PDAL_DOWNLOAD_HISTORY (
DL_ID, DOWNLOADER_ID, DOWNLOAD_DT, DOWNLOADER_IP,
DL_REASON, SEARCH_COND, RESULT_CNT, REG_DT
) VALUES (
PDAL_DOWNLOAD_HISTORY_SEQ.NEXTVAL,
#downloaderId#, SYSDATE, #downloaderIp#,
#dlReason#, #searchCond#, #resultCnt#, SYSDATE
)
]]>
</insert>
<select id="PersonalDataAccessLogDAO.countDownloadByPeriod"
parameterClass="java.util.HashMap" resultClass="java.lang.Integer">
<![CDATA[
SELECT COUNT(1) FROM PDAL_DOWNLOAD_HISTORY
WHERE DOWNLOAD_DT >= #fromDt# AND DOWNLOAD_DT <= #toDt#
]]>
</select>
<select id="PersonalDataAccessLogDAO.selectDownloadHistoryByPeriod"
parameterClass="java.util.HashMap"
resultClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryVO">
<![CDATA[
SELECT DL_ID AS dlId, DOWNLOADER_ID AS downloaderId, DOWNLOAD_DT AS downloadDt,
DOWNLOADER_IP AS downloaderIp, DL_REASON AS dlReason,
SEARCH_COND AS searchCond, RESULT_CNT AS resultCnt
FROM PDAL_DOWNLOAD_HISTORY
WHERE DOWNLOAD_DT >= #fromDt# AND DOWNLOAD_DT <= #toDt#
ORDER BY DL_ID DESC
]]>
</select>
</sqlMap>

View File

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
<!--
개인정보보호 기능개선 사업 (SFR-002, 장기 미사용 계정 관리) — 계정 관리 정책·현황·사전알림.
· #13/#9: ACCOUNT_MNG_POLICY 조회/수정(단일행)
· #11 : 장기 미사용 계정 현황 조회(취급자 COMTNEMPLYRINFO + 최종로그인 기준)
· #10 : 휴면 사전알림 대상(D-N일 전) 조회
-->
<sqlMap namespace="AccountMngDAO">
<!-- ================= #13/#9 계정 관리 정책 ================= -->
<select id="AccountMngDAO.selectPolicy"
resultClass="egovframework.com.cmm.privacy.account.AccountMngPolicyVO">
<![CDATA[
SELECT POLICY_ID AS policyId,
DORMANT_DAY AS dormantDay,
ALERT_D1 AS alertD1,
ALERT_D2 AS alertD2,
ALERT_D3 AS alertD3,
DELETE_PRESERVE_DAY AS deletePreserveDay,
BATCH_CYCLE AS batchCycle,
ADMIN_EMAIL AS adminEmail,
UPD_ID AS updId,
UPD_DT AS updDt
FROM ACCOUNT_MNG_POLICY
WHERE POLICY_ID = 1
]]>
</select>
<update id="AccountMngDAO.updatePolicy"
parameterClass="egovframework.com.cmm.privacy.account.AccountMngPolicyVO">
<![CDATA[
UPDATE ACCOUNT_MNG_POLICY
SET DORMANT_DAY = #dormantDay#,
ALERT_D1 = #alertD1#,
ALERT_D2 = #alertD2#,
ALERT_D3 = #alertD3#,
DELETE_PRESERVE_DAY = #deletePreserveDay#,
BATCH_CYCLE = #batchCycle#,
ADMIN_EMAIL = #adminEmail#,
UPD_ID = #updId#,
UPD_DT = NOW()
WHERE POLICY_ID = 1
]]>
</update>
<!-- ================= #11 장기 미사용 계정 현황(취급자) ================= -->
<!-- 미접속일수 = 오늘 - 최종로그인(없으면 가입일). 휴면예정일 = 최종로그인 + 기준일수. -->
<select id="AccountMngDAO.selectDormantStatusList"
parameterClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO"
resultClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO">
<![CDATA[
SELECT e.EMPLYR_ID AS accountId,
e.USER_NM AS userNm,
e.ORGNZT_ID AS orgnztId,
e.OFCPS_NM AS ofcpsNm,
DATE_FORMAT(IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE), '%Y-%m-%d') AS lastLoginDt,
DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) AS noConnectDays,
e.EMPLYR_STTUS_CODE AS sttusCode,
DATE_FORMAT(DATE_ADD(IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE),
INTERVAL (SELECT IFNULL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY) DAY), '%Y-%m-%d') AS dormantExpectDt,
'EMP' AS mberTyCode
FROM COMTNEMPLYRINFO e
]]>
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="accountId"> e.EMPLYR_ID = #accountId# </isNotEmpty>
<isNotEmpty prepend="AND" property="userNm"> e.USER_NM LIKE CONCAT('%', #userNm#, '%') </isNotEmpty>
<isNotEmpty prepend="AND" property="sttusCode"> e.EMPLYR_STTUS_CODE = #sttusCode# </isNotEmpty>
<isGreaterThan prepend="AND" property="minNoConnectDays" compareValue="0">
DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) &gt;= #minNoConnectDays#
</isGreaterThan>
</dynamic>
ORDER BY noConnectDays DESC
LIMIT #firstIndex#, #recordCountPerPage#
</select>
<select id="AccountMngDAO.selectDormantStatusListCount"
parameterClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO"
resultClass="java.lang.Integer">
SELECT COUNT(1)
FROM COMTNEMPLYRINFO e
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="accountId"> e.EMPLYR_ID = #accountId# </isNotEmpty>
<isNotEmpty prepend="AND" property="userNm"> e.USER_NM LIKE CONCAT('%', #userNm#, '%') </isNotEmpty>
<isNotEmpty prepend="AND" property="sttusCode"> e.EMPLYR_STTUS_CODE = #sttusCode# </isNotEmpty>
<isGreaterThan prepend="AND" property="minNoConnectDays" compareValue="0">
DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) &gt;= #minNoConnectDays#
</isGreaterThan>
</dynamic>
</select>
<!-- ================= #10 휴면 사전알림 대상(D-N일 전) ================= -->
<!-- 미접속일수 == (기준일수 - N) 인 취급자 = 휴면 D-N일 전. N은 #alertBefore# 로 전달. -->
<select id="AccountMngDAO.selectDormantAlertTargets"
parameterClass="java.util.HashMap"
resultClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO">
<![CDATA[
SELECT e.EMPLYR_ID AS accountId,
e.USER_NM AS userNm,
e.ESNTL_ID AS esntlId,
DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) AS noConnectDays,
'EMP' AS mberTyCode
FROM COMTNEMPLYRINFO e
WHERE e.EMPLYR_STTUS_CODE = 'P'
AND DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE))
= ((SELECT IFNULL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY) - #alertBefore#)
]]>
</select>
</sqlMap>

View File

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
<!--
개인정보보호 기능개선 사업 (SFR-002) — 계정 관리 정책·현황·사전알림 Oracle 쌍(이중관리 규약).
※ arko 런타임은 MySQL sqlmap 만 로드. 본 파일은 이중관리 관례상 병행 유지.
-->
<sqlMap namespace="AccountMngDAO">
<select id="AccountMngDAO.selectPolicy"
resultClass="egovframework.com.cmm.privacy.account.AccountMngPolicyVO">
<![CDATA[
SELECT POLICY_ID AS policyId, DORMANT_DAY AS dormantDay,
ALERT_D1 AS alertD1, ALERT_D2 AS alertD2, ALERT_D3 AS alertD3,
DELETE_PRESERVE_DAY AS deletePreserveDay, BATCH_CYCLE AS batchCycle,
ADMIN_EMAIL AS adminEmail, UPD_ID AS updId, UPD_DT AS updDt
FROM ACCOUNT_MNG_POLICY WHERE POLICY_ID = 1
]]>
</select>
<update id="AccountMngDAO.updatePolicy"
parameterClass="egovframework.com.cmm.privacy.account.AccountMngPolicyVO">
<![CDATA[
UPDATE ACCOUNT_MNG_POLICY
SET DORMANT_DAY = #dormantDay#, ALERT_D1 = #alertD1#, ALERT_D2 = #alertD2#,
ALERT_D3 = #alertD3#, DELETE_PRESERVE_DAY = #deletePreserveDay#,
BATCH_CYCLE = #batchCycle#, ADMIN_EMAIL = #adminEmail#,
UPD_ID = #updId#, UPD_DT = SYSDATE
WHERE POLICY_ID = 1
]]>
</update>
<select id="AccountMngDAO.selectDormantStatusList"
parameterClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO"
resultClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO">
SELECT * FROM (
SELECT ROWNUM AS RN, T.* FROM (
SELECT e.EMPLYR_ID AS accountId, e.USER_NM AS userNm, e.ORGNZT_ID AS orgnztId,
e.OFCPS_NM AS ofcpsNm,
TO_CHAR(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE), 'YYYY-MM-DD') AS lastLoginDt,
TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) AS noConnectDays,
e.EMPLYR_STTUS_CODE AS sttusCode,
TO_CHAR(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE)
+ (SELECT NVL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY), 'YYYY-MM-DD') AS dormantExpectDt,
'EMP' AS mberTyCode
FROM COMTNEMPLYRINFO e
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="accountId"> e.EMPLYR_ID = #accountId# </isNotEmpty>
<isNotEmpty prepend="AND" property="userNm"> e.USER_NM LIKE '%' || #userNm# || '%' </isNotEmpty>
<isNotEmpty prepend="AND" property="sttusCode"> e.EMPLYR_STTUS_CODE = #sttusCode# </isNotEmpty>
<isGreaterThan prepend="AND" property="minNoConnectDays" compareValue="0">
(TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE))) &gt;= #minNoConnectDays#
</isGreaterThan>
</dynamic>
ORDER BY noConnectDays DESC
) T WHERE ROWNUM &lt;= #firstIndex# + #recordCountPerPage#
) WHERE RN &gt; #firstIndex#
</select>
<select id="AccountMngDAO.selectDormantStatusListCount"
parameterClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO"
resultClass="java.lang.Integer">
SELECT COUNT(1) FROM COMTNEMPLYRINFO e
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="accountId"> e.EMPLYR_ID = #accountId# </isNotEmpty>
<isNotEmpty prepend="AND" property="userNm"> e.USER_NM LIKE '%' || #userNm# || '%' </isNotEmpty>
<isNotEmpty prepend="AND" property="sttusCode"> e.EMPLYR_STTUS_CODE = #sttusCode# </isNotEmpty>
<isGreaterThan prepend="AND" property="minNoConnectDays" compareValue="0">
(TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE))) &gt;= #minNoConnectDays#
</isGreaterThan>
</dynamic>
</select>
<select id="AccountMngDAO.selectDormantAlertTargets"
parameterClass="java.util.HashMap"
resultClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO">
<![CDATA[
SELECT e.EMPLYR_ID AS accountId, e.USER_NM AS userNm, e.ESNTL_ID AS esntlId,
TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) AS noConnectDays,
'EMP' AS mberTyCode
FROM COMTNEMPLYRINFO e
WHERE e.EMPLYR_STTUS_CODE = 'P'
AND (TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE)))
= ((SELECT NVL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY) - #alertBefore#)
]]>
</select>
</sqlMap>

Some files were not shown because too many files have changed in this diff Show More