Compare commits

...

10 Commits

Author SHA1 Message Date
infraurp
6b57520afd [보안/SER-004] SMS DB 자격증명 하드코딩 제거 + 보안 응답 헤더
Some checks failed
deploy-wise / deploy (push) Successful in 1m6s
deploy-dev / deploy (push) Has been cancelled
1) 계정정보 하드코딩 (CWE-798)
   - SmsBasicDBUtil 의 JDBC URL/USER/PASSWORD 소스 하드코딩 제거 →
     EgovProperties(Globals.sms.jdbc.url/username/password)로 외부화.
   ※ globals-*.properties 는 .gitignore 대상(자격증명 미추적)이므로, 배포 환경마다
     아래 키를 별도 설정해야 한다:
       Globals.sms.jdbc.url / .username / .password

2) 보안 응답 헤더 (CWE-693 등)
   - web.xml 에 Tomcat HttpHeaderSecurityFilter 추가.
     X-Frame-Options: SAMEORIGIN(관리자 iframe 사용 → DENY 아님),
     X-Content-Type-Options: nosniff. HSTS 는 HTTP 환경 오작동 방지로 off(운영 HTTPS 시 on).
   - 쿠키 HttpOnly 는 Tomcat 기본 적용 확인(추가 조치 불요).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbEjijyQdS54xso1d2ks3u
2026-08-31 10:31:41 +09:00
infraurp
e82e0611c0 [보안/SER-004] 임시비밀번호 생성 난수를 SecureRandom 으로 교체
CWE-330(취약한 난수). BizEntrprsFrtManageController.randomValue 가 임시비밀번호를
Math.random()/java.util.Random 으로 생성 → 시드 예측 가능. 암호학적 안전 난수
(SecureRandom)로 교체하고 문자 분포는 정수 범위(nextInt)로 균등 생성.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbEjijyQdS54xso1d2ks3u
2026-08-31 10:31:41 +09:00
infraurp
d75c0da00e [보안/SER-004] CSRF 토큰 방어 신규 구현
Spring Security 2.0.4 에 CSRF 기능이 없어 세션 동기화 토큰(Synchronizer Token)을
자체 구현. 웹취약점 점검(ZAP)의 "Absence of Anti-CSRF Tokens" 및 정적 분석에서
관리자 상태변경 요청 다수가 무방비였던 것을 조치.

- CsrfTokenInterceptor(신규): /mnt/ 상태변경 요청(POST/PUT/DELETE)에서 토큰 검증.
  이중 방어 — 토큰 없거나 불일치해도 '같은 출처'면 통과(정상 화면 회귀 방지),
  '교차 출처'만 차단(CSRF 공격 차단). 토큰은 SecureRandom 48자 hex.
- egov-com-servlet.xml: 인터셉터 리스트 등록 + bean 정의(enforce=true, /mnt/ 한정).
- mnt/include/head.jsp: 토큰 meta + 전역 스크립트로 모든 폼(hidden)·AJAX(X-CSRF-TOKEN
  헤더)에 자동 주입. 167개 관리자 화면 공통 적용, 개별 화면 수정 없음.

검증: cross-origin+토큰없음 → 403 차단, same-origin(토큰유무 무관) → 통과(회귀 없음).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbEjijyQdS54xso1d2ks3u
2026-08-31 10:31:22 +09:00
infraurp
a085956a8b [보안/SER-004] 인증 없는 휴면계정 해제 차단 + 메일토큰 난수 강화
웹취약점 점검(SER-004)에서 확인된 휴면계정 관련 취약점 2건 조치.

1) 인증 없는 휴면계정 해제 (CWE-306, 긴급)
   - actionDiapause.do 가 요청 파라미터 mber_id 만으로 즉시 deleteDiapause 실행 →
     아이디만 알면 제3자가 임의 계정의 휴면을 해제할 수 있었음(로컬 PoC 실증).
   - 파라미터를 신뢰하지 않고, ID/PW 인증 성공 시에만 세션에 적재되는 diapauseVO 의
     mber_id 로만 해제. 세션 부재 시 resCode=002 거부, 성공 시 1회성 소거.
   - 정상 경로 releaseDiapauseAjax(토큰 검증)와 동일 원칙. 정상 사용자는 원래도
     세션값(${sessionScope.diapauseVO.mber_id})을 보냈으므로 결과 동일(회귀 없음).
   - mbl/EgovDiapause.jsp: resCode 분기로 거부 시 재로그인 안내.

2) 예측 가능한 난수 (CWE-330, 중)
   - 휴면 활성화 메일 토큰(20자) 생성을 java.util.Random → SecureRandom 으로 교체.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbEjijyQdS54xso1d2ks3u
2026-08-31 10:31:08 +09:00
infraurp
722fd978ce 관리자 알림 테스트 발송 버튼 + 발주기관 확인 항목 정리
· 테스트 발송 버튼 — [계정 관리 정책] 화면의 관리자 수신 이메일 옆
  실제 알림은 월 1회 배치에서만 나가서, 수신 주소를 바꾼 관리자가 그것이
  맞는지 한 달을 기다려야 확인할 수 있었다. 같은 발송 경로(수신주소 조회 →
  메일 모듈 적재·발송)를 그대로 타는 테스트를 눌러 즉시 결과를 본다.
  경로가 다르면 테스트가 통과해도 실제 알림은 실패할 수 있으므로 반드시
  같은 경로여야 한다.

  - 성공·실패 모두 서버가 준 사유를 그대로 화면에 띄운다.
    "실패했습니다"만 뜨면 주소 미설정인지 메일서버 문제인지 구분할 수 없다.
  - 메일 모듈이 예외 없이 false 를 반환하는 경우도 실패로 잡는다
    (insertSndngMail 은 적재 후 곧바로 전송하고 실패 시 결과코드를 'F' 로 갱신한다.
     예외가 없다고 발송된 것이 아니다).
  - 상태를 바꾸지는 않지만 외부로 메일이 나가므로 POST·동일출처만 받는다.

· 발주기관 확인 항목.txt 신규
  제안요청서·제안서만으로 결정할 수 없어 발주기관 확인이 필요한 항목을
  근거 문구·현재 구현 상태·미확정 시 영향과 함께 정리했다. A(개발 완료 조건) /
  B(운영정보 필요) / C(문서에 명시 없음) / D(사업관리)로 나눴고, 확인 없이
  진행한 값은 근거를 명시해 [참고] 절에 따로 적었다.

검증: mvn compile EXIT=0, JSP Jasper 오류 0건, 로컬 반영·재기동 정상
(빈 생성 오류 0건), 정책화면 302→로그인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:18:23 +09:00
infraurp
bb721fe4fa 관리자 알림 — 탐지해 놓고 알리지 않던 것을 메일로 통보 (제안서 slide15)
제안요약서 slide15 "로그 조회 및 감사 기능"의 구성요소 중 "관리자 알림".
종전에는 무결성 위반을 에러 로그로만 남겨서, 로그를 들여다보지 않으면
아무도 모르는 상태였다 — 탐지해 놓고 알리지 않으면 탐지의 의미가 없다.

· PrivacyAdminAlertService + Impl 신설
  - 무결성 위반 탐지 시(해시체인 검증 배치)
  - 이상행위 탐지 시(정기점검 배치, 0건인 달은 보내지 않음)
    매달 "0건" 메일이 오면 곧 아무도 열지 않는다.

· 채널은 메일 — 다만 문서 근거는 없다
  RFP·제안서 어디에도 알림 채널이 명시돼 있지 않다("관리자 알림" 네 글자뿐).
  ACCOUNT_MNG_POLICY.ADMIN_EMAIL 이 이미 있고 휴면 사전알림이 그 주소로
  메일을 보내고 있어 같은 경로를 재사용했다. 운영에서 메일 장애를 한 곳에서
  확인할 수 있어야 한다는 이유도 있다. SMS 는 문자발송모듈 연동규격이
  미확정이라(SFR-003 과 같은 블로커) 붙일 수 없다.
  → 채널 확정은 발주기관 협의 항목으로 남긴다.

· 메일에 개인정보 본문을 담지 않는다
  건수·기간·취급자 계정ID·요약만 넣고 상세는 화면에서 보게 한다.
  메일함이 개인정보 저장소가 되면 안 된다.

· 배치를 막지 않는다
  발송 실패·수신주소 미설정 모두 로그만 남기고 배치는 계속한다. 다만 미설정은
  WARN 으로 구분해 남긴다 — "알림이 안 왔다"와 "보낼 주소가 없다"는 다른 문제다.

검증: mvn compile EXIT=0, 로컬 반영 후 재기동 정상(빈 생성 오류 0건 —
privacyAdminAlertService/sndngMailRegistService/accountMngDAO 배선 확인),
frt/main.do 200. 실제 발송은 월 1회 배치라 이번에 실행하지 못했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:08:51 +09:00
infraurp
4c7e677dec 접속기록 보관기간을 관리자 설정으로 (SFR-004 / 제안서 slide15)
제안요약서 slide15 현행진단이 "보관 기간 — 2년 이상, 기본값 730일,
관리자 설정값 미존재 → 신규 구축"으로 지목한 항목이다. 파기 배치가
자바 상수 ACCESS_LOG_RETENTION_DAYS=730 을 써서 관리자가 바꿀 수 없었다.

· ACCOUNT_MNG_POLICY.ACCESS_LOG_KEEP_DAY 신설(기본 730)
  이 표에 이미 LOGIN_HIST_KEEP_DAY 가 있어 사실상 보관·파기 정책의 단일
  창구다. 표를 새로 만들면 관리자가 보관기간을 두 화면에서 나눠 보게 된다.

· 법정 하한 730일을 세 겹으로 강제
  화면 JS / 서버 validatePolicy / 파기 배치. 화면만 막으면 요청을 직접
  만드는 경로가, 저장만 막으면 정책 행을 직접 고치는 경로가 남는다.
  배치는 정책값이 730 미만이어도 730일치를 남긴다 — 한 번 지운 접속기록은
  되돌릴 수 없어 설정 실수로 법정 보관기간을 깨는 쪽보다 더 남기는 쪽으로 기운다.
  DB CHECK 를 쓰지 않은 이유는 MariaDB/MySQL 5.7 에서 동작이 갈려 조용히
  통과할 수 있어서다.

· 접속기록 조회 화면의 "보관기간 N일" 안내도 정책값을 읽는다
  파기 배치가 실제로 쓰는 값과 달라지면 안내가 거짓말이 된다.
  정책 조회 실패 시 양쪽 모두 기본값으로 물러난다(안내 때문에 목록이 막히면 안 된다).

검증: mvn compile EXIT=0, JSP Jasper 오류 0건, DDL 적용 후
ACCESS_LOG_KEEP_DAY=730 확인, 재기동 정상, 정책화면·접속기록 302→로그인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:57:14 +09:00
infraurp
660d826a58 회원정보 암호화 관리 목록이 한 행 때문에 통째로 죽지 않게
목록은 페이지의 모든 행을 복호화하는데, 공용 MemberNmDecryptSupport 는
실패를 숨기지 않도록 예외를 던진다. 그 계약을 목록에 그대로 쓰면 암호문
한 건만 어긋나도 화면 전체가 500 이 된다 — 관리자 화면이 안 열리면 정작
무엇이 잘못됐는지 확인할 방법도 사라진다.

· 목록(MberCryptoAdminDAO.selectMberList)만 행 단위로 감싼다.
  실패한 행은 이름 자리에 "(복호화 실패)" 표식과 decryptFailed=true 를 남기고,
  나머지 행은 정상 표시한다. 실패를 감추는 게 아니라 어느 행인지 드러낸다.
· 단건 조회(selectMber)는 종전대로 예외를 올린다 — 상세는 값이 정확해야 한다.
  다만 컨트롤러가 받아 스택트레이스 대신 원인을 문장으로 안내하고 목록으로 되돌린다.
· 실패 표식은 마스킹 대상에서 제외한다(마스킹하면 표식이 뭉개진다).
· 화면에 실패 행을 붉게 표시하고 안내 문구를 추가했다.

이 처리는 로컬 더미 DB(백필 키 분실)에서 화면을 열기 위한 임시방편이 아니라,
운영에서 키를 교체하거나 암호문 한 건이 손상돼도 관리자 화면이 살아 있어야
한다는 요구에서 나온 것이다.

검증: mvn compile EXIT=0, JSP Jasper 오류 0건, 로컬 반영·재기동 후
mberCrypto/listView.do 302→로그인, frt/main.do 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:39:27 +09:00
infraurp
33bd57cae2 개인정보보호 기능개선 10차 — 접속·처리기록을 RFP대로 화면 1개로 정리
제안요청서 과업내용 "다"가 요구하는 화면은 하나다 —
"ㅇ 로그 조회 및 관리 기능 구현 / - 관리자용 로그 조회 화면 개발".
제안요약서 시안도 "접속기록 조회 및 감사" 한 장만 그려져 있다.
8·9차에서 이 영역을 여러 화면으로 벌려 놓은 것을 되돌린다.

· 삭제 — 정기 점검 리포트 화면 / 이상행위 감사 화면 / 개인정보 처리기록 화면
  AbnormalAccessController, abnormal/list.jsp, inspection/list.jsp 제거.
  InspectionResultController 는 화면 엔드포인트(listView/saveResultView)만 걷어내고
  8차 이전의 JSON 계약으로 되돌렸다.
  PersonalDataAccessLogSearchVO.excludeSelect 와 그 sqlmap 조건(Mysql/Oracle 각 3곳)도
  함께 제거 — processLog 진입점이 사라져 죽은 조건이 된다.

· 기능은 남긴다 (RFP 가 '기능'으로 명시한 항목이라 지우면 요건 미충족)
  · "처리기록 검색·조회 기능 제공"       → 처리구분 필터(조회/등록/수정/삭제 토글)
  · "비정상 접근 탐지 및 기록 기능 구현"  → 목록 '감사' 칸(이상/점검) 상시 표시
  · SFR-004 "접속기록 정기점검을 지원"   → 월 1회 배치 + PDAL_INSPECTION_REPORT 적재
  · 파기이력(SER-002-2)                    → 접속기록 화면의 "파기이력" 버튼
  AbnormalAccessDetectionService 6클래스는 감사 칸이 쓰므로 그대로 둔다.

· 메뉴 — 권한관리 하위 "접속기록 조회 및 감사"(9100044, ORDR 10) 하나로 수렴
  9100048/49/50/52/53 제거. install_mysql.sql PART 3 은 [7]+[8] 을 최종형 [7] 하나로
  대체했다(이미 중간형을 적용한 DB 를 위한 정리구문 포함, 신규 설치는 무영향).

검증: mvn compile EXIT=0, JSP 3종 Jasper 오류 0건, 로컬 반영·재기동 정상.
살아있는 화면 accessLog/purgeHistory/mberCrypto 302→로그인,
삭제한 inspection/abnormal/processLog **404**(제거 증명), frt/main.do 200,
통합본 전체 재실행 오류 0건(멱등).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:23:21 +09:00
infraurp
3d359a1a8f 개인정보보호 기능개선 9차 — 관리자 화면 배치를 제안서 Ⅲ-4 시안대로 재편
제안요약서 slide 14~15 의 화면 시안을 열어보니 8차 구현과 어긋나 있었다.
시안은 좌측메뉴를 "접속기록·감사" 한 묶음으로 그리고 그 아래 4개를 두며,
이상행위를 별도 화면이 아니라 접속기록 목록의 "감사" 칸에 인라인으로 표시한다.
8차는 이 넷을 권한관리 하위에 낱개로 붙여 놓았다 — 기능은 같은데 배치가 달랐다.

· 메뉴 재편 — "접속기록 · 감사"(9100052) 그룹 신설
  ① 접속기록 조회(9100044) ② 개인정보 처리기록(9100053, 신설)
  ③ 이상행위 감사(9100049)  ④ 정기 점검 리포트(9100048)
  파기이력(9100050)은 시안에 메뉴가 없어 내렸다. 화면은 그대로 두고
  정기 점검 리포트에서 버튼으로 연다(양방향 링크). 기능을 지운 게 아니라
  노출 위치만 시안에 맞춘 것이다.
  그룹 메뉴의 PROGRM_FILE_NM 은 NOT NULL 이고 기존 그룹이 모두 센티넬 'dir'
  을 쓴다 — NULL 로 넣었다가 ERROR 1048 로 실패한 뒤 형제 행을 실측해 정정.

· 접속기록 조회 화면에 "감사" 칸 신설
  이상(처리결과 F=차단·비인가)은 붉게, 점검(정상이나 대량조회·계정공유의심·
  인증실패반복 대상 또는 업무시간 밖)은 노랗게 표시하고 해당 행을 강조한다.
  요약 줄도 시안대로 "검색결과 N건 · 보관기간 730일 · 이상행위 N건 탐지".
  판정값(auditFlag)은 저장하지 않는다 — 탐지 임계치는 정책값이라 적재해 두면
  임계치를 조정한 뒤에도 과거 기준으로 굳은 값이 남는다.
  감사 표시는 집계 5회라 기간 미지정 시 최근 30일로 잘라 계산하고,
  실패해도 목록은 그대로 보여준다(PSR-001).

· ② 개인정보 처리기록 — 조회(S)를 뺀 등록·수정·삭제만
  화면·검색조건은 ①과 공유하고 excludeSelect 조건 하나만 다르다.
  같은 표를 두 벌 관리하지 않으려는 선택이다.

검증: mvn compile EXIT=0, JSP 4종 Jasper 오류 0건, 로컬 ROOT 반영 후 재기동
정상(25.4초), 신규 processLog 포함 5개 엔드포인트 302→로그인 / 대조군 404,
통합본 privacy.install_mysql.sql PART3 [8] 병합 후 전체 재실행 오류 0건(멱등).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:03:34 +09:00
37 changed files with 2126 additions and 935 deletions

View File

