회원정보 암호화 관리 목록이 한 행 때문에 통째로 죽지 않게

목록은 페이지의 모든 행을 복호화하는데, 공용 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>
This commit is contained in:
infraurp 2026-08-27 17:39:27 +09:00
parent 33bd57cae2
commit 660d826a58
4 changed files with 70 additions and 10 deletions

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);
// 복호화 실패 표식은 이름이 아니므로 마스킹하지 않는다(마스킹하면 표식이 뭉개진다).
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

@ -58,6 +58,9 @@ 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; }
#search_wrap .srow { white-space:nowrap; }
@ -112,7 +115,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,16 +152,24 @@ 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:choose>
<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>
<td class="mono"><c:out value="${result.moblphonNo}" /></td>