diff --git a/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyController.java b/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyController.java index e5f2bfc..21bdc56 100644 --- a/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyController.java +++ b/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyController.java @@ -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; *
경로 선정 근거 — 세션 연장·상태 조회는 전체관리자 전용이 아니라 @@ -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). + * + *
대상 ID 가 취급자(COMTNEMPLYRINFO)로 실존하는지 서버측에서 확인한다 — 오타로 + * 존재하지 않는 ID 가 등록되면 "예외를 걸어 뒀다"고 믿는 계정이 실제로는 잠기는 + * 상황이 되고, 그 사실은 잠긴 뒤에야 드러난다. 저장되는 ID 는 입력값이 아니라 + * 조회된 표기 그대로의 EMPLYR_ID 다(대소문자 불일치로 캐시 비교가 어긋나지 않게).
+ */ + @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"; + } + // ────────────────────────────────────────────────────────── 세션 상태·연장 /** diff --git a/src/main/java/egovframework/com/cmm/privacy/policy/LoginLockExemptDAO.java b/src/main/java/egovframework/com/cmm/privacy/policy/LoginLockExemptDAO.java new file mode 100644 index 0000000..ff0c9ca --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/policy/LoginLockExemptDAO.java @@ -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). + * + *statement 는 {@code PrivacyLoginLockExempt_SQL_Mysql.xml} + * (namespace {@code LoginLockExemptDAO}), sql-map-config 등록.
+ * + *루트 컨텍스트 {@code @Repository} 빈이므로 자식(servlet) 컨텍스트의 + * 인증 컨트롤러에서도 참조할 수 있다.
+ */ +@Repository("loginLockExemptDAO") +public class LoginLockExemptDAO extends EgovComAbstractDAO { + + /** 예외 계정ID 전체 조회 — 캐시 적재용(행 수가 한 자릿수 규모라 전건 조회로 충분). */ + @SuppressWarnings("unchecked") + public List저장된 표기 그대로의 EMPLYR_ID 와 성명을 돌려준다. 등록 시 이 값을 쓰면 + * 관리자가 대소문자를 다르게 입력해도 캐시의 문자열 비교가 어긋나지 않는다 + * (MySQL 기본 collation 은 대소문자 무시라 조회는 되지만 자바 비교는 아니다).
+ */ + 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); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/policy/LoginLockExemptService.java b/src/main/java/egovframework/com/cmm/privacy/policy/LoginLockExemptService.java new file mode 100644 index 0000000..bfb3dd0 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/policy/LoginLockExemptService.java @@ -0,0 +1,36 @@ +package egovframework.com.cmm.privacy.policy; + +import java.util.List; + +/** + * 로그인 실패 계정잠금 예외 서비스 (SER-001 / 개발항목 24). + * + *지정 계정을 인증 실패 잠금 판정에서만 제외한다. 실패 이력 적재와 + * 2차 인증은 예외 계정에도 그대로 적용된다 — 잠금 예외는 인증 예외가 아니다.
+ */ +public interface LoginLockExemptService { + + /** + * 잠금 예외 계정 여부(TTL 캐시). 로그인 시도마다 호출되는 지점 전용. + * + *조회 실패 시 예외를 던지지 않고 {@code false}(예외 아님)를 돌려준다 — + * 판단 불가일 때는 잠금이 정상 동작하는 쪽이 안전하다.
+ * + *대소문자를 구분하지 않는다(잠금 SQL 의 MySQL collation 과 동일 기준). + * 목록은 취급자(COMTNEMPLYRINFO) ID 만 담으므로, 호출부는 {@code userSe='USR'} 인 + * 경우에만 물어야 한다 — 다른 회원구분과 ID 문자열이 겹치면 엉뚱한 계정의 잠금이 풀린다.
+ */ + boolean isExempt(String loginId); + + /** 관리자 화면 목록(사유·등록자·등록일시 포함). DB 직접 조회. */ + List캐시 — {@code EgovLoginController.actionLogin()} 이 로그인 시도마다 두 번 + * (잠금 선확인·실패 처리) 조회하므로 {@link AccessControlPolicyServiceImpl} 과 동일한 + * 짧은 TTL(60초) 캐시를 둔다. 등록·삭제 시 즉시 무효화한다.
+ * + *fail-closed — {@link AccessControlPolicyServiceImpl} 과 반대 방향이다. + * 접근통제 정책 쪽은 조회에 실패해도 기본값(잠금 5회·30분)을 돌려준다. 그 기본값이 + * 이미 강화된 값이라 실패가 곧 완화가 아니기 때문이다. 반면 이 화이트리스트는 + * 있으면 잠금이 면제되는 목록이라, 조회 실패를 관대하게 다루면 그 순간 + * 무차별 대입에 대한 잠금이 통째로 풀린다. 따라서 조회 실패 시에는 빈 집합 + * (= 예외 계정 없음 = 전 계정 정상 잠금 판정)으로 취급한다. 테이블 미생성 등으로 + * 계속 실패하는 상황에서 매 로그인마다 DB를 때리지 않도록, 실패한 빈 집합도 + * 동일 TTL 로 캐싱한다(최대 60초 뒤 자동 재시도).
+ * + *+ * << 개정이력(Modification Information) >> + * 수정일 수정자 수정내용 + * ---------- ------ --------------------------- + * 2026.08.10 URP 최초 생성 (계정잠금 예외 화이트리스트) + *+ */ +@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
잠금 판정만 면제하는 화이트리스트 한 행이다. 인증 실패 이력(LOGIN_HISTORY) + * 적재와 2차 인증(OTP)은 예외 계정에도 그대로 적용된다.
+ * + *+ * << 개정이력(Modification Information) >> + * 수정일 수정자 수정내용 + * ---------- ------ --------------------------- + * 2026.08.10 URP 최초 생성 (계정잠금 예외 화이트리스트) + *+ */ +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; } +} diff --git a/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java b/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java index 053bfb0..521c718 100644 --- a/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java +++ b/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java @@ -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; @@ -87,6 +89,10 @@ public class EgovLoginController { /** EgovMessageSource */ @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); } } @@ -256,6 +274,15 @@ public class EgovLoginController { return "egovframework/com/uat/uia/EgovLoginUsr"; } } + + /** + * [개인정보/#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을 처리한다 @@ -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"; } diff --git a/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml b/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml index 139b8f3..6f9d77e 100644 --- a/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml +++ b/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml @@ -150,4 +150,7 @@
+ 여기 등록된 계정은 인증 실패가 누적되어도 잠기지 않습니다.
+ 잠금 해제 화면은 관리자 로그인이 있어야 들어갈 수 있어, 최고관리자 계정이 잠기면
+ 아무도 해제할 수 없는 상태가 되므로 그에 대비한 통로입니다.
+ 실패 이력(접속기록)은 예외 계정도 그대로 남고, 2단계 인증(OTP)도 예외 없이 적용됩니다.
+ 잠금 예외는 인증 예외가 아닙니다. 꼭 필요한 최소 인원만 등록하세요.
+
| 계정 ID | +사유 | +등록자 | +등록일시 | +해제 | +
|---|---|---|---|---|
| 불러오는 중... | ||||
| * | ++ + 개인정보취급자 계정만 등록할 수 있습니다. + | +
|---|---|
| * | ++ + | +