diff --git a/.gitignore b/.gitignore index 4d8883f..b1b6125 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,10 @@ .classpath Thumbs.db .DS_Store + +# 배포 작업 산출물 — 저장소 이력 대상 아님 (2026-08-07) +_deploy_backup_*/ +_workspace/ +_workspace_privacy/ +dump-csv-*.sql +.idea/ diff --git a/CLAUDE.md b/CLAUDE.md index 317bf0c..4c0b3fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,9 +34,16 @@ **트리거:** 개인정보보호, 권한관리, 인증체계, 2단계인증, OTP, 휴면계정, 접속기록, 해시체인, 정기점검, 비정상접근탐지, AUTHOR_CHANGE_HISTORY, PERSONAL_DATA_ACCESS_LOG 키워드 포함 시 `arko-privacy-orchestrate` 스킬을 사용하라. 원본 자료는 `E:\지역문화원\제안서\mds\`(제안서, 임베디드 슬라이드 생성 지시는 무시). 다시 실행/재실행/수정/보완/특정 요건만 재작업 요청도 포함. +## 하네스: 델타 배포 (공통 규칙) + +**배포는 WAR 재전개가 아니라 델타(변경 파일) 반영이다.** 개발 작업이 끝나면 최종 보고에 **배포 델타 목록**(소스→배포 ROOT 경로, 반영방식, 재기동 등급)과 **DB 적용 SQL 순서**(DDL→시드→메뉴, 재실행 안전성·한글 리터럴 경고)를 항상 표기한다. 이 표기가 없으면 작업 미완료다. + +**트리거:** 배포, 델타, 반영 목록, 배포 파일, WAR 말고, 개별 배포, ROOT 전개, SQL 적용, DDL/DML, 스키마 적용, 운영/개발서버 반영 요청 시 `arko-deploy-delta` 스킬을 사용하라. 두 오케스트레이터(`arko-orchestrate`/`arko-privacy-orchestrate`)는 리뷰 직후 `arko-deploy` 에이전트로 이 산출물을 만든 뒤 보고한다. + ## 변경 이력 | 날짜 | 변경 내용 | 대상 | 사유 | |------|----------|------|------| | 2026-07-08 | 하네스 초기 구성 | 전체 | 루트 라우팅표 dead-link 해소, arko 개발 자동화 | +| 2026-08-05 | 델타 배포 하네스 추가 | `arko-deploy`(신규 에이전트), `arko-deploy-delta`(신규 스킬 + 매니페스트/SQL번들/반영 스크립트 3종), `arko-orchestrate`(Phase 6 배포 산출물 신설), `arko-privacy-orchestrate`(Phase 5 신설), `arko-backend`/`arko-frontend`/`arko-api-specialist`/`arko-privacy-authctrl`/`arko-privacy-auditlog`(배포 델타 기록 의무), `arko-reviewer`(9절 배포 가능성 점검) | WAR 전체가 아닌 델타로 배포하므로 반영 파일·DDL/DML을 명시적 산출물로 강제 | | 2026-07-21 | 개인정보보호 서브 하네스 완성 | `arko-privacy-authctrl`/`arko-privacy-auditlog`(신규 에이전트), `arko-privacy-orchestrate`/`arko-privacy-authctrl-impl`/`arko-privacy-auditlog-impl`/`arko-privacy-traceability`(신규 스킬), `arko-reviewer.md`(개인정보보호 점검항목 8절 추가), `arko-analyst.md`(com.* 예외 포인터 추가), `arko-orchestrate/SKILL.md`(Phase 1 분기 추가) | 전일 중단된 arko-privacy-* 하네스 구축 이어서 완료 | diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountDeleteDAO.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountDeleteDAO.java new file mode 100644 index 0000000..9145e4a --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountDeleteDAO.java @@ -0,0 +1,50 @@ +package egovframework.com.cmm.privacy.account; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import egovframework.com.cmm.service.impl.EgovComAbstractDAO; + +/** + * 장기 미사용 계정 개인정보 파기 DAO (SFR-002 / 개발항목 #12). + * + *
statement 는 {@code PrivacyAccountDelete_SQL_Mysql.xml}(namespace {@code AccountDeleteDAO}), + * sql-map-config 등록. 보존기간은 SQL 내부에서 ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY 를 + * 직접 조회하므로 자바 측 하드코딩이 없다. 1회 처리건수 상한(200)도 SQL 측에 있다.
+ */ +@Repository("accountDeleteDAO") +public class AccountDeleteDAO extends EgovComAbstractDAO { + + /** 파기 대상(일반회원) — 휴면확정 후 보존기간 경과분, 최대 200건. */ + @SuppressWarnings("unchecked") + public List보존기간은 코드에 상수로 두지 않고 {@code ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY} + * 를 SQL 실행 시점에 조회해 적용한다(정책 화면 #13에서 변경 시 즉시 반영).
+ * + *파기 방식은 행 DELETE 가 아니라 PII 컬럼 마스킹 + 상태코드 'D' 전환이다. + * 게시글·활동확인·마일리지 등 다수 테이블이 MBER_ID/EMPLYR_ID 를 FK 없이 참조하고 있어 + * 행을 지우면 해당 이력이 고아가 되기 때문이며, 기존 회원탈퇴 컨벤션과도 동일하다.
+ * + *처리량 상한: 배치가 장기간 정지했다 재가동되는 날 백로그가 한꺼번에 처리되지 + * 않도록 1회 200건으로 제한(SQL의 LIMIT). 남은 대상은 다음 배치에서 이어 처리한다.
+ * + *계정 1건 처리는 마스킹→휴면행 제거→이력적재 순이며, 개별 실패는 로깅 후 계속(fail-safe) + * 하여 한 건의 오류가 배치 전체를 막지 않는다.
+ */ +@Service("accountDeleteService") +public class AccountDeleteServiceImpl implements AccountDeleteService { + + private static final Logger LOG = LoggerFactory.getLogger(AccountDeleteServiceImpl.class); + + private static final String TY_GNR = "GNR"; + private static final String TY_EMP = "EMP"; + + @Resource(name = "accountDeleteDAO") + private AccountDeleteDAO accountDeleteDAO; + + @Resource(name = "accountMngPolicyService") + private AccountMngPolicyService accountMngPolicyService; + + @Override + public int purgeExpiredAccounts() throws Exception { + // 파기는 되돌릴 수 없으므로 정책 플래그(ACCOUNT_MNG_POLICY.PURGE_USE_YN)가 'Y'일 때만 수행. + // 기본값 'N' — 관리자가 계정 관리 정책 화면에서 명시적으로 켜야 한다. + AccountMngPolicyVO policy = accountMngPolicyService.selectPolicy(); + if (policy == null || !"Y".equals(policy.getPurgeUseYn())) { + LOG.info("[개인정보/#12] 파기 배치 미사용(PURGE_USE_YN != 'Y') — 처리 없이 종료"); + return 0; + } + + int purged = 0; + purged += purge(accountDeleteDAO.selectExpiredGnrAccounts(), TY_GNR); + purged += purge(accountDeleteDAO.selectExpiredEmpAccounts(), TY_EMP); + LOG.info("[개인정보/#12] 장기 미사용 계정 파기 배치 완료 — {}건", Integer.valueOf(purged)); + return purged; + } + + /** 유형별 파기 처리. 개별 계정 실패는 건너뛰고 계속한다. */ + private int purge(List휴면 확정('P') 후 정책상 보존기간(ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY)이 + * 경과한 계정. preserveDay 는 조회 시점의 정책값으로, 파기 이력에 스냅샷으로 남긴다.
+ */ +public class AccountDeleteTargetVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 파기 대상 계정ID(일반회원 MBER_ID / 취급자 EMPLYR_ID) */ + private String mberId; + /** 고유식별자(ESNTL_ID) */ + private String esntlId; + /** 휴면 확정 기준일시 */ + private String diapauseDt; + /** 조회 시점 적용된 보존기간(일) */ + private int preserveDay; + + public String getMberId() { return mberId; } + public void setMberId(String mberId) { this.mberId = mberId; } + public String getEsntlId() { return esntlId; } + public void setEsntlId(String esntlId) { this.esntlId = esntlId; } + public String getDiapauseDt() { return diapauseDt; } + public void setDiapauseDt(String diapauseDt) { this.diapauseDt = diapauseDt; } + public int getPreserveDay() { return preserveDay; } + public void setPreserveDay(int preserveDay) { this.preserveDay = preserveDay; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/account/DormantAlertServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/account/DormantAlertServiceImpl.java index b32a9ad..afabd6a 100644 --- a/src/main/java/egovframework/com/cmm/privacy/account/DormantAlertServiceImpl.java +++ b/src/main/java/egovframework/com/cmm/privacy/account/DormantAlertServiceImpl.java @@ -1,7 +1,9 @@ package egovframework.com.cmm.privacy.account; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import javax.annotation.Resource; @@ -13,12 +15,19 @@ import egovframework.com.cop.ems.service.EgovSndngMailRegistService; import egovframework.com.cop.ems.service.SndngMailVO; /** - * 휴면 전 사전 알림 서비스 구현 (#10, D-30/D-7/D-Day + 관리자 알림). + * 휴면 전 사전 알림 서비스 구현 (#10, D-30/D-7/D-Day 관리자 알림). * *정책(ACCOUNT_MNG_POLICY)의 ALERT_D1/D2/D3(기본 30/7/0)일 전 대상을 조회해 - * 본인과 관리자(ADMIN_EMAIL)에게 사전 알림 메일을 발송한다. 기존 휴면 배치의 - * 유예/확정 본인 메일(EgovLoginServiceImpl.processDiapauseList)과 독립적으로 동작하며, - * 발송 실패가 배치 본류를 막지 않도록 전 구간 fail-safe.
+ * 관리자(ADMIN_EMAIL)에게 배치 1회 실행당 전체 대상을 모은 요약(digest) 메일 + * 1통으로 발송한다. 대상자가 많을 때 관리자 메일함이 건별 메일로 도배되는 것을 + * 막기 위함(2026-07-28 개선). 발송 실패가 배치 본류를 막지 않도록 전 구간 fail-safe. + * + *2026-07-30 본인 발송 제거: 발주 요구사항(Ⅲ-2.3, 요구사항 #4)은 + * '비활성화 전 관리자 알림 D-30/D-7/D-Day'이며 대상자 본인 개별 통지는 요건이 + * 아니다. 대상 5만건 규모에서 본인 건별 발송이 배치 처리량을 지배해 일일 상한을 + * 200건으로 묶어야 했고(휴면 전환 소진에 수년 소요), 이를 해소하기 위해 본인 발송을 + * 걷어냈다. 휴면 확정 체인의 본인 메일 2종도 함께 제거 + * (EgovLoginServiceImpl.processDiapauseList 주석 참조).
*/ @Service("dormantAlertService") public class DormantAlertServiceImpl implements DormantAlertService { @@ -46,6 +55,11 @@ public class DormantAlertServiceImpl implements DormantAlertService { String adminEmail = policy.getAdminEmail(); int sent = 0; + int targetCnt = 0; + + // 단계별 대상자를 모아두었다가 관리자에게는 배치 종료 후 요약 1통만 발송한다. + Map순번·해시체인 무결성(#39): 컨슈머는 단일 스레드다. 큐에서 하나씩 꺼내 * {@link PersonalDataLogService#persistNow}를 순차 호출하므로 {@code PREV_HASH_VAL}이 항상 - * 직전 레코드를 정확히 가리킨다. 병렬 컨슈머를 두지 않는다(전체 순번 보장).
+ * 직전 레코드를 정확히 가리킨다. 병렬 컨슈머를 두지 않는다(전체 순번 보장). + * 컨슈머 재기동·종료 flush 등 예외 경로에서도 {@code persistNow}가 {@code synchronized}라 + * 두 스레드가 동시에 봉인·적재하는 일은 없다. * - *드롭 금지·백프레셔: 큐가 가득 차면 레코드를 버리지 않고 {@code offer(timeout)}로 - * 대기(백프레셔)한다. enqueue는 업무 트랜잭션 커밋 이후(Aspect의 finally) 호출되므로 짧은 - * 대기가 업무 데이터 정합성을 해치지 않는다.
+ *백프레셔 상한(2026-07-30): 종전에는 큐가 가득 차면 {@code offer(timeout)}를 + * 무한 반복해 대기했다. 컨슈머가 정지하면(예: 파티션 DROP·전체 무결성 검증 중 테이블 + * 잠금으로 {@code persistNow}가 블로킹, 또는 컨슈머 스레드 사망) 요청 스레드가 이 지점에서 + * 영구 대기하고, 그 상태가 지속되면 WAS 스레드 풀이 고갈돼 사이트 전체가 정지한다. + * 복구 경로가 없는 구조라 대기를 {@link #MAX_OFFER_ATTEMPT}회로 제한한다. 상한을 넘기면 + * 해당 1건을 포기하되 유실 사실과 식별 정보를 ERROR 로그로 남기고 누적 건수를 집계한다 + * (접속기록은 법정 보관 대상이므로 조용한 유실은 금지 — 사후 추적 가능하게 남긴다).
+ * + *컨슈머 감시(2026-07-30): 적재 루프가 {@code Throwable}까지 흡수해 스스로 죽지 + * 않지만, 그것만으로는 외부 요인(스레드 강제 종료 등)에 대비할 수 없다. 별도 감시 스레드가 + * 주기적으로 컨슈머 생존과 큐 적체를 점검하고, 죽어 있으면 재기동한다. 감시는 스케줄러 + * 설정({@code task:annotation-driven})에 의존하지 않는 자체 데몬 스레드다 — 스케줄러 활성화 + * 여부와 무관하게 동작해야 하기 때문이다.
* *종료 flush: 컨테이너 종료 시 잔여 큐를 모두 적재한 뒤 스레드를 정리한다(유실 방지).
* @@ -39,12 +52,24 @@ public class AccessLogQueue { private static final Log LOG = LogFactory.getLog(AccessLogQueue.class); - /** 큐 용량(백프레셔 경계). 초과 시 드롭하지 않고 대기한다. */ + /** 큐 용량(백프레셔 경계). */ private static final int CAPACITY = 10000; - /** enqueue 대기 1회 최대 시간(초). 초과하면 경고 로깅 후 재대기(무한 유실 방지). */ + /** enqueue 대기 1회 최대 시간(초). */ private static final long OFFER_WAIT_SEC = 5L; + /** enqueue 대기 최대 횟수 — 초과 시 해당 건을 포기한다(요청 스레드 영구 대기 방지). */ + private static final int MAX_OFFER_ATTEMPT = 3; /** 종료 시 컨슈머 스레드 join 최대 대기(ms). */ private static final long SHUTDOWN_JOIN_MS = 10000L; + /** 컨슈머 생존·적체 감시 주기(ms). */ + private static final long WATCHDOG_INTERVAL_MS = 30000L; + /** 적체 경고 임계치(용량 대비). 이 이상 쌓이면 감시 스레드가 경고한다. */ + private static final int BACKLOG_WARN_SIZE = (int) (CAPACITY * 0.8); + /** 포화 해제(차단기 복구) 임계치 — 잔량이 이 수준까지 빠지면 정상 경로로 복귀한다. */ + private static final int SATURATION_CLEAR_SIZE = CAPACITY / 2; + /** 유실 로그 상세를 남기는 초기 건수(이후는 표본 기록 — 로그 폭주 방지). */ + private static final long DROP_LOG_HEAD = 20L; + /** 초기 건수 이후 유실 로그 표본 주기. */ + private static final long DROP_LOG_SAMPLE = 100L; private final BlockingQueue큐가 가득 차면 {@link #MAX_OFFER_ATTEMPT}회까지만 대기한다. 그 이상은 요청 스레드를 + * 붙잡지 않고 포기하며, 유실 사실을 ERROR로 남긴다. 종료 진행 중이면 인라인 적재한다 + * ({@code persistNow}가 {@code synchronized}라 컨슈머와 동시 실행돼도 순번은 안전).
*/ public void enqueue(PersonalDataAccessLogVO vo) { if (vo == null) { return; } + if (!running) { + // 종료 중 — 큐에 넣어도 처리되지 않을 수 있으므로 인라인 적재로 유실을 막는다. + personalDataLogService.persistNow(vo); + return; + } + // 차단기 ON — 대기하지 않고 즉시 판정(요청 지연 확산 차단). + if (saturated) { + if (!queue.offer(vo)) { + recordDrop(vo); + } + 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() - + ") — 컨슈머 지연. 드롭하지 않고 계속 대기."); + for (int attempt = 1; attempt <= MAX_OFFER_ATTEMPT; attempt++) { + if (!running) { + personalDataLogService.persistNow(vo); + return; } + if (queue.offer(vo, OFFER_WAIT_SEC, TimeUnit.SECONDS)) { + return; + } + LOG.warn("[개인정보/#33] 접속기록 큐 포화(size=" + queue.size() + ") — 컨슈머 지연, 대기 " + + attempt + "/" + MAX_OFFER_ATTEMPT); } - if (!queued) { - // 종료 중(running=false) — 컨슈머가 이미 멈췄으므로 단일 라이터 보장 하에 인라인 적재. - personalDataLogService.persistNow(vo); - } + // 상한 초과 — 요청 스레드를 더 붙잡지 않는다. 무한 대기로 WAS 스레드를 고갈시키는 것보다 + // 1건 유실이 낫다는 판단(단, recordDrop 로그로 사후 추적 가능하게 남긴다). + saturated = true; + LOG.error("[개인정보/#33] 접속기록 큐 포화 차단기 ON — 이후 대기 없이 즉시 판정한다. " + + "잔량이 " + SATURATION_CLEAR_SIZE + " 이하로 빠지면 자동 복구."); + recordDrop(vo); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); - LOG.error("[개인정보/#33] 접속기록 enqueue 인터럽트 — 인라인 적재 시도.", ie); + LOG.error("[개인정보/#33] 접속기록 enqueue 인터럽트 — 인라인 적재 시도. " + describe(vo), ie); try { personalDataLogService.persistNow(vo); } catch (Exception e) { - LOG.error("[개인정보/#33] 인라인 적재 실패", e); + LOG.error("[개인정보/#33] 인라인 적재 실패 — " + describe(vo), e); } } } - /** 단일 컨슈머 루프 — 큐에서 하나씩 꺼내 순차 적재(순번 보장). */ + /** + * 단일 컨슈머 루프 — 큐에서 하나씩 꺼내 순차 적재(순번 보장). + * + *{@code Throwable}까지 흡수한다. {@code Exception}만 잡던 종전 구조에서는 + * {@code Error}(OOM 등) 하나로 컨슈머가 죽고, {@code running}은 true로 남아 이후 모든 + * {@link #enqueue}가 포화 대기에 빠졌다.
+ */ private void consumeLoop() { while (running || !queue.isEmpty()) { try { @@ -111,20 +209,99 @@ public class AccessLogQueue { } catch (InterruptedException ie) { Thread.currentThread().interrupt(); // 종료 신호 — 남은 큐를 마저 비우고 루프 조건에서 빠져나간다. - } catch (Exception e) { - LOG.error("[개인정보/#33] 접속기록 적재 처리 실패(로깅 후 계속)", e); + } catch (Throwable t) { + LOG.error("[개인정보/#33] 접속기록 적재 처리 실패(로깅 후 계속)", t); } } LOG.info("[개인정보/#33] 접속기록 비동기 컨슈머 종료 — 잔여 큐 flush 완료."); } + /** 컨슈머 생존과 큐 적체를 주기 점검한다. 컨슈머가 죽어 있으면 재기동한다. */ + private void watchdogLoop() { + while (running) { + try { + Thread.sleep(WATCHDOG_INTERVAL_MS); + if (!running) { + break; + } + Thread cur = consumer; + if (cur == null || !cur.isAlive()) { + long n = consumerRestartCount.incrementAndGet(); + LOG.error("[개인정보/#33] 접속기록 컨슈머 사망 감지 — 재기동(누적 " + n + "회). 큐 잔량=" + + queue.size() + ", 누적 포기=" + droppedCount.get()); + startConsumer(); + } else if (queue.size() >= BACKLOG_WARN_SIZE) { + LOG.warn("[개인정보/#33] 접속기록 큐 적체 경고 — size=" + queue.size() + "/" + CAPACITY + + ". 이 상태가 지속되면 포화로 유실이 시작된다(누적 포기=" + droppedCount.get() + "건)."); + } + + // 차단기 복구 — 잔량이 절반 이하로 빠졌으면 정상(대기 허용) 경로로 되돌린다. + if (saturated && queue.size() <= SATURATION_CLEAR_SIZE) { + saturated = false; + LOG.info("[개인정보/#33] 접속기록 큐 포화 차단기 OFF — 정상 경로 복귀(size=" + queue.size() + + ", 누적 포기=" + droppedCount.get() + "건)."); + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } catch (Throwable t) { + LOG.error("[개인정보/#33] 접속기록 컨슈머 감시 중 예외(계속)", t); + } + } + LOG.info("[개인정보/#33] 접속기록 컨슈머 감시 종료."); + } + + /** + * 유실 1건을 집계하고 기록한다. + * + *접속기록은 법정 보관 대상이라 조용히 버리지 않는다. 다만 포화가 지속되면 유실 로그가 + * 그대로 로그 폭주가 되므로, 초기 {@link #DROP_LOG_HEAD}건은 전부 남기고 이후에는 + * {@link #DROP_LOG_SAMPLE}건마다 표본으로 남긴다. 누적 건수는 항상 정확히 집계된다.
+ */ + private void recordDrop(PersonalDataAccessLogVO vo) { + long dropped = droppedCount.incrementAndGet(); + if (dropped <= DROP_LOG_HEAD || dropped % DROP_LOG_SAMPLE == 0L) { + LOG.error("[개인정보/#33] 접속기록 1건 포기(누적 " + dropped + "건, size=" + queue.size() + + ") — 컨슈머 상태 점검 필요. 유실건: " + describe(vo)); + } + } + + /** 유실·실패 로그용 식별 정보. 개인정보 본문은 담지 않는다(#31 본문 미적재 원칙). */ + private String describe(PersonalDataAccessLogVO vo) { + if (vo == null) { + return "(null)"; + } + return "conectId=" + vo.getConectId() + ", conectDt=" + vo.getConectDt() + + ", jobSeCode=" + vo.getJobSeCode() + ", trgetId=" + vo.getTrgetId(); + } + + /** 큐 포화로 포기한 누적 건수(운영 점검·정기점검 리포트 참고용). */ + public long getDroppedCount() { + return droppedCount.get(); + } + + /** 컨슈머 재기동 누적 횟수(운영 점검용). */ + public long getConsumerRestartCount() { + return consumerRestartCount.get(); + } + + /** 현재 큐 잔량(운영 점검용). */ + public int getQueueSize() { + return queue.size(); + } + @PreDestroy public void stop() { running = false; - if (consumer != null) { - consumer.interrupt(); + Thread w = watchdog; + if (w != null) { + w.interrupt(); + } + Thread c = consumer; + if (c != null) { + c.interrupt(); try { - consumer.join(SHUTDOWN_JOIN_MS); // 잔여 flush 대기 + c.join(SHUTDOWN_JOIN_MS); // 잔여 flush 대기 } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } @@ -136,11 +313,15 @@ public class AccessLogQueue { try { personalDataLogService.persistNow(remaining.get(i)); } catch (Exception e) { - LOG.error("[개인정보/#33] 종료 flush 적재 실패", e); + LOG.error("[개인정보/#33] 종료 flush 적재 실패 — " + describe(remaining.get(i)), e); } } if (!remaining.isEmpty()) { LOG.info("[개인정보/#33] 종료 시 잔여 " + remaining.size() + "건 인라인 flush 완료."); } + if (droppedCount.get() > 0L) { + LOG.error("[개인정보/#33] 가동 중 큐 포화로 포기한 접속기록 누계 " + droppedCount.get() + + "건 — 원인 점검 필요(컨슈머 처리량/DB 지연)."); + } } } diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingController.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingController.java new file mode 100644 index 0000000..48cb873 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingController.java @@ -0,0 +1,267 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.net.URLEncoder; +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.validation.BindingResult; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.util.EgovUserDetailsHelper; +import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo; + +/** + * 업무유형 매핑 관리 컨트롤러 — 개인정보보호 기능개선 사업(SFR-004 / SER-002 / #32-1). + * + *{@code PDAL_JOB_MAPPING}(#32) 을 화면에서 등록·수정·사용중지·삭제한다. 이 매핑은 + * 접속기록 수집 Aspect(#31)가 (서비스 클래스, 메서드)를 업무코드·업무명으로 분류하는 기준이며, + * 접속기록 조회 화면(#47)의 '수행업무' 검색 드롭다운 소스이기도 하다.
+ * + *화면 경로 {@code /WEB-INF/jsp/site/mnt/sec/jobMapping/list.jsp}, {@code regist.jsp}
+ * + *수집 범위와의 관계(오해 주의): 매핑을 지우거나 사용중지해도 수집이 멈추지는 않는다. + * 수집 대상은 {@code context-syslogaop.xml} 의 pointcut 이 정하고, 이 테이블은 분류만 담당한다. + * 매핑이 없으면 메서드명으로 유도한 기본 분류로 적재된다.
+ * + *접근통제: 인터셉터 3단 중 ①인증판정이 비활성이고 ②③은 fail-open 이라 + * /mnt 하위라도 무인증 노출이 가능하므로, {@link #handlerUser()} 로 화면·엔드포인트 + * 단에서 직접 판정한다(PersonalDataAccessLogController 와 동일 방식).
+ * + *+ * << 개정이력(Modification Information) >> + * 수정일 수정자 수정내용 + * ---------- ------ --------------------------- + * 2026.08.07 URP 최초 생성 (개인정보보호 SFR-004 #32-1) + *+ */ +@Controller +public class JobMappingController { + + private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; + private static final String LIST_REDIRECT = "redirect:/mnt/sec/jobMapping/listView.do?page=9100045"; + + @Resource(name = "jobMappingManageService") + private JobMappingManageService jobMappingManageService; + + /** + * 업무유형 매핑 목록 화면(서버 렌더링). + * 뷰: /WEB-INF/jsp/site/mnt/sec/jobMapping/list.jsp + */ + @RequestMapping(value = "/mnt/sec/jobMapping/listView.do") + public String listView(@ModelAttribute("jobMappingSearchVO") JobMappingSearchVO searchVO, + @RequestParam(value = "resultMsg", required = false) String resultMsg, + ModelMap model) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + + // selectJobMappingManageList 내부에서 firstIndex/totalRecordCount 를 세팅한다. + List
{@code BindingResult} 를 반드시 받는다 — 숫자 필드(우선순위)를 비우거나 문자로 채우면 + * 바인딩이 실패하는데, 이 파라미터가 없으면 Spring 이 BindException 을 던져 500 오류 화면이 뜬다. + * 여기서 받아 사용자 메시지로 되돌린다.
+ */ + @RequestMapping(value = "/mnt/sec/jobMapping/save.do") + public String save(@ModelAttribute("jobMappingVO") JobMappingVO vo, + BindingResult bindingResult, + HttpServletRequest request) throws Exception { + LoginVO user = handlerUser(); + if (user == null) { + return LOGIN_REDIRECT; + } + if (!isPost(request) || !isSameOrigin(request)) { + return listRedirect("잘못된 요청 경로입니다."); + } + if (bindingResult.hasErrors()) { + return listRedirect("입력값 형식이 올바르지 않습니다. 우선순위는 숫자로 입력하세요."); + } + + try { + if (vo.getMappingId() > 0) { + jobMappingManageService.updateJobMapping(vo, user.getId()); + return listRedirect("수정되었습니다. 접속기록 분류에 즉시 반영됩니다."); + } + jobMappingManageService.insertJobMapping(vo, user.getId()); + return listRedirect("등록되었습니다. 접속기록 분류에 즉시 반영됩니다."); + } catch (IllegalArgumentException e) { + return listRedirect(e.getMessage()); + } + } + + /** 사용여부 전환(Y↔N) — 삭제 대신 권장하는 비활성화 경로. */ + @RequestMapping(value = "/mnt/sec/jobMapping/useAt.do") + public String updateUseAt(@RequestParam("mappingId") long mappingId, + @RequestParam("useAt") String useAt, + HttpServletRequest request) throws Exception { + LoginVO user = handlerUser(); + if (user == null) { + return LOGIN_REDIRECT; + } + if (!isPost(request) || !isSameOrigin(request)) { + return listRedirect("잘못된 요청 경로입니다."); + } + + try { + jobMappingManageService.updateJobMappingUseAt(mappingId, useAt, user.getId()); + return listRedirect("Y".equals(useAt) ? "사용으로 전환했습니다." : "사용중지로 전환했습니다."); + } catch (IllegalArgumentException e) { + return listRedirect(e.getMessage()); + } + } + + /** 매핑 삭제(행 제거). */ + @RequestMapping(value = "/mnt/sec/jobMapping/delete.do") + public String delete(@RequestParam("mappingId") long mappingId, + HttpServletRequest request) throws Exception { + LoginVO user = handlerUser(); + if (user == null) { + return LOGIN_REDIRECT; + } + if (!isPost(request) || !isSameOrigin(request)) { + return listRedirect("잘못된 요청 경로입니다."); + } + + try { + jobMappingManageService.deleteJobMapping(mappingId, user.getId()); + return listRedirect("삭제되었습니다."); + } catch (IllegalArgumentException e) { + return listRedirect(e.getMessage()); + } + } + + /** + * Resolver 캐시 재적재. 화면을 거치지 않고 DB 를 직접 수정한 경우 + * WAS 재기동 없이 반영하기 위한 수단이다. + */ + @RequestMapping(value = "/mnt/sec/jobMapping/refresh.do") + public String refresh(HttpServletRequest request) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + if (!isPost(request) || !isSameOrigin(request)) { + return listRedirect("잘못된 요청 경로입니다."); + } + jobMappingManageService.refreshCache(); + return listRedirect("매핑 캐시를 재적재했습니다."); + } + + /** 처리결과 메시지를 붙여 목록으로 리다이렉트. */ + private String listRedirect(String message) throws Exception { + if (message == null) { + return LIST_REDIRECT; + } + return LIST_REDIRECT + "&resultMsg=" + URLEncoder.encode(message, "UTF-8"); + } + + /** 상태를 바꾸는 요청은 POST 로 한정(링크 클릭·이미지 태그를 통한 변경 방지). */ + private boolean isPost(HttpServletRequest request) { + return "POST".equalsIgnoreCase(request.getMethod()); + } + + /** + * 취급자 영역(/mnt) 인증·구분 판정. + * + * @return 인증된 업무사용자(USR) LoginVO, 그 외에는 null + */ + private LoginVO handlerUser() { + if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) { + return null; + } + Object o = EgovUserDetailsHelper.getAuthenticatedUser(); + if (!(o instanceof LoginVO)) { + return null; + } + LoginVO loginVO = (LoginVO) o; + return "USR".equals(loginVO.getUserSe()) ? loginVO : null; + } + + /** + * CSRF 부분 완화. Spring Security 2.0.4 에는 CSRF 토큰 기능이 없으므로, + * Origin/Referer 가 전달된 경우에 한해 요청 호스트와 동일한지 확인한다 + * (헤더 미전송 환경은 과잉차단을 피해 통과 — AccountMngController 와 동일 규약). + */ + private boolean isSameOrigin(HttpServletRequest request) { + String source = request.getHeader("Origin"); + if (source == null) { + source = request.getHeader("Referer"); + } + if (source == null) { + return true; + } + String sourceHost = hostOf(source); + String requestHost = hostOf(request.getHeader("Host")); + return sourceHost != null && sourceHost.equalsIgnoreCase(requestHost); + } + + /** "https://host:8080/path" · "host:8080" 등에서 호스트명만 추출. */ + private String hostOf(String value) { + if (value == null) { + return null; + } + String host = value; + int scheme = host.indexOf("://"); + if (scheme >= 0) { + host = host.substring(scheme + 3); + } + int slash = host.indexOf('/'); + if (slash >= 0) { + host = host.substring(0, slash); + } + int colon = host.indexOf(':'); + if (colon >= 0) { + host = host.substring(0, colon); + } + return host.length() == 0 ? null : host; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingDAO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingDAO.java index 86e0246..a3b02f3 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingDAO.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingDAO.java @@ -25,4 +25,54 @@ public class JobMappingDAO extends EgovComAbstractDAO { public List#32 는 테이블·로더까지만 구현되어 매핑 행을 DB 직접 INSERT 로만 운영했다. + * 본 서비스는 취급자가 화면에서 매핑을 등록·수정·사용중지·삭제할 수 있게 한다. + * 매핑이 바뀌면 {@link JobMappingResolver} 캐시를 즉시 무효화하므로 WAS 재기동이 불요하다.
+ * + * @author URP + * @since 2026-08-07 + */ +public interface JobMappingManageService { + + /** 관리 목록 조회(검색 + 페이징). searchVO 에 firstIndex/totalRecordCount 를 세팅한다. */ + List클래스명이 {@code *Impl} 이고 패키지가 {@code egovframework.com..} 이므로 + * {@code context-transaction.xml} 의 requiredTx pointcut 에 걸려 메서드 단위 트랜잭션이 적용된다 + * (DB 반영 + 캐시 무효화가 함께 처리된다).
+ * + *캐시 무효화 범위: {@link JobMappingResolver#refresh()} 는 해당 JVM 의 캐시만 + * 비운다. arko 는 단일 Tomcat ROOT 전개라 현재는 충분하나, 다중 인스턴스로 확장하면 + * 인스턴스별 재적재 수단(관리 화면의 '캐시 재적재' 버튼을 각 노드에서 호출 등)이 필요하다.
+ * + *입력 검증: TARGET_CLASS/METHOD_PTTRN 은 Aspect 가 (클래스 단순명, 메서드명)으로 + * 매칭하는 값이라 자바 식별자 형식만 허용한다. 형식을 열어두면 어떤 값도 매칭되지 않는 + * 죽은 매핑이 쌓여 접속기록 분류가 조용히 폴백 처리된다.
+ * + * @author URP + * @since 2026-08-07 + */ +@Service("jobMappingManageService") +public class JobMappingManageServiceImpl implements JobMappingManageService { + + /** 자바 식별자(패키지 포함 FQN 허용) */ + private static final Pattern CLASS_PATTERN = + Pattern.compile("^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$"); + /** 메서드명 접두(부분 식별자) */ + private static final Pattern METHOD_PATTERN = + Pattern.compile("^[A-Za-z_$][A-Za-z0-9_$]*$"); + /** 업무코드 — 대문자/숫자/언더스코어 */ + private static final Pattern CODE_PATTERN = + Pattern.compile("^[A-Z0-9_]+$"); + + @Resource(name = "jobMappingDAO") + private JobMappingDAO jobMappingDAO; + + @Resource(name = "jobMappingResolver") + private JobMappingResolver jobMappingResolver; + + public List매핑 조회용 {@link JobMappingVO} 와 달리 USE_AT='N' 행도 조회 대상이다. + * 관리 화면에서는 사용중지된 매핑도 보여야 재사용·복구가 가능하기 때문이다 + * ({@code JobMappingDAO.selectJobMappingList} 는 Resolver 캐시용이라 'Y'만 조회).
+ * + * @author URP + * @since 2026-08-07 + */ +public class JobMappingSearchVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 대상 클래스명(부분 일치) */ + private String searchTargetClass; + /** 업무코드 또는 업무명(부분 일치) */ + private String searchJobSe; + /** 사용여부 Y/N (빈 값이면 전체) */ + private String searchUseAt; + /** 처리구분 S/I/U/D (빈 값이면 전체) */ + private String searchDmlGubun; + + // ---- 페이징 ---- + /** 현재 페이지 번호(1-base) */ + private int pageIndex = 1; + /** 페이지당 레코드 수 */ + private int recordCountPerPage = 20; + /** 조회 시작 offset(0-base) = (pageIndex-1) * recordCountPerPage */ + private int firstIndex = 0; + /** 전체 레코드 수(조회 후 세팅) */ + private int totalRecordCount = 0; + + public String getSearchTargetClass() { return searchTargetClass; } + public void setSearchTargetClass(String searchTargetClass) { this.searchTargetClass = searchTargetClass; } + + public String getSearchJobSe() { return searchJobSe; } + public void setSearchJobSe(String searchJobSe) { this.searchJobSe = searchJobSe; } + + public String getSearchUseAt() { return searchUseAt; } + public void setSearchUseAt(String searchUseAt) { this.searchUseAt = searchUseAt; } + + public String getSearchDmlGubun() { return searchDmlGubun; } + public void setSearchDmlGubun(String searchDmlGubun) { this.searchDmlGubun = searchDmlGubun; } + + public int getPageIndex() { return pageIndex; } + public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; } + + public int getRecordCountPerPage() { return recordCountPerPage; } + public void setRecordCountPerPage(int recordCountPerPage) { this.recordCountPerPage = recordCountPerPage; } + + public int getFirstIndex() { return firstIndex; } + public void setFirstIndex(int firstIndex) { this.firstIndex = firstIndex; } + + public int getTotalRecordCount() { return totalRecordCount; } + public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java index c240599..119aace 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java @@ -33,6 +33,16 @@ public class JobMappingVO implements Serializable { private String useAt; private String rm; + // ---- 변경 추적(#32-1 관리 화면) : 시드 적재 행은 등록자 NULL ---- + /** 등록자 계정ID */ + private String registerId; + /** 등록 일시 */ + private String regDt; + /** 최종 변경자 계정ID */ + private String updusrId; + /** 최종 변경 일시 */ + private String updtDt; + public long getMappingId() { return mappingId; } public void setMappingId(long mappingId) { this.mappingId = mappingId; } @@ -59,4 +69,16 @@ public class JobMappingVO implements Serializable { public String getRm() { return rm; } public void setRm(String rm) { this.rm = rm; } + + public String getRegisterId() { return registerId; } + public void setRegisterId(String registerId) { this.registerId = registerId; } + + public String getRegDt() { return regDt; } + public void setRegDt(String regDt) { this.regDt = regDt; } + + public String getUpdusrId() { return updusrId; } + public void setUpdusrId(String updusrId) { this.updusrId = updusrId; } + + public String getUpdtDt() { return updtDt; } + public void setUpdtDt(String updtDt) { this.updtDt = updtDt; } } diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java new file mode 100644 index 0000000..ab8dfe6 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java @@ -0,0 +1,195 @@ +package egovframework.com.cmm.privacy.auditlog; + +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.net.URLEncoder; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.util.EgovUserDetailsHelper; +import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo; + +/** + * 개인정보 접속기록 조회·다운로드 컨트롤러 — 개인정보보호 기능개선 사업(SFR-004 / SER-002 / #47). + * + *{@code PERSONAL_DATA_ACCESS_LOG}(#30) 적재분을 접속자ID·업무유형·처리대상·IP·처리구분· + * 처리결과·기간으로 복합 검색한다(#41 백엔드 연계). 화면: + * {@code /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp}
+ * + *다운로드(#42): 사유 입력이 필수이며 미입력 시 400으로 차단하고 파일을 열지 않는다. + * 다운로드자·일시·IP·사유·건수는 {@code PDAL_DOWNLOAD_HISTORY}에 적재된다 + * ({@link AccessLogDownloadService}). 개인정보 본문은 이력에 남기지 않는다.
+ * + *접근통제: 인터셉터 3단 중 ①인증판정이 비활성이고 ②③은 fail-open 이라 + * /mnt 하위라도 무인증 노출이 가능하므로, {@link #handlerUser()} 로 화면·엔드포인트 + * 단에서 직접 판정한다(LoginFailHistoryController 와 동일 방식).
+ * + *+ * << 개정이력(Modification Information) >> + * 수정일 수정자 수정내용 + * ---------- ------ --------------------------- + * 2026.08.06 URP 최초 생성 (개인정보보호 SFR-004 #47) + *+ */ +@Controller +public class PersonalDataAccessLogController { + + private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; + + @Resource(name = "personalDataAccessLogSearchService") + private PersonalDataAccessLogSearchService personalDataAccessLogSearchService; + + @Resource(name = "accessLogDownloadService") + private AccessLogDownloadService accessLogDownloadService; + + @Resource(name = "personalDataAccessLogSearchDAO") + private PersonalDataAccessLogSearchDAO personalDataAccessLogSearchDAO; + + /** + * 개인정보 접속기록 조회 화면(서버 렌더링). + * 뷰: /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp + */ + @RequestMapping(value = "/mnt/sec/accessLog/listView.do") + public String listView(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO, + ModelMap model) throws Exception { + if (handlerUser() == null) { + return LOGIN_REDIRECT; + } + + // selectAccessLogList 내부에서 firstIndex/totalRecordCount 를 세팅한다. + List
2026-08-07 소스 변경: 종전에는 PDAL_JOB_MAPPING(서비스 클래스·메서드 매핑)을 읽었으나, + * 수집이 관리자 화면 메뉴 기반({@code MenuAccessLogInterceptor})으로 바뀌어 업무유형이 + * 메뉴에서 나온다. 매핑 테이블을 보면 실제 적재값과 어긋난 선택지가 떠 검색 결과가 + * 항상 0건이 되므로, 적재된 로그의 DISTINCT 업무유형을 소스로 쓴다.
+ * + *조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe.
+ */ + private List수집이 관리자 화면 메뉴 기반으로 바뀌면서 업무유형은 메뉴에서 나온다(2026-08-07). + * PDAL_JOB_MAPPING 을 보면 적재값과 어긋난 선택지가 뜨므로 적재분에서 직접 뽑는다. + * 반환 타입은 (jobSeCode, jobSeNm) 두 프로퍼티만 쓰는 용도로 {@link JobMappingVO}를 재사용한다.
+ */ + @SuppressWarnings("unchecked") + public List[2026-08-07 사용 중단] 이 Aspect 는 더 이상 적용되지 않는다.
+ * {@code context-syslogaop.xml}의 {@code
한정 수집: {@code COMTNSYSLOG}의 select* 전 서비스 대상 방식이 아니라,
* 개인정보 처리 서비스로 한정한 pointcut(스키마 기반 {@code 본문 미적재 원칙: 처리 파라미터(개인정보 본문)는 로그에 담지 않는다. 식별자(TRGET_ID)만
* 베스트에포트로 취득한다(#36). 로그인 세션 한정(2026-08-07): 세션에 {@code loginVO}가 없는 요청은 수집하지 않는다.
+ * 종전에는 접속자ID를 {@code ANONYMOUS}로 적재했다. 근거는 두 가지다.
+ * ① 제안요청서(붙임2) SFR-004 정의가 "개인정보취급자 접속 및 업무처리 기록 관리"이고
+ * 세부내용 첫 항목도 "개인정보취급자의 접속기록을 저장·관리"로, 비로그인 상태(정보주체 본인의
+ * 가입·실명확인 등)는 대상이 아니다(SER-002·안전성 확보조치 기준 제8조도 동일 기준).
+ * ② 비정상접근 탐지(#35)가 {@code GROUP BY CONECT_ID} 기반이라, 모든 익명 요청이
+ * {@code ANONYMOUS} 한 계정으로 뭉쳐 계정공유의심(상이 IP 다수)·대량조회에 상시 오탐을 만든다.
+ * 제외되는 대표 경로: 회원가입({@code insert*}), 실명확인({@code MberSsnServiceImpl}),
+ * 휴면해제, 계정잠금 해제, 인증번호 발송. 이 경로들의 추적은 인증 실패 이력(#22)이 담당한다. 수집 범위(#36): 회원·업체 2개 지점 한정에서 전 개인정보 처리 서비스로 확장.
* pointcut 대상은 {@code context-syslogaop.xml}에 개인정보 취급 서비스 impl만 열거(과수집 방지).
* 신규 화면 추가 시에는 이 pointcut(또는 대상 서비스에 편입)과 업무유형 매핑({@link JobMappingResolver})만
@@ -56,7 +76,6 @@ 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;
@@ -95,6 +114,13 @@ public class PersonalDataLogAspect {
return;
}
+ // 로그인 세션이 없는 요청은 취급자 접속기록이 아니므로 수집하지 않는다(사유는 클래스 Javadoc).
+ // 매핑 조회보다 먼저 판정해 불필요한 작업을 하지 않는다.
+ LoginVO login = currentLogin(request);
+ if (login == null || !notBlank(login.getId())) {
+ return;
+ }
+
String targetSimpleName = pjp.getTarget().getClass().getSimpleName();
String methodName = pjp.getSignature().getName();
@@ -116,10 +142,8 @@ public class PersonalDataLogAspect {
dmlGubun = JobMappingResolver.deriveDmlGubun(methodName);
}
- LoginVO login = currentLogin(request);
-
PersonalDataAccessLogVO vo = new PersonalDataAccessLogVO();
- vo.setConectId(login != null && notBlank(login.getId()) ? login.getId() : ANONYMOUS);
+ vo.setConectId(login.getId());
vo.setConectDt(new SimpleDateFormat(DT_FORMAT).format(new Date()));
vo.setConectIp(safeIp(request));
vo.setJobSeCode(jobSeCode);
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java
index f96ce42..bfacdfc 100644
--- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java
+++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java
@@ -1,14 +1,20 @@
package egovframework.com.cmm.privacy.author;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.net.URLEncoder;
import java.util.List;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
@@ -18,8 +24,8 @@ import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
* 권한 신청·승인 워크플로 + 권한이력 조회·다운로드 컨트롤러
* (개인정보보호 SFR-001 / #3·#6).
*
- * 취급자 영역(/mnt) 백엔드 엔드포인트. 응답은 jsonView(화면 JSP는 #46 후속).
- * 처리자/신청자 식별자와 IP는 세션 loginVO·ClientIpResolver(#34)에서 취득한다. 취급자 영역(/mnt) 백엔드 엔드포인트. 처리자/신청자 식별자와 IP는
+ * 세션 loginVO·ClientIpResolver(#34)에서 취득한다. preHandle 판정 순서:
* 무엇을 남기는가: "개인정보취급자가 관리자 화면에서 어떤 메뉴를 열어 무슨 처리를 했는지"
+ * 1요청 = 1행으로 {@code PERSONAL_DATA_ACCESS_LOG}에 남긴다. 수행업무(JOB_SE_NM)는
+ * 기존 arko 메뉴 테이블의 메뉴명이다 — 별도 매핑 테이블을 관리하지 않아도 메뉴가 늘면
+ * 접속기록 업무명도 따라 늘어난다. 왜 서비스 AOP에서 인터셉터로 바꿨나(2026-08-07): 종전
+ * {@code PersonalDataLogAspect}는 서비스 impl 메서드를 가로챘다. 서비스는 관리자 화면과
+ * 프론트 화면이 공유하므로 어느 화면에서 호출됐는지 구분할 수 없었고, 그 결과
+ * 무인증 프론트 방문이 접속자ID {@code ANONYMOUS}, 수행업무 "재능나눔 회원관리"로 적재됐다
+ * (예: 공개 재능나눔 상세 {@code /frt/biz/talent/bizTalentView.do} → {@code selectTalentMber}).
+ * 요청 단위 인터셉터는 URL을 보므로 관리자 화면 요청만 정확히 골라낼 수 있다. 기록 대상 판정(3중 조건, 모두 만족해야 기록):
+ * 화면이 늘면: 개인정보 취급 화면을 새로 만들면 {@code includePatterns}에 한 줄을 더해야
+ * 수집된다(설정 파일 변경 → 컨텍스트 리로드). 자동 확장이 아닌 것은 의도된 선택이다 —
+ * 화이트리스트 없이 자동으로 늘리면 과수집을 통제할 지점이 사라진다. 메뉴명 해석 순서:
+ * 처리구분(DML_GUBUN): URL 마지막 세그먼트(.do 파일명)로 유도한다. HTTP 메서드는
+ * 쓰지 않는다 — 이 레거시는 삭제·수정도 GET 링크로 부르는 곳이 있어 메서드가 실제 행위를
+ * 대변하지 못한다. {@code ~View}로 끝나면 등록/수정 화면이므로 조회(S)로 판정하는 것이
+ * 핵심이다({@code EgovMberInsertView} = 등록화면 진입이지 등록이 아니다). 처리대상(TRGET_ID): 요청 파라미터에서 식별자만 우선순위로 취득한다. 검색어·
+ * 개인정보 본문은 담지 않는다(본문 미적재 원칙). 다건 선택(체크박스)이면 첫 값 + 건수를
+ * TRGET_CN에 남긴다. 업무 무영향 보장: {@code afterHandle} 대신 {@code afterCompletion}에서 적재하며
+ * (처리결과 성공/실패를 알아야 한다), 큐 enqueue만 하고 즉시 반환한다(#33). 이 인터셉터에서
+ * 발생한 예외는 전부 삼키고 로그만 남긴다 — 접속기록 수집이 업무를 막아서는 안 된다. {@code HttpServletResponse.getStatus()}는 Servlet 3.0부터다. 이 프로젝트는
+ * Servlet 2.5 API 로 컴파일되므로 직접 호출하면 컴파일되지 않는다. 런타임 컨테이너는
+ * Tomcat 9(Servlet 4.0)이라 실제로는 존재하므로 리플렉션으로 부르고, 없으면 성공으로 둔다. 판정 순서가 중요하다.
+ *
- *
@@ -50,12 +52,20 @@ import egovframework.com.cmm.util.EgovUserDetailsHelper;
* ---------- ------ ---------------------------
* 2026.07.21 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-001 / #2)
* 2026.07.22 URP 기존 메뉴권한 DB 기반 URL 접근제어로 개편(요건4) — 캐시/TTL/바이패스/fail-open
+ * 2026.07.28 URP 명시 매핑 판정을 미인증 판정보다 선행 — privacy 화면 무인증 노출 차단
*
*/
public class AuthorityInterceptor extends HandlerInterceptorAdapter {
private static final Logger LOG = LoggerFactory.getLogger(AuthorityInterceptor.class);
+ /** 미인증 화면 진입 시 유도할 로그인 URL(기존 arko 관례). */
+ private static final String LOGIN_URL = "/uat/uia/egovLoginUsr.do";
+
+ /** 미인증 AJAX/JSON 액션 거부 본문(계정 존재 여부 등 부가정보 미노출 — AccountMngController 와 동일 문구). */
+ private static final String DENY_JSON =
+ "{\"result\":\"fail\",\"message\":\"접근 권한이 없습니다. 관리자 계정으로 로그인 후 이용하세요.\"}";
+
/** 적용 경로 접두(개인정보취급자 영역). 기본 /mnt/ */
private String applyPathPrefix = "/mnt/";
@@ -109,17 +119,26 @@ public class AuthorityInterceptor extends HandlerInterceptorAdapter {
return true;
}
- // 3) 미인증은 통과(기존 인증판정 체계가 처리)
+ // 3) 명시 매핑 판정을 미인증 판정보다 먼저 수행한다.
+ // (authenticInterceptor 가 비활성이라 미인증을 걸러줄 다음 단계가 실제로는 없다.
+ // 매핑에 걸린 privacy 화면만 여기서 인증을 강제하고, 그 외 /mnt/ 는 기존 동작 유지.)
+ String requiredRole = resolveRequiredRole(path);
+
LoginVO loginVO = (LoginVO) EgovUserDetailsHelper.getAuthenticatedUser();
if (loginVO == null) {
- return true;
+ if (requiredRole == null) {
+ // 매핑 밖 경로 — 기존과 동일하게 통과(일반 arko 관리화면 동작 변경 없음)
+ return true;
+ }
+ LOG.warn("[개인정보/#2] 미인증 접근 차단: path={}, requiredRole={}", path, requiredRole);
+ denyUnauthenticated(request, response);
+ return false;
}
// 사용자 롤 목록(#1 getAuthorities 활용)
List
+ *
+ *
+ *
+ *
+ *
+ *
+ * << 개정이력(Modification Information) >>
+ * 수정일 수정자 수정내용
+ * ---------- ------ ---------------------------
+ * 2026.08.07 URP 최초 생성 (SFR-004 — 서비스 AOP 수집 → 관리자 화면 메뉴 접속기록 전환)
+ *
+ */
+public class MenuAccessLogInterceptor extends HandlerInterceptorAdapter {
+
+ private static final Logger LOG = LoggerFactory.getLogger(MenuAccessLogInterceptor.class);
+
+ /** 접속일시 형식 — 해시체인 재검증 시 DB 적재 문자열과 일치해야 한다(#39). */
+ private static final String DT_FORMAT = "yyyy-MM-dd HH:mm:ss";
+
+ /** 메뉴 미해석 경로의 업무코드. */
+ private static final String ETC_JOB_CODE = "MNT_ETC";
+ /** 메뉴 기반 업무코드 접두 — JOB_SE_CODE 는 VARCHAR(30). */
+ private static final String MENU_JOB_CODE_PREFIX = "MENU_";
+
+ /** JOB_SE_CODE 컬럼 길이 보호. */
+ private static final int JOB_SE_CODE_MAX = 30;
+ /** JOB_SE_NM 컬럼 길이 보호. */
+ private static final int JOB_SE_NM_MAX = 100;
+ /** TRGET_ID 컬럼 길이 보호. */
+ private static final int TRGET_ID_MAX = 50;
+
+ /**
+ * 처리대상 식별자 파라미터 우선순위. 식별자로 확정 가능한 이름만 둔다
+ * (searchKeyword 등 검색어는 개인정보 본문일 수 있어 절대 넣지 않는다).
+ */
+ private static final String[] TRGET_ID_PARAMS = {
+ "esntlId", "uniqId", "selectedId", "mberId", "emplyrId", "entrprsId",
+ "groupId", "userId", "checkedIdForDel", "checkedIdForUpdt", "sn"
+ };
+
+ // ===================== 설정(주입) =====================
+
+ /**
+ * 기록 대상 화면 정규식 화이트리스트(개인정보 취급 관리자 화면). 여기에 걸리는 요청만 기록한다.
+ * 미설정이면 아무것도 기록하지 않는다(과수집 방지 기본값).
+ */
+ private List
+ *
+ * 짧고 흔한 토큰({@code add}·{@code save}·{@code write}·{@code edit}·{@code chang})은
+ * 부분일치로 쓰면 오판정한다({@code EgovAddress~}의 'add', {@code exchange}의 'chang').
+ * 이들은 접두 일치로만 본다.
개인정보보호 기능개선 사업(SFR-004 접속기록) — {@link MenuAccessLogInterceptor}가 + * "관리자 화면에서 어떤 메뉴를 열었는지"를 접속기록의 수행업무로 남기기 위해, + * 기존 arko 메뉴 2테이블(COMTNMENUINFO + COMTNPROGRMLIST)을 조인해 캐싱하는 단일 행 표현.
+ * + *+ * << 개정이력(Modification Information) >> + * 수정일 수정자 수정내용 + * ---------- ------ --------------------------- + * 2026.08.07 URP 최초 생성 (개인정보보호 SFR-004 — 관리자 메뉴 접속기록 전환) + *+ */ +public class MenuNameVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 메뉴번호(MENU_NO) */ + private String menuNo; + + /** 메뉴명(MENU_NM) */ + private String menuNm; + + /** 프로그램 URL(쿼리스트링 포함 가능) */ + private String url; + + public String getMenuNo() { + return menuNo; + } + + public void setMenuNo(String menuNo) { + this.menuNo = menuNo; + } + + public String getMenuNm() { + return menuNm; + } + + public void setMenuNm(String menuNm) { + this.menuNm = menuNm; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/interceptor/PrivacyMenuAuthDAO.java b/src/main/java/egovframework/com/cmm/privacy/interceptor/PrivacyMenuAuthDAO.java index 2e77987..3b77cee 100644 --- a/src/main/java/egovframework/com/cmm/privacy/interceptor/PrivacyMenuAuthDAO.java +++ b/src/main/java/egovframework/com/cmm/privacy/interceptor/PrivacyMenuAuthDAO.java @@ -21,6 +21,7 @@ import egovframework.com.cmm.service.impl.EgovComAbstractDAO; * 수정일 수정자 수정내용 * ---------- ------ --------------------------- * 2026.07.22 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-001 요건4 / #2) + * 2026.08.07 URP selectMenuNames 추가 (SFR-004 — 관리자 메뉴 접속기록 전환) * */ @Repository("privacyMenuAuthDAO") @@ -37,4 +38,20 @@ public class PrivacyMenuAuthDAO extends EgovComAbstractDAO { public List
{@link MenuAccessLogInterceptor}가 "요청 URL·page 파라미터 → 메뉴명"을 해석하기 위해 + * 최초 요청 시 1회 전량 로드해 캐싱한다. 권한 배정(COMTNMENUCREATDTLS)과 무관하게 + * 메뉴에 연결된 프로그램 전체를 대상으로 한다 — 롤에 배정되지 않은 메뉴로 진입한 + * 요청도 기록해야 하기 때문이다(기록 대상 판정은 인터셉터의 경로 접두·취급자 구분이 담당).
+ * + * @return (menuNo, menuNm, url) 목록. 없으면 빈 목록. + * @throws Exception 조회 오류 + */ + @SuppressWarnings("unchecked") + public List솔트 적용 계정(S1: 형식)은 저장값에 솔트가 들어 있으므로, 로그인 시 + * 평문을 해시하기 전에 이 값을 먼저 읽어야 한다. 무솔트(기존) 계정이거나 + * 존재하지 않는 아이디면 null 이 되고, 그 경우 종전과 동일한 무솔트 해시로 + * 계산되어 actionLogin 이 0건을 돌려준다(동작·계정열거 노출 모두 종전과 동일).
+ * + * @param vo LoginVO (id, userSe 사용) + * @return 저장된 비밀번호 값(없으면 null) + * @exception Exception + */ + public String selectStoredPassword(LoginVO vo) throws Exception { + return (String)selectByPk("loginDAO.selectStoredPassword", vo); + } + /** * 인증서 로그인을 처리한다 * @param vo LoginVO diff --git a/src/main/java/egovframework/com/uss/umt/service/impl/EgovEntrprsManageServiceImpl.java b/src/main/java/egovframework/com/uss/umt/service/impl/EgovEntrprsManageServiceImpl.java index 6b74fa7..e4d3049 100644 --- a/src/main/java/egovframework/com/uss/umt/service/impl/EgovEntrprsManageServiceImpl.java +++ b/src/main/java/egovframework/com/uss/umt/service/impl/EgovEntrprsManageServiceImpl.java @@ -75,9 +75,10 @@ public class EgovEntrprsManageServiceImpl extends AbstractServiceImpl implements String uniqId = idgenService.getNextStringId(); entrprsManageVO.setUniqId(uniqId); //패스워드 암호화 - String pass = EgovFileScrty.encryptPassword(entrprsManageVO.getEntrprsMberPassword()); + //[개인정보보호 #27 후속] 신규 계정이므로 사용자별 랜덤 솔트를 적용한다(저장형식 'S1:솔트:해시'). + String pass = EgovFileScrty.encryptPasswordWithSalt(entrprsManageVO.getEntrprsMberPassword()); entrprsManageVO.setEntrprsMberPassword(pass); - + String result = entrprsManageDAO.insertEntrprsmber(entrprsManageVO); //return result; KTH. 20190627 OLD return uniqId; // 수정 후 @@ -101,7 +102,8 @@ public class EgovEntrprsManageServiceImpl extends AbstractServiceImpl implements */ public void updateEntrprsmber(EntrprsManageVO entrprsManageVO) throws Exception { //패스워드 암호화 - String pass = EgovFileScrty.encryptPassword(entrprsManageVO.getEntrprsMberPassword()); + //[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다. + String pass = EgovFileScrty.encryptPasswordWithSalt(entrprsManageVO.getEntrprsMberPassword()); entrprsManageVO.setEntrprsMberPassword(pass); entrprsManageDAO.updateEntrprsmber(entrprsManageVO); } diff --git a/src/main/java/egovframework/com/uss/umt/service/impl/EgovMberManageServiceImpl.java b/src/main/java/egovframework/com/uss/umt/service/impl/EgovMberManageServiceImpl.java index 369782b..818b9ac 100644 --- a/src/main/java/egovframework/com/uss/umt/service/impl/EgovMberManageServiceImpl.java +++ b/src/main/java/egovframework/com/uss/umt/service/impl/EgovMberManageServiceImpl.java @@ -78,7 +78,8 @@ public class EgovMberManageServiceImpl extends AbstractServiceImpl implements Eg String uniqId = idgenService.getNextStringId(); mberManageVO.setUniqId(uniqId); //패스워드 암호화 - String pass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); + //[개인정보보호 #27 후속] 신규 계정이므로 사용자별 랜덤 솔트를 적용한다(저장형식 'S1:솔트:해시'). + String pass = EgovFileScrty.encryptPasswordWithSalt(mberManageVO.getPassword()); mberManageVO.setPassword(pass); String result = mberManageDAO.insertMber(mberManageVO); return result; @@ -134,7 +135,8 @@ public class EgovMberManageServiceImpl extends AbstractServiceImpl implements Eg */ public void updateMber(MberManageVO mberManageVO) throws Exception { //패스워드 암호화 - String pass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); + //[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다. + String pass = EgovFileScrty.encryptPasswordWithSalt(mberManageVO.getPassword()); mberManageVO.setPassword(pass); mberManageDAO.updateMber(mberManageVO); } @@ -237,7 +239,10 @@ public class EgovMberManageServiceImpl extends AbstractServiceImpl implements Eg } public String searchMber(MberManageVO mberManageVO) throws Exception { - String pass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); + //[개인정보보호 #27 후속] 아이디+비밀번호를 WHERE 절에서 비교하는 경로. + //저장값을 먼저 읽어 같은 솔트로 해시한다(무솔트 계정이면 종전 동작 그대로). + String stored = mberManageDAO.selectStoredPasswordByMberId(mberManageVO); + String pass = EgovFileScrty.encryptPasswordAs(mberManageVO.getPassword(), stored); mberManageVO.setPassword(pass); return mberManageDAO.searchMber(mberManageVO); } diff --git a/src/main/java/egovframework/com/uss/umt/service/impl/EgovUserManageServiceImpl.java b/src/main/java/egovframework/com/uss/umt/service/impl/EgovUserManageServiceImpl.java index 3fdc788..1dd6be5 100644 --- a/src/main/java/egovframework/com/uss/umt/service/impl/EgovUserManageServiceImpl.java +++ b/src/main/java/egovframework/com/uss/umt/service/impl/EgovUserManageServiceImpl.java @@ -90,7 +90,8 @@ public class EgovUserManageServiceImpl extends AbstractServiceImpl implements Eg String uniqId = idgenService.getNextStringId(); userManageVO.setUniqId(uniqId); //패스워드 암호화 - String pass = EgovFileScrty.encryptPassword(userManageVO.getPassword()); + //[개인정보보호 #27 후속] 신규 계정이므로 사용자별 랜덤 솔트를 적용한다(저장형식 'S1:솔트:해시'). + String pass = EgovFileScrty.encryptPasswordWithSalt(userManageVO.getPassword()); userManageVO.setPassword(pass); String result = userManageDAO.insertUser(userManageVO); return result; @@ -135,7 +136,8 @@ public class EgovUserManageServiceImpl extends AbstractServiceImpl implements Eg */ public void updateUser(UserManageVO userManageVO) throws Exception { //패스워드 암호화 - String pass = EgovFileScrty.encryptPassword(userManageVO.getPassword()); + //[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다. + String pass = EgovFileScrty.encryptPasswordWithSalt(userManageVO.getPassword()); userManageVO.setPassword(pass); userManageDAO.updateUser(userManageVO); diff --git a/src/main/java/egovframework/com/uss/umt/service/impl/MberManageDAO.java b/src/main/java/egovframework/com/uss/umt/service/impl/MberManageDAO.java index e201253..136c1ee 100644 --- a/src/main/java/egovframework/com/uss/umt/service/impl/MberManageDAO.java +++ b/src/main/java/egovframework/com/uss/umt/service/impl/MberManageDAO.java @@ -174,6 +174,17 @@ public class MberManageDAO extends EgovComAbstractDAO{ public String searchMber(MberManageVO mberManageVO){ return (String)getSqlMapClientTemplate().queryForObject("mberManageDAO.searchMber_S", mberManageVO); } + + /** + * [개인정보보호 #27 후속] 회원아이디로 저장된 비밀번호 값을 조회한다. + * + *searchMber_S 는 비밀번호를 WHERE 절에서 비교하므로, 솔트 적용 계정을 판별하려면 + * 평문을 해시하기 전에 저장값(솔트 포함)을 먼저 읽어야 한다. selectPassword_S 는 + * ESNTL_ID 기준이라 아이디만 아는 이 경로에서는 쓸 수 없어 별도 statement 를 둔다.
+ */ + public String selectStoredPasswordByMberId(MberManageVO mberManageVO){ + return (String)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectStoredPasswordByMberId_S", mberManageVO); + } public int selectMberTalentCnt(String mberId) { diff --git a/src/main/java/egovframework/com/uss/umt/web/EgovEntrprsManageController.java b/src/main/java/egovframework/com/uss/umt/web/EgovEntrprsManageController.java index f98ce76..e2f93e2 100644 --- a/src/main/java/egovframework/com/uss/umt/web/EgovEntrprsManageController.java +++ b/src/main/java/egovframework/com/uss/umt/web/EgovEntrprsManageController.java @@ -852,7 +852,8 @@ public class EgovEntrprsManageController { } if (isCorrectPassword){ - entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPassword(newPassword)); + //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다. + entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword)); entrprsManageService.updatePassword(entrprsManageVO); model.addAttribute("entrprsManageVO", entrprsManageVO); resultMsg = "success.common.update"; diff --git a/src/main/java/egovframework/com/uss/umt/web/EgovMberManageController.java b/src/main/java/egovframework/com/uss/umt/web/EgovMberManageController.java index 5f89c17..2feff62 100644 --- a/src/main/java/egovframework/com/uss/umt/web/EgovMberManageController.java +++ b/src/main/java/egovframework/com/uss/umt/web/EgovMberManageController.java @@ -928,10 +928,11 @@ public class EgovMberManageController { String resultMsg = ""; resultVO = mberManageService.selectPassword(mberManageVO); //패스워드 암호화 - //String encryptPass = EgovFileScrty.encryptPassword(oldPassword); - String encryptPass = resultVO.getPassword(); - - if (encryptPass.equals(resultVO.getPassword())){ + // [개인정보/SER-001] 2026-08-05 현재 비밀번호 검증 복구. + // 종전: encryptPass 에 DB 값을 그대로 대입해 자기 자신과 비교 → 항상 true. + // 입력한 oldPassword 가 어디에도 쓰이지 않아, 현재 비밀번호를 몰라도 변경이 성립했다. + // [개인정보보호 #27 후속] 2026-08-06 솔트 적용 계정(S1: 형식) 대응 — matches 로 일원화. + if (EgovFileScrty.matches(oldPassword, resultVO.getPassword())){ if (newPassword.equals(newPassword2)){ isCorrectPassword = true; }else{ @@ -944,7 +945,8 @@ public class EgovMberManageController { } if (isCorrectPassword){ - mberManageVO.setPassword(EgovFileScrty.encryptPassword(newPassword)); + //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다. + mberManageVO.setPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword)); mberManageService.updatePassword(mberManageVO); model.addAttribute("mberManageVO", mberManageVO); resultMsg = "success.common.update"; diff --git a/src/main/java/egovframework/com/uss/umt/web/EgovUserManageController.java b/src/main/java/egovframework/com/uss/umt/web/EgovUserManageController.java index 0ec6118..2d69ead 100644 --- a/src/main/java/egovframework/com/uss/umt/web/EgovUserManageController.java +++ b/src/main/java/egovframework/com/uss/umt/web/EgovUserManageController.java @@ -609,10 +609,12 @@ public class EgovUserManageController { resultVO = userManageService.selectPassword(userManageVO); //패스워드 암호화 - //String encryptPass = EgovFileScrty.encryptPassword(oldPassword); - String encryptPass = resultVO.getPassword(); - - if (encryptPass.equals(resultVO.getPassword())){ + // [개인정보/SER-001] 2026-08-05 현재 비밀번호 검증 복구. + // 종전: encryptPass 에 DB 값을 그대로 대입해 자기 자신과 비교 → 항상 true. + // 입력한 oldPassword 가 어디에도 쓰이지 않아, 현재 비밀번호를 몰라도 변경이 성립했다 + // (fail.user.passwordUpdate1 분기는 도달 불가능한 죽은 코드였음). + // [개인정보보호 #27 후속] 2026-08-06 솔트 적용 계정(S1: 형식) 대응 — matches 로 일원화. + if (EgovFileScrty.matches(oldPassword, resultVO.getPassword())){ if (newPassword.equals(newPassword2)){ isCorrectPassword = true; }else{ @@ -625,7 +627,8 @@ public class EgovUserManageController { } if (isCorrectPassword){ - userManageVO.setPassword(EgovFileScrty.encryptPassword(newPassword)); + //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다. + userManageVO.setPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword)); userManageService.updatePassword(userManageVO); model.addAttribute("userManageVO", userManageVO); resultMsg = "success.common.update"; @@ -693,10 +696,12 @@ public class EgovUserManageController { resultVO = userManageService.selectPassword(userManageVO); //패스워드 암호화 - //String encryptPass = EgovFileScrty.encryptPassword(oldPassword); - String encryptPass = resultVO.getPassword(); - - if (encryptPass.equals(resultVO.getPassword())){ + // [개인정보/SER-001] 2026-08-05 현재 비밀번호 검증 복구. + // 종전: encryptPass 에 DB 값을 그대로 대입해 자기 자신과 비교 → 항상 true. + // 입력한 oldPassword 가 어디에도 쓰이지 않아, 현재 비밀번호를 몰라도 변경이 성립했다 + // (fail.user.passwordUpdate1 분기는 도달 불가능한 죽은 코드였음). + // [개인정보보호 #27 후속] 2026-08-06 솔트 적용 계정(S1: 형식) 대응 — matches 로 일원화. + if (EgovFileScrty.matches(oldPassword, resultVO.getPassword())){ if (newPassword.equals(newPassword2)){ isCorrectPassword = true; }else{ @@ -709,7 +714,8 @@ public class EgovUserManageController { } if (isCorrectPassword){ - userManageVO.setPassword(EgovFileScrty.encryptPassword(newPassword)); + //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다. + userManageVO.setPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword)); userManageService.updatePassword(userManageVO); model.addAttribute("userManageVO", userManageVO); resultMsg = "success.common.update"; diff --git a/src/main/java/egovframework/com/utl/sim/service/EgovFileScrty.java b/src/main/java/egovframework/com/utl/sim/service/EgovFileScrty.java index ac48847..6e2df30 100644 --- a/src/main/java/egovframework/com/utl/sim/service/EgovFileScrty.java +++ b/src/main/java/egovframework/com/utl/sim/service/EgovFileScrty.java @@ -262,7 +262,7 @@ public class EgovFileScrty { /** * 비밀번호를 암호화된 패스워드 검증(salt가 사용된 경우만 적용). - * + * * @param data 원 패스워드 * @param encoded 해쉬처리된 패스워드(Base64 인코딩) * @return @@ -270,15 +270,144 @@ public class EgovFileScrty { */ public static boolean checkPassword(String data, String encoded, byte[] salt) throws Exception { byte[] hashValue = null; // 해쉬값 - + MessageDigest md = MessageDigest.getInstance("SHA-256"); - + md.reset(); md.update(salt); hashValue = md.digest(data.getBytes()); - + return MessageDigest.isEqual(hashValue, Base64.decodeBase64(encoded.getBytes())); } + + // ===================================================================== + // [개인정보보호 #27 후속] 사용자별 랜덤 솔트 적용 (SER-003 인증정보 보호) + // --------------------------------------------------------------------- + // 점검노트(개인정보보호_비밀번호해시_점검노트.md) 권고 1의 이행분. + // 알고리즘은 종전과 동일한 SHA-256이며, 신규로 설정되는 비밀번호에만 + // 사용자별 랜덤 솔트를 부여한다. 기존 비밀번호(무솔트)는 손대지 않는다. + // + // [저장형식] 솔트 저장용 컬럼을 새로 만들지 않고, 비밀번호 컬럼 안에 + // 자기기술(self-describing) 형식으로 함께 담는다. + // + // S1:저장값이 솔트 형식(S1:)이면 그 솔트를 재사용해 같은 형식의 문자열을, + * 아니면(기존 무솔트 계정) 종전 방식의 해시를 돌려준다. 결과를 저장값과 + * 문자열 비교하면 검증이 되므로, 비밀번호를 WHERE 절에서 비교하는 + * 기존 로그인 SQL을 수정하지 않고 솔트를 도입할 수 있다.
+ * + * @param data 평문 비밀번호 + * @param stored DB에 저장된 비밀번호 값(없으면 null 허용) + * @return stored 와 같은 형식으로 암호화된 문자열 + * @exception Exception + */ + public static String encryptPasswordAs(String data, String stored) throws Exception { + String salt = extractSalt(stored); + if (salt == null) { + // 기존(무솔트) 계정 또는 존재하지 않는 계정 → 종전 동작 그대로 + return encryptPassword(data); + } + return SALT_PREFIX + salt + ":" + encryptPassword(data, salt.getBytes("UTF-8")); + } + + /** + * 평문 비밀번호가 저장값과 일치하는지 검증한다(솔트·무솔트 양쪽 지원). + * + *이미 저장값을 손에 쥐고 있는 경로(마이페이지 비밀번호 확인, 비밀번호 + * 변경 시 현재 비밀번호 확인 등)에서는 이 메서드를 쓴다. 종전의 + * {@code encryptPassword(raw).equals(stored)} 를 그대로 대체한다.
+ * + * @param data 평문 비밀번호 + * @param stored DB에 저장된 비밀번호 값 + * @return 일치 여부 + * @exception Exception + */ + public static boolean matches(String data, String stored) throws Exception { + if (data == null || stored == null) { + return false; + } + String salt = extractSalt(stored); + if (salt == null) { + // 무솔트 저장값: 종전 방식으로 해시 후 상수시간 비교 + return MessageDigest.isEqual(encryptPassword(data).getBytes("UTF-8"), stored.getBytes("UTF-8")); + } + String encoded = stored.substring(SALT_PREFIX.length() + salt.length() + 1); + return checkPassword(data, encoded, salt.getBytes("UTF-8")); + } + + /** + * 저장값이 솔트 형식이면 솔트를 꺼내고, 아니면 null 을 돌려준다. + * + * @param stored DB에 저장된 비밀번호 값 + * @return Base64 솔트 또는 null(무솔트·형식오류) + */ + private static String extractSalt(String stored) { + if (stored == null || !stored.startsWith(SALT_PREFIX)) { + return null; + } + int end = stored.indexOf(':', SALT_PREFIX.length()); + if (end < 0 || end == SALT_PREFIX.length()) { + // 접두어만 있고 형식이 깨진 값은 무솔트로 취급(검증 실패로 귀결) + return null; + } + return stored.substring(SALT_PREFIX.length(), end); + } /* public static void main(String[] args) { diff --git a/src/main/java/kr/or/arko/frt/biz/merge/service/impl/BizMberMergeServiceImpl.java b/src/main/java/kr/or/arko/frt/biz/merge/service/impl/BizMberMergeServiceImpl.java index 8996322..29fc2aa 100644 --- a/src/main/java/kr/or/arko/frt/biz/merge/service/impl/BizMberMergeServiceImpl.java +++ b/src/main/java/kr/or/arko/frt/biz/merge/service/impl/BizMberMergeServiceImpl.java @@ -42,7 +42,8 @@ public class BizMberMergeServiceImpl extends AbstractServiceImpl implements BizM public void updateLibMber(MberManageVO mberManageVO) throws Exception { //패스워드 암호화 - String pass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); + //[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다. + String pass = EgovFileScrty.encryptPasswordWithSalt(mberManageVO.getPassword()); mberManageVO.setPassword(pass); bizMberMergeDAO.updateLibMber(mberManageVO); } @@ -53,7 +54,8 @@ public class BizMberMergeServiceImpl extends AbstractServiceImpl implements BizM public void updateLibActivity(EntrprsManageVO entrprsManageVO) throws Exception { //패스워드 암호화 - String pass = EgovFileScrty.encryptPassword(entrprsManageVO.getEntrprsMberPassword()); + //[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다. + String pass = EgovFileScrty.encryptPasswordWithSalt(entrprsManageVO.getEntrprsMberPassword()); entrprsManageVO.setEntrprsMberPassword(pass); bizMberMergeDAO.updateLibActivity(entrprsManageVO); } diff --git a/src/main/java/kr/or/arko/frt/biz/umt/web/BizEntrprsFrtManageController.java b/src/main/java/kr/or/arko/frt/biz/umt/web/BizEntrprsFrtManageController.java index fece9e6..49d587a 100644 --- a/src/main/java/kr/or/arko/frt/biz/umt/web/BizEntrprsFrtManageController.java +++ b/src/main/java/kr/or/arko/frt/biz/umt/web/BizEntrprsFrtManageController.java @@ -647,10 +647,8 @@ public class BizEntrprsFrtManageController { String resultMsg = ""; resultVO = entrprsManageService.selectPassword(entrprsManageVO); - //패스워드 암호화 - String encryptPass = EgovFileScrty.encryptPassword(entrprsManageVO.getOldPassword()); - - if (encryptPass.equals(resultVO.getEntrprsMberPassword())){ + //패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증 + if (EgovFileScrty.matches(entrprsManageVO.getOldPassword(), resultVO.getEntrprsMberPassword())){ if (entrprsManageVO.getNewPassword().equals(entrprsManageVO.getNewPassword2())){ isCorrectPassword = true; }else{ @@ -663,7 +661,8 @@ public class BizEntrprsFrtManageController { } if (isCorrectPassword){ - entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPassword(entrprsManageVO.getNewPassword())); + //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다. + entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPasswordWithSalt(entrprsManageVO.getNewPassword())); entrprsManageService.updatePassword(entrprsManageVO); model.addAttribute("entrprsManageVO", entrprsManageVO); resultMsg = "success.common.update"; diff --git a/src/main/java/kr/or/arko/frt/biz/umt/web/BizGroupFrtManageController.java b/src/main/java/kr/or/arko/frt/biz/umt/web/BizGroupFrtManageController.java index 33af986..f8a0d89 100644 --- a/src/main/java/kr/or/arko/frt/biz/umt/web/BizGroupFrtManageController.java +++ b/src/main/java/kr/or/arko/frt/biz/umt/web/BizGroupFrtManageController.java @@ -533,10 +533,8 @@ public class BizGroupFrtManageController { String resultMsg = ""; resultVO = groupManageService.selectPassword(groupManageVO); - //패스워드 암호화 - String encryptPass = EgovFileScrty.encryptPassword(groupManageVO.getGroupPassword()); - - if (encryptPass.equals(resultVO.getGroupPassword())){ + //패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증 + if (EgovFileScrty.matches(groupManageVO.getGroupPassword(), resultVO.getGroupPassword())){ groupManageService.deleteGroupMberApp(loginVO.getUniqId()); model.addAttribute("resultMsg", "success.request.msg"); }else{ @@ -578,10 +576,8 @@ public class BizGroupFrtManageController { String resultMsg = ""; resultVO = groupManageService.selectPassword(groupManageVO); - //패스워드 암호화 - String encryptPass = EgovFileScrty.encryptPassword(oldPassword); - - if (encryptPass.equals(resultVO.getGroupPassword())){ + //패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증 + if (EgovFileScrty.matches(oldPassword, resultVO.getGroupPassword())){ if (newPassword.equals(newPassword2)){ isCorrectPassword = true; }else{ @@ -594,7 +590,8 @@ public class BizGroupFrtManageController { } if (isCorrectPassword){ - groupManageVO.setGroupPassword(EgovFileScrty.encryptPassword(newPassword)); + //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다. + groupManageVO.setGroupPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword)); groupManageService.updatePassword(groupManageVO); model.addAttribute("groupManageVO", groupManageVO); resultMsg = "success.common.update"; diff --git a/src/main/java/kr/or/arko/frt/biz/umt/web/BizMberFrtManageController.java b/src/main/java/kr/or/arko/frt/biz/umt/web/BizMberFrtManageController.java index 7a60730..c1cd4b8 100644 --- a/src/main/java/kr/or/arko/frt/biz/umt/web/BizMberFrtManageController.java +++ b/src/main/java/kr/or/arko/frt/biz/umt/web/BizMberFrtManageController.java @@ -513,14 +513,14 @@ public class BizMberFrtManageController { String resultMsg = ""; String returnUrl = ""; System.out.println("타입체크::"+user.getUserSe()); - System.out.println("1패스워드확인::"+mberManageVO.getPassword()); - String encryptPass = ""; - - if(user.getUserSe().equals("ENT")) + // 2026-08-05 [개인정보/SER-003] 평문 비밀번호 로그 출력 제거(종전 println 으로 평문 노출) + System.out.println("1패스워드확인::요청됨"); + + if(user.getUserSe().equals("ENT")) { entVO = entrManageService.selectPassword(entVO); - encryptPass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); - if (encryptPass.equals(entVO.getEntrprsMberPassword())){ + // [개인정보보호 #27 후속] 솔트 적용 계정(S1: 형식)·기존 무솔트 계정 모두 matches 로 검증한다. + if (EgovFileScrty.matches(mberManageVO.getPassword(), entVO.getEntrprsMberPassword())){ returnUrl = "redirect:/myp/ent/bizMberSelectUpdtView.do"; }else{ resultMsg="fail.user.passwordUpdate1"; @@ -531,11 +531,9 @@ public class BizMberFrtManageController { else { resultVO = mberManageService.selectPassword(mberManageVO); - encryptPass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); - - log.debug("encryptPass =>"+encryptPass); - - if (encryptPass.equals(resultVO.getPassword())) + // [개인정보보호 #27 후속] 솔트 적용 계정(S1: 형식)·기존 무솔트 계정 모두 matches 로 검증한다. + // 2026-08-06 [개인정보/SER-003] 비밀번호 해시값 log.debug 출력 제거(인증정보 노출). + if (EgovFileScrty.matches(mberManageVO.getPassword(), resultVO.getPassword())) { returnUrl = "redirect:/myp/umt/bizMberSelectUpdtView.do"; } @@ -548,10 +546,8 @@ public class BizMberFrtManageController { } System.out.println("비밀번호 체크후이동"+returnUrl); - //패스워드 암호화 - - System.out.println("3패스워드확인::"+EgovFileScrty.encryptPassword(mberManageVO.getPassword())); - + // 2026-08-06 [개인정보/SER-003] 비밀번호 해시값 표준출력 제거(인증정보 노출, 점검노트 권고 3). + // 종전: System.out.println("3패스워드확인::" + EgovFileScrty.encryptPassword(...)); return returnUrl; } @@ -835,9 +831,8 @@ public class BizMberFrtManageController { String resultMsg = ""; resultVO = mberManageService.selectPassword(mberManageVO); - //패스워드 암호화 - String encryptPass = EgovFileScrty.encryptPassword(oldPassword); - if (encryptPass.equals(resultVO.getPassword())){ + //패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증 + if (EgovFileScrty.matches(oldPassword, resultVO.getPassword())){ if (newPassword.equals(newPassword2)){ isCorrectPassword = true; }else{ @@ -850,7 +845,8 @@ public class BizMberFrtManageController { } if (isCorrectPassword){ - mberManageVO.setPassword(EgovFileScrty.encryptPassword(newPassword)); + //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다. + mberManageVO.setPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword)); mberManageService.updatePassword(mberManageVO); model.addAttribute("userManageVO", mberManageVO); resultMsg = "success.common.update"; @@ -956,9 +952,8 @@ public class BizMberFrtManageController { String resultMsg = ""; String returnUrl = ""; resultVO = mberManageService.selectPassword(mberManageVO); - //패스워드 암호화 - String encryptPass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); - if (encryptPass.equals(resultVO.getPassword())){ + //패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증 + if (EgovFileScrty.matches(mberManageVO.getPassword(), resultVO.getPassword())){ String userId = "USR01:"+user.getUniqId(); // 멤버 정보 삭제 (마일리지, 활동정보, 재능정보, 권한정보) mberManageService.deleteMber(userId); diff --git a/src/main/java/kr/or/arko/mbl/mypage/web/MblMypageController.java b/src/main/java/kr/or/arko/mbl/mypage/web/MblMypageController.java index a411abd..fb9f8ce 100644 --- a/src/main/java/kr/or/arko/mbl/mypage/web/MblMypageController.java +++ b/src/main/java/kr/or/arko/mbl/mypage/web/MblMypageController.java @@ -136,11 +136,11 @@ public class MblMypageController { String resultMsg = ""; String returnUrl = ""; System.out.println("타입체크::"+user.getUserSe()); - System.out.println("1패스워드확인::"+mberManageVO.getPassword()); - String encryptPass = ""; + // 2026-08-05 [개인정보/SER-003] 평문 비밀번호 로그 출력 제거(종전 println 으로 평문 노출) + System.out.println("1패스워드확인::요청됨"); entVO = entrManageService.selectPassword(entVO); - encryptPass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); - if (encryptPass.equals(entVO.getEntrprsMberPassword())){ + // [개인정보보호 #27 후속] 솔트 적용 계정(S1: 형식)·기존 무솔트 계정 모두 matches 로 검증한다. + if (EgovFileScrty.matches(mberManageVO.getPassword(), entVO.getEntrprsMberPassword())){ returnUrl = "redirect:/myp/ent/bizMberSelectUpdtView.do"; }else{ resultMsg="fail.user.passwordUpdate1"; diff --git a/src/main/java/kr/or/arko/mnt/biz/group/service/impl/BizGroupManageServiceImpl.java b/src/main/java/kr/or/arko/mnt/biz/group/service/impl/BizGroupManageServiceImpl.java index e3fb04a..663b1b0 100644 --- a/src/main/java/kr/or/arko/mnt/biz/group/service/impl/BizGroupManageServiceImpl.java +++ b/src/main/java/kr/or/arko/mnt/biz/group/service/impl/BizGroupManageServiceImpl.java @@ -68,7 +68,8 @@ public class BizGroupManageServiceImpl extends AbstractServiceImpl implements Bi String uniqId = idgenService.getNextStringId(); groupManageVO.setUniqId(uniqId); //패스워드 암호화 - String pass = EgovFileScrty.encryptPassword(groupManageVO.getGroupPassword()); + //[개인정보보호 #27 후속] 신규 계정이므로 사용자별 랜덤 솔트를 적용한다(저장형식 'S1:솔트:해시'). + String pass = EgovFileScrty.encryptPasswordWithSalt(groupManageVO.getGroupPassword()); groupManageVO.setGroupPassword(pass); return groupManageDAO.insertGroupMber(groupManageVO); diff --git a/src/main/java/kr/or/arko/mnt/biz/group/web/BizGroupManageController.java b/src/main/java/kr/or/arko/mnt/biz/group/web/BizGroupManageController.java index b5e0529..b6236ef 100644 --- a/src/main/java/kr/or/arko/mnt/biz/group/web/BizGroupManageController.java +++ b/src/main/java/kr/or/arko/mnt/biz/group/web/BizGroupManageController.java @@ -701,10 +701,11 @@ public class BizGroupManageController { String resultMsg = ""; resultVO = groupManageService.selectPassword(groupManageVO); //패스워드 암호화 - //String encryptPass = EgovFileScrty.encryptPassword(oldPassword); - String encryptPass = resultVO.getGroupPassword(); - - if (encryptPass.equals(resultVO.getGroupPassword())){ + // [개인정보/SER-001] 2026-08-05 현재 비밀번호 검증 복구. + // 종전: encryptPass 에 DB 값을 그대로 대입해 자기 자신과 비교 → 항상 true. + // 입력한 oldPassword 가 어디에도 쓰이지 않아, 현재 비밀번호를 몰라도 변경이 성립했다. + // [개인정보보호 #27 후속] 2026-08-06 솔트 적용 계정(S1: 형식) 대응 — matches 로 일원화. + if (EgovFileScrty.matches(oldPassword, resultVO.getGroupPassword())){ if (newPassword.equals(newPassword2)){ isCorrectPassword = true; }else{ @@ -717,7 +718,8 @@ public class BizGroupManageController { } if (isCorrectPassword){ - groupManageVO.setGroupPassword(EgovFileScrty.encryptPassword(newPassword)); + //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다. + groupManageVO.setGroupPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword)); groupManageService.updatePassword(groupManageVO); model.addAttribute("groupManageVO", groupManageVO); resultMsg = "success.common.update"; diff --git a/src/main/java/kr/or/arko/mnt/biz/mypage/web/BizEntrprsMntController.java b/src/main/java/kr/or/arko/mnt/biz/mypage/web/BizEntrprsMntController.java index 1a95402..5e4ef1e 100644 --- a/src/main/java/kr/or/arko/mnt/biz/mypage/web/BizEntrprsMntController.java +++ b/src/main/java/kr/or/arko/mnt/biz/mypage/web/BizEntrprsMntController.java @@ -267,10 +267,8 @@ public class BizEntrprsMntController { String resultMsg = ""; resultVO = entrprsManageService.selectPassword(entrprsManageVO); - //패스워드 암호화 - String encryptPass = EgovFileScrty.encryptPassword(oldPassword); - - if (encryptPass.equals(resultVO.getEntrprsMberPassword())){ + //패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증 + if (EgovFileScrty.matches(oldPassword, resultVO.getEntrprsMberPassword())){ if (newPassword.equals(newPassword2)){ isCorrectPassword = true; }else{ @@ -283,7 +281,8 @@ public class BizEntrprsMntController { } if (isCorrectPassword){ - entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPassword(newPassword)); + //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다. + entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword)); entrprsManageService.updatePassword(entrprsManageVO); model.addAttribute("entrprsManageVO", entrprsManageVO); resultMsg = "success.common.update"; diff --git a/src/main/resources/egovframework/spring/com/context-common.xml b/src/main/resources/egovframework/spring/com/context-common.xml index 2f39dc7..b72505f 100644 --- a/src/main/resources/egovframework/spring/com/context-common.xml +++ b/src/main/resources/egovframework/spring/com/context-common.xml @@ -18,8 +18,10 @@| NO. | +접속일시 | +접속자ID | +접속IP | +수행업무 | +처리구분 | +처리대상 | +처리결과 | +무결성해시 | +
|---|---|---|---|---|---|---|---|---|
|
+ |
+
+ |
+
+ |
+
+ |
+
+ |
+ ||||