개인정보보호 기능개선 사업 구현 (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:
parent
fde7598b48
commit
2a2ab99d10
42
CLAUDE.md
Normal file
42
CLAUDE.md
Normal 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
76
pom.xml
@ -15,7 +15,7 @@
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>mvn2</id>
|
||||
<url>http://repo1.maven.org/maven2/</url>
|
||||
<url>https://repo1.maven.org/maven2/</url>
|
||||
<releases>
|
||||
<enabled>true</enabled>
|
||||
</releases>
|
||||
@ -665,6 +665,80 @@
|
||||
<artifactId>backport-util-concurrent</artifactId>
|
||||
<version>3.1</version>
|
||||
</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>
|
||||
|
||||
<build>
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 완료.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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/save→I, update/modify/chang→U, delete/remov→D, 그 외 S(조회).
|
||||
*/
|
||||
public static String deriveDmlGubun(String methodName) {
|
||||
if (methodName == null) {
|
||||
return "S";
|
||||
}
|
||||
String m = methodName.toLowerCase();
|
||||
if (m.startsWith("insert") || m.startsWith("regist") || m.startsWith("creat")
|
||||
|| m.startsWith("add") || m.startsWith("save")) {
|
||||
return "I";
|
||||
}
|
||||
if (m.startsWith("update") || m.startsWith("modify") || m.startsWith("chang")) {
|
||||
return "U";
|
||||
}
|
||||
if (m.startsWith("delete") || m.startsWith("remov")) {
|
||||
return "D";
|
||||
}
|
||||
return "S";
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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 getEsntlId→getUniqId→getMberId→getEmplyrId→
|
||||
* getEntrprsId→getGroupId→getUserId→getId→getSn}) 중 첫 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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)); // N→G, C→M, R→R
|
||||
hist.setBeforeAuthor(beforeAuthor);
|
||||
hist.setAfterAuthor("R".equals(se) ? null : req.getAfterAuthor());
|
||||
hist.setChgResn(req.getRequestResn());
|
||||
hist.setAprovlId(confmId);
|
||||
hist.setChangerUniqId(confmUniqId);
|
||||
hist.setChangerId(confmId);
|
||||
hist.setChangerIp(confmIp);
|
||||
authorWorkflowDAO.insertChangeHistory(hist);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rejectRequest(long requestId, String confmId, String confmResn) throws Exception {
|
||||
AuthorChangeRequestVO req = authorWorkflowDAO.selectRequest(requestId);
|
||||
if (req == null) {
|
||||
throw new IllegalArgumentException("존재하지 않는 신청입니다: " + requestId);
|
||||
}
|
||||
if (!"R".equals(req.getConfmSttus())) {
|
||||
throw new IllegalStateException("이미 처리된 신청입니다: " + req.getConfmSttus());
|
||||
}
|
||||
req.setConfmSttus("J");
|
||||
req.setConfmId(confmId);
|
||||
req.setConfmResn(confmResn);
|
||||
authorWorkflowDAO.updateRequestStatus(req);
|
||||
}
|
||||
|
||||
/** 신청구분(N/C/R) → 이력 변경구분(G:부여/M:변경/R:회수) */
|
||||
private String mapChgSeCode(String requestSe) {
|
||||
if ("R".equals(requestSe)) {
|
||||
return "R";
|
||||
}
|
||||
if ("C".equals(requestSe)) {
|
||||
return "M";
|
||||
}
|
||||
return "G";
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 < cutoff)의 권한 변경 이력을 파기한다.
|
||||
*
|
||||
* @param cutoffDt 파기 기준일시 문자열(yyyy-MM-dd HH:mm:ss). 이 시각 이전 이력이 대상.
|
||||
* @return 파기된 건수
|
||||
* @throws Exception 파기 오류
|
||||
*/
|
||||
public int deleteExpired(String cutoffDt) throws Exception {
|
||||
return delete("AuthorHistoryPurgeDAO.deleteExpired", cutoffDt);
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 + "]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import java.util.List;
|
||||
import org.springframework.web.context.request.RequestAttributes;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
|
||||
import egovframework.com.cmm.LoginVO;
|
||||
import egovframework.com.cmm.service.EgovUserDetailsService;
|
||||
import egovframework.rte.fdl.cmmn.AbstractServiceImpl;
|
||||
|
||||
@ -29,6 +30,13 @@ import egovframework.rte.fdl.cmmn.AbstractServiceImpl;
|
||||
public class EgovUserDetailsSessionServiceImpl extends AbstractServiceImpl implements
|
||||
EgovUserDetailsService {
|
||||
|
||||
/** 사용자 권한(롤) 조회 DAO — 개인정보보호 기능개선 사업(SFR-001) */
|
||||
private EgovUserAuthorityDAO userAuthorityDAO;
|
||||
|
||||
public void setUserAuthorityDAO(EgovUserAuthorityDAO userAuthorityDAO) {
|
||||
this.userAuthorityDAO = userAuthorityDAO;
|
||||
}
|
||||
|
||||
public Object getAuthenticatedUser() {
|
||||
|
||||
|
||||
@ -37,11 +45,44 @@ public class EgovUserDetailsSessionServiceImpl extends AbstractServiceImpl imple
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증된 사용자의 권한(롤) 목록을 반환한다.
|
||||
*
|
||||
* <p>개인정보보호 기능개선 사업(SFR-001, 역할별 차등 접근) 구현.
|
||||
* 세션의 로그인 정보(LoginVO)에서 고유식별자(uniqId)를 얻어 기존 표준 권한 매핑
|
||||
* 테이블(COMTNEMPLYRSCRTYESTBS)을 조회한다 — 신규 권한 테이블 없음.
|
||||
* 미인증/미조회 시 빈 목록을 반환한다(fail-safe: 권한 없음).</p>
|
||||
*/
|
||||
public List<String> getAuthorities() {
|
||||
|
||||
// 권한 설정을 리턴한다.
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -270,6 +270,9 @@ private FilterConfig config;
|
||||
// 2025.07.21 나혁제 urp IP 등록처리
|
||||
allowList.add("220.118.247.143");
|
||||
allowList.add("175.198.62.216");
|
||||
// 2026.06.18 로컬 개발 접속 허용 (localhost)
|
||||
allowList.add("127.0.0.1");
|
||||
allowList.add("0:0:0:0:0:0:0:1");
|
||||
|
||||
for (String ip : allowList)
|
||||
{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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})하면
|
||||
* 우선순위(OTP→SMS→CERT) 순으로 사용 가능한 첫 수단을 선택한다.</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) 폴백 — 우선순위(OTP→SMS→CERT) 중 사용 가능한 첫 수단
|
||||
for (AuthMethodType type : AuthMethodType.priorityOrder()) {
|
||||
if (type == preferred) {
|
||||
continue; // 이미 위에서 확인
|
||||
}
|
||||
AuthMethodService candidate = getService(type);
|
||||
if (candidate != null && candidate.isAvailable(uniqId)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IllegalStateException("사용 가능한 추가 인증수단이 없습니다: uniqId=" + uniqId);
|
||||
}
|
||||
|
||||
private AuthMethodService getService(AuthMethodType type) {
|
||||
return (serviceMap == null || type == null) ? null : serviceMap.get(type.code());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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; // 기본수단
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴백 탐색 우선순위(OTP→SMS→CERT).
|
||||
*/
|
||||
public static AuthMethodType[] priorityOrder() {
|
||||
return new AuthMethodType[] { OTP, SMS, CERT };
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -289,32 +289,40 @@ public class EgovLoginServiceImpl extends AbstractServiceImpl implements
|
||||
// 휴먼고객 확인후 테이블 입력
|
||||
@Override
|
||||
public void insertDiapause(LoginVO loginVO) throws Exception {
|
||||
loginDAO.insertDiapause(loginVO);
|
||||
EgovWebUtil egovUtil = new EgovWebUtil();
|
||||
|
||||
List<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++) {
|
||||
LoginVO vo = list.get(i);
|
||||
HashMap<String, String> map = new HashMap<>();
|
||||
|
||||
if (vo.getDiapause_stat() == null) {
|
||||
egovUtil.sendIdDiapauseMail(vo.getEmail(), vo.getCreatDt(), vo.getName(), vo.getId());
|
||||
//System.out.println("--------111.휴면 유예 메일을 보냈다.----------");
|
||||
map.put("id", vo.getId());
|
||||
map.put("diapause_stat", "S");
|
||||
|
||||
} else if (vo.getDiapause_stat().equals("S")) {
|
||||
egovUtil.sendIdDiapauseStandbyMail(vo.getEmail(), vo.getCreatDt(), vo.getName(), vo.getId());
|
||||
//System.out.println("--------222.휴면 확정 메일을 보냈다.----------");
|
||||
map.put("id", vo.getId());
|
||||
map.put("diapause_stat", "P");
|
||||
}
|
||||
|
||||
loginDAO.updateDiapauseMber(map);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 휴먼고객 활성화
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -164,6 +164,16 @@ public class LoginDAO extends EgovComAbstractDAO {
|
||||
insert("loginDAO.insertDiapause", loginVO);
|
||||
}
|
||||
|
||||
// [개인정보보호 #7] 개인정보취급자(COMTNEMPLYRINFO) 휴면 대상 적재
|
||||
public void insertDiapauseEmplyr(LoginVO loginVO) {
|
||||
insert("loginDAO.insertDiapauseEmplyr", loginVO);
|
||||
}
|
||||
|
||||
// [개인정보보호 #7] 개인정보취급자 휴면 확인 대상 조회
|
||||
public List<LoginVO> selectDiapauseListEmplyr(LoginVO loginVO) {
|
||||
return list("loginDAO.selectDiapauseListEmplyr", loginVO);
|
||||
}
|
||||
|
||||
// 휴먼고객 활성화
|
||||
public void deleteDiapause(LoginVO vo) {
|
||||
delete("loginDAO.deleteDiapause", vo);
|
||||
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
@ -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 #{}} 문법 아님. 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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -71,6 +71,9 @@ public class EgovLoginController {
|
||||
|
||||
protected static final String JSON = "jsonView";
|
||||
|
||||
/** 개인정보보호(SER-001/#24) 계정 잠금 기본 임계값 — LOGIN_LOCK_COUNT 미설정 시 fallback */
|
||||
private static final int DEFAULT_LOCK_THRESHOLD = 5;
|
||||
|
||||
/** EgovLoginService */
|
||||
@Resource(name = "loginService")
|
||||
private EgovLoginService loginService;
|
||||
@ -149,8 +152,6 @@ public class EgovLoginController {
|
||||
public String actionLogin(@ModelAttribute("loginVO") LoginVO loginVO,
|
||||
HttpServletRequest request,
|
||||
ModelMap model)throws Exception {
|
||||
System.out.println("로그인");
|
||||
|
||||
|
||||
// Start~ KTH, 2019.12.16 가상키패드 보안 솔루션 적용건
|
||||
String password = request.getParameter("civpad_cipher_value_password");
|
||||
@ -164,17 +165,81 @@ public class EgovLoginController {
|
||||
loginVO.setPassword(password);
|
||||
// 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, 10→5).
|
||||
// ============================================================
|
||||
|
||||
// 1. 계정 잠금 선(先) 확인 — 잠긴 계정은 인증 시도 전 차단
|
||||
try {
|
||||
if (loginVO.getId() != null && !"".equals(loginVO.getId())) {
|
||||
LoginVO lockVo = loginService.selectlockCheck(loginVO);
|
||||
if (lockVo != null && "Y".equals(lockVo.getLockYn())) {
|
||||
model.addAttribute("message", "계정이 잠겼습니다. 관리자에게 문의 바랍니다.");
|
||||
return "egovframework/com/uat/uia/EgovLoginUsr";
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.error("[개인정보/#24] 계정 잠금 확인 오류 - 로그인 계속 진행: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 2. 일반 로그인 처리
|
||||
LoginVO resultVO = loginService.actionLogin(loginVO);
|
||||
if (resultVO != null && resultVO.getId() != null && !resultVO.getId().equals("")) {
|
||||
System.out.println("로그인시도1");
|
||||
// 2-1. 로그인 정보를 세션에 저장
|
||||
// 2-1. 성공 — 당일 실패 이력 정리 후 세션 저장
|
||||
try {
|
||||
loginService.deleteLoginSuccess(loginVO);
|
||||
} catch (Exception e) {
|
||||
LOG.error("[개인정보/#22] 로그인 성공 후 실패이력 정리 오류: " + e.getMessage());
|
||||
}
|
||||
request.getSession().setAttribute("loginVO", resultVO);
|
||||
return "redirect:/mnt/main.do";
|
||||
// [개인정보보호 SFR-003 / #14·#52] 로그인 통합 2단계 인증:
|
||||
// 비밀번호 인증 성공 직후 2차 인증 화면으로 유도한다(관리자 actionLogin 경로 전용).
|
||||
// secondFactor.do가 등록자→OTP 검증 / 미등록자→등록 유도를 처리하고,
|
||||
// 2차 인증 완료(SECOND_FACTOR_DONE) 후 returnUrl 기본값(/mnt/main.do)으로 이동한다.
|
||||
// 미등록자도 '등록하기'로 진행 가능(잠금 아님). 일반회원은 diapauseLogin.do 경로라 미영향.
|
||||
return "redirect:/uat/uia/secondFactor.do";
|
||||
|
||||
} else {
|
||||
System.out.println("로그인시도2");
|
||||
// 2-2. 실패 — 계정이 존재하는 경우에만 실패 이력 적재 + 임계 초과 시 잠금
|
||||
int currentFail = 0;
|
||||
int threshold = DEFAULT_LOCK_THRESHOLD;
|
||||
try {
|
||||
HashMap<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"));
|
||||
return "egovframework/com/uat/uia/EgovLoginUsr";
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -3,10 +3,17 @@ package egovframework.com.uss.umt.service.impl;
|
||||
import java.util.List;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import egovframework.com.cmm.LoginVO;
|
||||
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
|
||||
import egovframework.com.cmm.privacy.lock.AccountUnlockHistoryDAO;
|
||||
import egovframework.com.cmm.privacy.lock.AccountUnlockHistoryVO;
|
||||
import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
||||
import egovframework.com.uat.uia.service.impl.LoginDAO;
|
||||
import egovframework.com.uss.umt.service.DiapauseManageVO;
|
||||
import egovframework.com.uss.umt.service.EgovAccountUnlockManageService;
|
||||
@ -26,6 +33,10 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp
|
||||
@Resource(name="loginDAO")
|
||||
private LoginDAO loginDAO;
|
||||
|
||||
/** 개인정보보호(SER-001 / #24) 계정 잠금 해제 이력 DAO */
|
||||
@Resource(name="accountUnlockHistoryDAO")
|
||||
private AccountUnlockHistoryDAO accountUnlockHistoryDAO;
|
||||
|
||||
@Override
|
||||
public List selectUserLockCheckList(UserDefaultVO userSearchVO) {
|
||||
return diapauseManageDAO.selectUserLockCheckList(userSearchVO);
|
||||
@ -51,6 +62,9 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp
|
||||
vo.setUser_id(sUser_id[i]);
|
||||
vo.setUser_gubun(sUser_gubun[i]);
|
||||
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");
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -15,6 +15,9 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import egovframework.com.cmm.EgovWebUtil;
|
||||
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.uat.uia.service.EgovLoginService;
|
||||
import egovframework.com.uss.umt.service.EgovMberManageService;
|
||||
@ -54,6 +57,14 @@ public class BatchScheduler {
|
||||
@Resource(name = "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);
|
||||
}
|
||||
|
||||
/**
|
||||
* [개인정보보호 #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 + "건 발송");
|
||||
}
|
||||
|
||||
}
|
||||
@ -32,7 +32,10 @@
|
||||
|
||||
<!-- 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. 스프링 시큐리티를 이용한 인증을 사용할 빈 -->
|
||||
|
||||
@ -24,4 +24,39 @@
|
||||
</aop:aspect>
|
||||
</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>
|
||||
@ -185,6 +185,119 @@
|
||||
|
||||
]]>
|
||||
</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() >= 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() >= 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 > 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">
|
||||
<![CDATA[
|
||||
DELETE FROM COMTNDIAPAUSEMBER
|
||||
|
||||
@ -581,12 +581,47 @@
|
||||
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 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_STTUS = 'P'
|
||||
]]>
|
||||
</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">
|
||||
<![CDATA[
|
||||
DELETE FROM COMTNDIAPAUSEMBER
|
||||
@ -624,7 +659,8 @@
|
||||
FROM COMTNDIAPAUSEMBER A
|
||||
LEFT JOIN COMTNGNRLMBER B
|
||||
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>
|
||||
|
||||
<update id="loginDAO.updateDiapauseMber" parameterClass="java.util.HashMap">
|
||||
|
||||
@ -99,4 +99,34 @@
|
||||
|
||||
<!-- 마일리지신청 -->
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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 >= #fromDt# </isNotEmpty>
|
||||
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT <= #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 >= #fromDt# </isNotEmpty>
|
||||
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT <= #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>
|
||||
@ -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 >= #fromDt# </isNotEmpty>
|
||||
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT <= #toDt# </isNotEmpty>
|
||||
</dynamic>
|
||||
ORDER BY LOG_ID DESC
|
||||
) T WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
|
||||
) WHERE RN > #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 >= #fromDt# </isNotEmpty>
|
||||
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT <= #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>
|
||||
@ -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)) >= #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)) >= #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>
|
||||
@ -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))) >= #minNoConnectDays#
|
||||
</isGreaterThan>
|
||||
</dynamic>
|
||||
ORDER BY noConnectDays DESC
|
||||
) T WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
|
||||
) WHERE RN > #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))) >= #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
Loading…
Reference in New Issue
Block a user