로그인 실패 계정잠금 예외(SER-001 #24) — 최고관리자 완전 잠김 방지

관리자 계정이 5회 실패로 잠기면 해제 화면도 로그인이 있어야 들어갈 수 있어
아무도 풀 수 없는 상태가 될 수 있었다. 지정 계정을 LOGIN_LOCK_EXEMPT
화이트리스트로 등록하면 실패 잠금 판정만 면제되고, LOGIN_HISTORY 감사이력과
2단계 인증(OTP)은 예외 없이 그대로 적용된다. 캐시는 fail-closed로 조회
실패 시 전 계정 정상 잠금 판정을 유지한다.

접근통제 정책 화면에 '잠금 예외 계정' 섹션을 추가해 등록/해제하며,
같은 화면의 '잠금 해제 방식'은 실사용 중인 단일 옵션만 남기도록 단순화했다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
infraurp 2026-08-10 17:35:04 +09:00
parent 363138eef5
commit e35aa9db06
11 changed files with 707 additions and 4 deletions

View File

@ -8,6 +8,7 @@ import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.privacy.interceptor.SessionPolicyInterceptor;
@ -24,6 +25,7 @@ import egovframework.com.cmm.util.EgovUserDetailsHelper;
* <li>{@code /mnt/privacy/accessPolicyUpdate.do} — 정책 저장(AJAX/JSON)</li>
* <li>{@code /mnt/session/extend.do} — 세션 연장(만료 임박 안내에서 호출)</li>
* <li>{@code /mnt/session/status.do} — 로그인 경과/잔여시간 조회(상단 표시 갱신용)</li>
* <li>{@code /mnt/privacy/lockExempt*.do} — 잠금 예외 계정 조회/등록/해제(#24)</li>
* </ul>
*
* <p><b>경로 선정 근거</b> — 세션 연장·상태 조회는 전체관리자 전용이 아니라
@ -45,6 +47,9 @@ public class AccessControlPolicyController {
@Resource(name = "accessControlPolicyService")
private AccessControlPolicyService accessControlPolicyService;
@Resource(name = "loginLockExemptService")
private LoginLockExemptService loginLockExemptService;
// ────────────────────────────────────────────────────────── 정책 화면
/**
@ -136,6 +141,94 @@ public class AccessControlPolicyController {
return null;
}
// ────────────────────────────────────────────────────── 잠금 예외 계정(#24)
/**
* 잠금 예외 계정 목록(JSON). 정책 화면의 '잠금 예외 계정' 섹션이 진입 시 호출한다.
*/
@RequestMapping(value = "/mnt/privacy/lockExemptList.do")
public String lockExemptList(ModelMap model) throws Exception {
if (handlerUser() == null) {
return denyJson(model);
}
model.addAttribute("result", "success");
model.addAttribute("list", loginLockExemptService.selectExemptList());
return "jsonView";
}
/**
* 잠금 예외 계정 등록(JSON).
*
* <p>대상 ID 가 취급자(COMTNEMPLYRINFO)로 실존하는지 서버측에서 확인한다 — 오타로
* 존재하지 않는 ID 가 등록되면 "예외를 걸어 뒀다"고 믿는 계정이 실제로는 잠기는
* 상황이 되고, 그 사실은 잠긴 뒤에야 드러난다. 저장되는 ID 는 입력값이 아니라
* 조회된 표기 그대로의 EMPLYR_ID 다(대소문자 불일치로 캐시 비교가 어긋나지 않게).</p>
*/
@RequestMapping(value = "/mnt/privacy/lockExemptAdd.do")
public String lockExemptAdd(@RequestParam(value = "exemptId", required = false) String exemptId,
@RequestParam(value = "exemptResn", required = false) String exemptResn,
HttpServletRequest request, ModelMap model) throws Exception {
LoginVO loginVO = handlerUser();
if (loginVO == null) {
return denyJson(model);
}
if (!isSameOrigin(request)) {
return failJson(model, "잘못된 요청 경로입니다.");
}
String id = exemptId == null ? "" : exemptId.trim();
String resn = exemptResn == null ? "" : exemptResn.trim();
if (id.length() == 0) {
return failJson(model, "예외로 등록할 계정 ID를 입력하세요.");
}
if (id.length() > 20) {
return failJson(model, "계정 ID는 20자 이내로 입력하세요.");
}
if (resn.length() == 0) {
return failJson(model, "예외 등록 사유를 입력하세요. 사유는 감사 대응상 필수입니다.");
}
if (resn.length() > 500) {
return failJson(model, "예외 등록 사유는 500자 이내로 입력하세요.");
}
LoginLockExemptVO found = loginLockExemptService.selectEmplyr(id);
if (found == null) {
return failJson(model, "존재하지 않는 취급자 계정입니다. 계정 ID를 확인하세요.");
}
if (loginLockExemptService.isExempt(found.getExemptId())) {
return failJson(model, "이미 잠금 예외로 등록된 계정입니다.");
}
LoginLockExemptVO vo = new LoginLockExemptVO();
vo.setExemptId(found.getExemptId());
vo.setExemptResn(resn);
vo.setRegId(loginVO.getId());
loginLockExemptService.insertExempt(vo);
model.addAttribute("result", "success");
return "jsonView";
}
/** 잠금 예외 계정 해제(JSON). 해제 즉시 해당 계정은 다시 잠금 판정 대상이 된다. */
@RequestMapping(value = "/mnt/privacy/lockExemptDelete.do")
public String lockExemptDelete(@RequestParam(value = "exemptId", required = false) String exemptId,
HttpServletRequest request, ModelMap model) throws Exception {
if (handlerUser() == null) {
return denyJson(model);
}
if (!isSameOrigin(request)) {
return failJson(model, "잘못된 요청 경로입니다.");
}
String id = exemptId == null ? "" : exemptId.trim();
if (id.length() == 0) {
return failJson(model, "해제할 계정 ID가 없습니다.");
}
loginLockExemptService.deleteExempt(id);
model.addAttribute("result", "success");
return "jsonView";
}
// ────────────────────────────────────────────────────────── 세션 상태·연장
/**

View File

@ -0,0 +1,53 @@
package egovframework.com.cmm.privacy.policy;
import java.util.List;
import org.springframework.stereotype.Repository;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/**
* 로그인 실패 계정잠금 예외 DAO (SER-001 / 개발항목 24).
*
* <p>statement 는 {@code PrivacyLoginLockExempt_SQL_Mysql.xml}
* (namespace {@code LoginLockExemptDAO}), sql-map-config 등록.</p>
*
* <p>루트 컨텍스트 {@code @Repository} 빈이므로 자식(servlet) 컨텍스트의
* 인증 컨트롤러에서도 참조할 수 있다.</p>
*/
@Repository("loginLockExemptDAO")
public class LoginLockExemptDAO extends EgovComAbstractDAO {
/** 예외 계정ID 전체 조회 — 캐시 적재용(행 수가 한 자릿수 규모라 전건 조회로 충분). */
@SuppressWarnings("unchecked")
public List<String> selectExemptIds() {
return list("LoginLockExemptDAO.selectExemptIds", null);
}
/** 관리자 화면 목록(사유·등록자·등록일시·대상자 성명 포함). */
@SuppressWarnings("unchecked")
public List<LoginLockExemptVO> selectExemptList() {
return list("LoginLockExemptDAO.selectExemptList", null);
}
/**
* 등록 대상 ID 가 취급자(COMTNEMPLYRINFO)로 실존하는지 확인. 없으면 null.
*
* <p>저장된 표기 그대로의 EMPLYR_ID 와 성명을 돌려준다. 등록 시 이 값을 쓰면
* 관리자가 대소문자를 다르게 입력해도 캐시의 문자열 비교가 어긋나지 않는다
* (MySQL 기본 collation 은 대소문자 무시라 조회는 되지만 자바 비교는 아니다).</p>
*/
public LoginLockExemptVO selectEmplyr(String exemptId) {
return (LoginLockExemptVO) selectByPk("LoginLockExemptDAO.selectEmplyr", exemptId);
}
/** 예외 등록. PK 중복은 호출부에서 사전 확인 후 진입. */
public void insertExempt(LoginLockExemptVO vo) {
insert("LoginLockExemptDAO.insertExempt", vo);
}
/** 예외 해제(행 삭제). */
public int deleteExempt(String exemptId) {
return delete("LoginLockExemptDAO.deleteExempt", exemptId);
}
}

View File

@ -0,0 +1,36 @@
package egovframework.com.cmm.privacy.policy;
import java.util.List;
/**
* 로그인 실패 계정잠금 예외 서비스 (SER-001 / 개발항목 24).
*
* <p>지정 계정을 인증 실패 <b>잠금 판정</b>에서만 제외한다. 실패 이력 적재와
* 2차 인증은 예외 계정에도 그대로 적용된다 — 잠금 예외는 인증 예외가 아니다.</p>
*/
public interface LoginLockExemptService {
/**
* 잠금 예외 계정 여부(TTL 캐시). 로그인 시도마다 호출되는 지점 전용.
*
* <p>조회 실패 시 예외를 던지지 않고 {@code false}(예외 아님)를 돌려준다 —
* 판단 불가일 때는 잠금이 정상 동작하는 쪽이 안전하다.</p>
*
* <p>대소문자를 구분하지 않는다(잠금 SQL 의 MySQL collation 과 동일 기준).
* 목록은 취급자(COMTNEMPLYRINFO) ID 만 담으므로, 호출부는 {@code userSe='USR'} 인
* 경우에만 물어야 한다 — 다른 회원구분과 ID 문자열이 겹치면 엉뚱한 계정의 잠금이 풀린다.</p>
*/
boolean isExempt(String loginId);
/** 관리자 화면 목록(사유·등록자·등록일시 포함). DB 직접 조회. */
List<LoginLockExemptVO> selectExemptList() throws Exception;
/** 등록 대상 ID 가 취급자로 실존하는지 확인. 실존하면 저장 표기 그대로의 ID·성명, 없으면 null. */
LoginLockExemptVO selectEmplyr(String exemptId) throws Exception;
/** 예외 등록 + 캐시 무효화. */
void insertExempt(LoginLockExemptVO vo) throws Exception;
/** 예외 해제 + 캐시 무효화. */
void deleteExempt(String exemptId) throws Exception;
}

View File

@ -0,0 +1,125 @@
package egovframework.com.cmm.privacy.policy;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import javax.annotation.Resource;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.stereotype.Service;
/**
* 로그인 실패 계정잠금 예외 서비스 구현 (SER-001 / 개발항목 24).
*
* <p><b>캐시</b> — {@code EgovLoginController.actionLogin()} 이 로그인 시도마다 두 번
* (잠금 선확인·실패 처리) 조회하므로 {@link AccessControlPolicyServiceImpl} 과 동일한
* 짧은 TTL(60초) 캐시를 둔다. 등록·삭제 시 즉시 무효화한다.</p>
*
* <p><b>fail-closed — {@link AccessControlPolicyServiceImpl} 과 반대 방향이다.</b>
* 접근통제 정책 쪽은 조회에 실패해도 기본값(잠금 5회·30분)을 돌려준다. 그 기본값이
* 이미 강화된 값이라 실패가 곧 완화가 아니기 때문이다. 반면 이 화이트리스트는
* <b>있으면 잠금이 면제되는</b> 목록이라, 조회 실패를 관대하게 다루면 그 순간
* 무차별 대입에 대한 잠금이 통째로 풀린다. 따라서 조회 실패 시에는 빈 집합
* (= 예외 계정 없음 = 전 계정 정상 잠금 판정)으로 취급한다. 테이블 미생성 등으로
* 계속 실패하는 상황에서 매 로그인마다 DB를 때리지 않도록, 실패한 빈 집합도
* 동일 TTL 로 캐싱한다(최대 60초 뒤 자동 재시도).</p>
*
* <pre>
* &lt;&lt; 개정이력(Modification Information) &gt;&gt;
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.10 URP 최초 생성 (계정잠금 예외 화이트리스트)
* </pre>
*/
@Service("loginLockExemptService")
public class LoginLockExemptServiceImpl implements LoginLockExemptService {
private static final Log LOG = LogFactory.getLog(LoginLockExemptServiceImpl.class);
/** 캐시 수명(ms). 등록·삭제 시 즉시 무효화되므로 관리자 체감 지연은 없다. */
private static final long CACHE_TTL_MS = 60 * 1000L;
@Resource(name = "loginLockExemptDAO")
private LoginLockExemptDAO loginLockExemptDAO;
/** 캐시된 예외 ID 집합. 불변 집합 참조만 교체하므로 락 없이 안전(읽기 다수·쓰기 소수). */
private volatile Set<String> cachedIds;
private volatile long cachedAt;
@Override
public boolean isExempt(String loginId) {
if (loginId == null || loginId.trim().length() == 0) {
return false;
}
return cachedExemptIds().contains(normalize(loginId));
}
/**
* 잠금 판정 SQL(selectlockCheck·selectFailCount·updateFailLogin)은 모두
* {@code WHERE EMPLYR_ID = #id#} 로 MySQL 기본 collation(대소문자 무시)에 따라 매칭된다.
* 여기서만 자바 문자열을 그대로 비교하면 대소문자를 바꿔 시도하는 것만으로
* 화이트리스트를 우회해 실제 계정에 LOCK_YN='Y' 를 찍을 수 있다 — 이 기능의 존재 이유가 무너진다.
* 잠금 SQL 과 같은 기준으로 맞춘다.
*/
private String normalize(String id) {
return id.trim().toLowerCase(Locale.ENGLISH);
}
private Set<String> cachedExemptIds() {
Set<String> snapshot = cachedIds;
if (snapshot != null && (System.currentTimeMillis() - cachedAt) < CACHE_TTL_MS) {
return snapshot;
}
Set<String> loaded;
try {
List<String> ids = loginLockExemptDAO.selectExemptIds();
loaded = new HashSet<String>();
if (ids != null) {
for (String id : ids) {
if (id != null && id.trim().length() > 0) {
loaded.add(normalize(id));
}
}
}
loaded = Collections.unmodifiableSet(loaded);
} catch (Exception e) {
// fail-closed — 예외 없음으로 취급해 잠금 로직이 정상 동작하게 둔다(위 클래스 주석 참고).
LOG.error("[개인정보/24] 잠금 예외 목록 조회 실패 — 예외 없음(전 계정 잠금 판정)으로 진행: " + e.getMessage());
loaded = Collections.emptySet();
}
cachedIds = loaded;
cachedAt = System.currentTimeMillis();
return loaded;
}
@Override
public List<LoginLockExemptVO> selectExemptList() throws Exception {
return loginLockExemptDAO.selectExemptList();
}
@Override
public LoginLockExemptVO selectEmplyr(String exemptId) throws Exception {
return loginLockExemptDAO.selectEmplyr(exemptId);
}
@Override
public void insertExempt(LoginLockExemptVO vo) throws Exception {
loginLockExemptDAO.insertExempt(vo);
invalidate();
}
@Override
public void deleteExempt(String exemptId) throws Exception {
loginLockExemptDAO.deleteExempt(exemptId);
invalidate();
}
private void invalidate() {
cachedIds = null;
cachedAt = 0L;
}
}

View File

@ -0,0 +1,47 @@
package egovframework.com.cmm.privacy.policy;
import java.io.Serializable;
/**
* 로그인 실패 계정잠금 예외 VO — LOGIN_LOCK_EXEMPT 매핑 (SER-001 / 개발항목 24).
*
* <p>잠금 <b>판정</b>만 면제하는 화이트리스트 한 행이다. 인증 실패 이력(LOGIN_HISTORY)
* 적재와 2차 인증(OTP)은 예외 계정에도 그대로 적용된다.</p>
*
* <pre>
* &lt;&lt; 개정이력(Modification Information) &gt;&gt;
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.10 URP 최초 생성 (계정잠금 예외 화이트리스트)
* </pre>
*/
public class LoginLockExemptVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 잠금 예외 대상 계정ID(COMTNEMPLYRINFO.EMPLYR_ID). */
private String exemptId;
/** 예외 등록 사유(필수). */
private String exemptResn;
/** 등록한 관리자 계정ID. */
private String regId;
/** 등록일시(화면 표시용 문자열). */
private String regDt;
/** 대상자 성명 — 목록 조회 시 COMTNEMPLYRINFO 조인 결과. */
private String userNm;
public String getExemptId() { return exemptId; }
public void setExemptId(String exemptId) { this.exemptId = exemptId; }
public String getExemptResn() { return exemptResn; }
public void setExemptResn(String exemptResn) { this.exemptResn = exemptResn; }
public String getRegId() { return regId; }
public void setRegId(String regId) { this.regId = regId; }
public String getRegDt() { return regDt; }
public void setRegDt(String regDt) { this.regDt = regDt; }
public String getUserNm() { return userNm; }
public void setUserNm(String userNm) { this.userNm = userNm; }
}

View File

@ -28,6 +28,8 @@ import egovframework.com.cmm.EgovWebUtil;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.annotation.IncludedInfo;
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
import egovframework.com.cmm.privacy.interceptor.SessionPolicyInterceptor;
import egovframework.com.cmm.privacy.policy.LoginLockExemptService;
import egovframework.com.cmm.service.EgovCmmUseService;
import egovframework.com.cmm.service.Globals;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
@ -88,6 +90,10 @@ public class EgovLoginController {
@Resource(name="egovMessageSource")
EgovMessageSource egovMessageSource;
/** 개인정보보호(SER-001/#24) 계정 잠금 예외 화이트리스트 — 조회는 TTL 캐시(fail-closed) */
@Resource(name = "loginLockExemptService")
private LoginLockExemptService loginLockExemptService;
/** log */
protected static final Log LOG = LogFactory.getLog(EgovLoginController.class);
@ -184,8 +190,13 @@ public class EgovLoginController {
// ============================================================
// 1. 계정 잠금 선(先) 확인 — 잠긴 계정은 인증 시도 전 차단
// 단, LOGIN_LOCK_EXEMPT 등록 계정은 이 확인 자체를 건너뛴다(#24 break-glass).
// 잠금 해제 화면도 관리자 로그인이 있어야 들어갈 수 있어, 최고관리자가 잠기면
// 아무도 해제할 수 없는 완전 잠김이 된다. 예외 등록 전에 이미 LOCK_YN='Y' 가
// 찍혀 있었더라도 등록 시점부터는 그 값과 무관하게 로그인 시도를 막지 않는다.
try {
if (loginVO.getId() != null && !"".equals(loginVO.getId())) {
if (loginVO.getId() != null && !"".equals(loginVO.getId())
&& !isLockExempt(loginVO)) {
LoginVO lockVo = loginService.selectlockCheck(loginVO);
if (lockVo != null && "Y".equals(lockVo.getLockYn())) {
model.addAttribute("message", "계정이 잠겼습니다. 관리자에게 문의 바랍니다.");
@ -206,6 +217,10 @@ public class EgovLoginController {
LOG.error("[개인정보/#22] 로그인 성공 후 실패이력 정리 오류: " + e.getMessage());
}
request.getSession().setAttribute("loginVO", resultVO);
// [개인정보보호 SER-001 요건5] 로그인 시각 도장 — 관리자 상단 '로그인 후 경과시간' 기준점.
// 수동 로그아웃은 세션을 무효화하지 않으므로, 여기서 매 로그인마다 다시 찍지 않으면
// 같은 세션으로 재로그인했을 때 직전 로그인 시각이 그대로 쓰여 경과시간이 부풀려진다.
SessionPolicyInterceptor.stampLogin(request.getSession(), resultVO.getId());
// [개인정보보호 SFR-003 / #14·#52] 로그인 통합 2단계 인증:
// 비밀번호 인증 성공 직후 2차 인증 화면으로 유도한다(관리자 actionLogin 경로 전용).
// secondFactor.do가 등록자→OTP 검증 / 미등록자→등록 유도를 처리하고,
@ -237,7 +252,10 @@ public class EgovLoginController {
// 당일 누적 실패 횟수
currentFail = loginService.selectFailCount(map);
// 임계 초과 시 계정 잠금(LOCK_YN='Y')
if (currentFail >= threshold) {
// [#24] 잠금 예외 계정은 잠금만 건너뛴다 — 위 insertLoginFail(LOGIN_HISTORY)은
// 예외 여부와 무관하게 이미 수행됐다. 잠금을 면제해도 실패 시도 사실·시각·IP는
// 감사 추적에 그대로 남아야 한다.
if (currentFail >= threshold && !isLockExempt(loginVO)) {
loginService.updateFailLogin(loginVO);
}
}
@ -257,6 +275,15 @@ public class EgovLoginController {
}
}
/**
* [개인정보/#24] 계정 잠금 예외 여부. LOGIN_LOCK_EXEMPT 는 취급자(COMTNEMPLYRINFO) ID 목록이므로
* userSe='USR' 일 때만 조회한다. actionLogin 은 그룹(BIZ_GROUP)·활동처(COMTNENTRPRSMBER)도
* 타는 경로라, 구분 없이 물으면 관리자와 같은 ID 문자열을 쓰는 그룹/활동처 계정의 잠금까지 풀린다.
*/
private boolean isLockExempt(LoginVO loginVO) {
return "USR".equals(loginVO.getUserSe()) && loginLockExemptService.isExempt(loginVO.getId());
}
/**
* 일반(세션) 로그인2을 처리한다
* @param vo - 아이디, 비밀번호가 담긴 LoginVO
@ -629,6 +656,10 @@ public class EgovLoginController {
}
request.getSession().setAttribute("loginVO", null);
// [개인정보보호 SER-001 요건5] 세션은 살려두는 경로라 로그인 시각 도장을 명시적으로 지운다.
// 안 지우면 같은 세션으로 재로그인했을 때 직전 로그인 시각이 남아
// 로그인 직후 "1시간 N분 경과" 처럼 표시된다.
SessionPolicyInterceptor.clearLoginStamp(request.getSession(false));
// return "redirect:/j_spring_security_logout";
return "redirect:/frt/main.do";
}

View File

@ -150,4 +150,7 @@
<!-- 개인정보보호(SER-001 요건4·5) - 접근통제 정책(ACCESS_CONTROL_POLICY + LOGIN_LOCK_COUNT 동기화) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessControlPolicy_SQL_Mysql.xml"/>
<!-- 개인정보보호(SER-001 #24) - 로그인 실패 계정잠금 예외 화이트리스트(LOGIN_LOCK_EXEMPT) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyLoginLockExempt_SQL_Mysql.xml"/>
</sqlMapConfig>

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
<!--
개인정보보호 기능개선 사업 (SER-001 / 개발항목 24) — 로그인 실패 계정잠금 예외.
LOGIN_LOCK_EXEMPT 는 잠금 '판정'만 면제하는 화이트리스트다.
실패 이력(LOGIN_HISTORY) 적재와 2차 인증은 예외 계정에도 그대로 적용되므로
이 파일에는 그 두 경로를 건드리는 statement 가 없다.
[주의] iBatis 2 는 '#' 을 파라미터 구분자로 쓴다. SQL 주석에 '#' 을 쓰지 말 것.
-->
<sqlMap namespace="LoginLockExemptDAO">
<typeAlias alias="loginLockExemptVO"
type="egovframework.com.cmm.privacy.policy.LoginLockExemptVO"/>
<!-- ================= 캐시 적재용 ID 전건 조회 ================= -->
<select id="LoginLockExemptDAO.selectExemptIds" resultClass="java.lang.String">
<![CDATA[
SELECT EXEMPT_ID
FROM LOGIN_LOCK_EXEMPT
]]>
</select>
<!-- ================= 관리자 화면 목록 ================= -->
<select id="LoginLockExemptDAO.selectExemptList" resultClass="loginLockExemptVO">
<![CDATA[
SELECT e.EXEMPT_ID AS exemptId,
e.EXEMPT_RESN AS exemptResn,
e.REG_ID AS regId,
DATE_FORMAT(e.REG_DT, '%Y-%m-%d %H:%i:%s') AS regDt,
m.USER_NM AS userNm
FROM LOGIN_LOCK_EXEMPT e
LEFT JOIN COMTNEMPLYRINFO m ON m.EMPLYR_ID = e.EXEMPT_ID
ORDER BY e.REG_DT DESC, e.EXEMPT_ID
]]>
</select>
<!-- ================= 등록 대상 실존 확인(취급자 한정) ================= -->
<select id="LoginLockExemptDAO.selectEmplyr" parameterClass="java.lang.String"
resultClass="loginLockExemptVO">
<![CDATA[
SELECT CAST(EMPLYR_ID AS CHAR) AS exemptId,
USER_NM AS userNm
FROM COMTNEMPLYRINFO
WHERE EMPLYR_ID = #value#
LIMIT 1
]]>
</select>
<!-- ================= 등록 / 해제 ================= -->
<insert id="LoginLockExemptDAO.insertExempt" parameterClass="loginLockExemptVO">
<![CDATA[
INSERT INTO LOGIN_LOCK_EXEMPT (EXEMPT_ID, EXEMPT_RESN, REG_ID, REG_DT)
VALUES (#exemptId#, #exemptResn#, #regId#, NOW())
]]>
</insert>
<delete id="LoginLockExemptDAO.deleteExempt" parameterClass="java.lang.String">
<![CDATA[
DELETE FROM LOGIN_LOCK_EXEMPT
WHERE EXEMPT_ID = #value#
]]>
</delete>
</sqlMap>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
<!--
개인정보보호 기능개선 사업 (SER-001 / 개발항목 24) — 로그인 실패 계정잠금 예외 Oracle 쌍(이중관리 규약).
※ arko 런타임은 MySQL sqlmap 만 로드. 본 파일은 이중관리 관례상 병행 유지.
2026-08-10: 계정잠금 예외 화이트리스트 신규.
-->
<sqlMap namespace="LoginLockExemptDAO">
<typeAlias alias="loginLockExemptVO"
type="egovframework.com.cmm.privacy.policy.LoginLockExemptVO"/>
<select id="LoginLockExemptDAO.selectExemptIds" resultClass="java.lang.String">
<![CDATA[
SELECT EXEMPT_ID
FROM LOGIN_LOCK_EXEMPT
]]>
</select>
<select id="LoginLockExemptDAO.selectExemptList" resultClass="loginLockExemptVO">
<![CDATA[
SELECT e.EXEMPT_ID AS exemptId,
e.EXEMPT_RESN AS exemptResn,
e.REG_ID AS regId,
TO_CHAR(e.REG_DT, 'YYYY-MM-DD HH24:MI:SS') AS regDt,
m.USER_NM AS userNm
FROM LOGIN_LOCK_EXEMPT e
LEFT JOIN COMTNEMPLYRINFO m ON m.EMPLYR_ID = e.EXEMPT_ID
ORDER BY e.REG_DT DESC, e.EXEMPT_ID
]]>
</select>
<select id="LoginLockExemptDAO.selectEmplyr" parameterClass="java.lang.String"
resultClass="loginLockExemptVO">
<![CDATA[
SELECT EMPLYR_ID AS exemptId,
USER_NM AS userNm
FROM COMTNEMPLYRINFO
WHERE EMPLYR_ID = #value#
AND ROWNUM = 1
]]>
</select>
<insert id="LoginLockExemptDAO.insertExempt" parameterClass="loginLockExemptVO">
<![CDATA[
INSERT INTO LOGIN_LOCK_EXEMPT (EXEMPT_ID, EXEMPT_RESN, REG_ID, REG_DT)
VALUES (#exemptId#, #exemptResn#, #regId#, SYSDATE)
]]>
</insert>
<delete id="LoginLockExemptDAO.deleteExempt" parameterClass="java.lang.String">
<![CDATA[
DELETE FROM LOGIN_LOCK_EXEMPT
WHERE EXEMPT_ID = #value#
]]>
</delete>
</sqlMap>

View File

@ -14,6 +14,7 @@
· 계정 잠금 정책 : 인증 실패 잠금 횟수(현행 10 -> 5), 잠금 해제 방식(현행 DB 직접 해제)
· 자동 로그아웃 : 미사용 자동 로그아웃 분(현행 web.xml 60 -> 30), 만료 임박 안내
· 세션 재발급 : 2차 인증 성공 시 세션 재발급(현행 재발급 없음)
· 잠금 예외 계정 : 최고관리자 완전 잠김 방지 화이트리스트(LOGIN_LOCK_EXEMPT, 개발항목 24)
저장 시 web.xml 수정·재배포 없이 즉시 반영된다(SessionPolicyInterceptor / LOGIN_LOCK_COUNT 동기화).
--%>
@ -102,14 +103,96 @@ function fn_reload() {
location.href = "<c:url value='${PATH}/sec/accessPolicy/policyView.do' />?page=9100047";
}
// ── 잠금 예외 계정(#24) ────────────────────────────────────────────
// 값은 전부 .text() 로 넣는다 — 사유는 관리자 입력 문자열이라 HTML 로 이어붙이면 XSS 통로가 된다.
function fn_exemptRow(item) {
var tr = $("<tr />");
var name = item.userNm ? (" (" + item.userNm + ")") : "";
tr.append($("<td />").text(item.exemptId + name));
tr.append($("<td />").css("text-align", "left").text(item.exemptResn));
tr.append($("<td />").text(item.regId ? item.regId : "-"));
tr.append($("<td />").text(item.regDt ? item.regDt : "-"));
var btn = $("<a />").attr("href", "javascript:void(0);").addClass("btn1").text("해제");
btn.click(function() { fn_exemptDelete(item.exemptId); });
tr.append($("<td />").append(btn));
return tr;
}
function fn_exemptLoad() {
var tbody = $("#exemptBody");
tbody.empty().append($("<tr />").append($("<td colspan='5' />").text("불러오는 중...")));
$.post("<c:url value='${PATH}/privacy/lockExemptList.do' />", {}, function(data) {
tbody.empty();
if (!data || data.result !== "success") {
tbody.append($("<tr />").append($("<td colspan='5' />").text("목록을 불러오지 못했습니다.")));
return;
}
var list = data.list || [];
if (list.length === 0) {
tbody.append($("<tr />").append($("<td colspan='5' />").text("등록된 잠금 예외 계정이 없습니다.")));
return;
}
for (var i = 0; i < list.length; i++) {
tbody.append(fn_exemptRow(list[i]));
}
}, "json").fail(function() {
tbody.empty().append($("<tr />").append($("<td colspan='5' />").text("목록 조회 중 오류가 발생하였습니다.")));
});
}
function fn_exemptAdd() {
var id = $.trim($("#exemptId").val());
var resn = $.trim($("#exemptResn").val());
if (id.length === 0) { alert("예외로 등록할 계정 ID를 입력하세요."); $("#exemptId").focus(); return; }
if (resn.length === 0) { alert("예외 등록 사유를 입력하세요.\n사유는 감사 대응상 필수입니다."); $("#exemptResn").focus(); return; }
if (!confirm("[" + id + "] 계정을 로그인 실패 잠금 대상에서 제외합니다.\n\n"
+ "· 실패 이력(LOGIN_HISTORY)은 예외 계정도 그대로 기록됩니다.\n"
+ "· 2단계 인증(OTP)은 예외 없이 동일하게 적용됩니다.\n"
+ "· 무차별 대입 시도가 있어도 이 계정은 잠기지 않습니다.\n\n계속하시겠습니까?")) { return; }
$.post("<c:url value='${PATH}/privacy/lockExemptAdd.do' />",
{ exemptId: id, exemptResn: resn }, function(data) {
if (data && data.result === "success") {
alert("등록되었습니다. 다음 로그인 시도부터 적용됩니다.");
$("#exemptId").val(""); $("#exemptResn").val("");
fn_exemptLoad();
} else {
alert("등록에 실패하였습니다.\n" + (data && data.message ? data.message : ""));
}
}, "json").fail(function() {
alert("등록 처리 중 오류가 발생하였습니다.");
});
}
function fn_exemptDelete(id) {
if (!confirm("[" + id + "] 계정의 잠금 예외를 해제합니다.\n"
+ "해제 즉시 이 계정도 인증 실패 누적 시 잠기게 됩니다.\n\n계속하시겠습니까?")) { return; }
$.post("<c:url value='${PATH}/privacy/lockExemptDelete.do' />", { exemptId: id }, function(data) {
if (data && data.result === "success") {
alert("해제되었습니다.");
fn_exemptLoad();
} else {
alert("해제에 실패하였습니다.\n" + (data && data.message ? data.message : ""));
}
}, "json").fail(function() {
alert("해제 처리 중 오류가 발생하였습니다.");
});
}
$(document).ready(function() {
fn_toggleNotice();
fn_exemptLoad();
});
//-->
</script>
<style type="text/css">
.unit { margin-left:4px; color:#666; }
.sec_head { margin:16px 0 6px 0; padding-left:8px; border-left:3px solid #1f5f9c; font-weight:bold; color:#222; }
.exempt_note { margin:0 0 8px 0; padding:8px 10px; background:#f7f7f7; border:1px solid #ddd; color:#444; line-height:1.6; }
</style>
</head>
@ -141,8 +224,7 @@ $(document).ready(function() {
<th><label for="unlockMode">잠금 해제 방식</label> *</th>
<td>
<select name="unlockMode" id="unlockMode" title="잠금 해제 방식">
<option value="ADMIN_HIST" <c:if test="${policy.unlockMode ne 'ADMIN_HIST_RESN'}">selected="selected"</c:if>>관리자 화면 해제 + 해제 이력 적재</option>
<option value="ADMIN_HIST_RESN" <c:if test="${policy.unlockMode eq 'ADMIN_HIST_RESN'}">selected="selected"</c:if>>관리자 화면 해제 + 해제 사유 필수 + 이력 적재</option>
<option value="ADMIN_HIST" selected="selected">관리자 화면 해제 + 해제 이력 적재</option>
</select>
</td>
</tr>
@ -216,6 +298,67 @@ $(document).ready(function() {
<!--// 버튼 -->
</form:form>
<div class="sec_head">잠금 예외 계정</div>
<p class="exempt_note">
여기 등록된 계정은 인증 실패가 누적되어도 <strong>잠기지 않습니다</strong>.
잠금 해제 화면은 관리자 로그인이 있어야 들어갈 수 있어, 최고관리자 계정이 잠기면
아무도 해제할 수 없는 상태가 되므로 그에 대비한 통로입니다.<br />
실패 이력(접속기록)은 예외 계정도 그대로 남고, 2단계 인증(OTP)도 예외 없이 적용됩니다.
<strong>잠금 예외는 인증 예외가 아닙니다.</strong> 꼭 필요한 최소 인원만 등록하세요.
</p>
<table summary="잠금 예외 계정 목록" class="board_list">
<caption>잠금 예외 계정 목록</caption>
<colgroup>
<col width="20%" />
<col width="*" />
<col width="14%" />
<col width="18%" />
<col width="10%" />
</colgroup>
<thead>
<tr>
<th scope="col">계정 ID</th>
<th scope="col">사유</th>
<th scope="col">등록자</th>
<th scope="col">등록일시</th>
<th scope="col">해제</th>
</tr>
</thead>
<tbody id="exemptBody">
<tr><td colspan="5">불러오는 중...</td></tr>
</tbody>
</table>
<table summary="잠금 예외 계정 등록" class="write">
<caption>잠금 예외 계정 등록</caption>
<colgroup>
<col width="25%" />
<col width="*" />
</colgroup>
<tbody>
<tr>
<th><label for="exemptId">계정 ID</label> *</th>
<td>
<input type="text" id="exemptId" name="exemptId" class="tbox" maxlength="20"
style="width:200px;" title="잠금 예외로 등록할 계정 ID" />
<span class="unit">개인정보취급자 계정만 등록할 수 있습니다.</span>
</td>
</tr>
<tr>
<th><label for="exemptResn">사유</label> *</th>
<td>
<input type="text" id="exemptResn" name="exemptResn" class="tbox" maxlength="500"
style="width:90%;" title="예외 등록 사유" />
</td>
</tr>
</tbody>
</table>
<div class="btn_zone">
<a href="javascript:fn_exemptAdd();" class="btn2">예외 계정 등록</a>
</div>
</div>
<!--// 컨텐츠 영역 -->
</div>

View File

@ -0,0 +1,46 @@
-- =====================================================================
-- 로그인 실패 계정잠금 예외 : LOGIN_LOCK_EXEMPT (화이트리스트)
-- 문화품앗이 개인정보보호 기능개선 사업 (SER-001 / 개발항목 24)
-- 작성일: 2026-08-10 DB: MySQL 8 / MariaDB
-- ---------------------------------------------------------------------
-- [배경] 인증 실패 5회 잠금(LOGIN_LOCK_COUNT)은 userSe 구분 없이 전 계정에 적용된다.
-- 잠금 해제 화면(/uss/umt/EgovAccountUnlockManage.do)은 관리자 로그인이 있어야
-- 들어갈 수 있는 mnt 메뉴라서, 최고관리자 계정이 잠기면 아무도 해제할 수 없는
-- 완전 잠김(lock-out) 상태가 된다. 이 테이블은 그 break-glass 통로다.
--
-- [적용 범위] 잠금 판정만 면제한다. 아래는 예외 계정에도 그대로 적용된다.
-- · 인증 실패 이력(LOGIN_HISTORY) 적재 — 감사 추적은 예외 없이 유지
-- · 2차 인증(OTP 등, secondFactor.do) — 잠금 예외 != 인증 예외
-- · 비밀번호 검증 자체 — 틀린 비밀번호로 로그인되지 않는다
--
-- [시드] 초기 데이터 없음. 어떤 계정을 예외로 둘지는 운영팀이 관리자 화면
-- (/mnt/sec/accessPolicy/policyView.do '잠금 예외 계정' 섹션)에서 직접 등록한다.
-- 특정 ID를 스크립트에 하드코딩하지 않는다.
--
-- [재실행 안전] CREATE TABLE IF NOT EXISTS — 반복 실행 무해.
-- [한글 리터럴] COMMENT 에만 한글 사용. 적용 시 클라이언트 문자셋 utf8mb4 확인.
-- =====================================================================
CREATE TABLE IF NOT EXISTS LOGIN_LOCK_EXEMPT (
EXEMPT_ID VARCHAR(20) NOT NULL COMMENT '잠금 예외 대상 계정ID(COMTNEMPLYRINFO.EMPLYR_ID)',
EXEMPT_RESN VARCHAR(500) NOT NULL COMMENT '예외 등록 사유(필수 - 감사 대응)',
REG_ID VARCHAR(20) NULL COMMENT '등록한 관리자 계정ID',
REG_DT DATETIME NULL COMMENT '등록일시',
PRIMARY KEY (EXEMPT_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='로그인 실패 계정잠금 예외 계정(개인정보보호 SER-001 24, 최고관리자 잠김 방지)';
-- =====================================================================
-- [검증]
-- SELECT * FROM LOGIN_LOCK_EXEMPT;
-- -- 예외 등록 후에도 실패 이력은 남는지 확인:
-- SELECT * FROM LOGIN_HISTORY WHERE ID = '등록한ID' ORDER BY 1 DESC LIMIT 10;
--
-- [운영 메모]
-- 예외 등록 전에 이미 LOCK_YN='Y' 가 찍혀 있어도 등록 시점부터 로그인이 가능하다
-- (잠금 선확인 자체를 건너뛰므로). LOCK_YN 값 자체를 되돌릴 필요는 없다.
--
-- [롤백]
-- DROP TABLE LOGIN_LOCK_EXEMPT;
-- -- 테이블이 없으면 서비스가 fail-closed 로 동작해 전 계정이 정상 잠금 판정된다.
-- =====================================================================