@ -29,6 +29,10 @@ public class AccountMngController {
/** 현황 목록(#11). page 파라미터는 좌측메뉴 하이라이트(left.jsp 9100037 분기)용이라 유지한다. */
private static final String DORMANT_LIST_REDIRECT = "redirect:/mnt/sec/dormantStatus/listView.do?page=9100037";
/** 관리자 알림 테스트 발송(제안서 slide15 "관리자 알림"). */
@Resource(name = "privacyAdminAlertService")
private egovframework.com.cmm.privacy.batch.PrivacyAdminAlertService privacyAdminAlertService;
@Resource(name = "accountMngPolicyService")
private AccountMngPolicyService accountMngPolicyService;
@ -45,6 +49,35 @@ public class AccountMngController {
return "jsonView";
}
/**
* 관리자 알림 <b>테스트 발송</b>.
*
* <p>실제 알림(무결성 위반·이상행위 탐지)은 월 1회 배치에서만 나가서, 수신 주소를
* 바꾼 관리자가 그것이 맞는지 한 달을 기다려야 확인할 수 있었다. 같은 발송 경로를
* 그대로 타는 테스트를 눌러 즉시 결과를 본다.</p>
*
* <p>상태를 바꾸지는 않지만 외부로 메일이 나가는 동작이라 POST·동일출처만 받는다 —
* 링크 한 번으로 반복 발송되면 안 된다.</p>
*/
@RequestMapping(value = "/mnt/privacy/accountPolicyTestAlert.do",
method = org.springframework.web.bind.annotation.RequestMethod.POST)
public String testAlert(HttpServletRequest request, ModelMap model) throws Exception {
LoginVO loginVO = handlerUser();
if (loginVO == null) {
return denyJson(model);
}
if (!isSameOrigin(request)) {
model.addAttribute("result", "fail");
model.addAttribute("message", "잘못된 요청 경로입니다.");
return "jsonView";
}
String resultMsg = privacyAdminAlertService.sendTestMail(loginVO.getId());
// 성공·실패 모두 사유를 그대로 화면에 보여준다. "실패했습니다"만 뜨면 원인을 못 찾는다.
model.addAttribute("result", resultMsg != null && resultMsg.startsWith("테스트 메일을 발송") ? "success" : "fail");
model.addAttribute("message", resultMsg);
return "jsonView";
}
/** #13 정책 수정. */
@RequestMapping(value = "/mnt/privacy/accountPolicyUpdate.do")
public String updatePolicy(@ModelAttribute("policy") AccountMngPolicyVO policy,
@ -107,6 +140,14 @@ public class AccountMngController {
if (p.getLoginHistKeepDay() < 1 || p.getLoginHistKeepDay() > 3650) {
return "인증 실패 이력 보관기간은 1~3650일 범위로 입력하세요.";
}
// [개인정보/SFR-004] 접속기록 보관기간. 법정 하한(2년)은 서버에서도 거절한다 —
// 화면 검증만 두면 요청을 직접 만드는 경로로 우회된다.
if (p.getAccessLogKeepDay() < AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY
|| p.getAccessLogKeepDay() > 3650) {
return "접속기록 보관기간은 " + AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY
+ "~3650일 범위로 입력하세요. 최소 2년은 개인정보의 안전성 확보조치 기준 제8조가"
+ " 요구하는 법정 하한이라 더 짧게 설정할 수 없습니다.";
}
// #12 파기 배치 사용여부 — 체크박스 미전송 시 null 이 오므로 'N' 으로 보정
if (p.getPurgeUseYn() == null || p.getPurgeUseYn().trim().length() == 0) {
p.setPurgeUseYn("N");

View File

@ -21,6 +21,9 @@ public class AccountMngPolicyVO implements Serializable {
/** 휴면 미설정 시 기준일수 fallback(#9). */
public static final int DEFAULT_DORMANT_DAY = 365;
/** 접속기록 보관기간 법정 하한(일) — 2년. SFR-004 "최소 2년 이상 보관". */
public static final int MIN_ACCESS_LOG_KEEP_DAY = 730;
private int policyId = 1;
/** 휴면 전환 기준 미접속 일수(#9). */
private int dormantDay = DEFAULT_DORMANT_DAY;
@ -44,6 +47,16 @@ public class AccountMngPolicyVO implements Serializable {
* 경과분은 BatchScheduler.purgeExpiredLoginFailHistoryScheduler 가 파기한다.
*/
private int loginHistKeepDay = 730;
/**
* 개인정보 접속기록 보관기간(일). 제안서 slide15 "보관 기간 — 관리자 설정값 미존재 → 신규 구축".
*
* <p><b>법정 하한 {@link #MIN_ACCESS_LOG_KEEP_DAY}일.</b> 제안요청서 SFR-004 는
* "접속기록은 최소 2년 이상 보관 가능하여야 한다"고 못박고 있다. 관리자가 임의로 더 짧게
* 줄이면 요건 위반이 되므로 화면과 파기 배치 양쪽에서 하한을 강제한다 —
* 화면만 막으면 정책 행을 직접 고치는 경로가 남는다.</p>
*/
private int accessLogKeepDay = MIN_ACCESS_LOG_KEEP_DAY;
/** 사전알림 관리자 수신 이메일. */
private String adminEmail;
private String updId;
@ -65,6 +78,9 @@ public class AccountMngPolicyVO implements Serializable {
public void setBatchCycle(String batchCycle) { this.batchCycle = batchCycle; }
public String getPurgeUseYn() { return purgeUseYn; }
public void setPurgeUseYn(String purgeUseYn) { this.purgeUseYn = purgeUseYn; }
public int getAccessLogKeepDay() { return accessLogKeepDay; }
public void setAccessLogKeepDay(int accessLogKeepDay) { this.accessLogKeepDay = accessLogKeepDay; }
public int getLoginHistKeepDay() { return loginHistKeepDay; }
public void setLoginHistKeepDay(int loginHistKeepDay) { this.loginHistKeepDay = loginHistKeepDay; }
public String getAdminEmail() { return adminEmail; }

View File

@ -1,146 +0,0 @@
/**
* Class Name : AbnormalAccessController.java
* Description : 비정상 접근 탐지 알림함 화면 — 개인정보보호 기능개선 사업(SFR-004 / SER-002 / #49)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성 — 탐지 5유형(#35)의 화면 출력
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.auditlog;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
/**
* 비정상 접근 탐지 알림함 (과업내용 다-① "비정상 접근 탐지 및 기록", SER-002).
*
* <p>{@link AbnormalAccessDetectionService}(#35)의 5유형 탐지 결과를 기간 단위로 보여준다.
* 화면: {@code /WEB-INF/jsp/site/mnt/sec/abnormal/list.jsp}</p>
*
* <p><b>왜 저장 테이블이 아니라 즉시 집계인가:</b> 탐지 임계치는 정책값이라 언제든 바뀐다
* ({@link AbnormalAccessPolicy}). 탐지 결과를 별도 테이블에 적재해 두면 임계치를 조정한 뒤에도
* 과거 기준으로 굳은 행이 남아 "지금 기준으로 몇 건인가"에 답할 수 없다. 원천인 접속기록은
* 730일 보존되므로, 조회 시점 기준으로 다시 세는 편이 정확하다.
* 월 단위 스냅샷이 필요한 경우는 정기점검 리포트(#43)가 {@code ABNORMAL_SUMMARY} 로 남긴다.</p>
*
* <p><b>조회 부하 주의:</b> 5유형 각각이 접속기록 전체를 기간으로 훑는 집계라 기간이 넓으면
* 무거워진다. 그래서 기본 조회기간을 최근 30일로 두고, 최대 {@link #MAX_RANGE_DAYS}일로 제한한다.</p>
*
* <p><b>접근통제:</b> 인터셉터 ①인증판정이 비활성이라 화면 단에서 다시 판정한다
* ({@link PersonalDataAccessLogController} 와 동일 방식). 롤 판정은 {@code urlRoleMapping}
* 의 {@code /mnt/sec/*} = ROLE_ADMIN 이 담당한다.</p>
*/
@Controller
public class AbnormalAccessController {
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
/** 기본 조회기간(일) — 최근 30일. */
private static final int DEFAULT_RANGE_DAYS = 30;
/** 최대 조회기간(일). 5유형 집계가 접속기록 전체를 훑으므로 상한을 둔다. */
private static final int MAX_RANGE_DAYS = 366;
private static final String DATE_PATTERN = "yyyy-MM-dd";
@Resource(name = "abnormalAccessDetectionService")
private AbnormalAccessDetectionService abnormalAccessDetectionService;
/**
* 비정상 접근 탐지 결과 화면.
*
* @param fromDt 시작일(yyyy-MM-dd). 미지정 시 오늘 - 30일
* @param toDt 종료일(yyyy-MM-dd). 미지정 시 오늘
*/
@RequestMapping(value = "/mnt/sec/abnormal/listView.do")
public String listView(@RequestParam(value = "fromDt", required = false) String fromDt,
@RequestParam(value = "toDt", required = false) String toDt,
ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
SimpleDateFormat dateFmt = new SimpleDateFormat(DATE_PATTERN);
Calendar cal = Calendar.getInstance();
String toDate = isBlank(toDt) ? dateFmt.format(cal.getTime()) : toDt.trim();
String fromDate;
if (isBlank(fromDt)) {
cal.add(Calendar.DAY_OF_MONTH, -DEFAULT_RANGE_DAYS);
fromDate = dateFmt.format(cal.getTime());
} else {
fromDate = fromDt.trim();
}
String rangeMsg = null;
long days = diffDays(dateFmt, fromDate, toDate);
if (days < 0) {
// 시작 > 종료 — 조회하지 않고 되돌린다(뒤집힌 조건으로 "이상징후 0건"이 나오면 오독한다)
rangeMsg = "시작일이 종료일보다 뒤입니다. 기간을 다시 지정하세요.";
} else if (days > MAX_RANGE_DAYS) {
rangeMsg = "조회기간은 최대 " + MAX_RANGE_DAYS + "일까지입니다. 기간을 좁혀 주세요.";
}
if (rangeMsg == null) {
// 종료일 당일(23:59:59)까지 포함 — 접속기록 조회화면과 같은 규칙
List<AbnormalAccessResultVO> results =
abnormalAccessDetectionService.detect(fromDate + " 00:00:00", toDate + " 23:59:59");
model.addAttribute("resultList", results);
model.addAttribute("summary",
abnormalAccessDetectionService.summarize(fromDate + " 00:00:00", toDate + " 23:59:59"));
}
model.addAttribute("fromDt", fromDate);
model.addAttribute("toDt", toDate);
model.addAttribute("resultMsg", rangeMsg);
return "site/mnt/sec/abnormal/list";
}
/** 두 날짜(yyyy-MM-dd) 간 일수 차. 파싱 실패 시 0(=검증 통과)로 두고 조회를 진행한다. */
private long diffDays(SimpleDateFormat fmt, String from, String to) {
try {
Date f = fmt.parse(from);
Date t = fmt.parse(to);
return (t.getTime() - f.getTime()) / (24L * 60L * 60L * 1000L);
} catch (Exception e) {
return 0L;
}
}
private boolean isBlank(String v) {
return v == null || v.trim().length() == 0;
}
/**
* 인증된 업무사용자(USR)만 통과. 그 외에는 null.
* (일반회원 세션으로 관리자 화면에 들어오는 경로를 여기서 끊는다)
*/
private LoginVO handlerUser() {
if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) {
return null;
}
Object o = EgovUserDetailsHelper.getAuthenticatedUser();
if (!(o instanceof LoginVO)) {
return null;
}
LoginVO loginVO = (LoginVO) o;
return "USR".equals(loginVO.getUserSe()) ? loginVO : null;
}
}

View File

@ -9,9 +9,12 @@ import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Calendar;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
@ -58,6 +61,19 @@ public class PersonalDataAccessLogController {
/** 화면에 띄울 '내 다운로드 요청' 최대 건수(만료 전 기준). 폭주 방지용 상한이다. */
private static final int MY_JOB_MAX = 100;
/** 감사 표시 — 이상(처리결과 차단·비인가). */
private static final String AUDIT_ABNORMAL = "A";
/** 감사 표시 — 점검(정상 처리였으나 이상징후 대상). */
private static final String AUDIT_WATCH = "W";
/** 감사 표시 계산 시 기간 미지정이면 적용할 기본 조회기간(일). */
private static final int AUDIT_DEFAULT_DAYS = 30;
/** 업무시간 시작(시). AbnormalAccessPolicy 기본값과 동일. */
private static final int OFF_HOURS_START = 9;
/** 업무시간 종료(시). */
private static final int OFF_HOURS_END = 18;
/** 정책 조회 실패 시 화면 안내에 쓸 접속기록 보관기간 기본값(일). */
private static final int ACCESS_LOG_RETENTION_DAYS = 730;
@Resource(name = "personalDataAccessLogSearchService")
private PersonalDataAccessLogSearchService personalDataAccessLogSearchService;
@ -70,6 +86,31 @@ public class PersonalDataAccessLogController {
@Resource(name = "accessLogDownloadJobService")
private AccessLogDownloadJobService accessLogDownloadJobService;
/** 보관기간 안내 문구용 정책 조회(SFR-004 관리자 설정). */
@Resource(name = "accountMngDAO")
private egovframework.com.cmm.privacy.account.AccountMngDAO accountMngDAO;
/**
* 화면 안내에 쓸 접속기록 보관기간. 관리자 설정값을 그대로 보여준다
* (파기 배치가 실제로 쓰는 값과 같아야 안내가 거짓말이 되지 않는다).
* 조회 실패 시 기본값으로 물러난다 — 안내 문구 때문에 목록이 막히면 안 된다.
*/
private int resolveRetentionDays() {
try {
egovframework.com.cmm.privacy.account.AccountMngPolicyVO policy = accountMngDAO.selectPolicy();
if (policy != null && policy.getAccessLogKeepDay() > 0) {
return policy.getAccessLogKeepDay();
}
} catch (Exception e) {
// 무시 — 기본값 사용
}
return ACCESS_LOG_RETENTION_DAYS;
}
/** 감사 표시용 이상징후 탐지(#35). 목록 렌더 시 검색기간 기준으로 대조한다. */
@Resource(name = "abnormalAccessDetectionService")
private AbnormalAccessDetectionService abnormalAccessDetectionService;
/**
* 개인정보 접속기록 조회 화면(서버 렌더링).
* 뷰: /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp
@ -77,6 +118,19 @@ public class PersonalDataAccessLogController {
@RequestMapping(value = "/mnt/sec/accessLog/listView.do")
public String listView(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO,
ModelMap model) throws Exception {
return renderList(searchVO, model);
}
/**
* 목록 렌더링.
*
* <p><b>화면은 이 하나다.</b> 제안요청서 과업내용 "다"가 요구하는 화면은
* "관리자용 로그 조회 화면" 한 개이며, 제안요약서 시안도 이 한 장만 그려져 있다.
* 처리기록(등록·수정·삭제) 조회는 별도 화면이 아니라 <b>처리구분 필터</b>로,
* 비정상 접근 탐지는 목록의 <b>감사 칸</b>으로 이 화면 안에서 제공한다.</p>
*/
private String renderList(PersonalDataAccessLogSearchVO searchVO, ModelMap model)
throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
@ -97,9 +151,101 @@ public class PersonalDataAccessLogController {
model.addAttribute("dmlSummary", selectDmlSummary(searchVO));
// 내 다운로드 작업 목록(비동기) — 상태·진행률 확인 및 내려받기
model.addAttribute("dlJobList", selectMyDownloadJobs());
model.addAttribute("retentionDays", Integer.valueOf(resolveRetentionDays()));
// 시안의 "감사" 칸 — 목록 각 행에 이상/점검 표시를 채우고 기간 전체 탐지 요약을 함께 싣는다
markAudit(list, searchVO, model);
return "site/mnt/sec/accessLog/list";
}
/**
* 목록 각 행에 <b>감사 표시</b>를 채우고, 검색기간 전체의 탐지 요약을 모델에 싣는다.
*
* <p>판정 규칙(제안서 시안의 표기와 동일):</p>
* <ul>
* <li><b>이상(A)</b> — 처리결과가 실패·차단(F). 비인가 접근 시도가 여기 걸린다.</li>
* <li><b>점검(W)</b> — 처리는 정상이었으나 이상징후 대상인 경우. 대량조회·계정공유의심·
* 인증실패반복 대상자이거나, 업무시간 밖에 처리된 기록이다.</li>
* </ul>
*
* <p><b>기간을 반드시 좁힌다.</b> 탐지는 5개의 집계 질의라 기간이 열려 있으면 730일치를 훑는다.
* 검색조건에 기간이 없으면 최근 {@link #AUDIT_DEFAULT_DAYS}일로 잘라 계산한다 — 목록 한 장을
* 여는 비용이 감사 표시 때문에 커지면 안 된다(PSR-001).</p>
*
* <p><b>실패해도 목록은 보여준다.</b> 감사 표시는 부가 정보이므로, 탐지가 실패하면 표시 없이
* 넘어간다(목록 자체가 막히면 본래 목적인 접속기록 조회가 불가능해진다).</p>
*/
private void markAudit(List<PersonalDataAccessLogVO> list, PersonalDataAccessLogSearchVO searchVO,
ModelMap model) {
if (list == null || list.isEmpty()) {
return;
}
try {
SimpleDateFormat dtFmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String from = searchVO.getFromDtParam();
String to = searchVO.getToDtParam();
if (from == null || to == null) {
Calendar cal = Calendar.getInstance();
String computedTo = dtFmt.format(cal.getTime());
cal.add(Calendar.DAY_OF_MONTH, -AUDIT_DEFAULT_DAYS);
String computedFrom = dtFmt.format(cal.getTime());
from = (from == null) ? computedFrom : from;
to = (to == null) ? computedTo : to;
}
List<AbnormalAccessResultVO> results = abnormalAccessDetectionService.detect(from, to);
// 대상자형 유형(대량조회·계정공유·인증실패반복)의 접속자ID 집합 = 점검 대상
Set<String> watchIds = new HashSet<String>();
int detected = 0;
for (int i = 0; i < results.size(); i++) {
AbnormalAccessResultVO r = results.get(i);
detected += r.getCount();
List<Map<String, Object>> rows = r.getRows();
if (rows == null) {
continue;
}
for (int j = 0; j < rows.size(); j++) {
Object cid = rows.get(j).get("conectId");
if (cid != null) {
watchIds.add(cid.toString());
}
}
}
for (int i = 0; i < list.size(); i++) {
PersonalDataAccessLogVO vo = list.get(i);
if ("F".equals(vo.getProcessResult())) {
vo.setAuditFlag(AUDIT_ABNORMAL);
} else if ((vo.getConectId() != null && watchIds.contains(vo.getConectId()))
|| isOffHours(vo.getConectDt())) {
vo.setAuditFlag(AUDIT_WATCH);
}
}
model.addAttribute("auditDetected", Integer.valueOf(detected));
model.addAttribute("auditSummary", abnormalAccessDetectionService.summarize(from, to));
} catch (Exception e) {
// 감사 표시는 부가 정보다 — 실패해도 목록 조회 자체는 살린다.
model.addAttribute("auditError", Boolean.TRUE);
}
}
/**
* 업무시간(기본 09~18시) 밖 처리인지. 형식이 다르거나 파싱 불가면 false(과대표시 방지).
* 기준시각은 {@code AbnormalAccessPolicy} 기본값과 같다.
*/
private boolean isOffHours(String conectDt) {
if (conectDt == null || conectDt.length() < 13) {
return false;
}
try {
int hour = Integer.parseInt(conectDt.substring(11, 13));
return hour < OFF_HOURS_START || hour >= OFF_HOURS_END;
} catch (Exception e) {
return false;
}
}
/**
* 다운로드 <b>요청</b>(비동기) — 작업만 등록하고 즉시 목록으로 돌아온다.
*

View File

@ -58,6 +58,7 @@ public class PersonalDataAccessLogSearchVO implements Serializable {
/** 전체 레코드 수(조회 후 세팅) */
private int totalRecordCount = 0;
public String getConectId() { return conectId; }
public void setConectId(String conectId) { this.conectId = conectId; }
@ -94,6 +95,7 @@ public class PersonalDataAccessLogSearchVO implements Serializable {
public int getTotalRecordCount() { return totalRecordCount; }
public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; }
// =====================================================================
// SQL 바인딩용 파생값 (2026-08-07) — setter 없음(읽기 전용).
// 원본 필드는 화면 재표시용으로 건드리지 않는다.

View File

@ -45,6 +45,16 @@ public class PersonalDataAccessLogVO implements Serializable {
/** 직전 레코드 해시(해시체인) */
private String prevHashVal;
/**
* 감사 표시 — 제안서 시안의 목록 맨 오른쪽 "감사" 칸.
* {@code A}:이상(차단·비인가) / {@code W}:점검(정상이나 이상징후 대상) / {@code null}:해당없음.
*
* <p>DB 컬럼이 아니라 <b>조회 시점에 계산해 채우는 값</b>이다(컨트롤러가 탐지 결과와 대조).
* 탐지 임계치는 정책값이라 언제든 바뀌므로, 판정 결과를 적재해 두면 임계치를 조정한 뒤에도
* 과거 기준으로 굳은 값이 남는다 — 그래서 저장하지 않는다.</p>
*/
private String auditFlag;
public long getLogId() { return logId; }
public void setLogId(long logId) { this.logId = logId; }
@ -81,6 +91,9 @@ public class PersonalDataAccessLogVO implements Serializable {
public String getHashVal() { return hashVal; }
public void setHashVal(String hashVal) { this.hashVal = hashVal; }
public String getAuditFlag() { return auditFlag; }
public void setAuditFlag(String auditFlag) { this.auditFlag = auditFlag; }
public String getPrevHashVal() { return prevHashVal; }
public void setPrevHashVal(String prevHashVal) { this.prevHashVal = prevHashVal; }
}

View File

@ -1,6 +1,5 @@
package egovframework.com.cmm.privacy.batch;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -13,20 +12,13 @@ import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
/**
* 정기점검 결과 입력·보관 컨트롤러 (#44, SFR-004/SER-002).
*
* <p>취급자 영역(/mnt) 엔드포인트. 기존 {@code list.do}/{@code detail.do}/{@code saveResult.do}
* 는 jsonView 로 응답하는 백엔드 계약이고, 2026.08.27 추가된 {@code listView.do}/
* {@code saveResultView.do} 가 관리자 화면(#48)이다 — 두 계약을 병존시켜 기존 호출부를 깨지 않는다.
* <p>취급자 영역(/mnt) 백엔드 엔드포인트. 응답은 jsonView(화면 JSP는 #48 대시보드 후속).
* 점검자 식별자는 세션 loginVO 에서 취득한다.</p>
*
* <p><b>화면이 필요한 이유:</b> 월 1회 점검 자체는 배치(#43)가 집계하지만, "누가 언제 보고 무엇을
* 조치했는가"는 사람이 입력해야 남는다. 그 입력 경로가 JSON 엔드포인트뿐이면 점검 사실을
* 증빙으로 제출할 수 없다(SFR-004-7).</p>
*
* @author arko-privacy-auditlog (Fork C - 7차)
* @since 2026-07-21
*/
@ -35,8 +27,6 @@ public class InspectionResultController {
protected static final String JSON = "jsonView";
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
@Resource(name = "inspectionResultService")
private InspectionResultService inspectionResultService;
@ -45,82 +35,6 @@ public class InspectionResultController {
return (o instanceof LoginVO) ? (LoginVO) o : null;
}
/**
* 정기점검 리포트 목록·상세 화면(서버 렌더링, #48).
* 뷰: /WEB-INF/jsp/site/mnt/sec/inspection/list.jsp
*
* <p>월 1행짜리 표라 페이징을 두지 않는다(연 12행). 연도 필터와 점검여부 필터만 제공한다.
* {@code rptId} 가 있으면 그 리포트 상세와 점검결과 입력 폼을 함께 편다 —
* 목록에서 상세로 화면을 옮기면 점검 중 다른 달과 대조하기가 불편하다.</p>
*/
@RequestMapping(value = "/mnt/sec/inspection/listView.do")
public String listView(@org.springframework.web.bind.annotation.RequestParam(value = "rptYmPrefix", required = false) String rptYmPrefix,
@org.springframework.web.bind.annotation.RequestParam(value = "inspectedYn", required = false) String inspectedYn,
@org.springframework.web.bind.annotation.RequestParam(value = "rptId", required = false) Long rptId,
@org.springframework.web.bind.annotation.RequestParam(value = "resultMsg", required = false) String resultMsg,
ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
Map<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());
model.addAttribute("rptYmPrefix", rptYmPrefix);
model.addAttribute("inspectedYn", inspectedYn);
model.addAttribute("resultMsg", resultMsg);
if (rptId != null) {
model.addAttribute("report", inspectionResultService.selectReport(rptId.longValue()));
}
return "site/mnt/sec/inspection/list";
}
/**
* 점검결과 입력·보관(화면용, #48). 저장 후 같은 리포트를 펼친 목록으로 되돌린다.
*
* <p>배치가 적재한 집계값은 손대지 않고 점검자 입력 필드만 갱신한다
* (감사 증빙이므로 집계값이 사후에 바뀌면 안 된다).</p>
*/
@RequestMapping(value = "/mnt/sec/inspection/saveResultView.do")
public String saveResultView(InspectionReportVO vo, HttpServletRequest request) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
vo.setInspectorId(user.getId());
String message;
try {
int cnt = inspectionResultService.saveInspectionResult(vo);
message = (cnt > 0) ? "점검결과를 저장했습니다." : "저장 대상 리포트가 없습니다.";
} catch (IllegalArgumentException e) {
// 점검결과 미입력 등 유효성 위반 — 화면에 사유를 그대로 보여준다
message = e.getMessage();
}
// Spring 3.0.5 에는 RedirectAttributes 가 없다(3.1 도입). 이 코드베이스의 관용대로
// 결과 메시지를 URL 인코딩해 쿼리스트링으로 넘긴다(AccountMngController 와 동일 방식).
return "redirect:/mnt/sec/inspection/listView.do?rptId=" + vo.getRptId()
+ "&resultMsg=" + URLEncoder.encode(message == null ? "" : message, "UTF-8");
}
/**
* 인증된 업무사용자(USR)만 통과. 그 외에는 null.
* (인터셉터 ①인증판정이 비활성이라 화면 단에서 다시 판정한다 — 다른 개인정보보호 화면과 동일)
*/
private LoginVO handlerUser() {
if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) {
return null;
}
Object o = EgovUserDetailsHelper.getAuthenticatedUser();
if (!(o instanceof LoginVO)) {
return null;
}
LoginVO loginVO = (LoginVO) o;
return "USR".equals(loginVO.getUserSe()) ? loginVO : null;
}
/** 정기점검 리포트 월별 목록 (검색: rptYmPrefix 연도, inspectedYn Y/N) */
@RequestMapping(value = "/mnt/sec/inspection/list.do")
public String list(@org.springframework.web.bind.annotation.RequestParam(value = "rptYmPrefix", required = false) String rptYmPrefix,

View File

@ -0,0 +1,70 @@
/**
* Class Name : PrivacyAdminAlertService.java
* Description : 개인정보보호 관리자 알림 — 개인정보보호 기능개선 사업(제안서 slide15 "관리자 알림")
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.batch;
import egovframework.com.cmm.privacy.batch.HashChainVerifyResultVO;
/**
* 개인정보 접속기록 관련 이상 상황을 <b>관리자에게 통보</b>한다.
*
* <p>제안요약서 slide15 "로그 조회 및 감사 기능"의 구성요소 중 <b>관리자 알림</b>이다.
* 종전에는 무결성 위반을 에러 로그로만 남겨서, 로그를 들여다보지 않으면 아무도 모르는
* 상태였다 — 탐지해 놓고 알리지 않으면 탐지의 의미가 없다.</p>
*
* <p><b>수신자</b>는 {@code ACCOUNT_MNG_POLICY.ADMIN_EMAIL}(계정 관리 정책 화면에서 설정)이며,
* 발송은 휴면 사전알림({@code DormantAlertService})이 쓰는 표준 메일 모듈을 그대로 재사용한다.
* 알림 채널을 새로 만들지 않는 이유는, 운영에서 메일 발송 경로가 하나여야 장애를 한 곳에서
* 확인할 수 있기 때문이다.</p>
*
* <p><b>메일에 개인정보 본문을 담지 않는다.</b> 알림에는 건수·취급자 계정ID·기간처럼 조치에
* 필요한 최소 정보만 넣는다. 상세는 관리자가 접속기록 화면에서 확인한다 — 메일함이
* 개인정보 저장소가 되면 안 된다.</p>
*
* <p><b>배치를 막지 않는다.</b> 모든 메서드는 실패해도 예외를 올리지 않는다(발송 실패로
* 파기·검증 배치가 중단되면 본말이 전도된다). 발송 여부와 무관하게 로그는 항상 남긴다.</p>
*/
public interface PrivacyAdminAlertService {
/**
* 접속기록 해시체인 <b>무결성 위반</b> 통보.
*
* @param result 검증 결과(위반이 아니면 아무것도 보내지 않는다)
* @return 발송 건수(0 = 미발송)
*/
int alertIntegrityViolation(HashChainVerifyResultVO result);
/**
* <b>이상행위 탐지</b> 통보(정기점검 배치가 월 1회 호출).
*
* @param periodFrom 집계 시작일시
* @param periodTo 집계 종료일시
* @param summary 탐지 요약 문자열({@code AbnormalAccessDetectionService.summarize})
* @param detected 탐지 합계(0이면 보내지 않는다 — 조용한 달에는 메일도 조용해야 한다)
* @return 발송 건수(0 = 미발송)
*/
int alertAbnormalAccess(String periodFrom, String periodTo, String summary, int detected);
/**
* <b>테스트 발송</b> — 알림 설정이 실제로 동작하는지 확인한다.
*
* <p>실제 알림은 월 1회 배치에서만 나가므로, 설정을 바꾼 관리자가 그것이 맞는지
* 한 달을 기다려 확인해야 하는 문제가 있었다. 이 메서드는 같은 경로(수신주소 조회 →
* 메일 모듈 적재·발송)를 그대로 타서 즉시 결과를 알려준다 — 경로가 다르면
* 테스트가 통과해도 실제 알림은 실패할 수 있으므로 반드시 같은 경로여야 한다.</p>
*
* @param requesterId 발송을 요청한 취급자 계정ID(본문에 기록해 누가 눌렀는지 남긴다)
* @return 사람이 읽을 결과 문자열. 성공하면 수신 주소를, 실패하면 사유를 담는다.
*/
String sendTestMail(String requesterId);
}

View File

@ -0,0 +1,176 @@
/**
* Class Name : PrivacyAdminAlertServiceImpl.java
* Description : 개인정보보호 관리자 알림 구현 — 개인정보보호 기능개선 사업(제안서 slide15)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.batch;
import javax.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import egovframework.com.cmm.privacy.account.AccountMngDAO;
import egovframework.com.cmm.privacy.account.AccountMngPolicyVO;
import egovframework.com.cop.ems.service.EgovSndngMailRegistService;
import egovframework.com.cop.ems.service.SndngMailVO;
/**
* {@link PrivacyAdminAlertService} 구현.
*
* <p>발송 경로는 휴면 사전알림({@code DormantAlertServiceImpl.sendMail})과 동일하다 —
* {@code SndngMailVO} 를 만들어 표준 메일 모듈에 적재하면 발송 배치가 실제 전송을 맡는다.</p>
*/
@Service("privacyAdminAlertService")
public class PrivacyAdminAlertServiceImpl implements PrivacyAdminAlertService {
private static final Logger LOG = LoggerFactory.getLogger(PrivacyAdminAlertServiceImpl.class);
/** 메일 제목 접두 — 관리자 메일함에서 걸러 보기 쉽게 고정한다. */
private static final String SUBJECT_PREFIX = "[문화품앗이/개인정보] ";
/** 발신자 계정 — 기존 알림과 동일. */
private static final String SENDER = "webmaster";
@Resource(name = "accountMngDAO")
private AccountMngDAO accountMngDAO;
@Resource(name = "sndngMailRegistService")
private EgovSndngMailRegistService sndngMailRegistService;
@Override
public int alertIntegrityViolation(HashChainVerifyResultVO result) {
if (result == null || result.isOk()) {
return 0;
}
StringBuilder body = new StringBuilder();
body.append("개인정보 접속기록의 해시체인 무결성 검증에서 불일치가 발견되었습니다.\n\n");
body.append("- 검증 일시 : ").append(nvl(result.getVerifiedAt())).append('\n');
body.append("- 검증 대상 : ").append(result.getTotalCount()).append("건\n");
body.append("- 최초 불일치 : 기록번호 ").append(result.getMismatchLogId()).append('\n');
body.append('\n');
body.append("접속기록은 각 행이 이전 행의 해시를 물고 있어, 중간의 어떤 행이 바뀌면\n");
body.append("그 뒤가 전부 어긋납니다. 위 기록번호부터가 검증에 실패한 지점입니다.\n\n");
body.append("확인 경로: 시스템관리 > 권한관리 > 접속기록 조회 및 감사\n");
body.append("※ 이 메일에는 개인정보 본문이 포함되어 있지 않습니다. 상세는 화면에서 확인하십시오.\n");
return send("접속기록 무결성 위반 탐지", body.toString(), "무결성 위반");
}
@Override
public int alertAbnormalAccess(String periodFrom, String periodTo, String summary, int detected) {
if (detected <= 0) {
// 조용한 달에는 메일도 조용해야 한다. 매달 "0건" 메일이 오면 곧 아무도 열지 않는다.
return 0;
}
StringBuilder body = new StringBuilder();
body.append("개인정보 접속기록 정기점검에서 이상행위가 탐지되었습니다.\n\n");
body.append("- 집계 기간 : ").append(nvl(periodFrom)).append(" ~ ").append(nvl(periodTo)).append('\n');
body.append("- 탐지 합계 : ").append(detected).append("건\n");
body.append("- 탐지 요약 : ").append(nvl(summary)).append('\n');
body.append('\n');
body.append("탐지되었다는 것이 곧 위반은 아닙니다. 업무상 정당한 대량조회나 야간작업이\n");
body.append("있을 수 있으므로, 아래 화면에서 원본 기록을 확인한 뒤 판단하십시오.\n\n");
body.append("확인 경로: 시스템관리 > 권한관리 > 접속기록 조회 및 감사\n");
body.append(" (목록 오른쪽 '감사' 칸의 이상/점검 표시로 해당 기록을 찾을 수 있습니다)\n");
body.append("※ 이 메일에는 정보주체의 개인정보가 포함되어 있지 않습니다.\n");
return send("접속기록 이상행위 탐지", body.toString(), "이상행위 탐지");
}
@Override
public String sendTestMail(String requesterId) {
String adminEmail = resolveAdminEmail();
if (adminEmail == null) {
return "관리자 수신 이메일이 설정되어 있지 않습니다."
+ " 이 화면의 [관리자 수신 이메일] 항목에 주소를 입력하고 저장한 뒤 다시 시도하세요.";
}
StringBuilder body = new StringBuilder();
body.append("개인정보보호 관리자 알림 테스트 메일입니다.\n\n");
body.append("이 메일이 도착했다면 아래 알림이 같은 경로로 정상 발송됩니다.\n");
body.append(" - 접속기록 무결성 위반 탐지 (매월 1일 03:00 검증 배치)\n");
body.append(" - 접속기록 이상행위 탐지 (매월 1일 05:00 정기점검 배치)\n");
body.append(" - 계정 휴면 사전알림 (매일 06:00, D-30 / D-7 / D-Day)\n\n");
body.append("- 요청자 : ").append(nvl(requesterId)).append('\n');
body.append("- 수신주소 : ").append(adminEmail).append('\n');
body.append("- 발송시각 : ").append(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new java.util.Date())).append('\n');
String err = doSend(adminEmail, "관리자 알림 테스트", body.toString(), "테스트");
if (err == null) {
return "테스트 메일을 발송했습니다. 수신함(" + adminEmail + ")을 확인하세요."
+ " 도착하지 않으면 메일서버 설정과 발송 이력(COMTNSNDNGMAIL)을 확인해야 합니다.";
}
return "발송에 실패했습니다: " + err;
}
/** 정책에서 관리자 수신 주소를 읽는다. 미설정·조회실패면 null. */
private String resolveAdminEmail() {
try {
AccountMngPolicyVO policy = accountMngDAO.selectPolicy();
if (policy != null && policy.getAdminEmail() != null
&& policy.getAdminEmail().trim().length() > 0) {
return policy.getAdminEmail().trim();
}
} catch (Exception e) {
LOG.warn("[개인정보/알림] 정책 조회 실패: {}", e.toString());
}
return null;
}
/**
* 관리자 메일 적재·발송. 수신자 미설정이면 보내지 않되 <b>로그는 남긴다</b> —
* "알림이 안 왔다"와 "보낼 주소가 없다"를 구분할 수 있어야 한다.
*
* <p>표준 메일 모듈은 적재와 발송을 한 번에 한다({@code insertSndngMail} 이
* COMTNSNDNGMAIL 적재 후 곧바로 전송하고, 실패하면 발송결과코드를 'F' 로 갱신한다).
* 그래서 반환값 false 도 실패로 취급한다 — 예외가 없다고 발송된 것이 아니다.</p>
*
* @return 성공이면 null, 실패면 사유 문자열
*/
private String doSend(String adminEmail, String subject, String body, String kind) {
try {
SndngMailVO vo = new SndngMailVO();
vo.setDsptchPerson(SENDER);
vo.setRecptnPerson(adminEmail);
vo.setSj(SUBJECT_PREFIX + subject);
vo.setEmailCn(body);
vo.setAtchFileId("");
boolean ok = sndngMailRegistService.insertSndngMail(vo);
if (!ok) {
LOG.error("[개인정보/알림] {} 알림 발송 실패(메일모듈이 false 반환) → {}", kind, adminEmail);
return "메일 모듈이 발송 실패를 반환했습니다. 메일서버 설정을 확인하세요.";
}
LOG.info("[개인정보/알림] {} 알림 발송 완료 → {}", kind, adminEmail);
return null;
} catch (Exception e) {
// 발송 실패로 파기·검증 배치가 멈추면 본말이 전도된다.
LOG.error("[개인정보/알림] {} 알림 발송 오류 — 배치는 계속합니다: {}", kind, e.toString());
return e.toString();
}
}
/** 배치용 발송 — 결과를 건수로만 돌려준다. */
private int send(String subject, String body, String kind) {
String adminEmail = resolveAdminEmail();
if (adminEmail == null) {
LOG.warn("[개인정보/알림] 관리자 수신 이메일 미설정(ACCOUNT_MNG_POLICY.ADMIN_EMAIL) —"
+ " {} 알림을 보내지 못했습니다. 계정 관리 정책 화면에서 주소를 등록하십시오.", kind);
return 0;
}
return doSend(adminEmail, subject, body, kind) == null ? 1 : 0;
}
private String nvl(Object v) {
return v == null ? "-" : String.valueOf(v);
}
}

View File

@ -16,6 +16,7 @@ import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.stereotype.Service;
import egovframework.com.cmm.privacy.account.AccountMngPolicyVO;
import egovframework.com.cmm.privacy.auditlog.AbnormalAccessDetectionService;
import egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryDAO;
import egovframework.com.cmm.privacy.auditlog.HashChainService;
@ -46,6 +47,35 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
*/
private static final int ACCESS_LOG_RETENTION_DAYS = 730;
/**
* 접속기록 보관기간을 정책({@code ACCOUNT_MNG_POLICY.ACCESS_LOG_KEEP_DAY})에서 읽는다.
*
* <p>제안서 slide15 가 "관리자 설정값 미존재 → 신규 구축"으로 지목한 항목이라 상수 대신
* 정책값을 쓴다. 다만 <b>법정 하한(2년)은 정책값보다 우선</b>한다 — 정책 행을 직접 고쳐
* 730 미만으로 낮추더라도 파기 배치는 2년치를 남긴다. 한 번 지운 접속기록은 되돌릴 수
* 없으므로, 설정 실수로 법정 보관기간을 깨는 쪽보다 더 오래 남기는 쪽으로 기운다.</p>
*
* <p>정책 조회가 실패하면 상수 기본값을 쓴다(배치가 멈추면 파티션이 무한정 쌓인다).</p>
*/
private int resolveAccessLogRetentionDays() {
int days = ACCESS_LOG_RETENTION_DAYS;
try {
AccountMngPolicyVO policy = accountMngDAO.selectPolicy();
if (policy != null && policy.getAccessLogKeepDay() > 0) {
days = policy.getAccessLogKeepDay();
}
} catch (Exception e) {
LOG.warn("[개인정보/SFR-004] 접속기록 보관기간 정책 조회 실패 — 기본값 "
+ ACCESS_LOG_RETENTION_DAYS + "일 적용: " + e.toString());
}
if (days < AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY) {
LOG.warn("[개인정보/SFR-004] 설정된 보관기간(" + days + "일)이 법정 하한 미만 — "
+ AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY + "일로 보정해 파기한다.");
days = AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY;
}
return days;
}
/** 미래 파티션 선행 확보 개월 수(당월 포함). 적재 시 파티션 누락 방지. */
private static final int PARTITION_MONTHS_AHEAD = 2;
@ -60,6 +90,10 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
@Resource(name = "accessLogPartitionDAO")
private AccessLogPartitionDAO accessLogPartitionDAO;
/** 보관기간 정책 조회용(SFR-004 관리자 설정). */
@Resource(name = "accountMngDAO")
private egovframework.com.cmm.privacy.account.AccountMngDAO accountMngDAO;
@Resource(name = "inspectionReportDAO")
private InspectionReportDAO inspectionReportDAO;
@ -71,6 +105,10 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
@Resource(name = "accessLogDownloadHistoryDAO")
private AccessLogDownloadHistoryDAO accessLogDownloadHistoryDAO;
/** 제안서 slide15 "관리자 알림" — 무결성 위반·이상행위 탐지 통보. */
@Resource(name = "privacyAdminAlertService")
private PrivacyAdminAlertService privacyAdminAlertService;
/** 정기점검 리포트 취급자별 처리현황 요약 최대 표기 행 수. */
private static final int HANDLER_SUMMARY_TOP = 20;
@ -111,12 +149,36 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
}
/**
* 무결성 위반 즉시 관리자 통보.
* TODO: 실제 통보 채널(관리자 메일/SMS/알림함 #49) 연동. 현재는 에러 로그로 남긴다.
* 무결성 위반 즉시 관리자 통보(제안서 slide15 "관리자 알림").
*
* <p>에러 로그는 그대로 남긴다 — 메일이 막혀 있어도 흔적은 서버에 있어야 한다.
* 실제 통보는 {@link PrivacyAdminAlertService} 가 관리자 메일로 보낸다.</p>
*/
private void notifyAdmin(HashChainVerifyResultVO result) {
LOG.error("[개인정보/#40] 관리자 통보 필요 — 무결성 위반 LOG_ID=" + result.getMismatchLogId()
+ " (검증일시 " + result.getVerifiedAt() + "). TODO: 메일/SMS/알림함 연동.");
LOG.error("[개인정보/#40] 관리자 통보 — 무결성 위반 LOG_ID=" + result.getMismatchLogId()
+ " (검증일시 " + result.getVerifiedAt() + ").");
privacyAdminAlertService.alertIntegrityViolation(result);
}
/**
* 기간 내 이상행위 탐지 합계. 알림 발송 여부를 정하는 값이라 요약 문자열을 파싱하지 않고
* 탐지 결과의 건수를 그대로 더한다(문자열 파싱은 문구가 바뀌면 조용히 어긋난다).
* 집계에 실패하면 0 을 돌려 알림을 보내지 않는다 — 근거 없이 알리지 않는다.
*/
private int countDetected(Map<String, Object> param) {
try {
List<egovframework.com.cmm.privacy.auditlog.AbnormalAccessResultVO> results =
abnormalAccessDetectionService.detect(param.get("fromDt").toString(),
param.get("toDt").toString());
int sum = 0;
for (int i = 0; results != null && i < results.size(); i++) {
sum += results.get(i).getCount();
}
return sum;
} catch (Exception e) {
LOG.error("[개인정보/알림] 이상행위 건수 집계 실패 - 알림 생략: " + e.getMessage());
return 0;
}
}
@Override
@ -200,6 +262,18 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
report.setDownloadCnt(downloadCnt);
inspectionReportDAO.insertInspectionReport(report);
// [제안서 slide15 "관리자 알림"] 이상행위가 탐지된 달에만 관리자에게 통보한다.
// 리포트를 적재한 뒤에 보내는 이유는, 메일을 받은 관리자가 화면에서 바로 근거를
// 확인할 수 있어야 하기 때문이다(먼저 보내면 리포트가 아직 없을 수 있다).
try {
int detected = countDetected(param);
privacyAdminAlertService.alertAbnormalAccess(
report.getPeriodFrom(), report.getPeriodTo(), abnormalSummary, detected);
} catch (Exception e) {
LOG.error("[개인정보/알림] 이상행위 알림 처리 오류 - 리포트는 정상 생성됨: " + e.getMessage());
}
LOG.info("[개인정보/#43] 정기점검 리포트 생성 — " + rptYm + " 총 " + totalCnt + "건(실패 " + failCnt
+ "), 무결성=" + report.getIntegrityResult() + ", 취급자 "
+ (handlers == null ? 0 : handlers.size()) + "종.");
@ -227,7 +301,8 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
ensureFuturePartitions();
// 2) 보관기간(730일) 경과 파티션 파기. 파티션 상한(경계) <= 파기기준일이면 파티션 전체가 만료.
Date cutoff = minusDays(new Date(), ACCESS_LOG_RETENTION_DAYS);
int retentionDays = resolveAccessLogRetentionDays();
Date cutoff = minusDays(new Date(), retentionDays);
List<Map<String, Object>> partitions = accessLogPartitionDAO.selectPartitions(PDAL_TABLE);
int dropped = 0;
if (partitions != null) {
@ -242,14 +317,14 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
if (!boundary.after(cutoff)) { // boundary <= cutoff → 파티션 전체가 보관기간 경과
accessLogPartitionDAO.dropPartition(name);
recordPurge(name, boundaryStr, "DROP",
"보관기간(" + ACCESS_LOG_RETENTION_DAYS + "일) 경과 파티션 파기");
"보관기간(" + retentionDays + "일) 경과 파티션 파기");
dropped++;
LOG.info("[개인정보/#37] 접속기록 파티션 파기 — " + name + " (경계 " + boundaryStr + ")");
}
}
}
LOG.info("[개인정보/#37] 접속기록 파기 배치 완료 — 파티션 " + dropped
+ "개 DROP(기준일시 " + formatDate(cutoff) + ", 보관 " + ACCESS_LOG_RETENTION_DAYS + "일).");
+ "개 DROP(기준일시 " + formatDate(cutoff) + ", 보관 " + retentionDays + "일).");
return dropped;
}

View File

@ -180,7 +180,16 @@ public class MberCryptoAdminController {
return reauthRedirect(viewUrl(mberId, reason));
}
MberCryptoVO vo = mberCryptoAdminService.selectMber(mberId);
MberCryptoVO vo;
try {
vo = mberCryptoAdminService.selectMber(mberId);
} catch (IllegalStateException e) {
// 복호화 실패 — 상세는 값이 정확해야 하므로 열지 않는다.
// 다만 스택트레이스를 그대로 띄우지 않고 원인을 문장으로 알린다.
return listRedirect("이 회원의 이름을 복호화할 수 없습니다."
+ " 저장된 암호문이 현재 암호화 키와 맞지 않습니다(키 교체 또는 암호문 손상)."
+ " 대상 회원ID: " + mberId);
}
if (vo == null) {
return listRedirect("대상 회원을 찾을 수 없습니다.");
}
@ -344,7 +353,10 @@ public class MberCryptoAdminController {
}
for (int i = 0; i < list.size(); i++) {
MberCryptoVO vo = list.get(i);
vo.setMberNm(MaskingUtil.maskName(vo.getMberNm()));
// 복호화 실패 표식은 이름이 아니므로 마스킹하지 않는다(마스킹하면 표식이 뭉개진다).
if (!vo.isDecryptFailed()) {
vo.setMberNm(MaskingUtil.maskName(vo.getMberNm()));
}
vo.setMoblphonNo(MaskingUtil.maskPhone(vo.getMoblphonNo()));
vo.setMberEmailAdres(MaskingUtil.maskEmail(vo.getMberEmailAdres()));
// 암호문·해시는 화면으로 나갈 이유가 없다. 목록 모델에서 지운다.

View File

@ -35,11 +35,33 @@ import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
@Repository("mberCryptoAdminDAO")
public class MberCryptoAdminDAO extends EgovComAbstractDAO {
/** 회원 목록(검색·페이징). 반환 직전 이름을 복호화해 {@code mberNm} 에 되돌려 쓴다. */
/** 목록에서 복호화가 실패한 행의 이름 자리에 대신 넣는 표식. */
public static final String DECRYPT_FAIL_MARK = "(복호화 실패)";
/**
* 회원 목록(검색·페이징). 반환 직전 이름을 복호화해 {@code mberNm} 에 되돌려 쓴다.
*
* <p><b>행 단위로 실패를 견딘다.</b> 공용 {@link MemberNmDecryptSupport} 는 실패를 숨기지
* 않도록 예외를 던지는데, 목록에서 그대로 쓰면 한 행이 깨진 순간 페이지 전체가 죽는다.
* 키를 교체했거나 암호문 한 건이 손상된 상황에서 관리자 화면마저 열리지 않으면 원인을
* 확인할 방법이 없어진다. 그래서 여기서만 행 단위로 감싸 실패한 행에 표식을 남기고
* 나머지는 정상 표시한다 — 실패를 감추는 게 아니라 어느 행인지 드러내는 처리다.
* 단건 조회({@link #selectMber})는 값이 정확해야 하므로 종전대로 예외를 그대로 올린다.</p>
*/
@SuppressWarnings("unchecked")
public List<MberCryptoVO> selectMberList(MberCryptoSearchVO searchVO) {
List<MberCryptoVO> rows = list("MberCryptoAdminDAO.selectMberList", searchVO);
MemberNmDecryptSupport.decryptListInPlace(rows, "mberNm");
if (rows != null) {
for (int i = 0; i < rows.size(); i++) {
MberCryptoVO row = rows.get(i);
try {
MemberNmDecryptSupport.decryptInPlace(row, "mberNm");
} catch (RuntimeException e) {
row.setMberNm(DECRYPT_FAIL_MARK);
row.setDecryptFailed(true);
}
}
}
return rows;
}

View File

@ -63,6 +63,17 @@ public class MberCryptoVO implements Serializable {
/** 신규 등록 시 초기 비밀번호(평문 입력). 저장 시 솔트 해시로 변환되며 조회에는 실리지 않는다. */
private String password;
/**
* 이 행의 이름 복호화가 실패했는지. DB 컬럼이 아니라 조회 시점에 채우는 값이다.
*
* <p><b>왜 이 표식이 필요한가:</b> 목록은 페이지의 모든 행을 복호화하는데, 키를 교체했거나
* 암호문 한 건이 손상되면 그 한 행 때문에 화면 전체가 죽는다. 관리자 화면이 통째로 열리지
* 않으면 정작 문제를 확인할 방법도 사라진다. 그래서 목록에서는 실패한 행만 표시해 두고
* 나머지는 정상적으로 보여준다 — 실패를 <u>숨기는</u> 것이 아니라 <u>행 단위로 드러내는</u> 것이다.
* (상세 조회는 값이 정확해야 하므로 지금처럼 실패를 그대로 알린다)</p>
*/
private boolean decryptFailed;
public String getMberId() { return mberId; }
public void setMberId(String mberId) { this.mberId = mberId; }
@ -107,4 +118,7 @@ public class MberCryptoVO implements Serializable {
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public boolean isDecryptFailed() { return decryptFailed; }
public void setDecryptFailed(boolean decryptFailed) { this.decryptFailed = decryptFailed; }
}

View File

@ -0,0 +1,236 @@
package egovframework.com.cmm.privacy.interceptor;
import java.io.PrintWriter;
import java.security.SecureRandom;
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;
/**
* CSRF(크로스사이트 요청 위조) 방지 인터셉터 — 개인정보보호 기능개선 사업(SER-004 / 웹취약점 조치).
*
* <p>Spring Security 2.0.4 에는 CSRF 토큰 기능이 없어, 세션 기반 동기화 토큰(Synchronizer Token)을
* 이 인터셉터로 자체 구현한다. 개인정보취급자 영역(기본 {@code /mnt/})의 상태변경 요청
* (POST/PUT/DELETE/PATCH)에 대해 요청 토큰과 세션 토큰의 일치를 검증한다.</p>
*
* <p><b>이중 방어(회귀 최소화):</b> 본 프로젝트는 관리자 화면이 폼 전송(다수)과 AJAX(일부)로 혼재한다.
* 공통 헤더({@code /mnt/include/head.jsp})가 모든 폼에 토큰 hidden 을, 모든 AJAX 에 {@code X-CSRF-TOKEN}
* 헤더를 자동 주입하지만, 공통 헤더를 쓰지 않는 화면·동적 생성 폼이 있을 수 있다. 따라서 토큰이
* 없거나 불일치해도 <b>같은 출처(Origin/Referer host == Host)</b> 요청이면 통과시킨다(정상 화면 회귀 방지).
* CSRF 공격은 본질적으로 다른 출처(cross-origin)에서 발생하므로, 토큰 불일치 + 교차 출처인 경우에만 차단한다.
* 토큰이 정상 첨부된 요청은 출처와 무관하게 통과하므로 방어의 상한은 토큰이 담당한다.</p>
*
* <p><b>적용 범위 판정:</b> 본 프로젝트는 전용 {@code <mvc:interceptors>} 경로매핑이 아니라
* {@code selectAnnotaionMapper}가 {@code /**}/*.do 전체에 인터셉터를 적용하므로, 적용 범위 판정을
* 이 클래스 내부에서 수행한다(그 외 경로는 통과). {@code enforce=false}로 두면 검증 실패를 로그로만
* 남기고 통과시켜(관찰 모드), 운영 반영 전 토큰 미첨부 화면을 식별할 수 있다.</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.30 URP 최초 생성 (개인정보보호 기능개선 사업 SER-004 / CSRF 조치)
* </pre>
*/
public class CsrfTokenInterceptor extends HandlerInterceptorAdapter {
private static final Logger LOG = LoggerFactory.getLogger(CsrfTokenInterceptor.class);
/** 세션에 저장되는 CSRF 토큰 키. head.jsp 가 ${sessionScope.CSRF_TOKEN} 으로 읽는다. */
public static final String ATTR_CSRF_TOKEN = "CSRF_TOKEN";
/** 요청이 토큰을 싣는 헤더명(AJAX) */
private static final String HEADER_NAME = "X-CSRF-TOKEN";
/** 요청이 토큰을 싣는 파라미터명(폼 전송) */
private static final String PARAM_NAME = "_csrf";
private static final SecureRandom RANDOM = new SecureRandom();
/** 강제 여부. false 면 검증 실패를 로그만 남기고 통과(관찰 모드). */
private boolean enforce = true;
/** 적용 경로 접두(개인정보취급자 영역). 기본 /mnt/ */
private String applyPathPrefix = "/mnt/";
/** 적용 제외 정규식 패턴(contextPath 제외 URI 대상) */
private List<String> excludePatterns;
public void setEnforce(boolean enforce) { this.enforce = enforce; }
public void setApplyPathPrefix(String applyPathPrefix) { this.applyPathPrefix = applyPathPrefix; }
public void setExcludePatterns(List<String> excludePatterns) { this.excludePatterns = excludePatterns; }
@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) 세션 토큰 보장 — GET 진입 시에도 발급되어 head.jsp 가 화면에 심을 수 있게 한다.
HttpSession session = request.getSession(true);
String sessionToken = (String) session.getAttribute(ATTR_CSRF_TOKEN);
if (sessionToken == null || sessionToken.length() == 0) {
sessionToken = generateToken();
session.setAttribute(ATTR_CSRF_TOKEN, sessionToken);
}
// 3) 제외 패턴(로그인/프레임/공통 include 등) 통과
if (isExcluded(path)) {
return true;
}
// 4) 안전 메서드(GET/HEAD/OPTIONS/TRACE)는 상태를 바꾸지 않으므로 검증 대상 아님
String method = request.getMethod();
if (method == null || isSafeMethod(method)) {
return true;
}
// 5) 상태변경 요청 — 토큰 검증
String requestToken = request.getHeader(HEADER_NAME);
if (requestToken == null || requestToken.length() == 0) {
requestToken = request.getParameter(PARAM_NAME);
}
if (requestToken != null && constantTimeEquals(sessionToken, requestToken)) {
return true; // 토큰 일치 — 정상
}
// 6) 토큰 불일치/부재 — 같은 출처면 통과(회귀 방지), 교차 출처면 CSRF 로 차단
if (isSameOrigin(request)) {
LOG.warn("[개인정보/SER-004] CSRF 토큰 미첨부(같은 출처라 통과) — path={}, method={}", path, method);
return true;
}
if (!enforce) {
LOG.warn("[개인정보/SER-004] CSRF 토큰 불일치(관찰 모드 통과) — path={}, method={}, origin={}",
new Object[] { path, method, originOf(request) });
return true;
}
LOG.error("[개인정보/SER-004] CSRF 차단 — 교차 출처 상태변경 요청. path={}, method={}, origin={}",
new Object[] { path, method, originOf(request) });
writeForbidden(request, response);
return false;
}
/** 차단 응답. AJAX(JSON) 요청과 일반 폼 요청을 구분해 응답한다. */
private void writeForbidden(HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403
String accept = request.getHeader("Accept");
String xrw = request.getHeader("X-Requested-With");
boolean ajax = (xrw != null) || (accept != null && accept.contains("application/json"));
if (ajax) {
response.setContentType("application/json; charset=UTF-8");
PrintWriter out = response.getWriter();
out.print("{\"resCode\":\"403\",\"message\":\"잘못된 요청입니다. 페이지를 새로고침한 뒤 다시 시도해 주세요.\"}");
out.flush();
} else {
response.setContentType("text/html; charset=UTF-8");
PrintWriter out = response.getWriter();
out.print("<html><head><meta charset='UTF-8'></head><body>"
+ "<script>alert('잘못된 요청입니다. 페이지를 새로고침한 뒤 다시 시도해 주세요.');history.back();</script>"
+ "</body></html>");
out.flush();
}
}
private boolean isSafeMethod(String method) {
return "GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method)
|| "OPTIONS".equalsIgnoreCase(method) || "TRACE".equalsIgnoreCase(method);
}
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;
}
/**
* 같은 출처 여부. Origin(우선) 또는 Referer 의 host 가 요청 Host 와 같으면 true.
* 두 헤더가 모두 없으면(브라우저 정상 폼 전송은 통상 Referer 를 보냄) 관례상 통과(true)로 처리해
* 프록시·직접 호출 등에서의 과잉 차단을 피한다. CSRF 공격은 헤더가 실려 교차 출처로 판별된다.
*/
private boolean isSameOrigin(HttpServletRequest request) {
String source = originOf(request);
if (source == null) {
return true; // 출처 헤더 없음 — 관례상 통과(토큰이 주 방어)
}
String sourceHost = hostOf(source);
String requestHost = hostOf(request.getHeader("Host"));
return sourceHost != null && sourceHost.equalsIgnoreCase(requestHost);
}
private String originOf(HttpServletRequest request) {
String source = request.getHeader("Origin");
if (source == null) {
source = request.getHeader("Referer");
}
return source;
}
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;
}
/** SecureRandom 24바이트를 48자 소문자 hex 로 인코딩(외부 의존 없음, URL·헤더 안전). */
private String generateToken() {
byte[] bytes = new byte[24];
RANDOM.nextBytes(bytes);
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (int i = 0; i < bytes.length; i++) {
int v = bytes[i] & 0xFF;
sb.append(Character.forDigit(v >> 4, 16));
sb.append(Character.forDigit(v & 0x0F, 16));
}
return sb.toString();
}
/** 길이·내용 비교(타이밍 부채널 완화). */
private boolean constantTimeEquals(String a, String b) {
if (a == null || b == null) {
return false;
}
if (a.length() != b.length()) {
return false;
}
int result = 0;
for (int i = 0; i < a.length(); i++) {
result |= a.charAt(i) ^ b.charAt(i);
}
return result == 0;
}
}

View File

@ -12,6 +12,8 @@ import org.apache.commons.dbcp.PoolingDriver;
import org.apache.commons.pool.impl.GenericObjectPool;
import org.apache.log4j.Logger;
import egovframework.com.cmm.service.EgovProperties;
/**
* 문자메시지를 위한 DB Util 클래스 (프레임워크 비종속 버전)
* Apache commons의 DBCP를 활용한 예로 각 프로젝트에 맞게 수정 필요
@ -39,12 +41,14 @@ public class SmsBasicDBUtil {
private static final String JDBC_ALIAS = "default";
/** JDBC Driver 명 */
private static final String JDBC_DRIVER = "org.gjt.mm.mysql.Driver";
/* [보안/SER-004] SMS 발송 DB 접속정보 하드코딩(CWE-798) 제거 — 프로퍼티(globals)로 외부화.
Globals.sms.jdbc.url / .username / .password 로 설정한다(환경별 분리·암호화 계층 이관). */
/** JDBC 접속 URL */
private static final String JDBC_URL = "jdbc:mysql://192.168.200.24:1621/com";
private static final String JDBC_URL = EgovProperties.getProperty("Globals.sms.jdbc.url");
/** JDBC 접속 사용자ID */
private static final String JDBC_USER = "com";
private static final String JDBC_USER = EgovProperties.getProperty("Globals.sms.jdbc.username");
/** JDBC 접속 패스워드 */
private static final String JDBC_PASSWORD = "com01";
private static final String JDBC_PASSWORD = EgovProperties.getProperty("Globals.sms.jdbc.password");
/** 한번에 pool에서 갖다 쓸 수 있는 최대 커넥션 개수 */
private static final int MAX_ACTIVE = 20;
/** 사용되지 않고 pool에 저정될 수 있는 최대 커넥션 개수 */

View File

@ -3,7 +3,7 @@ package egovframework.com.uat.uia.web;
import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.List;
import java.util.Random;
import java.security.SecureRandom;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
@ -441,7 +441,9 @@ public class EgovLoginController {
char[] charaters = {'a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','0','1','2','3','4','5','6','7','8','9'};
StringBuffer sb = new StringBuffer();
Random rn = new Random();
// [보안/SER-004] 휴면 활성화 메일 토큰 난수를 예측 가능한 java.util.Random 에서
// 암호학적 안전 난수(SecureRandom)로 교체(CWE-330). 토큰 재현·추측 방지.
SecureRandom rn = new SecureRandom();
for( int i = 0 ; i < 20 ; i++ ){
sb.append( charaters[ rn.nextInt( charaters.length ) ] );
}
@ -461,14 +463,30 @@ public class EgovLoginController {
/**
* 휴면계정 활성화
*
* [보안/SER-004] 인증 없는 휴면계정 해제 차단.
* 종전: 요청 파라미터 mber_id 만으로 즉시 deleteDiapause 실행 → 아이디만 알면
* 제3자가 임의 계정의 휴면을 해제할 수 있었다(본인확인 부재, CWE-306).
* 변경: 파라미터를 신뢰하지 않고, ID/PW 인증에 성공해야만 세션에 적재되는
* diapauseVO(EgovSpringSecurityLoginFilter/diapauseLogin 에서 setAttribute)의
* mber_id 로만 해제한다. 프론트 경로(releaseDiapauseAjax)가 이메일 토큰을
* 검증하는 것과 동일하게, 모바일 경로는 '로그인 세션' 을 본인확인 근거로 삼는다.
* 세션 부재·불일치 시 해제하지 않고 resCode=002 로 거부, 성공 시 1회성 소거.
*/
@RequestMapping(value = "/uat/uia/actionDiapause.do")
public String actionDiapause(HttpServletRequest request, HttpServletResponse response
,@RequestParam(value="mber_id", required=true) String mber_id
,ModelMap model) throws Exception {
LoginVO diapauseVO = (LoginVO) request.getSession().getAttribute("diapauseVO");
if (diapauseVO == null || diapauseVO.getMber_id() == null || "".equals(diapauseVO.getMber_id())) {
// 로그인(본인확인) 없이 직접 호출된 요청 — 해제 거부
model.addAttribute("resCode", "002");
return JSON;
}
LoginVO vo = new LoginVO();
vo.setMber_id(mber_id);
vo.setMber_id(diapauseVO.getMber_id());
loginService.deleteDiapause(vo);
request.getSession().removeAttribute("diapauseVO"); // 재사용 방지
model.addAttribute("resCode", "001");
return JSON;
}

View File

@ -3,7 +3,7 @@ package kr.or.arko.frt.biz.umt.web;
import java.util.Calendar;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.security.SecureRandom;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
@ -491,57 +491,60 @@ public class BizEntrprsFrtManageController {
* @return
*/
public String randomValue(String type, int size) {
// [보안/SER-004] 임시비밀번호 생성 난수를 예측 가능한 Math.random()/java.util.Random 에서
// 암호학적 안전 난수(SecureRandom)로 교체(CWE-330). 문자 분포는 정수 범위로 균등 생성한다.
StringBuffer strPwd = new StringBuffer();
char str[] = new char[1];
// 특수기호 포함
if (type.equals("P")) {
for (int i = 0; i < size; i++) {
str[0] = (char) ((Math.random() * 94) + 33);
str[0] = (char) (SECURE_RANDOM.nextInt(94) + 33);
strPwd.append(str);
}
} else if (type.equals("A")) {
// 대문자로만
for (int i = 0; i < size; i++) {
str[0] = (char) ((Math.random() * 26) + 65);
str[0] = (char) (SECURE_RANDOM.nextInt(26) + 65);
strPwd.append(str);
}
} else if (type.equals("S")) {
// 소문자로만
for (int i = 0; i < size; i++) {
str[0] = (char) ((Math.random() * 26) + 97);
str[0] = (char) (SECURE_RANDOM.nextInt(26) + 97);
strPwd.append(str);
}
} else if (type.equals("I")) {
// 숫자형으로
int strs[] = new int[1];
for (int i = 0; i < size; i++) {
strs[0] = (int) (Math.random() * 9);
strs[0] = SECURE_RANDOM.nextInt(9);
strPwd.append(strs[0]);
}
} else if (type.equals("C")) {
// 소문자, 숫자형
Random rnd = new Random();
for (int i = 0; i < size; i++) {
if (rnd.nextBoolean()) {
strPwd.append((char) ((int) (rnd.nextInt(26)) + 97));
if (SECURE_RANDOM.nextBoolean()) {
strPwd.append((char) (SECURE_RANDOM.nextInt(26) + 97));
} else {
strPwd.append((rnd.nextInt(10)));
strPwd.append((SECURE_RANDOM.nextInt(10)));
}
}
} else if (type.equals("T")) {
// 대문자, 숫자형
Random rnd = new Random();
for (int i = 0; i < size; i++) {
if (rnd.nextBoolean()) {
strPwd.append((char) ((int) (rnd.nextInt(26)) + 65));
if (SECURE_RANDOM.nextBoolean()) {
strPwd.append((char) (SECURE_RANDOM.nextInt(26) + 65));
} else {
strPwd.append((rnd.nextInt(10)));
strPwd.append((SECURE_RANDOM.nextInt(10)));
}
}
}
return strPwd.toString();
}
/** [보안/SER-004] 임시비밀번호용 암호학적 안전 난수원(thread-safe, 공유). */
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
/**********************************************************

View File

@ -39,6 +39,7 @@
BATCH_CYCLE AS batchCycle,
PURGE_USE_YN AS purgeUseYn,
LOGIN_HIST_KEEP_DAY AS loginHistKeepDay,
ACCESS_LOG_KEEP_DAY AS accessLogKeepDay,
ADMIN_EMAIL AS adminEmail,
UPD_ID AS updId,
UPD_DT AS updDt
@ -59,6 +60,7 @@
BATCH_CYCLE = #batchCycle#,
PURGE_USE_YN = #purgeUseYn#,
LOGIN_HIST_KEEP_DAY = #loginHistKeepDay#,
ACCESS_LOG_KEEP_DAY = #accessLogKeepDay#,
ADMIN_EMAIL = #adminEmail#,
UPD_ID = #updId#,
UPD_DT = NOW()

View File

@ -23,6 +23,7 @@
DELETE_PRESERVE_DAY AS deletePreserveDay, BATCH_CYCLE AS batchCycle,
PURGE_USE_YN AS purgeUseYn,
LOGIN_HIST_KEEP_DAY AS loginHistKeepDay,
ACCESS_LOG_KEEP_DAY AS accessLogKeepDay,
ADMIN_EMAIL AS adminEmail, UPD_ID AS updId, UPD_DT AS updDt
FROM ACCOUNT_MNG_POLICY WHERE POLICY_ID = 1
]]>
@ -36,6 +37,7 @@
ALERT_D3 = #alertD3#, DELETE_PRESERVE_DAY = #deletePreserveDay#,
BATCH_CYCLE = #batchCycle#, PURGE_USE_YN = #purgeUseYn#,
LOGIN_HIST_KEEP_DAY = #loginHistKeepDay#,
ACCESS_LOG_KEEP_DAY = #accessLogKeepDay#,
ADMIN_EMAIL = #adminEmail#,
UPD_ID = #updId#, UPD_DT = SYSDATE
WHERE POLICY_ID = 1

View File

@ -105,6 +105,8 @@
<!-- ② 2단계 인증 판정(SFR-003/#14) → ③ 권한 판정(SFR-001/#2) -->
<ref local="secondFactorInterceptor" />
<ref local="authorityInterceptor" />
<!-- ④ CSRF 토큰 검증(SER-004 / 웹취약점 조치) — 상태변경 요청 위조 차단 -->
<ref local="csrfTokenInterceptor" />
<!-- <ref bean="egovWebLogInterceptor" /> -->
</list>
</property>
@ -251,6 +253,27 @@
</property>
</bean>
<!-- ============================================================
CSRF 토큰 검증 인터셉터 (SER-004 / 웹취약점 조치 · 2026.08.30)
· Spring Security 2.0.4 에 CSRF 기능이 없어 세션 동기화 토큰을 자체 구현.
· /mnt/ 상태변경 요청(POST/PUT/DELETE/PATCH)에서 토큰↔세션 일치 검증.
· 이중 방어: 토큰 없거나 불일치해도 '같은 출처'면 통과(정상 화면 회귀 방지),
'교차 출처'만 차단 → head.jsp 미적용 화면도 안 깨지고 CSRF 공격만 막는다.
· 토큰은 /mnt/include/head.jsp 가 모든 폼(hidden)·AJAX(X-CSRF-TOKEN 헤더)에 자동 주입.
· enforce=false 로 두면 관찰 모드(로그만). excludePatterns 는 프레임·공통 include 제외.
============================================================ -->
<bean id="csrfTokenInterceptor" class="egovframework.com.cmm.privacy.interceptor.CsrfTokenInterceptor">
<property name="enforce" value="true" />
<property name="applyPathPrefix" value="/mnt/" />
<property name="excludePatterns">
<list>
<value>\A/mnt/(main|left|top|right|footer)\.do.*\Z</value>
<value>\A/mnt/(include)/.*\.do.*\Z</value>
<value>\A/mnt/session/.*\Z</value>
</list>
</property>
</bean>
<!-- ============================================================
[개인정보보호 SFR-004 / 2026.08.07] 관리자 화면 메뉴 접속기록.
종전 서비스 AOP 수집(personalDataLogAspect, context-syslogaop.xml)을 대체한다.

View File

@ -26,10 +26,15 @@ function actionDiapause() {
$.ajax({
url : "/uat/uia/actionDiapause.do",
type : "POST",
data : {"mber_id" : $("#mber_id").val(), "esntl_id" : $("#esntl_id").val()},
dataType : "json",
success:function(object){
alert("휴먼계정이 해제 되었습니다. \n다시 로그인 하세요.");
// [보안] 서버가 로그인 세션(diapauseVO)으로 본인확인 후 해제한다.
// resCode 001=성공, 002=본인확인 실패(세션 만료 등) → 재로그인 유도.
if(object && object.resCode === "001"){
alert("휴면계정이 해제 되었습니다. \n다시 로그인 하세요.");
} else {
alert("본인확인이 만료되었습니다. \n다시 로그인 후 시도해 주세요.");
}
location.href = "/uat/uia/egovLoginUsr.do";
},
error:function(xhr,status,error){alert(status);}

View File

@ -5,6 +5,9 @@
<html xmlns="http://www.w3.org/1999/xhtml" lang="ko" xml:lang="ko">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<%-- [보안/SER-004] CSRF 토큰. CsrfTokenInterceptor 가 세션에 발급한 값을 화면에 노출한다. --%>
<meta name="_csrf" content="${sessionScope.CSRF_TOKEN}" />
<meta name="_csrf_header" content="X-CSRF-TOKEN" />
<title>통합관리시스템
<c:forEach items="${siteMenuNavigation}" var="result" varStatus="status">
&gt;
@ -34,3 +37,53 @@
</style>
<script type="text/javascript" src="/js/site/frt/util.js"></script>
<%-- ============================================================
[보안/SER-004] CSRF 토큰 전역 주입 (jQuery 로드 이후)
· 모든 폼에 hidden(_csrf) 을 자동 추가 → 폼 전송(다수) 커버
· 모든 jQuery AJAX 요청에 X-CSRF-TOKEN 헤더 자동 첨부 → AJAX 커버
· 개별 화면 수정 없이 공통 헤더 한 곳으로 적용. 동적 생성 폼은 submit 시점에 재확인.
============================================================ --%>
<script type="text/javascript">
(function(){
var token = null, headerName = 'X-CSRF-TOKEN';
try {
token = document.querySelector('meta[name="_csrf"]').getAttribute('content');
headerName = document.querySelector('meta[name="_csrf_header"]').getAttribute('content') || headerName;
} catch(e) { return; }
if (!token) { return; }
// 1) jQuery AJAX 전역 헤더
if (window.jQuery) {
jQuery(document).ajaxSend(function(e, xhr){ xhr.setRequestHeader(headerName, token); });
}
// 2) 폼에 hidden 토큰 주입 (없을 때만)
function ensureField(form){
if (!form || form.__csrfDone) { return; }
var exist = form.querySelector('input[name="_csrf"]');
if (!exist) {
var input = document.createElement('input');
input.type = 'hidden'; input.name = '_csrf'; input.value = token;
form.appendChild(input);
} else {
exist.value = token;
}
form.__csrfDone = true;
}
function injectAll(){
var forms = document.getElementsByTagName('form');
for (var i = 0; i < forms.length; i++) { ensureField(forms[i]); }
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectAll);
} else {
injectAll();
}
// 동적 생성·직전 추가 폼 대비: submit 순간에도 재확인
document.addEventListener('submit', function(ev){
var f = ev.target;
if (f && f.tagName === 'FORM') { f.__csrfDone = false; ensureField(f); }
}, true);
})();
</script>

View File

@ -94,10 +94,10 @@
</li>
</c:if>
<c:if test="${menuUpperVo2.menuNo eq '9100030'}">
<li <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100047' || param.page eq '9100048' || param.page eq '9100049' || param.page eq '9100050' || param.page eq '9100051'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a>
<li <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100047' || param.page eq '9100051'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a>
<c:forEach items="${list_menulist}" var="menuUpperVo3">
<c:if test="${menuUpperVo3.menuNo eq '9100030'}">
<ul <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100047' || param.page eq '9100048' || param.page eq '9100049' || param.page eq '9100050' || param.page eq '9100051'}">style="display: block;"</c:if>>
<ul <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100047' || param.page eq '9100051'}">style="display: block;"</c:if>>
<c:forEach items="${list_menulist}" var="menuUpperVo4">
<c:choose>
<c:when test="${menuUpperVo4.menuNo eq '9100031'}">
@ -122,7 +122,8 @@
<c:when test="${menuUpperVo4.menuNo eq '9100039'}">
<li <c:if test="${param.page eq '9100039'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<%-- [개인정보/#47] 개인정보 접속기록 조회·다운로드 (9100040~43은 게시판관리가 선점 → 9100044) --%>
<%-- [개인정보/RFP 과업내용 다] 접속기록 조회 및 감사 — 이 영역이 요구하는 화면은 이 하나다.
처리기록은 처리구분 필터, 이상행위는 목록의 감사 칸, 파기이력은 화면 내 버튼으로 제공한다. --%>
<c:when test="${menuUpperVo4.menuNo eq '9100044'}">
<li <c:if test="${param.page eq '9100044'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
@ -131,18 +132,6 @@
<c:when test="${menuUpperVo4.menuNo eq '9100047'}">
<li <c:if test="${param.page eq '9100047'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<%-- [개인정보/SFR-004-7 #48] 접속기록 정기점검 리포트(점검결과 입력·보관) --%>
<c:when test="${menuUpperVo4.menuNo eq '9100048'}">
<li <c:if test="${param.page eq '9100048'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<%-- [개인정보/과업내용 다-① #49] 비정상접근 탐지 5유형 --%>
<c:when test="${menuUpperVo4.menuNo eq '9100049'}">
<li <c:if test="${param.page eq '9100049'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<%-- [개인정보/SER-002-2] 접속기록 파기이력(파티션 DROP/ADD 증빙) --%>
<c:when test="${menuUpperVo4.menuNo eq '9100050'}">
<li <c:if test="${param.page eq '9100050'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<%-- [개인정보/추가제안 Action4] 회원정보 암호화 관리(복호화 CRUD·사유·재인증) --%>
<c:when test="${menuUpperVo4.menuNo eq '9100051'}">
<li <c:if test="${param.page eq '9100051'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>

View File

@ -1,221 +0,0 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<%--
비정상 접근 탐지 알림함 (개인정보보호 과업내용 다-① / SER-002 / #49)
· AbnormalAccessDetectionService(#35)의 5유형을 기간 단위로 즉시 집계해 보여준다
· 탐지 결과를 별도 테이블에 적재하지 않는 이유는 컨트롤러 Javadoc 참조(임계치 변경 시 과거 기준이 굳는 문제)
· 각 항목은 접속기록 조회화면(#47)으로 조건을 넘겨 바로 원본 기록을 확인할 수 있게 연결한다
--%>
<c:set var="PATH" value="/mnt" />
<script type="text/javascript">
<!--
function fn_search() {
document.frm.method = "get";
document.frm.action = "<c:url value='${PATH}/sec/abnormal/listView.do' />";
document.frm.submit();
}
/* 최근 N일로 기간을 다시 잡는다(자주 쓰는 구간을 손으로 입력하지 않게) */
function fn_range(days) {
var to = new Date();
var from = new Date();
from.setDate(from.getDate() - days);
document.getElementById("fromDt").value = fn_fmt(from);
document.getElementById("toDt").value = fn_fmt(to);
fn_search();
}
function fn_fmt(d) {
var m = d.getMonth() + 1;
var day = d.getDate();
return d.getFullYear() + "-" + (m < 10 ? "0" + m : m) + "-" + (day < 10 ? "0" + day : day);
}
//-->
</script>
<style type="text/css">
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.msg { margin:6px 0 10px; padding:8px 10px; background:#fff8e6; border:1px solid #e6d5a8; font-size:13px; color:#7a5b00; }
.mono { font-family:Consolas,monospace; }
.nodata { color:#aaa; }
.btn_area { margin:8px 0; text-align:right; }
.sum_line { margin:10px 0 14px; padding:10px 12px; background:#eef2f7; border:1px solid #dde3ea; font-size:13px; color:#334; }
.card_wrap { overflow:hidden; margin:0 0 6px; }
.card { float:left; width:18.4%; margin:0 1.9% 12px 0; box-sizing:border-box; border:1px solid #dde3ea; background:#fff; }
.card.last { margin-right:0; }
.card .hd { padding:7px 9px; background:#f7f9fb; border-bottom:1px solid #dde3ea; font-size:12px; color:#445; font-weight:bold; }
.card .bd { padding:12px 9px; text-align:center; }
.card .cnt { font-size:26px; font-weight:bold; color:#4a7ebb; line-height:1.1; }
.card .cnt.hit { color:#c25b5b; }
.card .unit { font-size:12px; color:#778; }
.card .dt { padding:0 9px 10px; font-size:11px; color:#889; text-align:center; word-break:break-all; }
.detail_box { margin:6px 0 18px; }
.detail_box h3 { margin:0 0 6px; font-size:14px; color:#334; }
.detail_box h3 .hint { font-size:12px; font-weight:normal; color:#889; margin-left:8px; }
.clear { clear:both; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<c:if test="${not empty resultMsg}">
<div class="msg"><c:out value="${resultMsg}" /></div>
</c:if>
<form id="frm" name="frm" method="get" onsubmit="fn_search(); return false;">
<!-- 검색창 -->
<div id="search_wrap">
<fieldset class="date_search">
<span>탐지기간 :</span>
<input type="text" name="fromDt" id="fromDt" class="tbox" title="시작일"
value="<c:out value='${fromDt}'/>" placeholder="yyyy-MM-dd" maxlength="10" />
~
<input type="text" name="toDt" id="toDt" class="tbox" title="종료일"
value="<c:out value='${toDt}'/>" placeholder="yyyy-MM-dd" maxlength="10" />
<input type="submit" class="btn_search" value="탐지" />
<span>
<a href="#none" onclick="fn_range(7); return false;">최근 7일</a> ·
<a href="#none" onclick="fn_range(30); return false;">30일</a> ·
<a href="#none" onclick="fn_range(90); return false;">90일</a>
</span>
</fieldset>
</div>
<!-- //검색창 -->
</form>
<div class="guide">
· 개인정보 접속기록(730일 보존)을 <strong>조회 시점의 정책 임계치</strong>로 다시 집계한 결과입니다. 탐지 결과를 따로 저장하지 않으므로 임계치를 조정하면 과거 기간도 새 기준으로 다시 계산됩니다.<br />
· <strong>탐지되었다는 것이 곧 위반은 아닙니다.</strong> 업무상 정당한 대량조회·야간작업이 있을 수 있으므로, 각 항목의 <strong>접속기록 보기</strong>로 원본 기록을 확인한 뒤 판단하세요.<br />
· 월 단위 점검 기록으로 남겨야 하는 경우에는 <strong>정기점검 리포트</strong> 화면에서 점검결과·조치사항을 입력하세요(이 화면은 조회 전용입니다).<br />
· 5유형 각각이 기간 전체를 훑는 집계라 기간이 넓으면 조회가 느려집니다(최대 366일).
</div>
<c:if test="${not empty summary}">
<div class="sum_line">
<strong><c:out value="${fromDt}" /> ~ <c:out value="${toDt}" /></strong> 탐지 요약 &nbsp;:&nbsp; <c:out value="${summary}" />
</div>
</c:if>
<%-- 유형별 요약 카드 5장 --%>
<div class="card_wrap">
<c:forEach items="${resultList}" var="r" varStatus="st">
<div class="card <c:if test='${st.last}'>last</c:if>">
<div class="hd"><c:out value="${r.type.label}" /></div>
<div class="bd">
<span class="cnt <c:if test='${r.count > 0}'>hit</c:if>"><c:out value="${r.count}" /></span>
<span class="unit">
<c:choose>
<c:when test="${r.type eq 'UNAUTHORIZED_ACCESS' or r.type eq 'OFF_HOURS_ACCESS'}">건</c:when>
<c:otherwise>명</c:otherwise>
</c:choose>
</span>
</div>
<div class="dt"><c:out value="${r.detail}" /></div>
</div>
</c:forEach>
</div>
<div class="clear"></div>
<%-- 유형별 상세 --%>
<c:forEach items="${resultList}" var="r">
<div class="detail_box">
<h3>
<c:out value="${r.type.label}" />
<span class="hint">
<c:choose>
<c:when test="${r.type eq 'UNAUTHORIZED_ACCESS'}">처리결과가 실패·차단(F)으로 남은 기록입니다 — 권한 없는 화면 접근 시도가 여기에 잡힙니다.</c:when>
<c:when test="${r.type eq 'MASS_QUERY'}">단위기간 조회(S) 건수가 임계를 넘은 취급자입니다.</c:when>
<c:when test="${r.type eq 'OFF_HOURS_ACCESS'}">업무시간 밖(기본 09시 이전·18시 이후)에 처리된 기록입니다.</c:when>
<c:when test="${r.type eq 'ACCOUNT_SHARING'}">같은 계정이 서로 다른 IP에서 사용된 경우입니다 — 계정 공유·탈취 의심 신호입니다.</c:when>
<c:otherwise>같은 취급자에게서 실패(F) 기록이 반복된 경우입니다.</c:otherwise>
</c:choose>
</span>
</h3>
<c:choose>
<%-- 대상자형(대량조회·계정공유·인증실패반복) : 누가 걸렸는지 목록으로 --%>
<c:when test="${not empty r.rows}">
<table summary="<c:out value='${r.type.label}'/> 의심 대상 목록" class="list1">
<caption><c:out value="${r.type.label}" /> 의심 대상 목록</caption>
<colgroup><col width="8%" /><col width="30%" /><col width="20%" /><col width="*" /></colgroup>
<thead>
<tr>
<th>NO.</th>
<th>접속자ID</th>
<th>
<c:choose>
<c:when test="${r.type eq 'ACCOUNT_SHARING'}">사용 IP 수</c:when>
<c:when test="${r.type eq 'REPEATED_AUTH_FAILURE'}">실패 횟수</c:when>
<c:otherwise>조회 건수</c:otherwise>
</c:choose>
</th>
<th>원본 확인</th>
</tr>
</thead>
<tbody>
<c:forEach items="${r.rows}" var="row" varStatus="st">
<tr>
<td><c:out value="${st.index + 1}" /></td>
<td class="mono"><c:out value="${row.conectId}" /></td>
<td>
<c:choose>
<c:when test="${r.type eq 'ACCOUNT_SHARING'}"><c:out value="${row.ipCnt}" /></c:when>
<c:otherwise><c:out value="${row.cnt}" /></c:otherwise>
</c:choose>
</td>
<td>
<%-- 접속기록 조회화면(#47)으로 조건을 넘겨 원본을 바로 확인한다.
인증실패반복은 처리결과 F, 대량조회는 처리구분 S 로 좁혀 준다. --%>
<a href="<c:url value='${PATH}/sec/accessLog/listView.do'/>?conectId=<c:out value='${row.conectId}'/>&fromDt=<c:out value='${fromDt}'/>&toDt=<c:out value='${toDt}'/><c:if test="${r.type eq 'REPEATED_AUTH_FAILURE'}">&processResult=F</c:if><c:if test="${r.type eq 'MASS_QUERY'}">&dmlGubun=S</c:if>">접속기록 보기</a>
</td>
</tr>
</c:forEach>
</tbody>
</table>
</c:when>
<%-- 건수형(비인가접근·비정상시간대) : 대상자가 아니라 기간 전체 건수라 목록이 없다 --%>
<c:when test="${r.type eq 'UNAUTHORIZED_ACCESS' and r.count > 0}">
<div class="sum_line">
기간 내 <strong><c:out value="${r.count}" /></strong>건 —
<a href="<c:url value='${PATH}/sec/accessLog/listView.do'/>?processResult=F&fromDt=<c:out value='${fromDt}'/>&toDt=<c:out value='${toDt}'/>">접속기록에서 실패·차단 기록 보기</a>
</div>
</c:when>
<c:when test="${r.type eq 'OFF_HOURS_ACCESS' and r.count > 0}">
<div class="sum_line">
기간 내 <strong><c:out value="${r.count}" /></strong>건 —
<a href="<c:url value='${PATH}/sec/accessLog/listView.do'/>?fromDt=<c:out value='${fromDt}'/>&toDt=<c:out value='${toDt}'/>">접속기록에서 해당 기간 기록 보기</a>
<span class="nodata">(시간대 조건은 접속기록 화면의 접속일시로 확인하세요)</span>
</div>
</c:when>
<c:otherwise>
<div class="sum_line"><span class="nodata">해당 기간에 탐지된 항목이 없습니다.</span></div>
</c:otherwise>
</c:choose>
</div>
</c:forEach>
<c:if test="${empty resultList}">
<table summary="비정상 접근 탐지 결과" class="list1">
<caption>비정상 접근 탐지 결과</caption>
<tbody>
<tr><td><spring:message code="common.nodata.msg" /></td></tr>
</tbody>
</table>
</c:if>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -177,6 +177,13 @@ function fn_dml(gubun) {
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
.badge.s { background:#4a7ebb; }
.badge.f { background:#c25b5b; }
/* 감사 표시 — 시안의 목록 맨 오른쪽 "감사" 칸. 이상(차단)은 붉게, 점검(이상징후 대상)은 노랗게. */
.badge.audit_a { background:#c25b5b; }
.badge.audit_w { background:#d6a13a; }
tr.audit_a td { background:#fdf3f3; }
tr.audit_w td { background:#fffaf0; }
.audit_line { margin:0 10px; font-size:12px; color:#445; }
.audit_line strong.hit { color:#c25b5b; }
.dml { display:inline-block; padding:1px 6px; border-radius:3px; font-size:12px; border:1px solid #cbd5e0; color:#445; }
.dml.d { border-color:#c25b5b; color:#c25b5b; }
.ip { font-family:Consolas,monospace; }
@ -262,6 +269,8 @@ function fn_dml(gubun) {
<!-- //검색창 -->
<div class="guide">
· 개인정보 <strong>접속·처리 전체 기록</strong>입니다. <strong>등록·수정·삭제만</strong> 보려면 아래 처리행위 요약에서 해당 항목을 누르세요(누르면 그 구분만 검색됩니다).<br />
· <strong>감사</strong> 칸의 <span class="badge audit_a">이상</span>은 처리가 차단·실패한 기록(비인가 접근 시도), <span class="badge audit_w">점검</span>은 처리는 정상이었으나 대량조회·계정공유의심·인증실패반복 대상이거나 업무시간 밖 처리임을 뜻합니다. <strong>탐지되었다는 것이 곧 위반은 아닙니다</strong> — 업무상 정당한 경우가 있으므로 확인 후 판단하세요.<br />
· 개인정보를 처리(조회·등록·수정·삭제)한 기록입니다. 기간은 <strong>yyyy-MM-dd</strong> 또는 <strong>yyyy-MM-dd HH:mm:ss</strong> 형식으로 입력하세요.
날짜만 입력하면 <strong>종료일 당일(23:59:59)까지 포함</strong>해 검색합니다.<br />
· <strong>접속자ID·처리대상ID·접속IP는 앞부분만 입력해도 검색</strong>됩니다(예: IP에 <code>192.168.</code> 입력 시 해당 대역 전체).<br />
@ -273,7 +282,14 @@ function fn_dml(gubun) {
</div>
<div class="btn_area">
총 <strong><c:out value="${personalDataAccessLogSearchVO.totalRecordCount}" /></strong>건
검색결과 <strong><c:out value="${personalDataAccessLogSearchVO.totalRecordCount}" /></strong>건
<span class="audit_line">
· 보관기간 <strong><c:out value="${retentionDays}" />일</strong>
<c:if test="${not empty auditDetected}">
· 이상행위 <strong <c:if test="${auditDetected > 0}">class="hit"</c:if>><c:out value="${auditDetected}" /></strong>건 탐지<c:if test="${not empty auditSummary}"> <span class="nodata" title="<c:out value='${auditSummary}'/>">(상세)</span></c:if>
</c:if>
<c:if test="${auditError}">· <span class="nodata">이상행위 집계 실패 — 목록만 표시합니다</span></c:if>
</span>
<%-- 처리행위 요약 — 현재 검색조건 기준. 목록을 넘겨보지 않고
"조회만 했는지 / 수정·삭제까지 했는지"를 바로 판별하기 위한 값이다.
각 항목을 누르면 해당 처리구분으로 다시 검색한다. --%>
@ -283,7 +299,11 @@ function fn_dml(gubun) {
· <a href="#none" onclick="fn_dml('U'); return false;" title="수정 기록만 보기">수정 <strong><c:out value="${dmlSummary.U}" /></strong></a>
· <a href="#none" onclick="fn_dml('D'); return false;" title="삭제 기록만 보기">삭제 <strong class="d"><c:out value="${dmlSummary.D}" /></strong></a>]
</span>
<input type="button" class="btn_dl" value="CSV 다운로드 요청(사유 필수)" onclick="fn_downloadRequest();" />
<input type="button" class="btn_dl" value="엑셀 다운로드 요청(사유 필수)" onclick="fn_downloadRequest();" />
<%-- 접속기록 파기이력 진입점. 별도 메뉴를 두지 않는다(RFP 가 요구하는 화면은 이 하나).
보관기간 경과분을 규정대로 파기했다는 증빙이라 조회 화면에서 바로 열 수 있게 둔다. --%>
<input type="button" class="btn_search" value="파기이력"
onclick="location.href='<c:url value="${PATH}/sec/purgeHistory/listView.do"/>?page=9100044';" />
</div>
<%-- 비동기 다운로드 작업 목록 — 요청자 본인 것만 보인다.
@ -355,14 +375,15 @@ function fn_dml(gubun) {
<caption>개인정보 접속기록 목록</caption>
<colgroup>
<col width="5%" />
<col width="13%" />
<col width="12%" />
<col width="9%" />
<col width="10%" />
<col width="11%" />
<col width="*" />
<col width="7%" />
<col width="12%" />
<col width="11%" />
<col width="7%" />
<col width="6%" />
<col width="8%" />
<col width="9%" />
</colgroup>
<thead>
<tr>
@ -374,12 +395,13 @@ function fn_dml(gubun) {
<th>처리구분</th>
<th>처리대상</th>
<th>처리결과</th>
<th>감사</th>
<th>무결성해시</th>
</tr>
</thead>
<tbody>
<c:forEach items="${resultList}" var="result" varStatus="status">
<tr>
<tr<c:if test="${result.auditFlag eq 'A'}"> class="audit_a"</c:if><c:if test="${result.auditFlag eq 'W'}"> class="audit_w"</c:if>>
<td><c:out value="${personalDataAccessLogSearchVO.totalRecordCount - (status.index + (personalDataAccessLogSearchVO.pageIndex-1)*personalDataAccessLogSearchVO.recordCountPerPage)}" /></td>
<td><c:out value="${result.conectDt}" /></td>
<td>
@ -415,6 +437,15 @@ function fn_dml(gubun) {
<c:otherwise><span class="badge s">성공</span></c:otherwise>
</c:choose>
</td>
<%-- 감사 : 이상(차단·비인가) / 점검(정상이나 이상징후 대상) / 해당없음
판정은 조회 시점에 계산한다(PersonalDataAccessLogController.markAudit) --%>
<td>
<c:choose>
<c:when test="${result.auditFlag eq 'A'}"><span class="badge audit_a" title="처리가 차단·실패한 기록입니다. 비인가 접근 시도일 수 있습니다.">이상</span></c:when>
<c:when test="${result.auditFlag eq 'W'}"><span class="badge audit_w" title="처리는 정상이었으나 대량조회·계정공유의심·인증실패반복 대상이거나 업무시간 밖 처리입니다.">점검</span></c:when>
<c:otherwise><span class="nodata">-</span></c:otherwise>
</c:choose>
</td>
<td class="hash" title="<c:out value='${result.hashVal}'/>">
<c:out value="${fn:substring(result.hashVal, 0, 12)}" />…
</td>
@ -422,7 +453,7 @@ function fn_dml(gubun) {
</c:forEach>
<c:if test="${fn:length(resultList) == 0}">
<tr>
<td colspan="9"><spring:message code="common.nodata.msg" /></td>
<td colspan="10"><spring:message code="common.nodata.msg" /></td>
</tr>
</c:if>
</tbody>

View File

@ -66,6 +66,16 @@ function fn_save() {
frm.loginHistKeepDay.focus(); return;
}
}
/* [개인정보/SFR-004] 접속기록 보관기간 — 법정 하한 730일(2년)은 확인 없이 거절한다.
인증 실패 이력(위 항목)은 권고라 경고 후 진행할 수 있지만, 접속기록 2년은
제안요청서 SFR-004 가 "최소 2년 이상"으로 못박은 요건이라 타협 대상이 아니다. */
var logKeep = fn_num(frm.accessLogKeepDay.value);
if (logKeep < 730 || logKeep > 3650) {
alert("접속기록 보관기간은 730~3650일 범위로 입력하세요.\n\n"
+ "최소 2년(730일)은 개인정보의 안전성 확보조치 기준 제8조 및\n"
+ "제안요청서 SFR-004 가 요구하는 법정 하한이라 더 짧게 설정할 수 없습니다.");
frm.accessLogKeepDay.focus(); return;
}
var email = $.trim(frm.adminEmail.value);
if (email.length > 0 && !/^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$/.test(email)) {
alert("관리자 수신 이메일 형식이 올바르지 않습니다.");
@ -83,6 +93,16 @@ function fn_save() {
}
}, "json").fail(function() { alert("저장 처리 중 오류가 발생하였습니다."); });
}
/* 관리자 알림 테스트 발송 — 실제 알림과 같은 경로로 즉시 보내 설정을 확인한다.
저장된 주소로 나가므로, 입력칸만 고친 상태에서 눌러도 예전 주소로 간다(안내 문구 참조). */
function fn_testAlert() {
if (!confirm("저장된 관리자 수신 이메일로 테스트 메일을 발송합니다.\n계속하시겠습니까?")) { return; }
$.post("<c:url value='${PATH}/privacy/accountPolicyTestAlert.do' />", {}, function(data) {
/* 성공·실패 모두 서버가 준 사유를 그대로 보여준다.
"실패했습니다"만 뜨면 주소 미설정인지 메일서버 문제인지 구분할 수 없다. */
alert((data && data.message) ? data.message : "결과를 확인할 수 없습니다.");
}, "json").fail(function() { alert("테스트 발송 요청 중 오류가 발생하였습니다."); });
}
function fn_reload() {
location.href = "<c:url value='${PATH}/sec/accountPolicy/policyView.do' />?page=9100038";
}
@ -172,6 +192,17 @@ function fn_reload() {
이력은 [시스템관리 &gt; 권한관리 &gt; <strong>인증 실패 이력</strong>] 화면에서 조회합니다.</span>
</td>
</tr>
<%-- [개인정보/SFR-004 · 제안서 slide15] 접속기록 보관기간 관리자 설정 --%>
<tr>
<th><label for="accessLogKeepDay">접속기록 보관기간</label> *</th>
<td>
<form:input path="accessLogKeepDay" title="접속기록 보관기간" class="tbox" maxlength="4" cssStyle="width:80px; text-align:right;" />
<span class="unit">일 (<strong>최소 730 = 2년, 법정 하한</strong>). 매월 1일 02:00 배치가 이 기간을 넘긴
<strong>월 파티션 단위로</strong> 접속기록을 파기합니다(개별 레코드 삭제는 사용하지 않습니다).<br />
파기 내역은 [<strong>접속기록 조회 및 감사</strong>] 화면의 <strong>파기이력</strong> 버튼에서 확인합니다.
730일 미만은 저장되지 않으며, 정책 값이 그보다 작아도 배치는 730일치를 남깁니다.</span>
</td>
</tr>
<tr>
<th><label for="batchCycle">배치 주기</label> *</th>
<td>
@ -186,8 +217,12 @@ function fn_reload() {
<tr>
<th><label for="adminEmail">관리자 수신 이메일</label></th>
<td>
<form:input path="adminEmail" title="관리자 수신 이메일" class="tbox" maxlength="200" cssStyle="width:60%;" />
<span class="unit">사전알림 관리자 사본 수신 주소 (미입력 시 관리자 알림 미발송)</span>
<form:input path="adminEmail" title="관리자 수신 이메일" class="tbox" maxlength="200" cssStyle="width:50%;" />
<input type="button" class="btn_search" value="테스트 발송" onclick="fn_testAlert();" />
<span class="unit">사전알림·무결성 위반·이상행위 탐지 알림의 수신 주소 (미입력 시 관리자 알림 미발송)<br />
<strong>테스트 발송</strong>은 실제 알림과 <strong>같은 경로</strong>로 메일을 보냅니다 —
주소를 바꾼 뒤 이 버튼으로 확인하세요(실제 알림은 월 1회 배치에서만 나갑니다).<br />
<span style="color:#c25b5b;">주소를 바꿨다면 먼저 [저장]한 뒤 테스트하세요.</span> 테스트는 저장된 주소로 발송합니다.</span>
</td>
</tr>
<tr>

View File

@ -1,243 +0,0 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<%--
개인정보 접속기록 정기점검 리포트 (개인정보보호 SFR-004-7 / SER-002 / #48)
· 월 1회 배치(#43)가 전월분 접속기록을 집계해 1행씩 적재한 PDAL_INSPECTION_REPORT
· 점검자가 점검결과·조치사항을 입력하면 INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER/INSPECT_DT 가 채워진다(#44)
· 배치 집계값(총건수·무결성·이상징후 등)은 화면에서 수정하지 않는다 — 감사 증빙이라 사후 변경되면 안 된다
--%>
<c:set var="PATH" value="/mnt" />
<script type="text/javascript">
<!--
function fn_list() {
document.frm.method = "get";
document.frm.action = "<c:url value='${PATH}/sec/inspection/listView.do' />";
document.frm.submit();
}
/* 상세 펼치기 — 목록을 떠나지 않고 같은 화면에 상세·입력폼을 연다(다른 달과 대조하며 점검) */
function fn_detail(rptId) {
location.href = "<c:url value='${PATH}/sec/inspection/listView.do' />"
+ "?rptId=" + rptId
+ "&rptYmPrefix=" + encodeURIComponent(document.getElementById("rptYmPrefix").value)
+ "&inspectedYn=" + encodeURIComponent(document.getElementById("inspectedYn").value);
}
/* 점검결과 저장 — 점검결과는 필수. 서버도 다시 막지만(IllegalArgumentException)
빈 값으로 왕복하는 낭비를 줄인다. */
function fn_saveResult() {
var r = document.getElementById("inspectResult");
if (r.value.replace(/^\s+|\s+$/g, "") == "") {
alert("점검결과는 필수 입력입니다.");
r.focus();
return;
}
var f = document.saveFrm;
f.method = "post";
f.action = "<c:url value='${PATH}/sec/inspection/saveResultView.do' />";
f.submit();
}
//-->
</script>
<style type="text/css">
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
.badge.ok { background:#4a7ebb; }
.badge.ng { background:#c25b5b; }
.badge.wait { background:#999; }
.mono { font-family:Consolas,monospace; }
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.msg { margin:6px 0 10px; padding:8px 10px; background:#fff8e6; border:1px solid #e6d5a8; font-size:13px; color:#7a5b00; }
.btn_area { margin:8px 0; text-align:right; }
.nodata { color:#aaa; }
.rpt_detail { margin:14px 0 20px; padding:0; border:1px solid #dde3ea; }
.rpt_detail h3 { margin:0; padding:8px 12px; background:#eef2f7; font-size:14px; color:#334; border-bottom:1px solid #dde3ea; }
.rpt_detail .inner { padding:12px; }
.rpt_detail dl { margin:0 0 10px; font-size:13px; }
.rpt_detail dt { float:left; width:120px; clear:both; color:#556; padding:3px 0; }
.rpt_detail dd { margin-left:130px; padding:3px 0; word-break:break-all; }
.rpt_detail textarea { width:98%; }
.summary_box { background:#fbfcfd; border:1px solid #e5eaf0; padding:8px 10px; font-size:12px; color:#445; white-space:pre-wrap; word-break:break-all; }
tr.on td { background:#f2f6fb; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<c:if test="${not empty resultMsg}">
<div class="msg"><c:out value="${resultMsg}" /></div>
</c:if>
<form id="frm" name="frm" method="get" onsubmit="fn_list(); return false;">
<!-- 검색창 -->
<div id="search_wrap">
<fieldset class="date_search">
<span>대상 연도 :</span>
<input type="text" name="rptYmPrefix" id="rptYmPrefix" class="tbox" title="대상 연도(yyyy)"
value="<c:out value='${rptYmPrefix}'/>" placeholder="yyyy" maxlength="4" />
<span>점검여부 :</span>
<select name="inspectedYn" id="inspectedYn" title="점검여부">
<option value="" <c:if test="${empty inspectedYn}">selected="selected"</c:if>>전체</option>
<option value="Y" <c:if test="${inspectedYn eq 'Y'}">selected="selected"</c:if>>점검완료</option>
<option value="N" <c:if test="${inspectedYn eq 'N'}">selected="selected"</c:if>>미점검</option>
</select>
<input type="submit" class="btn_search" value="검색" />
</fieldset>
</div>
<!-- //검색창 -->
</form>
<div class="guide">
· 매월 1일 배치가 <strong>전월분 접속기록</strong>을 집계해 리포트 1건을 생성합니다(개인정보의 안전성 확보조치 기준 제8조 — 월 1회 이상 점검).<br />
· <strong>집계값(총 처리건수·실패건수·무결성 검증결과·이상징후·다운로드 건수)은 배치가 적재한 값이며 화면에서 수정할 수 없습니다</strong> — 사후 변경이 가능하면 점검 증빙이 되지 못합니다.<br />
· 점검자가 입력하는 항목은 <strong>점검결과·조치사항</strong>이며, 저장 시 <strong>점검자 계정과 점검일시가 자동 기록</strong>됩니다.<br />
· <strong>무결성 위반(VIOLATION)</strong>은 접속기록 해시체인이 끊긴 상태로, 위·변조 가능성을 뜻합니다. 최초 불일치 기록번호를 확인해 즉시 조치사항에 남기세요.
</div>
<div class="btn_area">
총 <strong><c:out value="${totalCount}" /></strong>건
</div>
<table summary="정기점검 리포트 목록" class="list1">
<caption>정기점검 리포트 목록</caption>
<colgroup>
<col width="9%" /><col width="15%" /><col width="9%" /><col width="9%" />
<col width="11%" /><col width="*" /><col width="10%" /><col width="12%" />
</colgroup>
<thead>
<tr>
<th>대상연월</th>
<th>집계기간</th>
<th>총 처리</th>
<th>실패/차단</th>
<th>무결성</th>
<th>이상징후 요약</th>
<th>점검여부</th>
<th>점검자/일시</th>
</tr>
</thead>
<tbody>
<c:forEach items="${resultList}" var="r">
<tr <c:if test="${not empty report and report.rptId eq r.rptId}">class="on"</c:if>>
<td><a href="#none" onclick="fn_detail('<c:out value="${r.rptId}"/>'); return false;"><strong><c:out value="${r.rptYm}" /></strong></a></td>
<td class="mono"><c:out value="${fn:substring(r.periodFrom, 0, 10)}" /> ~ <c:out value="${fn:substring(r.periodTo, 0, 10)}" /></td>
<td><c:out value="${r.totalLogCnt}" /></td>
<td><c:out value="${r.failCnt}" /></td>
<td>
<c:choose>
<c:when test="${r.integrityResult eq 'OK'}"><span class="badge ok">정상</span></c:when>
<c:otherwise><span class="badge ng">위반</span></c:otherwise>
</c:choose>
</td>
<td><c:out value="${r.abnormalSummary}" /></td>
<td>
<c:choose>
<c:when test="${not empty r.inspectDt}"><span class="badge ok">점검완료</span></c:when>
<c:otherwise><span class="badge wait">미점검</span></c:otherwise>
</c:choose>
</td>
<td>
<c:choose>
<c:when test="${not empty r.inspectDt}">
<c:out value="${r.inspectorId}" /><br /><span class="mono"><c:out value="${r.inspectDt}" /></span>
</c:when>
<c:otherwise><span class="nodata">-</span></c:otherwise>
</c:choose>
</td>
</tr>
</c:forEach>
<c:if test="${fn:length(resultList) == 0}">
<tr>
<td colspan="8"><spring:message code="common.nodata.msg" /></td>
</tr>
</c:if>
</tbody>
</table>
<%-- 상세 + 점검결과 입력 --%>
<c:if test="${not empty report}">
<div class="rpt_detail">
<h3><c:out value="${report.rptYm}" /> 정기점검 리포트 상세</h3>
<div class="inner">
<dl>
<dt>집계기간</dt>
<dd class="mono"><c:out value="${report.periodFrom}" /> ~ <c:out value="${report.periodTo}" /></dd>
<dt>총 처리건수</dt>
<dd><c:out value="${report.totalLogCnt}" />건 (실패/차단 <c:out value="${report.failCnt}" />건)</dd>
<dt>다운로드</dt>
<dd><c:out value="${report.downloadCnt}" />건</dd>
<dt>무결성 검증</dt>
<dd>
<c:choose>
<c:when test="${report.integrityResult eq 'OK'}">
<span class="badge ok">정상</span> 검증대상 <c:out value="${report.integrityTotalCnt}" />건 — 해시체인 연결 이상 없음
</c:when>
<c:otherwise>
<span class="badge ng">위반</span> 검증대상 <c:out value="${report.integrityTotalCnt}" />건 —
최초 불일치 기록번호 <strong class="mono"><c:out value="${report.integrityMismatchId}" /></strong>
</c:otherwise>
</c:choose>
</dd>
<dt>이상징후</dt>
<dd><div class="summary_box"><c:out value="${report.abnormalSummary}" /></div></dd>
<dt>취급자별 현황</dt>
<dd><div class="summary_box"><c:out value="${report.handlerSummary}" /></div></dd>
<dt>리포트 생성</dt>
<dd class="mono"><c:out value="${report.regDt}" /></dd>
</dl>
<form name="saveFrm" id="saveFrm" method="post">
<input type="hidden" name="rptId" value="<c:out value='${report.rptId}'/>" />
<table summary="점검결과 입력" class="list1">
<caption>점검결과 입력</caption>
<colgroup><col width="15%" /><col width="*" /></colgroup>
<tbody>
<tr>
<th scope="row">점검결과 <span style="color:#c25b5b;">*</span></th>
<td>
<textarea name="inspectResult" id="inspectResult" rows="3" title="점검결과"><c:out value="${report.inspectResult}" /></textarea>
</td>
</tr>
<tr>
<th scope="row">조치사항</th>
<td>
<textarea name="actnMatter" id="actnMatter" rows="4" title="조치사항"><c:out value="${report.actnMatter}" /></textarea>
</td>
</tr>
<tr>
<th scope="row">점검자 / 점검일시</th>
<td>
<c:choose>
<c:when test="${not empty report.inspectDt}">
<c:out value="${report.inspectorId}" /> / <span class="mono"><c:out value="${report.inspectDt}" /></span>
<span class="nodata">(저장 시 현재 로그인 계정·시각으로 갱신됩니다)</span>
</c:when>
<c:otherwise><span class="nodata">저장 시 현재 로그인 계정·시각이 자동 기록됩니다.</span></c:otherwise>
</c:choose>
</td>
</tr>
</tbody>
</table>
<div class="btn_area">
<input type="button" class="btn_search" value="점검결과 저장" onclick="fn_saveResult();" />
</div>
</form>
</div>
</div>
</c:if>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -58,13 +58,20 @@ function fn_history() {
.msg { margin:6px 0 10px; padding:8px 10px; background:#fff8e6; border:1px solid #e6d5a8; font-size:13px; color:#7a5b00; }
.btn_area { margin:8px 0; text-align:right; }
.nodata { color:#aaa; }
/* 복호화 실패 행 — 숨기지 않고 어느 행인지 드러낸다(화면은 계속 열린다) */
.badge.decfail { background:#8a3b3b; }
tr.decfail td { background:#fdf3f3; }
.reauth { float:left; font-size:12px; color:#556; padding-top:4px; }
.reauth strong { color:#4a7ebb; }
/* 검색조건 5개 + 버튼 = 한 줄. 공용 .tbox 폭(비율값)으로는 컨테이너를 넘겨 줄이 접히므로
이 화면에서만 px 로 고정한다. 폭을 더 줄여야 하면 input.tbox 의 width 부터 조정할 것. */
#search_wrap .srow { white-space:nowrap; }
#search_wrap .srow + .srow { margin-top:4px; }
#search_wrap .srow input.tbox { width:120px; }
#search_wrap .srow span { padding-left:12px; }
#search_wrap .srow input.tbox { width:88px; }
#search_wrap .srow input.tbox.nm { width:78px; }
#search_wrap .srow select { max-width:80px; }
#search_wrap .srow span { padding-left:9px; }
#search_wrap .srow > span:first-child { padding-left:0; }
#search_wrap .srow .btn_search { margin-left:10px; }
</style>
</head>
@ -85,19 +92,20 @@ function fn_history() {
<!-- 검색창 -->
<div id="search_wrap">
<fieldset class="date_search">
<%-- 검색조건 5개 + 버튼을 한 줄에 넣는다. 공용 .tbox 폭으로는 컨테이너를 넘겨
줄이 접히므로 이 화면에서만 px 로 좁히고 nowrap 으로 묶는다
(접속기록 조회 화면과 같은 방식 — 공용 CSS 는 건드리지 않는다). --%>
<div class="srow">
<span>회원ID :</span>
<form:input path="mberId" title="회원ID" cssClass="tbox" />
<span>이름(완전일치) :</span>
<form:input path="mberNm" title="이름 완전일치" cssClass="tbox" />
<form:input path="mberNm" title="이름 완전일치" cssClass="tbox nm" />
<span>상태 :</span>
<select name="mberSttus" id="mberSttus" title="회원상태">
<option value="" <c:if test="${empty mberCryptoSearchVO.mberSttus}">selected="selected"</c:if>>전체</option>
<option value="P" <c:if test="${mberCryptoSearchVO.mberSttus eq 'P'}">selected="selected"</c:if>>정상</option>
<option value="D" <c:if test="${mberCryptoSearchVO.mberSttus eq 'D'}">selected="selected"</c:if>>탈퇴</option>
</select>
</div>
<div class="srow">
<span>이메일 :</span>
<form:input path="mberEmailAdres" title="이메일" cssClass="tbox" />
<span>휴대폰 :</span>
@ -112,7 +120,8 @@ function fn_history() {
· 이 목록의 이름·휴대폰·이메일은 <strong>항상 마스킹</strong>되어 표시됩니다. 평문은 <strong>사유를 입력하고 상세를 연 한 건</strong>에서만 보입니다.<br />
· <strong>이름 검색은 완전일치만 가능합니다.</strong> 이름은 암호화(AES-GCM, 랜덤 IV) 저장되어 같은 이름도 저장된 값이 매번 달라 부분검색(예: '홍')이 원리적으로 불가능합니다 — 검색 전용 해시로 <u>정확히 같은 이름</u>만 찾습니다.<br />
· 조회·수정·파기에는 <strong>사유 입력과 재인증(본인 비밀번호 재확인, 10분 유효)</strong>이 필요하며, 처리자·일시·IP·사유가 접근 이력에 기록됩니다.<br />
· 이 화면의 모든 요청은 <strong>개인정보 접속기록</strong>에도 자동으로 남습니다.
· 이 화면의 모든 요청은 <strong>개인정보 접속기록</strong>에도 자동으로 남습니다.<br />
· <span class="badge decfail">복호화 실패</span> 표시는 그 행의 암호문이 <strong>현재 암호화 키와 맞지 않는다</strong>는 뜻입니다(키 교체 또는 암호문 손상). 해당 행만 이름을 볼 수 없고 나머지 조회는 정상 동작하며, 상세는 열리지 않습니다.
</div>
<div class="btn_area">
@ -148,15 +157,23 @@ function fn_history() {
</thead>
<tbody>
<c:forEach items="${resultList}" var="result" varStatus="status">
<tr>
<tr<c:if test="${result.decryptFailed}"> class="decfail"</c:if>>
<td><c:out value="${mberCryptoSearchVO.totalRecordCount - (status.index + (mberCryptoSearchVO.pageIndex-1)*mberCryptoSearchVO.recordCountPerPage)}" /></td>
<td class="mono"><c:out value="${result.mberId}" /></td>
<td>
<c:out value="${result.mberNm}" />
<%-- 암호화 여부 표시 — 이관 후 남은 미암호화 행을 눈으로 찾을 수 있게 한다 --%>
<c:choose>
<c:when test="${not empty result.cryptoKeyVer}"><span class="badge enc" title="암호화 저장(키 버전 ${result.cryptoKeyVer})">암호화</span></c:when>
<c:otherwise><span class="badge plain" title="암호문이 없는 행 — 백필 대상이거나 파기된 회원입니다">미암호화</span></c:otherwise>
<c:when test="${result.decryptFailed}">
<span class="nodata"><c:out value="${result.mberNm}" /></span>
<span class="badge decfail" title="저장된 암호문이 현재 암호화 키와 맞지 않습니다(키 교체 또는 암호문 손상). 이 행만 표시되지 않으며 나머지 조회는 정상입니다.">복호화 실패</span>
</c:when>
<c:otherwise>
<c:out value="${result.mberNm}" />
<%-- 암호화 여부 표시 — 이관 후 남은 미암호화 행을 눈으로 찾을 수 있게 한다 --%>
<c:choose>
<c:when test="${not empty result.cryptoKeyVer}"><span class="badge enc" title="암호화 저장(키 버전 ${result.cryptoKeyVer})">암호화</span></c:when>
<c:otherwise><span class="badge plain" title="암호문이 없는 행 — 백필 대상이거나 파기된 회원입니다">미암호화</span></c:otherwise>
</c:choose>
</c:otherwise>
</c:choose>
</td>
<td><c:out value="${result.mberEmailAdres}" /></td>

View File

@ -78,6 +78,10 @@ function fn_list(pageIndex) {
<div class="btn_area">
총 <strong><c:out value="${accessLogPurgeHistorySearchVO.totalRecordCount}" /></strong>건
<%-- 이 화면은 좌측메뉴에 없다 — RFP 가 요구하는 화면은 "접속기록 조회 및 감사" 하나다.
그 화면의 "파기이력" 버튼으로 들어오므로 돌아갈 길을 같이 둔다. --%>
<input type="button" class="btn_search" value="접속기록 조회로"
onclick="location.href='<c:url value="${PATH}/sec/accessLog/listView.do"/>?page=9100044';" />
</div>
<table summary="개인정보 접속기록 파기·증설 이력 목록" class="list1">

View File

@ -13,6 +13,34 @@
************************************************************************************************
-->
<display-name>egovframework default</display-name>
<!-- [보안/SER-004] 보안 응답 헤더 (Tomcat 내장 필터, 2026.08.31)
· X-Frame-Options: SAMEORIGIN — 클릭재킹 방지. 관리자 화면이 같은 출처 iframe 을
쓰므로 DENY 가 아닌 SAMEORIGIN(같은 출처 프레임은 허용).
· X-Content-Type-Options: nosniff — MIME 스니핑 방지(ZAP 지적 항목).
· HSTS 는 HTTP 로컬/혼합 환경 오작동 방지를 위해 off. 운영 HTTPS 전환 시 hstsEnabled=true 로.
· 쿠키 HttpOnly 는 Tomcat 기본 적용(Set-Cookie 확인). Secure 는 운영 HTTPS 에서 context useHttpOnly/secure 로. -->
<filter>
<filter-name>httpHeaderSecurityFilter</filter-name>
<filter-class>org.apache.catalina.filters.HttpHeaderSecurityFilter</filter-class>
<init-param>
<param-name>antiClickJackingOption</param-name>
<param-value>SAMEORIGIN</param-value>
</init-param>
<init-param>
<param-name>blockContentTypeSniffingEnabled</param-name>
<param-value>true</param-value>
</init-param>
<init-param>
<param-name>hstsEnabled</param-name>
<param-value>false</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>httpHeaderSecurityFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>

View File

@ -3,6 +3,23 @@
-- 복원 방법: 이 파일을 그대로 실행하면 뷰가 원래 정의로 복원된다.
-- 주의: 이 정의들은 여전히 평문 COMTNGNRLMBER.MBER_NM 을 참조한다 — 복원 시점에
-- 평문 컬럼이 이미 삭제된 상태라면 복원도 실패한다.
--
-- ★★ [2026-08-27] 운영에 그대로 실행하면 안 된다 — 대소문자 ★★
-- 이 백업은 **로컬 DB(Windows, lower_case_table_names=1)** 에서 떠서
-- 뷰명·테이블명이 전부 소문자다(`comtnbbsuserinfo`, `comtngnrlmber` ...).
-- 운영은 @@lower_case_table_names = 0 이고 실제 이름은 대문자·혼합 케이스다
-- (`COMTNBBSUSERINFO`, `excelAppconfirmList`, `COMTNGNRLMBER` — 2026-08-27 운영 실측).
-- 그대로 돌리면 ① 참조 테이블을 못 찾아 1146 으로 실패하거나,
-- ② 원래 뷰를 복구하는 게 아니라 **소문자 뷰가 새로 하나 더 생긴다**.
-- → 운영 복원이 필요하면 이 파일을 그대로 쓰지 말고, 운영에서 DROP 직전에
-- 아래로 정의를 떠서 보관한 것을 사용할 것.
--
-- SELECT TABLE_NAME, VIEW_DEFINITION FROM information_schema.views
-- WHERE TABLE_SCHEMA = DATABASE()
-- AND LOWER(TABLE_NAME) IN ('comtnbbsuserinfo','comvnusermaster2','comvnusermaster_1',
-- 'excelappconfirmlist','excelappconfirmlist_auth',
-- 'selectaddedoptionsinf','selectboardarticlelist',
-- 'selectmiddleappconfirmlist');
-- =====================================================================
-- ===== comtnbbsuserinfo =====

View File

@ -0,0 +1,59 @@
-- =====================================================================
-- 접속기록 보관기간 관리자 설정 : ACCOUNT_MNG_POLICY.ACCESS_LOG_KEEP_DAY
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / 제안서 slide15)
-- 작성일: 2026-08-27 DB: MySQL 8 / MariaDB (스키마 csv)
-- ---------------------------------------------------------------------
-- [배경] 제안요약서 slide15 "로그 조회 및 감사 기능" 현행진단 표:
-- 구분 "보관 기간" / 소스분석 "2년 이상 — 기본값 730일, 관리자 설정값 미존재"
-- / 개선방안 "신규 구축"
-- 지금까지 파기 배치가 자바 상수(ACCESS_LOG_RETENTION_DAYS = 730)를 써서
-- 관리자가 바꿀 수 없었다. 정책 테이블로 빼 화면에서 설정하게 한다.
--
-- [왜 ACCOUNT_MNG_POLICY 인가] 이 표에는 이미 LOGIN_HIST_KEEP_DAY(인증 실패 이력 보관일)가
-- 들어 있어 사실상 '보관·파기 정책'의 단일 창구다. 표를 새로 만들면 관리자가
-- 보관기간을 두 화면에서 나눠 보게 된다.
--
-- ★ 법정 하한 730일 ★
-- 제안요청서 SFR-004 "접속기록은 최소 2년 이상 보관 가능하여야 한다".
-- 컬럼 기본값을 730 으로 두고, 화면·서비스 양쪽에서 730 미만 입력을 거절한다.
-- DB 제약(CHECK)으로 막지 않는 이유는 MariaDB/MySQL 5.7 에서 CHECK 동작이 갈려
-- "조용히 통과"할 수 있기 때문이다 — 검증은 애플리케이션에서 확실히 한다.
--
-- [재실행 안전] information_schema 확인 후 ALTER — 반복 실행 무해.
-- [무중단] ALGORITHM=INPLACE, LOCK=NONE (단일행 테이블이라 사실상 즉시)
-- =====================================================================
DROP PROCEDURE IF EXISTS PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY;
DELIMITER $$
CREATE PROCEDURE PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY()
BEGIN
DECLARE v_cnt INT DEFAULT 0;
SELECT COUNT(*) INTO v_cnt FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ACCOUNT_MNG_POLICY'
AND COLUMN_NAME = 'ACCESS_LOG_KEEP_DAY';
IF v_cnt = 0 THEN
ALTER TABLE ACCOUNT_MNG_POLICY
ADD COLUMN ACCESS_LOG_KEEP_DAY INT NOT NULL DEFAULT 730
COMMENT '개인정보 접속기록 보관기간(일). 법정 하한 730(2년)',
ALGORITHM=INPLACE, LOCK=NONE;
END IF;
END$$
DELIMITER ;
CALL PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY();
DROP PROCEDURE IF EXISTS PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY;
-- 기존 행이 있는데 값이 비정상(2년 미만)이면 법정 하한으로 올린다.
UPDATE ACCOUNT_MNG_POLICY SET ACCESS_LOG_KEEP_DAY = 730
WHERE POLICY_ID = 1 AND (ACCESS_LOG_KEEP_DAY IS NULL OR ACCESS_LOG_KEEP_DAY < 730);
-- ---------------------------------------------------------------------
-- [적용 확인]
-- ---------------------------------------------------------------------
-- SELECT POLICY_ID, ACCESS_LOG_KEEP_DAY, LOGIN_HIST_KEEP_DAY FROM ACCOUNT_MNG_POLICY;
-- ---------------------------------------------------------------------
-- [롤백] 배치가 다시 자바 상수 730 을 쓰게 되므로 동작은 같다.
-- ---------------------------------------------------------------------
-- ALTER TABLE ACCOUNT_MNG_POLICY DROP COLUMN ACCESS_LOG_KEEP_DAY;

View File

@ -1,3 +1,18 @@
-- #####################################################################
-- ★★ [2026-08-27] 이 파일을 운영에 직접 실행하지 말 것 — 정본은 ★★
-- src/script/mysql/privacy.install_mysql.sql 의 PART 4 이다.
--
-- 이 사본은 REPLACE() 검색문자열에 테이블명을 **소문자로 하드코딩**한
-- 2026-08-27 이전 버전이다. 운영 DB 는 객체명이 대문자로 저장되어 있고
-- (@@lower_case_table_names = 0, MySQL 5.7.31 — 2026-08-27 운영 실측),
-- REPLACE() 는 항상 대소문자를 구분하므로 치환이 전부 불발된다.
-- 그런데도 CREATE OR REPLACE VIEW 는 **오류 없이 성공**해서
-- USER_NM_HASH 없는 뉴가 남고, WAS 기동 시에야 터진다.
--
-- install_mysql.sql PART 4 는 information_schema 에서 실제 저장된 케이스를 읽어
-- 검색문자열을 조립하고, 치환이 불발되면 SIGNAL 로 중단한다.
-- #####################################################################
-- 개인정보보호 기능개선 사업 추가제안 Action4 (2026-08-11)
-- comvnusermaster / comvnuserinfo 뷰 재정의: GNR 분기를 암호문(MBER_NM_ENC)으로 전환하고
-- 검색용 해시 컬럼(USER_NM_HASH)을 추가한다.

View File

@ -0,0 +1,117 @@
-- =====================================================================
-- 관리자 메뉴 재편 : "접속기록 · 감사" 그룹 신설 (MENU_NO 9100052)
-- 문화품앗이 개인정보보호 기능개선 사업 — 제안서 Ⅲ-4 시안 정합
-- 작성일: 2026-08-27 DB: MySQL 8 / MariaDB (스키마 csv)
-- ---------------------------------------------------------------------
-- [왜] 제안요약서(개인정보보호강화 파일럿 개발항목.pptx) slide 14~15 의 화면 시안이
-- 좌측메뉴를 "접속기록 · 감사" 한 묶음으로 그리고 그 아래 4개를 두고 있다.
-- 2026-08-27 1차 구현은 이 넷을 권한관리(9100030) 하위에 낱개로 붙여 시안과 어긋났다.
-- 이 스크립트가 그 배치를 시안대로 되돌린다.
--
-- [TO-BE 구조]
-- 시스템관리(9100000)
-- └ 접속기록 · 감사 (9100052, ORDR 5) ← 신설
-- ① 접속기록 조회 9100044 (ORDR 1) ← 권한관리에서 이설
-- ② 개인정보 처리기록 9100053 (ORDR 2) ← 신설(조회 S 제외 = 등록·수정·삭제만)
-- ③ 이상행위 감사 9100049 (ORDR 3) ← 이설 + 명칭변경(비정상접근 탐지 →)
-- ④ 정기 점검 리포트 9100048 (ORDR 4) ← 이설 + 명칭변경(정기점검 리포트 →)
--
-- [내려가는 것] 접속기록 파기이력(9100050) — 시안에 메뉴가 없다. 메뉴만 제거하고
-- 화면(/mnt/sec/purgeHistory/listView.do)과 컨트롤러는 그대로 둔다.
-- 진입은 정기 점검 리포트 화면의 "접속기록 파기이력" 버튼으로 한다.
-- (기능을 지우는 게 아니라 노출 위치만 시안에 맞추는 것)
--
-- [그대로 두는 것] 회원정보 암호화 관리(9100051)는 추가제안 Action4 항목이라
-- 이 시안(Ⅲ-4 접속기록)의 범위가 아니다. 권한관리 하위에 그대로 둔다.
--
-- [ORDR 5 를 쓰는 이유] 시스템관리 하위 상위메뉴는 1~4,7,9 가 사용 중이고 5·6·8 이 비어 있다.
-- 권한관리(3) 바로 뒤에 두고 싶지만 4(게시판관리)가 선점하고 있어 다음 빈 번호를 쓴다.
--
-- ★★ 이 DML만으로는 좌측메뉴가 바뀌지 않는다 — left.jsp 동반 배포 필수 ★★
-- ① 9100052 그룹 블록 신설(하위 9100044·9100053·9100049·9100048)
-- ② 권한관리(9100030) 블록에서 9100044·9100048·9100049·9100050 분기 제거
-- ③ 권한관리 상위 하이라이트 조건 2곳에서 위 4개 제거
-- (소스에는 이미 반영 완료)
--
-- [재실행 안전] INSERT 는 NOT EXISTS, UPDATE 는 결과가 같은 값으로 수렴, DELETE 는 대상 한정.
-- [한글 리터럴 주의] mysql.exe 에 stdin 파이프로 넣지 말 것 — SOURCE 로 실행하고
-- 적용 후 HEX(MENU_NM) 가 EA/EB/EC/ED 로 시작하는지 확인한다.
-- =====================================================================
-- ── [1] 상위 그룹 "접속기록 · 감사" (9100052) ──
-- [실측] 그룹(2depth) 메뉴는 PROGRM_FILE_NM 이 NOT NULL 이라 비울 수 없고,
-- 기존 그룹(시스템관리·사용자관리·권한관리·게시판관리·메뉴관리)이 모두
-- 센티넬 값 'dir'(COMTNPROGRMLIST 에 URL='dir' 로 1행 존재)을 쓴다.
-- RELATE_IMAGE_PATH/NM 도 기존 그룹과 같이 NULL 로 맞춘다.
-- (NULL 을 넣었다가 ERROR 1048 로 실패한 뒤 형제 행을 실측해 정정)
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '접속기록 · 감사' AS MENU_NM, 'dir' AS PROGRM_FILE_NM, 9100052 AS MENU_NO,
9100000 AS UPPER_MENU_NO, 5 AS MENU_ORDR,
'개인정보 접속·처리기록 조회 및 감사(제안서 Ⅲ-4)' AS MENU_DC,
NULL AS RELATE_IMAGE_PATH, NULL AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO)
AND EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = 'dir'); -- FK 안전
-- ── [2] ② 개인정보 처리기록 (9100053) 프로그램 + 메뉴 신설 ──
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'ProcessLogList' AS PROGRM_FILE_NM, '/mnt/sec/processLog/' AS PROGRM_STRE_PATH,
'개인정보 처리기록' AS PROGRM_KOREAN_NM,
'개인정보를 실제로 변경한 기록(등록·수정·삭제)만 조회' AS PROGRM_DC,
'/mnt/sec/processLog/listView.do?page=9100053' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '개인정보 처리기록' AS MENU_NM, 'ProcessLogList' AS PROGRM_FILE_NM, 9100053 AS MENU_NO,
9100052 AS UPPER_MENU_NO, 2 AS MENU_ORDR, '등록·수정·삭제 기록만 조회' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
-- ── [3] 기존 3개 이설 + 명칭을 시안 표기로 정정 ──
UPDATE COMTNMENUINFO SET UPPER_MENU_NO = 9100052, MENU_ORDR = 1,
MENU_NM = '접속기록 조회'
WHERE MENU_NO = 9100044;
UPDATE COMTNMENUINFO SET UPPER_MENU_NO = 9100052, MENU_ORDR = 3,
MENU_NM = '이상행위 감사',
MENU_DC = '비정상 접근 5유형 탐지 결과 조회'
WHERE MENU_NO = 9100049;
UPDATE COMTNMENUINFO SET UPPER_MENU_NO = 9100052, MENU_ORDR = 4,
MENU_NM = '정기 점검 리포트'
WHERE MENU_NO = 9100048;
-- ── [4] 신규 2개(9100052 그룹, 9100053)에 노출권한 부여 ──
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT n.mno, g.ac
FROM (SELECT 9100052 AS mno UNION ALL SELECT 9100053) n
CROSS JOIN (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO)
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = n.mno AND x.AUTHOR_CODE = g.ac);
-- ── [5] 접속기록 파기이력(9100050) 메뉴 내리기 ──
-- 화면·컨트롤러·프로그램(URL) 등록은 남긴다. 좌측메뉴 노출만 제거한다.
DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO = 9100050;
DELETE FROM COMTNMENUINFO WHERE MENU_NO = 9100050;
-- ── [검증] 시안과 같은 구조인지 + 한글 손상 여부 ──
-- SELECT m.MENU_NO, m.UPPER_MENU_NO, m.MENU_ORDR, m.MENU_NM, HEX(m.MENU_NM), p.URL,
-- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO = m.MENU_NO) grants
-- FROM COMTNMENUINFO m LEFT JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM = p.PROGRM_FILE_NM
-- WHERE m.MENU_NO = 9100052 OR m.UPPER_MENU_NO = 9100052
-- ORDER BY m.UPPER_MENU_NO, m.MENU_ORDR;
-- ── [롤백] 1차 구현(권한관리 하위 낱개 배치)으로 되돌리기 ──
-- UPDATE COMTNMENUINFO SET UPPER_MENU_NO=9100030, MENU_ORDR=10, MENU_NM='개인정보 접속기록' WHERE MENU_NO=9100044;
-- UPDATE COMTNMENUINFO SET UPPER_MENU_NO=9100030, MENU_ORDR=15, MENU_NM='비정상접근 탐지' WHERE MENU_NO=9100049;
-- UPDATE COMTNMENUINFO SET UPPER_MENU_NO=9100030, MENU_ORDR=14, MENU_NM='정기점검 리포트' WHERE MENU_NO=9100048;
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO IN (9100052, 9100053);
-- DELETE FROM COMTNMENUINFO WHERE MENU_NO IN (9100052, 9100053);
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'ProcessLogList';
-- (9100050 파기이력 메뉴 복원은 privacy.menu_privacy_screens4_insert_mysql.sql 재실행)

View File

@ -0,0 +1,63 @@
-- =====================================================================
-- 관리자 메뉴 정리 : 접속·처리기록 관리를 화면 1개로 통합
-- 문화품앗이 개인정보보호 기능개선 사업 (RFP 과업내용 다 / SFR-004)
-- 작성일: 2026-08-27 DB: MySQL 8 / MariaDB (스키마 csv)
-- ---------------------------------------------------------------------
-- [근거] 제안요청서 과업내용 "다. 개인정보취급자 접속 및 처리기록 관리 기능 개발" 이
-- 요구하는 화면은 하나다 —
-- "ㅇ 로그 조회 및 관리 기능 구현
-- - 관리자용 로그 조회 화면 개발
-- - 조건별 검색 및 다운로드 기능 제공
-- - 로그 다운로드 이력, 무결성 확보 및 관리 기능 적용"
-- 제안요약서 slide 14~15 의 시안도 이 한 화면("접속기록 조회 및 감사")만 그려져 있다.
--
-- [따라서] 2026-08-27 에 만들었던 "접속기록 · 감사" 그룹(9100052)과 하위 3개 메뉴
-- (개인정보 처리기록 9100053 / 이상행위 감사 9100049 / 정기 점검 리포트 9100048)를
-- 메뉴에서 내리고, 접속기록 화면 하나로 되돌린다.
--
-- ★ 기능은 지우지 않는다 ★
-- RFP 는 이 셋을 '기능'으로는 명시하고 있다 —
-- · "비정상 접근 탐지 및 기록 기능 구현" → 목록의 '감사' 칸(이상/점검)으로 상시 표시
-- · "처리기록 검색·조회 기능 제공" → 처리구분(조회/등록/수정/삭제) 필터로 제공
-- · SFR-004 "접속기록 정기점검을 지원" → 화면 내 버튼으로 진입
-- 화면(컨트롤러·JSP)은 그대로 살아 있고 진입 동선만 접속기록 화면 안으로 옮긴다.
-- 메뉴에서 내렸다고 기능이 없어진 것이 아니다 — 검수 시 이 주석이 근거다.
--
-- [남는 메뉴] 권한관리(9100030) 하위 ORDR 10 : "접속기록 조회 및 감사" (9100044)
--
-- [재실행 안전] UPDATE 는 같은 값으로 수렴, DELETE 는 대상 한정 → 반복 실행 무해.
-- [한글 리터럴 주의] SOURCE 로 실행하고 적용 후 HEX(MENU_NM) 확인.
-- ★★ left.jsp 동반 배포 필수 — 9100052 그룹 블록 제거 + 권한관리 블록에 9100044 분기 복원 ★★
-- =====================================================================
-- ── [1] 접속기록 화면을 권한관리 하위로 되돌리고 시안 표기명으로 정정 ──
UPDATE COMTNMENUINFO
SET UPPER_MENU_NO = 9100030,
MENU_ORDR = 10,
MENU_NM = '접속기록 조회 및 감사',
MENU_DC = '개인정보 접속·처리기록 조회, 이상행위 감사, 다운로드(사유·이력)'
WHERE MENU_NO = 9100044;
UPDATE COMTNPROGRMLIST
SET PROGRM_KOREAN_NM = '접속기록 조회 및 감사',
PROGRM_DC = '개인정보 접속·처리기록 조회 및 감사(RFP 과업내용 다 / SFR-004)'
WHERE PROGRM_FILE_NM = 'AccessLogList';
-- ── [2] 하위 3개 메뉴 내리기 (화면·컨트롤러·프로그램 등록은 유지) ──
-- 9100048 정기 점검 리포트 / 9100049 이상행위 감사 / 9100053 개인정보 처리기록
DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO IN (9100048, 9100049, 9100053);
DELETE FROM COMTNMENUINFO WHERE MENU_NO IN (9100048, 9100049, 9100053);
-- ── [3] "접속기록 · 감사" 그룹(9100052) 제거 ──
DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO = 9100052;
DELETE FROM COMTNMENUINFO WHERE MENU_NO = 9100052;
-- ── [검증] 권한관리 하위에 9100044 하나만 남고 9100048/49/52/53 이 없어야 한다 ──
-- SELECT MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_NM, HEX(MENU_NM)
-- FROM COMTNMENUINFO WHERE MENU_NO IN (9100044,9100048,9100049,9100052,9100053)
-- ORDER BY MENU_ORDR;
-- → 9100044 / 9100030 / 10 / 접속기록 조회 및 감사 (1행만)
-- ── [롤백] 그룹 구조로 되돌리기 ──
-- privacy.menu_privacy_screens4_insert_mysql.sql 재실행(메뉴 재생성)
-- → privacy.menu_audit_group_regroup_mysql.sql 재실행(그룹 재편)

View File

@ -1,11 +1,16 @@
-- #####################################################################
-- arko_renual 설치용 통합 SQL (개인정보보호 기능개선 사업 · arko 대비 변동분)
-- 대상 DB : MySQL 5.7 / MariaDB (스키마 csv)
-- 구성 : [PART 1] DDL(테이블 생성·변경, 24개 테이블/인덱스)
-- 구성 : [PART 1] DDL(테이블 생성·변경, 25개 테이블/인덱스 + 정책 컬럼)
-- → [PART 2] DML(기준/시드 데이터)
-- → [PART 3] 관리자 메뉴 등록 DML(좌측메뉴 노출, COMTNPROGRMLIST/
-- COMTNMENUINFO/COMTNMENUCREATDTLS, 9100035~9100039 + 9100044 + 9100047)
-- COMTNMENUINFO/COMTNMENUCREATDTLS,
-- 9100035~9100039 + 9100044 + 9100047 + 9100051)
-- ※ 접속·처리기록 영역의 화면은 9100044 "접속기록 조회 및 감사" 하나다
-- (RFP 과업내용 다 = "관리자용 로그 조회 화면" 단수, 시안도 한 장).
-- 처리기록·이상행위·파기이력은 그 화면 안에서 제공한다.
-- ※ 9100045(업무유형 매핑 관리)·9100046(로그인 접속기록)은 2026-08-11 폐기
-- — 폐기된 번호는 재사용하지 않는다(과거 권한매핑 잔재와 섞인다)
-- → [PART 4] 뷰 GNR 컷오버(comvnusermaster/comvnuserinfo, 회원 이름 암호화
-- 최종 단계) — **자체 게이트 내장**, 백필 미완료 시 자동 SKIP(PART 4 헤더 참조)
-- 실행순서: 반드시 PART 1 → PART 2 → PART 3 → PART 4 순서로 실행
@ -30,6 +35,40 @@
-- `backup/privacy.mber_nm_dead_views_backup_20260810.sql`(죽은 뷰 정의 백업)은
-- 되돌리기용 스냅샷 성격이라 이 통합 파일 실행 **전**에 별도로 실행하는 것을 권장하며
-- 통합하지 않았다(설치 로직이 아니라 사고 대비 아카이브라 실행 시점이 다름).
-- 2026-08-27 개정(운영 실측 대조 반영):
-- [운영 실측 2026-08-27 15:31] @@version=5.7.31-log (**MySQL 5.7, MariaDB 아님**),
-- @@lower_case_table_names=**0**(대소문자 구분),
-- @@sql_mode=ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,
-- ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
-- (로컬 8.0.46 은 lctn=1, sql_mode 는 사실상 동일 → sql_mode 는 위험요소 아님)
-- 운영 DDL 덤프(csv_structure_Dump20260520) ↔ 로컬 csv 전수 대조 결과를 반영했다.
-- ① [치명] 운영은 객체명이 **대문자**(`COMTNGNRLMBER`), 로컬은 소문자다. PART 4 의
-- REPLACE() 는 lower_case_table_names 와 무관하게 항상 대소문자를 구분하므로,
-- 종전의 소문자 하드코딩은 운영에서 5개 치환이 전부 불발되고도
-- CREATE OR REPLACE VIEW 가 **에러 없이 성공**해 USER_NM_HASH 없는 뷰를 남겼다.
-- → information_schema 에서 실제 저장된 케이스를 읽어 검색문자열을 조립하고,
-- 치환 결과가 기대(USER_NM_HASH 4회 + MBER_NM_ENC + 평문참조 0)와 다르면
-- SIGNAL SQLSTATE '45000' 으로 **중단**하는 하드 게이트를 추가했다.
-- ② PART 1 의 죽은 뷰 8개 DROP 도 같은 이유로 운영에서 조용히 실패했다(운영 실제명은
-- `excelAppconfirmList` 등 혼합 케이스). → information_schema 기반 동적 DROP 으로 교체.
-- ③ 로컬 DB 에만 있고 저장소 어느 SQL 에도 없던 인덱스 2건
-- (COMTNEMPLYRINFO.IX_EMPLYR_ESNTL, BIZ_GROUP.IX_BIZ_GROUP_ESNTL) 을 PART 1 에 추가.
-- ④ PART 4 끝단 '반영 후 검증' SELECT 들도 뷰명을 소문자로 직접 참조하고 있었다.
-- 운영에서는 뷰 교체가 **성공한 직후** 이 SELECT 가 1146 으로 터져, 성공한 컷오버가
-- 실패처럼 보인다. → @v_master/@v_info 를 쓰는 동적 SQL 로 교체.
-- ⑤ PART 1 죽은 뷰 8개는 DROP 전에 정의를 privacy_view_def_backup 에 백업하도록 했다.
-- (backup/privacy.mber_nm_dead_views_backup_20260810.sql 은 로컬 소문자 캡처라
-- 운영 롤백 자산으로 쓸 수 없다 — 해당 파일에도 경고를 달아 두었다)
-- ⑥ **[치명] PART 4 가 스키마 한정자를 고려하지 않고 있었다.**
-- information_schema.views.VIEW_DEFINITION 은 테이블을 항상 `csv`.`TBL` 로 저장하는데
-- (mysqldump 출력에는 없다), 2026-08-11 통합 시 원본 ddl 파일에 있던 `csv`. 가
-- 누락돼 5개 치환 중 4개가 **어느 환경에서도** 불발되는 상태였다.
-- dev 가 멀쩡한 것은 단독 파일(ddl/privacy.mber_nm_view_hash_cutover_mysql.sql,
-- `csv`. 포함)로 컷오버했기 때문이고, 이 통합본으로는 한 번도 성공한 적이 없다.
-- → 치환 전에 `REPLACE(@def, CONCAT('`', DATABASE(), '`.'), '')` 로 한정자를 제거한다.
-- (CREATE OR REPLACE VIEW 시 MySQL 이 다시 붙이므로 결과물은 동일)
-- 검증: 운영 런타임 형식(대문자+한정자) 재현 후 MySQL 엔진에서 실행 →
-- hash_cnt=4 / uses_enc=1 / plaintext_gone=1.
-- #####################################################################
@ -66,7 +105,7 @@ CREATE TABLE IF NOT EXISTS ACCOUNT_MNG_POLICY (
UPD_ID VARCHAR(20) NULL COMMENT '최종 수정자',
UPD_DT DATETIME NULL COMMENT '최종 수정일시',
PRIMARY KEY (POLICY_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='계정 관리 정책(개인정보보호 SFR-002, 단일행)';
-- 기본 정책 시드(없을 때만). 기준 365일·알림 D-30/7/0·보존 365일.
@ -98,7 +137,7 @@ CREATE TABLE IF NOT EXISTS ACCOUNT_UNLOCK_HISTORY (
KEY IX_AUH_TRGET (TRGET_ID, UNLOCK_DT),
KEY IX_AUH_USER (UNLOCK_USER_ID, UNLOCK_DT),
KEY IX_AUH_DT (UNLOCK_DT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='계정 잠금 해제 이력(개인정보보호 SER-001 #24)';
@ -151,7 +190,7 @@ CREATE TABLE IF NOT EXISTS AUTHOR_CHANGE_HISTORY (
KEY IX_ACH_TRGET (TRGET_UNIQ_ID, CHG_DT),
KEY IX_ACH_CHANGER (CHANGER_UNIQ_ID, CHG_DT),
KEY IX_ACH_CHGDT (CHG_DT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='권한 변경 이력(개인정보보호 SFR-001, 3년 이상 보관)';
-- ─────────────────────────────────────────────────────────────
@ -188,7 +227,7 @@ CREATE TABLE IF NOT EXISTS AUTHOR_CHANGE_REQUEST (
PRIMARY KEY (REQUEST_ID),
KEY IX_ACR_TRGET (TRGET_UNIQ_ID, REQUEST_DT),
KEY IX_ACR_STTUS (CONFM_STTUS, REQUEST_DT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='권한 변경 신청·승인 워크플로(개인정보보호 SFR-001 #3)';
CREATE TABLE IF NOT EXISTS AUTHOR_HIST_DOWNLOAD_HISTORY (
@ -202,7 +241,7 @@ CREATE TABLE IF NOT EXISTS AUTHOR_HIST_DOWNLOAD_HISTORY (
DWLD_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '다운로드 일시',
PRIMARY KEY (DWLD_ID),
KEY IX_AHD_DWLD (DOWNLOADER_ID, DWLD_DT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='권한이력 다운로드 증빙 이력(개인정보보호 SFR-001 #6)';
-- ─────────────────────────────────────────────────────────────
@ -251,7 +290,7 @@ CREATE TABLE IF NOT EXISTS PDAL_DOWNLOAD_HISTORY (
PRIMARY KEY (DL_ID),
KEY IX_PDDH_DT (DOWNLOAD_DT),
KEY IX_PDDH_USER (DOWNLOADER_ID, DOWNLOAD_DT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='개인정보 접속기록 다운로드 이력(#42, SER-002)';
-- [Oracle] 시퀀스 필요:
@ -295,7 +334,7 @@ CREATE TABLE IF NOT EXISTS PDAL_INSPECTION_REPORT (
PRIMARY KEY (RPT_ID),
UNIQUE KEY UX_PIR_YM (RPT_YM),
KEY IX_PIR_REGDT (REG_DT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='개인정보 접속기록 정기점검 리포트(#43, 월 1회)';
-- ─────────────────────────────────────────────────────────────
@ -438,7 +477,7 @@ CREATE TABLE IF NOT EXISTS OTP_DEVICE (
DEVICE_LABEL VARCHAR(100) NULL COMMENT '기기/계정 라벨(참고용)',
USE_YN CHAR(1) NOT NULL DEFAULT 'Y' COMMENT '사용여부 Y:사용 N:해지',
PRIMARY KEY (UNIQ_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='OTP 2단계 인증 비밀키 영구 저장(개인정보보호 SFR-003 No.18)';
-- ─────────────────────────────────────────────────────────────
@ -552,7 +591,7 @@ CREATE TABLE IF NOT EXISTS ACCOUNT_DELETE_HISTORY (
PRIMARY KEY (DEL_ID),
KEY IX_ADH_MBER (MBER_ID),
KEY IX_ADH_DELDT (DEL_DT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='장기 미사용 계정 개인정보 파기 이력(개인정보보호 SFR-002 #12)';
@ -745,7 +784,7 @@ CREATE TABLE IF NOT EXISTS CONECT_IP_LOCATION (
UPDT_DT DATETIME NULL COMMENT '최종 변경일시',
PRIMARY KEY (LOC_ID),
UNIQUE KEY UK_CIL_CIDR (IP_CIDR)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='접속위치 매핑(IP 대역 → 위치명) — 로그인·로그아웃 접속기록 62';
@ -790,7 +829,7 @@ CREATE TABLE IF NOT EXISTS AUTHOR_HIST_DOWNLOAD_JOB (
PRIMARY KEY (JOB_ID),
KEY IX_AHDJ_REQUESTER (REQUESTER_ID, REG_DT),
KEY IX_AHDJ_STATUS (STATUS, REG_DT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='권한변경 이력 CSV 비동기 다운로드 작업';
@ -818,7 +857,7 @@ CREATE TABLE IF NOT EXISTS ACCOUNT_ACTION_HISTORY (
KEY IX_AAH_TRGET (TRGET_ID, ACTION_DT),
KEY IX_AAH_USER (ACTION_USER_ID, ACTION_DT),
KEY IX_AAH_DT (ACTION_DT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='장기 미사용 계정 조치(잠금/말소) 이력(개인정보보호 SFR-002 #11)';
@ -841,7 +880,7 @@ CREATE TABLE IF NOT EXISTS LOGIN_LOCK_EXEMPT (
REG_ID VARCHAR(20) NULL COMMENT '등록한 관리자 계정ID',
REG_DT DATETIME NULL COMMENT '등록일시',
PRIMARY KEY (EXEMPT_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='로그인 실패 계정잠금 예외 계정(개인정보보호 SER-001 24, 최고관리자 잠김 방지)';
@ -867,7 +906,7 @@ CREATE TABLE IF NOT EXISTS ACCESS_CONTROL_POLICY (
UPD_ID VARCHAR(20) NULL COMMENT '최종 수정자',
UPD_DT DATETIME NULL COMMENT '최종 수정일시',
PRIMARY KEY (POLICY_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='접근통제 정책(개인정보보호 SER-001 요건4-5, 단일행)';
INSERT INTO ACCESS_CONTROL_POLICY
@ -927,7 +966,7 @@ CREATE TABLE IF NOT EXISTS PDAL_DOWNLOAD_JOB (
PRIMARY KEY (JOB_ID),
KEY IX_PDJ_REQUESTER (REQUESTER_ID, REG_DT),
KEY IX_PDJ_STATUS (STATUS, REG_DT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='개인정보 접속기록 CSV 비동기 다운로드 작업';
@ -951,6 +990,46 @@ SET @ddl := IF(@idx > 0,
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ─────────────────────────────
-- [2026-08-27 드리프트 보완] 스크립트에 빠져 있던 ESNTL_ID 조회 인덱스 2건
-- ─────────────────────────────
-- =====================================================================
-- [발견 경위] 운영 스키마 덤프(csv_structure_Dump20260520) ↔ 로컬 csv 스키마
-- 전수 대조 결과, 로컬에만 존재하고 저장소 어느 SQL 파일에도 없는
-- 인덱스 2개를 발견했다(개발 중 DB에 직접 수실행하고 스크립트화를 누락).
-- 이 파일만 운영에 돌리면 둘 다 누락돼 로컬에서만 빠른 조회가 된다.
--
-- · COMTNEMPLYRINFO(ESNTL_ID) : comvnusermaster/comvnuserinfo 뉴의 USR 분기와
-- 권한·미사용계정 화면이 ESNTL_ID 로 조인한다.
-- · BIZ_GROUP(ESNTL_ID) : 동일 뉴의 GRO 분기.
--
-- [안전성] 추가(additive) 인덱스라 기존 동작에 영향 없음.
-- [재실행] information_schema 존재 확인 후 생성 — 반복 실행 무해.
-- [롤백] DROP INDEX IX_EMPLYR_ESNTL ON COMTNEMPLYRINFO;
-- DROP INDEX IX_BIZ_GROUP_ESNTL ON BIZ_GROUP;
-- =====================================================================
SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'COMTNEMPLYRINFO'
AND INDEX_NAME = 'IX_EMPLYR_ESNTL');
SET @ddl := IF(@idx > 0,
'SELECT ''SKIP: IX_EMPLYR_ESNTL already exists'' AS msg',
'CREATE INDEX IX_EMPLYR_ESNTL ON COMTNEMPLYRINFO (ESNTL_ID)'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'BIZ_GROUP'
AND INDEX_NAME = 'IX_BIZ_GROUP_ESNTL');
SET @ddl := IF(@idx > 0,
'SELECT ''SKIP: IX_BIZ_GROUP_ESNTL already exists'' AS msg',
'CREATE INDEX IX_BIZ_GROUP_ESNTL ON BIZ_GROUP (ESNTL_ID)'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ─────────────────────────────────────────────────────────────
-- [privacy.mber_nm_encrypt_create_mysql.sql] (2026-08-11 통합)
-- ─────────────────────────────────────────────────────────────
@ -1028,7 +1107,7 @@ CREATE TABLE IF NOT EXISTS MIG_CRYPTO_PROGRESS (
ON UPDATE CURRENT_TIMESTAMP COMMENT '갱신 일시',
RM VARCHAR(500) NULL COMMENT '비고(마지막 오류 메시지 등)',
PRIMARY KEY (JOB_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='개인정보 암호화 이관 배치 진행상태';
@ -1043,15 +1122,129 @@ CREATE TABLE IF NOT EXISTS MIG_CRYPTO_PROGRESS (
-- [백업] 운영 반영 전 `src/script/mysql/backup/privacy.mber_nm_dead_views_backup_20260810.sql`
-- 로 정의를 먼저 백업해 둘 것(복원 시 그대로 재실행).
-- [재실행 안전] DROP VIEW IF EXISTS.
-- ★ [2026-08-27 대소문자 보정] 운영 DB(csv_structure_Dump20260520 실측)는 객체명을
-- 대문자/혼합 케이스로 저장한다(COMTNBBSUSERINFO, excelAppconfirmList ...). 로컬
-- Windows MySQL 은 전부 소문자라 종전의 소문자 DROP 문이 로컬에서만 동작하고
-- 운영에서는 IF EXISTS 때문에 **오류 없이 조용히 아무것도 지우지 않는다**.
-- → lower_case_table_names 설정에 의존하지 않도록 information_schema 에서
-- 실제 저장된 이름을 읽어 동적으로 DROP 한다(양쪽 환경 모두 동작).
-- =====================================================================
DROP VIEW IF EXISTS comtnbbsuserinfo;
DROP VIEW IF EXISTS comvnusermaster2;
DROP VIEW IF EXISTS comvnusermaster_1;
DROP VIEW IF EXISTS excelappconfirmlist;
DROP VIEW IF EXISTS excelappconfirmlist_auth;
DROP VIEW IF EXISTS selectaddedoptionsinf;
DROP VIEW IF EXISTS selectboardarticlelist;
DROP VIEW IF EXISTS selectmiddleappconfirmlist;
DROP PROCEDURE IF EXISTS PRC_PRIVACY_DROP_DEAD_VIEWS;
DELIMITER $$
CREATE PROCEDURE PRC_PRIVACY_DROP_DEAD_VIEWS()
BEGIN
DECLARE v_done INT DEFAULT 0;
DECLARE v_name VARCHAR(64);
DECLARE cur CURSOR FOR
SELECT TABLE_NAME FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE()
AND LOWER(TABLE_NAME) IN ('comtnbbsuserinfo','comvnusermaster2','comvnusermaster_1',
'excelappconfirmlist','excelappconfirmlist_auth',
'selectaddedoptionsinf','selectboardarticlelist',
'selectmiddleappconfirmlist');
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
-- [2026-08-27] DROP 전에 **운영본 정의를 그 DB 안에** 백업한다.
-- backup/privacy.mber_nm_dead_views_backup_20260810.sql 은 로컬(소문자) 캡처라
-- 운영 복원에 쓸 수 없다. 롤백 자산은 반드시 운영에서 떠 둬야 한다.
CREATE TABLE IF NOT EXISTS privacy_view_def_backup (
view_name VARCHAR(64) NOT NULL,
backup_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
view_definition MEDIUMTEXT NOT NULL,
PRIMARY KEY (view_name, backup_at)
);
INSERT INTO privacy_view_def_backup (view_name, view_definition)
SELECT TABLE_NAME, VIEW_DEFINITION FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE()
AND LOWER(TABLE_NAME) IN ('comtnbbsuserinfo','comvnusermaster2','comvnusermaster_1',
'excelappconfirmlist','excelappconfirmlist_auth',
'selectaddedoptionsinf','selectboardarticlelist',
'selectmiddleappconfirmlist')
AND NOT EXISTS (SELECT 1 FROM privacy_view_def_backup b
WHERE b.view_name = information_schema.views.TABLE_NAME);
OPEN cur;
drop_loop: LOOP
FETCH cur INTO v_name;
IF v_done = 1 THEN LEAVE drop_loop; END IF;
SET @drop_sql = CONCAT('DROP VIEW IF EXISTS `', v_name, '`');
PREPARE stmt_dv FROM @drop_sql;
EXECUTE stmt_dv;
DEALLOCATE PREPARE stmt_dv;
SELECT CONCAT('DROPPED dead view (definition backed up): ', v_name) AS DEAD_VIEW_STATUS;
END LOOP;
CLOSE cur;
END$$
DELIMITER ;
CALL PRC_PRIVACY_DROP_DEAD_VIEWS();
DROP PROCEDURE IF EXISTS PRC_PRIVACY_DROP_DEAD_VIEWS;
-- ─────────────────────────────
-- [2026-08-27] 신규 utf8mb4 테이블 collation 수렴 (환경간 차이 제거)
-- ─────────────────────────────
-- =====================================================================
-- [문제] `DEFAULT CHARSET=utf8mb4` 만 쓰면 서버 기본값을 따라가서
-- · 운영 MySQL 5.7 → utf8mb4_general_ci
-- · 로컬 MySQL 8.0 → utf8mb4_0900_ai_ci (5.7 에는 존재하지도 않는 값)
-- 로 **같은 스크립트가 환경마다 다른 테이블을 만든다** — 로컬 테스트가
-- 운영을 정확히 재현하지 못하고, 정렬·비교 의미가 미묘하게 달라진다.
--
-- [선택] utf8mb4_unicode_ci — 5.7/8.0 양쪽에 존재하고, 운영의 기존 utf8mb4
-- 테이블(BIZ_VOLUNTEER 등 13개)이 이미 쓰는 값이라 향후 조인이 생겨도
-- 'Illegal mix of collations' 가 나지 않는다.
-- (현재 개인정보보호 SQL 은 utf8mb4 테이블과 조인하지 않음을 실측 확인했으나,
-- 상대가 utf8mb3 일 때는 MySQL 이 더 넓은 utf8mb4 로 자동 변환하므로 무해하다)
--
-- [동작] 위 CREATE TABLE 은 IF NOT EXISTS 라 이미 만들어진 테이블의 collation 을
-- 바꾸지 못한다. 그래서 여기서 다른 값인 것만 골라 ALTER 한다.
-- · 운영(신규 설치) : 이미 unicode_ci 로 만들어져 있으므로 **전부 SKIP**
-- · 로컬(기존 0900_ai_ci) : 해당 테이블만 unicode_ci 로 전환
-- ※ CHARSET=utf8(utf8mb3) 로 만든 PERSONAL_DATA_ACCESS_LOG / PDAL_PURGE_HISTORY 는
-- 대상이 아니다(양쪽 모두 utf8mb3_general_ci 로 이미 동일 — 대용량 파티션
-- 테이블을 불필요하게 재작성하지 않기 위해서도 제외한다).
-- [재실행] 다른 것만 고르므로 반복 실행 무해(두 번째부터는 전부 SKIP).
-- =====================================================================
DROP PROCEDURE IF EXISTS PRC_PRIVACY_ALIGN_COLLATION;
DELIMITER $$
CREATE PROCEDURE PRC_PRIVACY_ALIGN_COLLATION()
BEGIN
DECLARE v_done INT DEFAULT 0;
DECLARE v_name VARCHAR(64);
DECLARE cur CURSOR FOR
SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_TYPE = 'BASE TABLE'
AND TABLE_COLLATION LIKE 'utf8mb4%'
AND TABLE_COLLATION <> 'utf8mb4_unicode_ci'
AND LOWER(TABLE_NAME) IN (
'access_control_policy','account_action_history','account_delete_history',
'account_mng_policy','account_unlock_history','author_change_history',
'author_change_request','author_hist_download_history','author_hist_download_job',
'conect_ip_location','login_lock_exempt','mber_crypto_access_history',
'mig_crypto_progress','otp_device','pdal_download_history','pdal_download_job',
'pdal_inspection_report','privacy_view_def_backup');
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
OPEN cur;
align_loop: LOOP
FETCH cur INTO v_name;
IF v_done = 1 THEN LEAVE align_loop; END IF;
SET @alter_sql = CONCAT('ALTER TABLE `', v_name,
'` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
PREPARE stmt_col FROM @alter_sql;
EXECUTE stmt_col;
DEALLOCATE PREPARE stmt_col;
SELECT CONCAT('COLLATION aligned -> utf8mb4_unicode_ci : ', v_name) AS COLLATION_STATUS;
END LOOP;
CLOSE cur;
END$$
DELIMITER ;
CALL PRC_PRIVACY_ALIGN_COLLATION();
DROP PROCEDURE IF EXISTS PRC_PRIVACY_ALIGN_COLLATION;
-- ---------------------------------------------------------------------
@ -1077,10 +1270,40 @@ CREATE TABLE IF NOT EXISTS MBER_CRYPTO_ACCESS_HISTORY
KEY IDX_MCAH_01 (ACCESS_DT),
KEY IDX_MCAH_02 (HANDLR_ID),
KEY IDX_MCAH_03 (TRGET_MBER_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='암호화 회원정보 관리자 접근 이력(사유·재인증, 추가제안 Action4)';
-- ---------------------------------------------------------------------
-- [privacy.access_log_keep_day_add_mysql.sql] 2026-08-27 추가
-- 접속기록 보관기간 관리자 설정 (SFR-004 / 제안서 slide15 "관리자 설정값 미존재 → 신규 구축")
-- · 파기 배치가 쓰던 자바 상수 730 을 정책값으로 뺀다.
-- · 법정 하한 730일(2년) — 화면·서버 검증과 파기 배치 양쪽에서 강제한다.
-- DB CHECK 를 쓰지 않는 이유는 MariaDB/MySQL 5.7 에서 동작이 갈려 조용히 통과할 수 있어서다.
-- ---------------------------------------------------------------------
DROP PROCEDURE IF EXISTS PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY;
DELIMITER $$
CREATE PROCEDURE PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY()
BEGIN
DECLARE v_cnt INT DEFAULT 0;
SELECT COUNT(*) INTO v_cnt FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ACCOUNT_MNG_POLICY'
AND COLUMN_NAME = 'ACCESS_LOG_KEEP_DAY';
IF v_cnt = 0 THEN
ALTER TABLE ACCOUNT_MNG_POLICY
ADD COLUMN ACCESS_LOG_KEEP_DAY INT NOT NULL DEFAULT 730
COMMENT '개인정보 접속기록 보관기간(일). 법정 하한 730(2년)',
ALGORITHM=INPLACE, LOCK=NONE;
END IF;
END$$
DELIMITER ;
CALL PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY();
DROP PROCEDURE IF EXISTS PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY;
UPDATE ACCOUNT_MNG_POLICY SET ACCESS_LOG_KEEP_DAY = 730
WHERE POLICY_ID = 1 AND (ACCESS_LOG_KEEP_DAY IS NULL OR ACCESS_LOG_KEEP_DAY < 730);
-- =====================================================================
-- PART 2. DML (기준/시드 데이터) ※ 멱등: NOT EXISTS 가드로 중복 적재 방지
-- 수록: ACCOUNT_MNG_POLICY(기본정책 1행)
@ -1130,23 +1353,33 @@ WHERE NOT EXISTS (SELECT 1 FROM CONECT_IP_LOCATION x WHERE x.IP_CIDR = seed.IP_C
-- =====================================================================
-- PART 3. 관리자 메뉴 등록 DML (좌측/상단 메뉴 노출)
-- 구성: 권한관리(9100030) 하위에 순서대로 9100035~9100039 5건 등록
-- 권한 신청·승인 / 권한 변경 이력 / 미사용계정 현황 / 계정 관리 정책 / 인증 실패 이력
-- 구성: 권한관리(9100030) 하위에 MENU_ORDR 순으로 등록
-- [1]~[5] 9100035~9100039 권한 신청·승인 / 권한 변경 이력 / 미사용계정 현황 /
-- 계정 관리 정책 / 인증 실패 이력
-- [6] 9100047(ORDR 13) 접근통제 정책
-- [7] 9100044 명칭 정리("접속기록 조회 및 감사") + 9100051(ORDR 17) 회원정보 암호화 관리
-- + 중간 작업에서 만들었다가 내린 메뉴(9100048/49/50/52/53) 정리
-- ※ 접속기록 조회(9100044, ORDR 10)의 등록 자체는 위 [1]~[5] 블록에 들어 있다.
-- 원본: src/script/mysql/dml/privacy.menu_author_request_insert_mysql.sql
-- src/script/mysql/dml/privacy.menu_author_history_insert_mysql.sql
-- src/script/mysql/dml/privacy.menu_dormant_status_insert_mysql.sql
-- src/script/mysql/dml/privacy.menu_account_policy_insert_mysql.sql
-- src/script/mysql/dml/privacy.menu_access_log_insert_mysql.sql
-- src/script/mysql/dml/privacy.menu_access_policy_insert_mysql.sql
-- src/script/mysql/dml/privacy.menu_privacy_screens4_insert_mysql.sql
-- (개별 파일에 화면별 배치 근거·롤백 SQL이 더 상세히 있음 — 여기서는 통합 실행용)
--
-- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 배포 필수 ★★
-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라
-- 권한관리(9100030) 블록에 9100035~9100038 각각의 <c:when> 분기 +
-- 시스템관리/권한관리 상위 하이라이트 조건(param.page eq '910003x')이
-- 반드시 함께 배포돼야 렌더된다. (arko_renual 소스에는 4건 모두 이미 반영 완료 —
-- 운영 배포 시 WAR/배포본에 이 JSP가 포함됐는지만 확인하면 됨)
-- 권한관리(9100030) 블록에 등록한 메뉴번호마다 <c:when> 분기 +
-- 시스템관리/권한관리 상위 하이라이트 조건(param.page eq '91000xx')이
-- 반드시 함께 배포돼야 렌더된다. (arko_renual 소스에는 9100035~9100039·9100044·
-- 9100047·9100051 전부 이미 반영 완료 — 운영 배포 시 WAR/배포본에 이 JSP가
-- 포함됐는지만 확인하면 됨)
--
-- [재실행 안전] 모든 INSERT NOT EXISTS 가드 → 반복 실행 무해.
-- [노출권한] 4건 모두 IS_AUTHENTICATED_FULLY, ROLE_ADMIN 동일
-- [노출권한] 전 건 IS_AUTHENTICATED_FULLY, ROLE_ADMIN 동일
-- (실제 접근 차단은 urlRoleMapping 의 /mnt/sec/* = ROLE_ADMIN 이 담당)
-- [Oracle] 대상 운영 DB가 MySQL(csv)이라 Oracle 쌍 미작성
-- =====================================================================
@ -1276,40 +1509,42 @@ SELECT 9100047, g.ac
-- ('AuthorRequestList','AuthorHistoryList','DormantStatusList','AccountPolicySet');
-- ── [7] 개인정보보호 화면 4종 메뉴(9100048~9100051) — 2026-08-27 추가 ──
-- 9100048 / ORDR 14 정기점검 리포트 /mnt/sec/inspection/listView.do (SFR-004-7)
-- 9100049 / ORDR 15 비정상접근 탐지 /mnt/sec/abnormal/listView.do (과업내용 다-①)
-- 9100050 / ORDR 16 접속기록 파기이력 /mnt/sec/purgeHistory/listView.do (SER-002-2)
-- 9100051 / ORDR 17 회원정보 암호화관리 /mnt/sec/mberCrypto/listView.do (추가제안 Action4)
-- ★ left.jsp 9100048~9100051 <c:when> 분기 4개 + 상위 하이라이트 조건 2곳 동반 배포 필수
-- ※ 9100045·9100046 은 2026-08-11 화면 폐기와 함께 삭제된 번호라 재사용하지 않는다.
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'InspectionReportList' AS PROGRM_FILE_NM, '/mnt/sec/inspection/' AS PROGRM_STRE_PATH,
'정기점검 리포트' AS PROGRM_KOREAN_NM,
'접속기록 정기점검 리포트 조회·점검결과 입력(SFR-004-7)' AS PROGRM_DC,
'/mnt/sec/inspection/listView.do?page=9100048' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
-- ── [7] 개인정보보호 화면 메뉴 최종형 (2026-08-27) ──
-- [확정 근거] 제안요청서 과업내용 "다. 개인정보취급자 접속 및 처리기록 관리 기능 개발" 이
-- 요구하는 화면은 하나다 — "관리자용 로그 조회 화면 개발".
-- 제안요약서 slide 14~15 의 시안도 "접속기록 조회 및 감사" 한 장만 그려져 있다.
-- 따라서 접속·처리기록 영역은 **메뉴 1개**로 끝난다.
--
-- [그 한 화면 안에서 제공하는 것] — RFP 가 '기능'으로 명시한 항목들이라 지우지 않는다
-- · "처리기록 검색·조회 기능 제공" → 처리구분(조회/등록/수정/삭제) 필터
-- · "비정상 접근 탐지 및 기록 기능 구현" → 목록의 '감사' 칸(이상/점검) 상시 표시
-- · SFR-004 "접속기록 정기점검을 지원" → 월 1회 배치 + PDAL_INSPECTION_REPORT 적재
-- · 파기이력(SER-002-2) → 화면 내 "파기이력" 버튼
--
-- [이 블록이 하는 일]
-- (1) 9100044 를 "접속기록 조회 및 감사" 로 정리(권한관리 9100030 하위, ORDR 10)
-- (2) 9100051 회원정보 암호화 관리(추가제안 Action4) 메뉴 등록
-- (3) 2026-08-27 중간 작업에서 만들었다가 시안 대조 후 내린 메뉴 정리
-- (9100048 정기점검 / 9100049 이상행위 / 9100050 파기이력 /
-- 9100052 그룹 / 9100053 처리기록) — 이미 적용한 DB 를 위한 정리구문이다.
-- 신규 설치라면 해당 행이 없으므로 아무 일도 일어나지 않는다.
--
-- ★ left.jsp 동반 배포 필수 — 권한관리 블록에 9100044·9100051 분기 + 하이라이트 조건
-- [재실행 안전] INSERT 는 NOT EXISTS, UPDATE 는 같은 값으로 수렴, DELETE 는 대상 한정.
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'AbnormalAccessList' AS PROGRM_FILE_NM, '/mnt/sec/abnormal/' AS PROGRM_STRE_PATH,
'비정상접근 탐지' AS PROGRM_KOREAN_NM,
'비정상 접근 탐지 5유형 조회(과업내용 다-①)' AS PROGRM_DC,
'/mnt/sec/abnormal/listView.do?page=9100049' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
-- (1) 접속기록 화면 명칭 정리
UPDATE COMTNMENUINFO
SET UPPER_MENU_NO = 9100030, MENU_ORDR = 10,
MENU_NM = '접속기록 조회 및 감사',
MENU_DC = '개인정보 접속·처리기록 조회, 이상행위 감사, 다운로드(사유·이력)'
WHERE MENU_NO = 9100044;
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'AccessLogPurgeHistory' AS PROGRM_FILE_NM, '/mnt/sec/purgeHistory/' AS PROGRM_STRE_PATH,
'접속기록 파기이력' AS PROGRM_KOREAN_NM,
'접속기록 파티션 파기·증설 이력 조회(SER-002-2)' AS PROGRM_DC,
'/mnt/sec/purgeHistory/listView.do?page=9100050' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
UPDATE COMTNPROGRMLIST
SET PROGRM_KOREAN_NM = '접속기록 조회 및 감사',
PROGRM_DC = '개인정보 접속·처리기록 조회 및 감사(RFP 과업내용 다 / SFR-004)'
WHERE PROGRM_FILE_NM = 'AccessLogList';
-- (2) 회원정보 암호화 관리 (추가제안 Action4)
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'MberCryptoAdmin' AS PROGRM_FILE_NM, '/mnt/sec/mberCrypto/' AS PROGRM_STRE_PATH,
@ -1319,46 +1554,27 @@ SELECT * FROM (
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '정기점검 리포트' AS MENU_NM, 'InspectionReportList' AS PROGRM_FILE_NM, 9100048 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 14 AS MENU_ORDR, '접속기록 정기점검 리포트 조회·점검결과 입력' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '비정상접근 탐지' AS MENU_NM, 'AbnormalAccessList' AS PROGRM_FILE_NM, 9100049 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 15 AS MENU_ORDR, '비정상 접근 탐지 5유형 조회' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '접속기록 파기이력' AS MENU_NM, 'AccessLogPurgeHistory' AS PROGRM_FILE_NM, 9100050 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 16 AS MENU_ORDR, '접속기록 파티션 파기·증설 이력' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '회원정보 암호화 관리' AS MENU_NM, 'MberCryptoAdmin' AS PROGRM_FILE_NM, 9100051 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 17 AS MENU_ORDR, '암호화 회원정보 복호화 조회·수정·파기·등록' AS MENU_DC,
9100030 AS UPPER_MENU_NO, 17 AS MENU_ORDR, '암호화 회원정보 복호화 CRUD(사유·재인증)' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT n.mno, g.ac
FROM (SELECT 9100048 AS mno UNION ALL SELECT 9100049 UNION ALL
SELECT 9100050 UNION ALL SELECT 9100051) n
CROSS JOIN (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
SELECT 9100051, g.ac
FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO)
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = n.mno AND x.AUTHOR_CODE = g.ac);
WHERE x.MENU_NO = 9100051 AND x.AUTHOR_CODE = g.ac);
-- (3) 중간 작업에서 만들었다가 내린 메뉴 정리 (화면·컨트롤러는 유지/삭제되었음)
DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO IN (9100048, 9100049, 9100050, 9100052, 9100053);
DELETE FROM COMTNMENUINFO WHERE MENU_NO IN (9100048, 9100049, 9100050, 9100052, 9100053);
-- [검증] SELECT MENU_NO,UPPER_MENU_NO,MENU_ORDR,MENU_NM,HEX(MENU_NM)
-- FROM COMTNMENUINFO WHERE MENU_NO IN (9100044,9100051) ORDER BY MENU_ORDR;
-- =====================================================================
@ -1421,109 +1637,166 @@ BEGIN
FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnuserinfo';
-- **★★ [2026-08-27 대소문자 보정] ★★**
-- REPLACE() 는 lower_case_table_names 설정과 무관하게 **항상 대소문자를 구분**한다.
-- 운영 DB(csv_structure_Dump20260520 실측)의 VIEW_DEFINITION 은 테이블명이 대문자
-- (`COMTNGNRLMBER`)인데, 종전 코드는 소문자(`comtngnrlmber`)를 찾고 있었다.
-- -> 운영에서 5개 REPLACE 가 전부 불발되고, 바뀌지 않은 정의로
-- CREATE OR REPLACE VIEW 가 **에러 없이 성공**해 USER_NM_HASH 없는 뉴가 남는다.
-- 아래처럼 실제 저장된 객체명을 information_schema 에서 읽어 검색문자열을 조립한다.
SET @n_gnr = NULL; SET @n_emp = NULL; SET @n_ent = NULL; SET @n_grp = NULL;
SET @v_master = NULL; SET @v_info = NULL;
SELECT TABLE_NAME INTO @n_gnr FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comtngnrlmber' LIMIT 1;
SELECT TABLE_NAME INTO @n_emp FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comtnemplyrinfo' LIMIT 1;
SELECT TABLE_NAME INTO @n_ent FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comtnentrprsmber' LIMIT 1;
SELECT TABLE_NAME INTO @n_grp FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'biz_group' LIMIT 1;
SELECT TABLE_NAME INTO @v_master FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comvnusermaster' LIMIT 1;
SELECT TABLE_NAME INTO @v_info FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comvnuserinfo' LIMIT 1;
IF @n_gnr IS NULL OR @n_emp IS NULL OR @n_ent IS NULL OR @n_grp IS NULL
OR @v_master IS NULL OR @v_info IS NULL THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'PART4 ABORT: base table or view not found (COMTNGNRLMBER/COMTNEMPLYRINFO/COMTNENTRPRSMBER/BIZ_GROUP/COMVNUSERMASTER/COMVNUSERINFO)';
END IF;
-- 1. comvnusermaster 재정의
-- GNR 분기: MBER_NM(평문) -> MBER_NM_ENC(암호문), USER_NM_HASH 컬럼 추가(MBER_NM_HASH)
-- USR/ENT/GRO 분기: USER_NM_HASH 컬럼 추가(NULL — 암호화 대상 아님)
-- GNR 분기: MBER_NM(평문) -> MBER_NM_ENC(암호문), USER_NM_HASH 컴럼 추가(MBER_NM_HASH)
-- USR/ENT/GRO 분기: USER_NM_HASH 컴럼 추가(NULL - 암호화 대상 아님)
-- **★★ [2026-08-27] 스키마 한정자 제거 ★★**
-- information_schema.views.VIEW_DEFINITION 은 테이블을 항상
-- `csv`.`COMTNGNRLMBER` 처럼 **스키마 한정자를 붙여** 저장한다
-- (mysqldump 출력에는 없어서 덤프로 검증하면 몰라보인다).
-- 그래서 'from `COMTNGNRLMBER` where' 같은 검색문자열은
-- 실제 텍스트 'from `csv`.`COMTNGNRLMBER` where' 와 맞지 않는다.
-- → 치환 전에 한정자를 제거한다. CREATE OR REPLACE VIEW 시
-- MySQL 이 다시 붙이므로 결과물은 동일하다.
SET @qual = CONCAT('`', DATABASE(), '`.');
SET @def_master = (
SELECT VIEW_DEFINITION FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnusermaster'
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comvnusermaster'
);
SET @def_master = REPLACE(@def_master, @qual, '');
SET @def_master = REPLACE(
@def_master,
'`comtngnrlmber`.`MBER_NM` AS `USER_NM`',
'`comtngnrlmber`.`MBER_NM_ENC` AS `USER_NM`'
);
SET @def_master = REPLACE(
@def_master,
' end) AS `ADULT` from `comtngnrlmber` where',
' end) AS `ADULT`,`comtngnrlmber`.`MBER_NM_HASH` AS `USER_NM_HASH` from `comtngnrlmber` where'
);
SET @def_master = REPLACE(
@def_master,
'\'N\' AS `ADULT` from `comtnemplyrinfo` union all',
'\'N\' AS `ADULT`,NULL AS `USER_NM_HASH` from `comtnemplyrinfo` union all'
);
SET @def_master = REPLACE(
@def_master,
'\'N\' AS `ADULT` from `comtnentrprsmber` where',
'\'N\' AS `ADULT`,NULL AS `USER_NM_HASH` from `comtnentrprsmber` where'
);
SET @def_master = REPLACE(
@def_master,
'\'N\' AS `ADULT` from `biz_group` where',
'\'N\' AS `ADULT`,NULL AS `USER_NM_HASH` from `biz_group` where'
);
SET @def_master = REPLACE(@def_master,
CONCAT('`', @n_gnr, '`.`MBER_NM` AS `USER_NM`'),
CONCAT('`', @n_gnr, '`.`MBER_NM_ENC` AS `USER_NM`'));
SET @def_master = REPLACE(@def_master,
CONCAT(' end) AS `ADULT` from `', @n_gnr, '` where'),
CONCAT(' end) AS `ADULT`,`', @n_gnr, '`.`MBER_NM_HASH` AS `USER_NM_HASH` from `', @n_gnr, '` where'));
SET @def_master = REPLACE(@def_master,
CONCAT('\'', 'N', '\'', ' AS `ADULT` from `', @n_emp, '` union all'),
CONCAT('\'', 'N', '\'', ' AS `ADULT`,NULL AS `USER_NM_HASH` from `', @n_emp, '` union all'));
SET @def_master = REPLACE(@def_master,
CONCAT('\'', 'N', '\'', ' AS `ADULT` from `', @n_ent, '` where'),
CONCAT('\'', 'N', '\'', ' AS `ADULT`,NULL AS `USER_NM_HASH` from `', @n_ent, '` where'));
SET @def_master = REPLACE(@def_master,
CONCAT('\'', 'N', '\'', ' AS `ADULT` from `', @n_grp, '` where'),
CONCAT('\'', 'N', '\'', ' AS `ADULT`,NULL AS `USER_NM_HASH` from `', @n_grp, '` where'));
SELECT
(LENGTH(@def_master) - LENGTH(REPLACE(@def_master, 'USER_NM_HASH', ''))) / LENGTH('USER_NM_HASH') AS hash_column_occurrences,
(@def_master LIKE '%MBER_NM_ENC%') AS gnr_uses_enc_column,
(@def_master NOT LIKE '%`comtngnrlmber`.`MBER_NM` AS `USER_NM`%') AS old_plaintext_reference_removed;
(@def_master NOT LIKE CONCAT('%`', @n_gnr, '`.`MBER_NM` AS `USER_NM`%')) AS old_plaintext_reference_removed;
SET @sql_master = CONCAT('CREATE OR REPLACE VIEW comvnusermaster AS ', @def_master);
-- ★ 하드 게이트: 치환이 하나라도 불발되면 여기서 중단한다.
-- (종전에는 위 SELECT 가 '보고용'이라 불발돼도 그대로 진행 -> 깨진 뉴가 남았다)
IF (LENGTH(@def_master) - LENGTH(REPLACE(@def_master, 'USER_NM_HASH', ''))) / LENGTH('USER_NM_HASH') <> 4
OR @def_master NOT LIKE '%MBER_NM_ENC%'
OR @def_master LIKE CONCAT('%`', @n_gnr, '`.`MBER_NM` AS `USER_NM`%') THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'PART4 ABORT: comvnusermaster rewrite incomplete (expected 4 USER_NM_HASH + MBER_NM_ENC + no plaintext MBER_NM). View NOT replaced.';
END IF;
SET @sql_master = CONCAT('CREATE OR REPLACE VIEW `', @v_master, '` AS ', @def_master);
PREPARE stmt_master FROM @sql_master;
EXECUTE stmt_master;
DEALLOCATE PREPARE stmt_master;
-- 2. comvnuserinfo 재정의 (USER_GUBUN_NM 한글 리터럴은 REPLACE 대상에 포함하지 않는다 — 그대로 보존)
-- 2. comvnuserinfo 재정의 (USER_GUBUN_NM 한글 리터럴은 REPLACE 대상에 포함하지 않는다 - 그대로 보존)
SET @def_info = (
SELECT VIEW_DEFINITION FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnuserinfo'
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comvnuserinfo'
);
SET @def_info = REPLACE(@def_info, @qual, '');
SET @def_info = REPLACE(
@def_info,
'`comtngnrlmber`.`MBER_NM` AS `USER_NM`',
'`comtngnrlmber`.`MBER_NM_ENC` AS `USER_NM`'
);
SET @def_info = REPLACE(
@def_info,
'`comtngnrlmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `comtngnrlmber` union all',
'`comtngnrlmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,`comtngnrlmber`.`MBER_NM_HASH` AS `USER_NM_HASH` from `comtngnrlmber` union all'
);
SET @def_info = REPLACE(
@def_info,
'`biz_group`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `biz_group` union all',
'`biz_group`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `biz_group` union all'
);
SET @def_info = REPLACE(
@def_info,
'`comtnentrprsmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `comtnentrprsmber` union all',
'`comtnentrprsmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `comtnentrprsmber` union all'
);
SET @def_info = REPLACE(
@def_info,
'`comtnemplyrinfo`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `comtnemplyrinfo`',
'`comtnemplyrinfo`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `comtnemplyrinfo`'
);
SET @def_info = REPLACE(@def_info,
CONCAT('`', @n_gnr, '`.`MBER_NM` AS `USER_NM`'),
CONCAT('`', @n_gnr, '`.`MBER_NM_ENC` AS `USER_NM`'));
SET @def_info = REPLACE(@def_info,
CONCAT('`', @n_gnr, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `', @n_gnr, '` union all'),
CONCAT('`', @n_gnr, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,`', @n_gnr, '`.`MBER_NM_HASH` AS `USER_NM_HASH` from `', @n_gnr, '` union all'));
SET @def_info = REPLACE(@def_info,
CONCAT('`', @n_grp, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `', @n_grp, '` union all'),
CONCAT('`', @n_grp, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `', @n_grp, '` union all'));
SET @def_info = REPLACE(@def_info,
CONCAT('`', @n_ent, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `', @n_ent, '` union all'),
CONCAT('`', @n_ent, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `', @n_ent, '` union all'));
SET @def_info = REPLACE(@def_info,
CONCAT('`', @n_emp, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `', @n_emp, '`'),
CONCAT('`', @n_emp, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `', @n_emp, '`'));
SELECT
(LENGTH(@def_info) - LENGTH(REPLACE(@def_info, 'USER_NM_HASH', ''))) / LENGTH('USER_NM_HASH') AS hash_column_occurrences,
(@def_info LIKE '%MBER_NM_ENC%') AS gnr_uses_enc_column,
(@def_info NOT LIKE '%`comtngnrlmber`.`MBER_NM` AS `USER_NM`%') AS old_plaintext_reference_removed;
(@def_info NOT LIKE CONCAT('%`', @n_gnr, '`.`MBER_NM` AS `USER_NM`%')) AS old_plaintext_reference_removed;
SET @sql_info = CONCAT('CREATE OR REPLACE VIEW comvnuserinfo AS ', @def_info);
-- ★ 하드 게이트 (comvnuserinfo)
IF (LENGTH(@def_info) - LENGTH(REPLACE(@def_info, 'USER_NM_HASH', ''))) / LENGTH('USER_NM_HASH') <> 4
OR @def_info NOT LIKE '%MBER_NM_ENC%'
OR @def_info LIKE CONCAT('%`', @n_gnr, '`.`MBER_NM` AS `USER_NM`%') THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'PART4 ABORT: comvnuserinfo rewrite incomplete (expected 4 USER_NM_HASH + MBER_NM_ENC + no plaintext MBER_NM). View NOT replaced.';
END IF;
SET @sql_info = CONCAT('CREATE OR REPLACE VIEW `', @v_info, '` AS ', @def_info);
PREPARE stmt_info FROM @sql_info;
EXECUTE stmt_info;
DEALLOCATE PREPARE stmt_info;
-- 3. 반영 후 검증
-- ★ [2026-08-27] 이 검증 SELECT 들도 뉴 이름을 직접 참조하므로 동적 SQL 로 바꿈.
-- 운영(lower_case_table_names=0, 2026-08-27 실측)에서는 소문자 comvnusermaster 가
-- 존재하지 않아 1146(Table doesn't exist) 로 프로시저가 중단된다.
-- — 뉴 교체는 이미 성공한 뒤라, 성공한 컷오버가 실패처럼 보이게 된다.
-- (information_schema 비교는 콜레이션상 대소문자 무관이라 LOWER() 로 충분하다)
SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION
FROM information_schema.columns
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('comvnusermaster', 'comvnuserinfo')
AND LOWER(TABLE_NAME) IN ('comvnusermaster', 'comvnuserinfo')
AND COLUMN_NAME IN ('USER_NM', 'USER_SE', 'USER_GUBUN', 'USER_NM_HASH')
ORDER BY TABLE_NAME, ORDINAL_POSITION;
SELECT USER_ID, USER_SE, LEFT(USER_NM, 20) AS user_nm_sample, LEFT(USER_NM_HASH, 20) AS hash_sample
FROM comvnusermaster WHERE USER_SE = 'GNR' LIMIT 1;
SELECT USER_ID, USER_SE, LEFT(USER_NM, 20) AS user_nm_sample, USER_NM_HASH
FROM comvnusermaster WHERE USER_SE = 'USR' LIMIT 1;
SELECT USER_SE, COUNT(*) FROM comvnusermaster GROUP BY USER_SE;
SET @v_chk = CONCAT(
'SELECT USER_ID, USER_SE, LEFT(USER_NM, 20) AS user_nm_sample,',
' LEFT(USER_NM_HASH, 20) AS hash_sample FROM `', @v_master, '`',
' WHERE USER_SE = ''GNR'' LIMIT 1');
PREPARE stmt_chk FROM @v_chk; EXECUTE stmt_chk; DEALLOCATE PREPARE stmt_chk;
SELECT USER_GUBUN, LEFT(USER_NM, 20) AS user_nm_sample, LEFT(USER_NM_HASH, 20) AS hash_sample
FROM comvnuserinfo WHERE USER_GUBUN = 'GNR' LIMIT 1;
SELECT USER_GUBUN, COUNT(*) FROM comvnuserinfo GROUP BY USER_GUBUN;
SET @v_chk = CONCAT(
'SELECT USER_ID, USER_SE, LEFT(USER_NM, 20) AS user_nm_sample, USER_NM_HASH',
' FROM `', @v_master, '` WHERE USER_SE = ''USR'' LIMIT 1');
PREPARE stmt_chk FROM @v_chk; EXECUTE stmt_chk; DEALLOCATE PREPARE stmt_chk;
SET @v_chk = CONCAT('SELECT USER_SE, COUNT(*) AS cnt FROM `', @v_master, '` GROUP BY USER_SE');
PREPARE stmt_chk FROM @v_chk; EXECUTE stmt_chk; DEALLOCATE PREPARE stmt_chk;
SET @v_chk = CONCAT(
'SELECT USER_GUBUN, LEFT(USER_NM, 20) AS user_nm_sample,',
' LEFT(USER_NM_HASH, 20) AS hash_sample FROM `', @v_info, '`',
' WHERE USER_GUBUN = ''GNR'' LIMIT 1');
PREPARE stmt_chk FROM @v_chk; EXECUTE stmt_chk; DEALLOCATE PREPARE stmt_chk;
SET @v_chk = CONCAT('SELECT USER_GUBUN, COUNT(*) AS cnt FROM `', @v_info, '` GROUP BY USER_GUBUN');
PREPARE stmt_chk FROM @v_chk; EXECUTE stmt_chk; DEALLOCATE PREPARE stmt_chk;
END IF;
END$$
DELIMITER ;
@ -1532,7 +1805,19 @@ CALL PRC_PRIVACY_MBER_NM_VIEW_CUTOVER();
DROP PROCEDURE IF EXISTS PRC_PRIVACY_MBER_NM_VIEW_CUTOVER;
-- ── [롤백] ──
-- SELECT view_definition FROM privacy_view_def_backup WHERE view_name='comvnusermaster' ORDER BY backup_at DESC LIMIT 1;
-- -- 위 값을 CREATE OR REPLACE VIEW comvnusermaster AS <value> 로 재실행(comvnuserinfo 동일)
-- ★ 운영은 뷰 이름이 대문자(COMVNUSERMASTER)다. 소문자로 CREATE OR REPLACE 하면
-- lower_case_table_names=0 인 환경에서 **원본을 되돌리는 게 아니라 소문자 뷰가 새로
-- 하나 더 생긴다**. 아래처럼 실제 저장된 이름을 조회해서 그 이름으로 재실행할 것.
--
-- -- ① 실제 저장된 뷰 이름 확인
-- SELECT TABLE_NAME FROM information_schema.views
-- WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) IN ('comvnusermaster','comvnuserinfo');
--
-- -- ② 백업된 원본 정의 조회
-- SELECT view_definition FROM privacy_view_def_backup
-- WHERE view_name = 'comvnusermaster' ORDER BY backup_at DESC LIMIT 1;
--
-- -- ③ ①의 실제 이름 + ②의 값으로 재실행 (comvnuserinfo 동일)
-- -- CREATE OR REPLACE VIEW `COMVNUSERMASTER` AS <②의 value>
-- ############################## END ##############################

View File

@ -0,0 +1,294 @@
================================================================================
2026년 문화품앗이 개인정보보호 기능개선 용역 — 발주기관 확인 항목
--------------------------------------------------------------------------------
작성일 : 2026-08-27
대상 : 한국문화원연합회 (발주기관)
근거 : 붙임2_제안요청서.hwp / 개인정보보호강화 파일럿 개발항목.pptx(제안요약서)
작성 : 개발 수행 중 소스·DB 실측으로 확인된 사항
--------------------------------------------------------------------------------
이 문서는 개발을 진행하면서 "제안요청서·제안서만으로는 결정할 수 없어 발주기관의
확인이나 결정이 필요한 항목"을 모은 것입니다. 각 항목에는 근거 문구, 현재 구현
상태, 결정이 필요한 내용, 미확정 시의 영향을 함께 적었습니다.
임의로 값을 정하지 않고 제안서 근거값(또는 법정 기준)으로 잠정 구현해 둔 상태이며,
확인 결과에 따라 설정값 변경 또는 추가 개발로 반영합니다.
================================================================================
================================================================================
[A] 확정되어야 개발이 완료되는 항목 (미확정 시 해당 기능 미완)
================================================================================
--------------------------------------------------------------------------------
A-1. 휴대폰(SMS) 인증 — 문자발송모듈 연동 규격
--------------------------------------------------------------------------------
근거 : SFR-003 "OTP, 휴대폰인증, 공동인증서 등 안전한 인증수단을 적용하여야 한다"
IAR-001 "휴대폰 인증 적용 시 기존 문자발송모듈과 연계 가능하여야 한다"
제안요청서 운영환경 : 문자발송모듈 = KT-TCS / MCSAgent3.0
현재 : 2단계 인증 3종 중
- OTP : 구현 완료 (TOTP RFC6238, 비밀키 암호화 저장, 실동작)
- 공동인증서 : 구현 완료 (기존 GPKI 자산 위임, 인증서 DN 만 대조)
- 휴대폰SMS : 인터페이스와 빈 등록까지만 되어 있고 실제 발송·검증 미구현
확인요청 : ① KT-TCS / MCSAgent3.0 연동 규격서(발송 API, 인증정보, 발신번호)
② 발송 비용 부담 주체 및 월 발송 한도
③ 인증번호 유효시간(제안 설계값 3분)과 재발송 제한 정책
미확정시 : 인증수단 3종 중 1종(휴대폰)이 미완 상태로 남습니다.
현재 OTP·공동인증서만으로도 2단계 인증은 동작하므로 SFR-003 의
"추가 인증 절차 제공"은 충족하나, "휴대폰인증 적용"은 미충족입니다.
--------------------------------------------------------------------------------
A-2. 회원정보 암호화 마스터 키 — 운영 환경 확인 필요 ★ 시급 ★
--------------------------------------------------------------------------------
근거 : 제안서 추가제안 Action4 "회원정보 암호화 저장"
개인정보 보호법 제29조 / 안전성 확보조치 기준 제7조
현재 : 회원 이름(COMTNGNRLMBER.MBER_NM)을 AES-128/GCM 으로 암호화하도록
개발 완료되어 있고, 개발 PC 기준 77,053건이 이미 암호화되어 있습니다.
그런데 암호화 키를 서버에 영구 보관하는 설정이 어느 환경에도 없습니다.
- 개발서버 기동 설정(arko-dev.service) : 키 설정 없음
- 운영 WAS 컨테이너 설정(01-was-setup.sh) : 키 설정 없음
키는 프로세스 실행 시점에만 존재하는 값이라, 서버를 재기동하면
사라지고 기존 암호문을 복호화할 수 없게 됩니다.
확인요청 : ① 운영 DB(csv)의 COMTNGNRLMBER 에 암호문이 이미 적재되어 있는지
SELECT COUNT(*), COUNT(MBER_NM_ENC) FROM COMTNGNRLMBER;
② 적재되어 있다면 그때 사용한 마스터 키/pepper 값의 보관처
③ 키 보관 방식 결정 (서버 환경변수 / 권한 600 키파일 / 별도 금고)
④ 키 관리 책임자 지정
미확정시 : 회원 이름을 표시하는 관리자 화면이 동작하지 않습니다.
※ 키를 새로 만들면 기존 암호문을 영구히 읽을 수 없습니다. 운영 데이터에
대해서는 절대 새 키로 재암호화하지 마시고, 반드시 원래 키를 확보한 뒤
적용해야 합니다.
--------------------------------------------------------------------------------
A-3. 계정 삭제 — 보존기간 및 자동 삭제 시행 여부
--------------------------------------------------------------------------------
근거 : 과업내용 가 "장기 미사용 계정 및 회원 탈퇴 후 보존기간이 경과한 계정의
삭제 기능 구현"
현재 : 삭제 기능은 개발 완료되었으나 안전을 위해 기본 비활성 상태입니다.
- 보존기간 기본값 : 365일 (ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY)
- 자동삭제 사용여부: '아니오' (PURGE_USE_YN = 'N')
현재는 관리자가 화면에서 개별 승인해 말소하는 방식만 동작합니다.
확인요청 : ① 탈퇴 후 보존기간 일수 (내부 규정/타 시스템 기준)
② 보존기간 경과 계정을 자동 삭제할지, 관리자 승인 후 삭제할지
③ 삭제 대상에 일반회원도 포함할지, 개인정보취급자 계정만 대상으로 할지
(현재는 취급자 계정 한정으로 구현)
미확정시 : 자동 삭제가 계속 비활성으로 남습니다. 되돌릴 수 없는 처리라 임의로
켜지 않았습니다.
================================================================================
[B] 운영 환경 정보가 있어야 정확해지는 항목
================================================================================
--------------------------------------------------------------------------------
B-1. 내부망 IP 대역 / 신뢰 프록시 대역
--------------------------------------------------------------------------------
근거 : SFR-003 "개인정보취급자의 외부 접속 시 추가인증 절차를 제공하여야 한다"
SFR-004 "접속기록에는 접속지 정보(IP)가 포함되어야 한다"
현재 : 잠정값으로 사설 대역(127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
192.168.0.0/16)을 내부망으로 간주하고 있습니다.
판정이 불가능하면 안전측으로 '외부 접속'으로 보아 추가 인증을 요구합니다.
확인요청 : ① 실제 내부망(사내/전용선) IP 대역
② 웹서버 구성(Apache + L4 등) 및 프록시 헤더 전달 여부
- 접속기록의 IP 가 L4 장비 IP 로 고정되어 쌓이면 접속기록의
"접속지 정보"가 사실상 무의미해집니다.
미확정시 : ① 외부 접속인데 내부로 오판하여 추가 인증을 건너뛸 수 있습니다.
② 접속기록의 IP 가 전부 동일한 값으로 남을 수 있습니다.
--------------------------------------------------------------------------------
B-2. 배치 스케줄러 운영 방식
--------------------------------------------------------------------------------
근거 : SFR-002(휴면 배치), SFR-004(파기·무결성검증·정기점검 배치)
현재 : 개인정보보호 배치는 애플리케이션 스케줄러(@Scheduled)로 구현되어 있습니다.
- 매일 01:00 휴면 전환 / 06:00 휴면 사전알림
- 매월 1일 02:00 접속기록 파기 / 03:00 무결성 검증
04:00 권한이력 파기 / 05:00 정기점검 리포트
확인요청 : ① 기존 운영 배치(crontab 등)와 중복 실행 우려가 있는지
② WAS 이중화 시 배치가 양쪽에서 동시에 도는 구성인지
미확정시 : 동일 배치가 중복 실행될 수 있습니다(파기 배치는 중복 실행되어도
결과가 같도록 만들었으나, 운영 구성 확인이 필요합니다).
--------------------------------------------------------------------------------
B-3. 기존 로그인 인증 인터셉터 재활성 여부
--------------------------------------------------------------------------------
근거 : SER-001 "비인가자의 개인정보처리시스템 접근을 통제하여야 한다"
현재 : 접근통제 인터셉터는 ① 인증판정 → ② 2차 인증 → ③ 권한 판정 3단으로
설계되어 있으나, ① 인증판정(로그인 체크)은 본 사업 착수 전부터
비활성(주석) 상태였습니다. ②③은 활성화되어 동작합니다.
개인정보 관련 화면은 화면별로 로그인 여부를 다시 확인하도록 보완했습니다.
확인요청 : ① 기존에 비활성화한 사유가 있는지
② 재활성 시 영향받는 화면 범위를 함께 점검할 수 있는지
미확정시 : 개인정보 화면은 보호되지만, 그 밖의 관리자 화면은 종전 상태 그대로입니다.
================================================================================
[C] 제안서·제안요청서에 명시가 없어 판단이 필요한 항목
================================================================================
--------------------------------------------------------------------------------
C-1. "관리자 알림"의 통보 수단
--------------------------------------------------------------------------------
근거 : 제안요약서 slide15 "정기 점검 레포트 / 관리자 알림"
SFR-002 "계정 비활성화 전 관리자 알림 기능을 제공하여야 한다"
확인사항 : 두 문서 어디에도 알림 수단(메일/문자/화면)이 명시되어 있지 않습니다.
현재 : 메일로 구현했습니다.
- 계정 휴면 사전알림 : D-30 / D-7 / D-Day, 본인과 관리자에게 발송
- 접속기록 무결성 위반 탐지 시 관리자에게 발송
- 이상행위 탐지 시(정기점검 배치) 관리자에게 발송
수신 주소는 관리자가 [계정 관리 정책] 화면에서 설정합니다.
메일 본문에는 개인정보를 담지 않고 건수·기간·요약만 넣었습니다.
확인요청 : ① 메일로 충분한지, 문자(SMS) 또는 화면 알림이 필요한지
② SFR-002 의 "관리자 알림"이 가리키는 대상
- (해석1) 시스템 관리자에게 "이 계정들이 곧 비활성화된다"고 보고
- (해석2) 비활성화될 당사자에게 "곧 잠긴다"고 통지
현재는 두 해석을 모두 만족하도록 본인과 관리자 양쪽에 발송합니다.
③ 로그인 시 화면 팝업 안내가 필요한지
※ 참고 : 로그인하면 최종접속일이 갱신되어 미접속 일수가 0이 되므로,
화면 안내는 "곧 잠깁니다"가 아니라 "오랫동안 접속하지 않으셨습니다"
형태가 됩니다.
미확정시 : 메일 기준으로 유지합니다. 문자 발송은 A-1(문자발송모듈 규격) 확정
이후에만 추가할 수 있습니다.
--------------------------------------------------------------------------------
C-2. 접속·처리기록 관리 화면 구성
--------------------------------------------------------------------------------
근거 : 과업내용 다 "로그 조회 및 관리 기능 구현 — 관리자용 로그 조회 화면 개발"
제안요약서 slide14~15 화면 시안
확인사항 : 제안요청서가 요구하는 화면은 "관리자용 로그 조회 화면" 한 개이고,
제안요약서 시안도 "접속기록 조회 및 감사" 한 장만 그려져 있습니다.
다만 시안의 좌측 메뉴에는 4개 항목(① 접속기록 조회 ② 개인정보 처리기록
③ 이상행위 감사 ④ 정기 점검 리포트)이 이름만 나열되어 있습니다.
현재 : 화면 하나("접속기록 조회 및 감사")로 구성하고, 나머지 기능은 그 화면
안에서 제공합니다.
- 처리기록 조회 : 처리구분(조회/등록/수정/삭제) 필터
- 이상행위 감사 : 목록의 '감사' 칸에 이상/점검 표시
- 정기점검 : 월 1회 배치가 집계·적재
- 파기이력 : 화면 내 '파기이력' 버튼
확인요청 : 시안의 좌측 메뉴 4개를 각각 별도 화면으로 만들어야 하는지,
현재처럼 한 화면에서 제공하는 것으로 충분한지
미확정시 : 현재 구성(화면 1개)을 유지합니다. 별도 화면이 필요하면 기능은 이미
있으므로 메뉴와 화면만 분리하면 됩니다.
--------------------------------------------------------------------------------
C-3. 개인정보 처리기록의 "처리 항목"·"처리 목적"
--------------------------------------------------------------------------------
근거 : 제안요약서 slide13 처리기록 예시
처리자 / 처리 일시 / 처리 정보주체 / 처리 항목(성명·연락처·주소) /
처리 유형 / 처리 목적(봉사활동 매칭 상담)
확인사항 : 위 6개 항목 중 "처리 항목"과 "처리 목적"은 현재 접속기록에 수집되지
않습니다. 나머지 4개는 수집·저장되고 있습니다.
- 처리 항목 : 어떤 정보를 열람했는지. 화면 단위로 매핑 정의가 필요합니다.
- 처리 목적 : 시스템이 알 수 없는 값으로, 취급자가 직접 입력해야 합니다.
확인요청 : ① 두 항목을 접속기록에 반드시 남겨야 하는지
② "처리 목적"을 남긴다면, 개인정보 취급 화면에 들어갈 때마다 사유를
입력받는 방식을 수용할 수 있는지 (취급자 업무 흐름에 매번 입력이
추가됩니다)
③ 대상 화면 범위 (전 화면 / 특정 화면 한정)
미확정시 : 현재 수집 항목(접속자·일시·IP·업무유형·처리대상·처리구분·결과)을
유지합니다. 안전성 확보조치 기준 제8조의 필수 5개 항목은 충족합니다.
--------------------------------------------------------------------------------
C-4. 개인정보 마스킹 적용 범위 (추가제안)
--------------------------------------------------------------------------------
근거 : 제안요약서 slide17 "개인정보 마스킹 처리(추가제안)"
- 신청자 목록 화면과 다운로드 엑셀에 이름·휴대폰번호가 평문 노출
- 다운로드 파일이 외부 업체 전달 양식으로 추정되는 정황
현재 : 마스킹 기능(이름·휴대폰·이메일)은 개발되어 있고 회원정보 관리 화면과
2단계 인증 화면에 적용되어 있습니다.
신청자 목록·엑셀 다운로드에는 아직 적용하지 않았습니다.
확인요청 : ① 신청자 목록 화면을 마스킹할 경우 담당자 업무에 지장이 없는지
② 다운로드 엑셀의 실제 사용처(외부 업체 전달 여부)와, 마스킹 시
해당 업무가 가능한지
③ 마스킹 예외가 필요한 화면/역할이 있는지
미확정시 : 업무 영향이 커서 임의로 적용하지 않았습니다. 확인 후 반영합니다.
================================================================================
[D] 사업관리·산출물 관련
================================================================================
--------------------------------------------------------------------------------
D-1. 성능 검증 기준선
--------------------------------------------------------------------------------
근거 : PSR-001 시스템 성능 유지 / 제안요약서 slide19 "기능개선 전·후 성능 측정"
확인사항 : 제안서는 개선 전과 후의 응답속도를 비교하도록 계획하고 있으나,
개선 전 시점의 측정치가 확보되어 있지 않습니다.
확인요청 : ① 측정 대상 서비스와 시나리오
② 측정 시점 및 방법(운영 부하 영향 고려)
③ 개선 전 측정치가 없을 경우의 대안(현재 상태를 기준선으로 삼을지)
--------------------------------------------------------------------------------
D-2. 산출물 목록·양식
--------------------------------------------------------------------------------
근거 : 각 요구사항의 "산출정보" 항목
요구사항정의서 / 기능설계서 / 인터페이스설계서 / 데이터정의서 /
로그정책서 / 테스트결과서 / 적용결과보고서 등
확인요청 : ① 발주기관 표준 양식 제공 여부
② 제출 시점 및 형태(문서/전자)
③ 개인정보보호위원회 점검 대응용 조치결과보고서 양식의 필요 여부
================================================================================
[참고] 확인 없이 진행한 사항 (근거를 명시해 두었습니다)
================================================================================
· 접속기록 보관기간 : 730일(2년)
→ SFR-004 "최소 2년 이상 보관". 관리자가 화면에서 늘릴 수 있으나 2년 미만으로는
줄일 수 없도록 화면·서버·배치 세 곳에서 막았습니다.
· 권한 변경 이력 보관기간 : 1,095일(3년)
→ SFR-001 "최소 3년 이상 보관".
· 계정 잠금 임계 : 5회 / 자동 로그아웃 : 30분
→ 제안서 slide10 "잠금 5회, 로그아웃 30분으로 강화".
· 휴면 전환 기준 : 정책 테이블 설정값(기본 365일, 사전알림 D-30/D-7/D-Day)
→ 제안서 slide7 시안은 "60일 휴면예정 / 90일 자동잠금"을 예시로 들고 있어
설정값 변경만으로 시안과 동일하게 맞출 수 있습니다. 운영 기준 확정 시 반영합니다.
================================================================================
문의: 본 문서의 각 항목은 개발 소스와 DB 실측을 근거로 작성되었습니다.
항목별 상세 근거(파일·행 번호)가 필요하시면 별도로 제공해 드립니다.
================================================================================